-
Notifications
You must be signed in to change notification settings - Fork 0
PR-2: TenantKey Model & KEK Management #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
ef0e29c
feat: Add TenantKey model with envelope encryption and KEK management
kevalyq e982ee0
fix: Replace toHaveLength() with strlen()->toBe() for binary data
kevalyq 55e79e5
refactor: Address Copilot review comments
kevalyq e5947c1
refactor: Add strict base64 validation in accessors
kevalyq bf812f7
fix: Throw exceptions for invalid attribute data instead of silent fa…
kevalyq File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,326 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * SPDX-FileCopyrightText: 2025 SecPal Contributors | ||
| * | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| namespace App\Models; | ||
|
|
||
| use Illuminate\Database\Eloquent\Model; | ||
|
|
||
| /** | ||
| * TenantKey model for managing per-tenant envelope encryption keys. | ||
| * | ||
| * This model stores wrapped Data Encryption Keys (DEK) and Index Keys (idx_key) | ||
| * using envelope encryption with a Key Encryption Key (KEK). | ||
| * | ||
| * @property int $id | ||
| * @property string $dek_wrapped BYTEA wrapped DEK | ||
| * @property string $dek_nonce BYTEA nonce for DEK | ||
| * @property string $idx_wrapped BYTEA wrapped idx_key | ||
| * @property string $idx_nonce BYTEA nonce for idx_key | ||
| * @property int $key_version Key version for rotation | ||
| * @property \Illuminate\Support\Carbon $created_at | ||
| */ | ||
| class TenantKey extends Model | ||
| { | ||
| /** | ||
| * The table associated with the model. | ||
| * | ||
| * @var string | ||
| */ | ||
| protected $table = 'tenant_keys'; | ||
|
|
||
| /** | ||
| * Indicates that the model should not use the updated_at column. | ||
| */ | ||
| public const UPDATED_AT = null; | ||
|
|
||
| /** | ||
| * The attributes that are mass assignable. | ||
| * | ||
| * @var list<string> | ||
| */ | ||
| 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<string, string> | ||
| */ | ||
| protected function casts(): array | ||
| { | ||
| return [ | ||
| 'key_version' => 'integer', | ||
| 'created_at' => 'datetime', | ||
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * Get the DEK wrapped attribute accessor. | ||
| * | ||
| * @return \Illuminate\Database\Eloquent\Casts\Attribute<string, string> | ||
| */ | ||
| 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<string, string> | ||
| */ | ||
| 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<string, string> | ||
| */ | ||
| 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<string, string> | ||
| */ | ||
| 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<string, mixed> | ||
| */ | ||
| 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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.