diff --git a/app/Models/TenantKey.php b/app/Models/TenantKey.php new file mode 100644 index 0000000..76c05e5 --- /dev/null +++ b/app/Models/TenantKey.php @@ -0,0 +1,326 @@ + + */ + protected $fillable = [ + 'dek_wrapped', + 'dek_nonce', + 'idx_wrapped', + 'idx_nonce', + 'key_version', + ]; + + /** + * Get the attributes that should be cast. + * + * Binary fields use custom accessors for base64 encoding/decoding. + * + * @return array + */ + protected function casts(): array + { + return [ + 'key_version' => 'integer', + 'created_at' => 'datetime', + ]; + } + + /** + * Get the DEK wrapped attribute accessor. + * + * @return \Illuminate\Database\Eloquent\Casts\Attribute + */ + protected function dekWrapped(): \Illuminate\Database\Eloquent\Casts\Attribute + { + return \Illuminate\Database\Eloquent\Casts\Attribute::make( + get: fn (mixed $value): string => is_string($value) ? (base64_decode($value, true) ?: throw new \RuntimeException('Invalid base64 data for dek_wrapped')) : throw new \RuntimeException('dek_wrapped must be a string'), + set: fn (string $value): string => base64_encode($value), + ); + } + + /** + * Get/set dek_nonce as binary via base64. + * + * @return \Illuminate\Database\Eloquent\Casts\Attribute + */ + protected function dekNonce(): \Illuminate\Database\Eloquent\Casts\Attribute + { + return \Illuminate\Database\Eloquent\Casts\Attribute::make( + get: fn (mixed $value): string => is_string($value) ? (base64_decode($value, true) ?: throw new \RuntimeException('Invalid base64 data for dek_nonce')) : throw new \RuntimeException('dek_nonce must be a string'), + set: fn (string $value): string => base64_encode($value), + ); + } + + /** + * Get/set idx_wrapped as binary via base64. + * + * @return \Illuminate\Database\Eloquent\Casts\Attribute + */ + protected function idxWrapped(): \Illuminate\Database\Eloquent\Casts\Attribute + { + return \Illuminate\Database\Eloquent\Casts\Attribute::make( + get: fn (mixed $value): string => is_string($value) ? (base64_decode($value, true) ?: throw new \RuntimeException('Invalid base64 data for idx_wrapped')) : throw new \RuntimeException('idx_wrapped must be a string'), + set: fn (string $value): string => base64_encode($value), + ); + } + + /** + * Get the idx key nonce attribute accessor. + * + * @return \Illuminate\Database\Eloquent\Casts\Attribute + */ + protected function idxNonce(): \Illuminate\Database\Eloquent\Casts\Attribute + { + return \Illuminate\Database\Eloquent\Casts\Attribute::make( + get: fn (mixed $value): string => is_string($value) ? (base64_decode($value, true) ?: throw new \RuntimeException('Invalid base64 data for idx_nonce')) : throw new \RuntimeException('idx_nonce must be a string'), + set: fn (string $value): string => base64_encode($value), + ); + } + + /** + * Default attribute values. + * + * @var array + */ + protected $attributes = [ + 'key_version' => 1, + ]; + + /** + * Get the foreign key column name for the tenant. + */ + public function getTenantIdColumn(): string + { + return 'id'; // tenant_keys.id is the tenant identifier + } + + /** + * Get the path to the KEK file. + */ + protected static function getKekPath(): string + { + return storage_path('app/keys/kek.key'); + } + + /** + * Load the Key Encryption Key (KEK) from storage. + * + * @throws \RuntimeException if KEK file is missing + */ + protected static function loadKek(): string + { + $path = self::getKekPath(); + + if (! file_exists($path)) { + throw new \RuntimeException('KEK file not found at: '.$path); + } + + $kek = file_get_contents($path); + + if ($kek === false || strlen($kek) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { + throw new \RuntimeException('Invalid KEK file'); + } + + return $kek; + } + + /** + * Generate a new KEK and store it securely. + * + * @throws \RuntimeException if unable to create directory or write file + */ + public static function generateKek(): void + { + $path = self::getKekPath(); + $dir = dirname($path); + + if (! is_dir($dir) && ! mkdir($dir, 0700, true)) { + throw new \RuntimeException('Failed to create keys directory'); + } + + $kek = sodium_crypto_secretbox_keygen(); + + if (file_put_contents($path, $kek) === false) { + throw new \RuntimeException('Failed to write KEK file'); + } + + chmod($path, 0600); + } + + /** + * Generate new envelope keys (DEK and idx_key) wrapped with KEK. + * + * @return array{dek_wrapped: string, dek_nonce: string, idx_wrapped: string, idx_nonce: string} + * + * @throws \RuntimeException if KEK is not available + */ + public static function generateEnvelopeKeys(): array + { + $kek = self::loadKek(); + + // Generate Data Encryption Key (DEK) + $dek = sodium_crypto_secretbox_keygen(); + $dekNonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $dekWrapped = sodium_crypto_secretbox($dek, $dekNonce, $kek); + + // Generate Index Key for blind indexes + $idxKey = sodium_crypto_secretbox_keygen(); // Same size as DEK, used for HMAC-SHA256 + $idxNonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $idxWrapped = sodium_crypto_secretbox($idxKey, $idxNonce, $kek); + + sodium_memzero($kek); + sodium_memzero($dek); + sodium_memzero($idxKey); + + return [ + 'dek_wrapped' => $dekWrapped, + 'dek_nonce' => $dekNonce, + 'idx_wrapped' => $idxWrapped, + 'idx_nonce' => $idxNonce, + ]; + } + + /** + * Unwrap the Data Encryption Key (DEK). + * + * @throws \RuntimeException if unwrapping fails + */ + public function unwrapDek(): string + { + $kek = self::loadKek(); + + $dek = sodium_crypto_secretbox_open( + $this->dek_wrapped, + $this->dek_nonce, + $kek + ); + + sodium_memzero($kek); + + if ($dek === false) { + throw new \RuntimeException('Failed to unwrap DEK'); + } + + return $dek; + } + + /** + * Unwrap the Index Key for blind indexes. + * + * @throws \RuntimeException if unwrapping fails + */ + public function unwrapIdxKey(): string + { + $kek = self::loadKek(); + + $idxKey = sodium_crypto_secretbox_open( + $this->idx_wrapped, + $this->idx_nonce, + $kek + ); + + sodium_memzero($kek); + + if ($idxKey === false) { + throw new \RuntimeException('Failed to unwrap idx_key'); + } + + return $idxKey; + } + + /** + * Encrypt plaintext data using the tenant's DEK. + * + * @return array{ciphertext: string, nonce: string} + * + * @throws \RuntimeException if encryption fails + */ + public function encrypt(string $plaintext): array + { + $dek = $this->unwrapDek(); + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $dek); + + sodium_memzero($dek); + + return [ + 'ciphertext' => $ciphertext, + 'nonce' => $nonce, + ]; + } + + /** + * Decrypt ciphertext using the tenant's DEK. + * + * @throws \RuntimeException if decryption fails + */ + public function decrypt(string $ciphertext, string $nonce): string + { + $dek = $this->unwrapDek(); + $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $dek); + + sodium_memzero($dek); + + if ($plaintext === false) { + throw new \RuntimeException('Failed to decrypt data'); + } + + return $plaintext; + } + + /** + * Generate a blind index for searchable encrypted fields. + * + * Uses HMAC-SHA256 with the tenant's index key. + */ + public function generateBlindIndex(string $plaintext): string + { + $idxKey = $this->unwrapIdxKey(); + $index = hash_hmac('sha256', $plaintext, $idxKey, true); + + sodium_memzero($idxKey); + + return $index; + } +} diff --git a/tests/Feature/TenantKeyTest.php b/tests/Feature/TenantKeyTest.php new file mode 100644 index 0000000..524b053 --- /dev/null +++ b/tests/Feature/TenantKeyTest.php @@ -0,0 +1,182 @@ +toBeTrue(); + expect(filesize($kekPath))->toBe(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + + // Check file permissions (0600 = owner read/write only) + $perms = fileperms($kekPath) & 0777; + expect($perms)->toBe(0600); +}); + +test('throws exception when KEK file is missing', function (): void { + expect(fn () => TenantKey::generateEnvelopeKeys()) + ->toThrow(RuntimeException::class, 'KEK file not found'); +}); + +test('generates envelope keys with correct structure', function (): void { + TenantKey::generateKek(); + + $keys = TenantKey::generateEnvelopeKeys(); + + expect($keys)->toHaveKeys(['dek_wrapped', 'dek_nonce', 'idx_wrapped', 'idx_nonce']); + expect(strlen($keys['dek_nonce']))->toBe(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + expect(strlen($keys['idx_nonce']))->toBe(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + // Wrapped keys include MAC, so they're longer than the original key + expect(strlen($keys['dek_wrapped']))->toBeGreaterThan(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + expect(strlen($keys['idx_wrapped']))->toBeGreaterThan(32); +}); + +test('generates unique nonces for each key generation', function (): void { + TenantKey::generateKek(); + + $keys1 = TenantKey::generateEnvelopeKeys(); + $keys2 = TenantKey::generateEnvelopeKeys(); + + expect($keys1['dek_nonce'])->not->toBe($keys2['dek_nonce']); + expect($keys1['idx_nonce'])->not->toBe($keys2['idx_nonce']); + expect($keys1['dek_wrapped'])->not->toBe($keys2['dek_wrapped']); + expect($keys1['idx_wrapped'])->not->toBe($keys2['idx_wrapped']); +}); + +test('unwraps DEK correctly', function (): void { + TenantKey::generateKek(); + + $keys = TenantKey::generateEnvelopeKeys(); + + $tenantKey = TenantKey::create($keys); + + $dek = $tenantKey->unwrapDek(); + + expect(strlen($dek))->toBe(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); +}); + +test('unwraps idx_key correctly', function (): void { + TenantKey::generateKek(); + + $keys = TenantKey::generateEnvelopeKeys(); + + $tenantKey = TenantKey::create($keys); + + $idxKey = $tenantKey->unwrapIdxKey(); + + expect(strlen($idxKey))->toBe(32); // HMAC-SHA256 key size +}); + +test('encrypts and decrypts data correctly', function (): void { + TenantKey::generateKek(); + + $keys = TenantKey::generateEnvelopeKeys(); + $tenantKey = TenantKey::create($keys); + + $plaintext = 'sensitive data'; + + $encrypted = $tenantKey->encrypt($plaintext); + + expect($encrypted)->toHaveKeys(['ciphertext', 'nonce']); + expect(strlen($encrypted['nonce']))->toBe(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + + $decrypted = $tenantKey->decrypt($encrypted['ciphertext'], $encrypted['nonce']); + + expect($decrypted)->toBe($plaintext); +}); + +test('throws exception on decryption with wrong nonce', function (): void { + TenantKey::generateKek(); + + $keys = TenantKey::generateEnvelopeKeys(); + $tenantKey = TenantKey::create($keys); + + $encrypted = $tenantKey->encrypt('test data'); + $wrongNonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + + expect(fn () => $tenantKey->decrypt($encrypted['ciphertext'], $wrongNonce)) + ->toThrow(RuntimeException::class, 'Failed to decrypt data'); +}); + +test('generates consistent blind index for same plaintext', function (): void { + TenantKey::generateKek(); + + $keys = TenantKey::generateEnvelopeKeys(); + $tenantKey = TenantKey::create($keys); + + $plaintext = 'test@example.com'; + + $index1 = $tenantKey->generateBlindIndex($plaintext); + $index2 = $tenantKey->generateBlindIndex($plaintext); + + expect($index1)->toBe($index2); + expect(strlen($index1))->toBe(32); // SHA-256 produces 32 bytes +}); + +test('generates different blind indexes for different plaintexts', function (): void { + TenantKey::generateKek(); + + $keys = TenantKey::generateEnvelopeKeys(); + $tenantKey = TenantKey::create($keys); + + $index1 = $tenantKey->generateBlindIndex('test1@example.com'); + $index2 = $tenantKey->generateBlindIndex('test2@example.com'); + + expect($index1)->not->toBe($index2); +}); + +test('different tenants have different blind indexes for same plaintext', function (): void { + TenantKey::generateKek(); + + $keys1 = TenantKey::generateEnvelopeKeys(); + $keys2 = TenantKey::generateEnvelopeKeys(); + + $tenant1 = TenantKey::create($keys1); + $tenant2 = TenantKey::create($keys2); + + $plaintext = 'test@example.com'; + + $index1 = $tenant1->generateBlindIndex($plaintext); + $index2 = $tenant2->generateBlindIndex($plaintext); + + expect($index1)->not->toBe($index2); +}); + +test('key_version defaults to 1', function (): void { + TenantKey::generateKek(); + + $keys = TenantKey::generateEnvelopeKeys(); + $tenantKey = TenantKey::create($keys); + + expect($tenantKey->key_version)->toBe(1); +});