From e1146149e08f592132c8829453726cedba3d3a9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ya=C4=9Fmur=20=C3=87i=C3=A7ekda=C4=9F=C4=B1?= Date: Thu, 1 Jan 2026 23:00:22 +0000 Subject: [PATCH 1/2] feat(secret): encrypt secret metadata at rest --- .../state/backend/sqlite/ddl/statement.go | 37 ++++++------ .../state/backend/sqlite/persist/secret.go | 43 +++++++++++-- .../backend/sqlite/persist/secret_load.go | 60 ++++++++++++++++--- .../sqlite/persist/secret_load_sqlite_test.go | 26 ++++---- .../backend/sqlite/persist/testing_helper.go | 33 +++++++--- 5 files changed, 153 insertions(+), 46 deletions(-) diff --git a/app/nexus/internal/state/backend/sqlite/ddl/statement.go b/app/nexus/internal/state/backend/sqlite/ddl/statement.go index 2ebd66bc..57ffd57e 100644 --- a/app/nexus/internal/state/backend/sqlite/ddl/statement.go +++ b/app/nexus/internal/state/backend/sqlite/ddl/statement.go @@ -38,11 +38,12 @@ CREATE TABLE IF NOT EXISTS secrets ( CREATE TABLE IF NOT EXISTS secret_metadata ( path TEXT PRIMARY KEY, - current_version INTEGER NOT NULL, - oldest_version INTEGER NOT NULL, - created_time DATETIME NOT NULL, - updated_time DATETIME NOT NULL, - max_versions INTEGER NOT NULL + nonce BLOB NOT NULL, + encrypted_current_version BLOB NOT NULL, + encrypted_oldest_version BLOB NOT NULL, + encrypted_created_time BLOB NOT NULL, + encrypted_updated_time BLOB NOT NULL, + encrypted_max_versions BLOB NOT NULL ); CREATE INDEX IF NOT EXISTS idx_secrets_path ON secrets(path); @@ -53,14 +54,16 @@ CREATE INDEX IF NOT EXISTS idx_secrets_created_time ON secrets(created_time); // metadata. It updates the current version, oldest version, max versions, and // updated time in conflict with the existing path. const QueryUpdateSecretMetadata = ` -INSERT INTO secret_metadata (path, current_version, oldest_version, - created_time, updated_time, max_versions) -VALUES (?, ?, ?, ?, ?, ?) +INSERT INTO secret_metadata (path, nonce, encrypted_current_version, encrypted_oldest_version, + encrypted_created_time, encrypted_updated_time, encrypted_max_versions) +VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(path) DO UPDATE SET - current_version = excluded.current_version, - oldest_version = excluded.oldest_version, - updated_time = excluded.updated_time, - max_versions = excluded.max_versions + nonce = excluded.nonce, + encrypted_current_version = excluded.encrypted_current_version, + encrypted_oldest_version = excluded.encrypted_oldest_version, + encrypted_created_time = excluded.encrypted_created_time, + encrypted_updated_time = excluded.encrypted_updated_time, + encrypted_max_versions = excluded.encrypted_max_versions ` // QueryUpsertSecret is a SQL query for inserting or updating the `secrets` @@ -76,15 +79,15 @@ ON CONFLICT(path, version) DO UPDATE SET // QuerySecretMetadata is a SQL query to fetch metadata of a secret by its path. const QuerySecretMetadata = ` -SELECT current_version, oldest_version, created_time, updated_time, max_versions -FROM secret_metadata +SELECT nonce, encrypted_current_version, encrypted_oldest_version, encrypted_created_time, encrypted_updated_time, encrypted_max_versions +FROM secret_metadata WHERE path = ? ` // QuerySecretVersions retrieves all versions of a secret from the database. const QuerySecretVersions = ` -SELECT version, nonce, encrypted_data, created_time, deleted_time -FROM secrets +SELECT version, nonce, encrypted_data, created_time, deleted_time +FROM secrets WHERE path = ? ORDER BY version ` @@ -113,7 +116,7 @@ ON CONFLICT(id) DO UPDATE SET // QueryDeletePolicy defines the SQL statement to delete a policy by its ID. const QueryDeletePolicy = ` -DELETE FROM policies +DELETE FROM policies WHERE id = ? ` diff --git a/app/nexus/internal/state/backend/sqlite/persist/secret.go b/app/nexus/internal/state/backend/sqlite/persist/secret.go index b52931f5..38e38a9d 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/secret.go +++ b/app/nexus/internal/state/backend/sqlite/persist/secret.go @@ -8,6 +8,7 @@ import ( "context" "database/sql" "encoding/json" + "strconv" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/kv" @@ -20,7 +21,7 @@ import ( // StoreSecret stores a secret at the specified path with its metadata and // versions. It performs the following operations atomically within a // transaction: -// - Updates the secret metadata (current version, creation time, update time) +// - Encrypts and updates the secret metadata (current version, creation time, update time) // - Stores all secret versions with their respective data encrypted using // AES-GCM // @@ -65,17 +66,49 @@ func (s *DataStore) StoreSecret( } } }(tx) + nonce, nonceErr := generateNonce(s) + if nonceErr != nil { + return sdkErrors.ErrCryptoNonceGenerationFailed.Wrap(nonceErr) + } + // time.Time → []byte (Unix seconds as string) + createdBytes := []byte(strconv.FormatInt(secret.Metadata.CreatedTime.Unix(), 10)) + updatedBytes := []byte(strconv.FormatInt(secret.Metadata.UpdatedTime.Unix(), 10)) + + // int → []byte (decimal string) + currentVersionBytes := []byte(strconv.Itoa(secret.Metadata.CurrentVersion)) + oldestVersionBytes := []byte(strconv.Itoa(secret.Metadata.OldestVersion)) + maxVersionsBytes := []byte(strconv.Itoa(secret.Metadata.MaxVersions)) + // Encrypt metadata + encryptedCurrentVersion, encryptErr := encryptWithNonce(s, nonce, currentVersionBytes) + if encryptErr != nil { + return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr) + } + encryptedOldestVersion, encryptErr := encryptWithNonce(s, nonce, oldestVersionBytes) + if encryptErr != nil { + return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr) + } + encryptedMaxVersions, encryptErr := encryptWithNonce(s, nonce, maxVersionsBytes) + if encryptErr != nil { + return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr) + } + encryptedCreatedTime, encryptErr := encryptWithNonce(s, nonce, createdBytes) + if encryptErr != nil { + return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr) + } + encryptedUpdatedTime, encryptErr := encryptWithNonce(s, nonce, updatedBytes) + if encryptErr != nil { + return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr) + } // Update metadata _, err = tx.ExecContext(ctx, ddl.QueryUpdateSecretMetadata, - path, secret.Metadata.CurrentVersion, secret.Metadata.OldestVersion, - secret.Metadata.CreatedTime, - secret.Metadata.UpdatedTime, secret.Metadata.MaxVersions, + path, nonce, encryptedCurrentVersion, encryptedOldestVersion, + encryptedCreatedTime, + encryptedUpdatedTime, encryptedMaxVersions, ) if err != nil { return sdkErrors.ErrEntityQueryFailed.Wrap(err) } - // Update versions for version, sv := range secret.Versions { md, marshalErr := json.Marshal(sv.Data) diff --git a/app/nexus/internal/state/backend/sqlite/persist/secret_load.go b/app/nexus/internal/state/backend/sqlite/persist/secret_load.go index 9038eb81..5b231aca 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/secret_load.go +++ b/app/nexus/internal/state/backend/sqlite/persist/secret_load.go @@ -9,6 +9,7 @@ import ( "database/sql" "encoding/json" "errors" + "strconv" "time" sdkErrors "github.com/spiffe/spike-sdk-go/errors" @@ -21,9 +22,9 @@ import ( // loadSecretInternal retrieves a secret and all its versions from the database // for the specified path. It performs the actual database operations including -// loading metadata, fetching all versions, and decrypting the secret data. +// loading and decrypting metadata, fetching all versions, and decrypting the secret data. // -// The function first queries for secret metadata (current version, timestamps), +// The function first queries and decrypts secret metadata (current version, timestamps), // then retrieves all versions of the secret, decrypts each version, and // reconstructs the complete secret structure. // @@ -49,6 +50,7 @@ import ( // // The function handles the following operations: // 1. Queries secret metadata from the secret_metadata table +// 2. Decrypts secret metadata // 2. Fetches all versions from the secrets table // 3. Decrypts each version using the DataStore's cipher // 4. Unmarshals JSON data into a map[string]string format @@ -61,14 +63,24 @@ func (s *DataStore) loadSecretInternal( validation.NonNilContextOrDie(ctx, fName) var secret kv.Value + var ( + metaNonce []byte + encryptedCurrentVersion []byte + encryptedOldestVersion []byte + encryptedCreatedTime []byte + encryptedUpdatedTime []byte + encryptedMaxVersions []byte + ) // Load metadata metaErr := s.db.QueryRowContext(ctx, ddl.QuerySecretMetadata, path).Scan( - &secret.Metadata.CurrentVersion, - &secret.Metadata.OldestVersion, - &secret.Metadata.CreatedTime, - &secret.Metadata.UpdatedTime, - &secret.Metadata.MaxVersions) + &metaNonce, + &encryptedCurrentVersion, + &encryptedOldestVersion, + &encryptedCreatedTime, + &encryptedUpdatedTime, + &encryptedMaxVersions, + ) if metaErr != nil { if errors.Is(metaErr, sql.ErrNoRows) { return nil, sdkErrors.ErrEntityNotFound @@ -77,6 +89,40 @@ func (s *DataStore) loadSecretInternal( return nil, sdkErrors.ErrEntityLoadFailed } + // Decrypt metadata + currentVersionBytes, decryptErr := s.decrypt(encryptedCurrentVersion, metaNonce) + if decryptErr != nil { + return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr) + } + oldestVersionBytes, decryptErr := s.decrypt(encryptedOldestVersion, metaNonce) + if decryptErr != nil { + return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr) + } + createdBytes, decryptErr := s.decrypt(encryptedCreatedTime, metaNonce) + if decryptErr != nil { + return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr) + } + updatedBytes, decryptErr := s.decrypt(encryptedUpdatedTime, metaNonce) + if decryptErr != nil { + return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr) + } + maxVersionsBytes, decryptErr := s.decrypt(encryptedMaxVersions, metaNonce) + if decryptErr != nil { + return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr) + } + + // Decode into struct + secret.Metadata.CurrentVersion, _ = strconv.Atoi(string(currentVersionBytes)) + secret.Metadata.OldestVersion, _ = strconv.Atoi(string(oldestVersionBytes)) + + createdSec, _ := strconv.ParseInt(string(createdBytes), 10, 64) + updatedSec, _ := strconv.ParseInt(string(updatedBytes), 10, 64) + + secret.Metadata.CreatedTime = time.Unix(createdSec, 0) + secret.Metadata.UpdatedTime = time.Unix(updatedSec, 0) + + secret.Metadata.MaxVersions, _ = strconv.Atoi(string(maxVersionsBytes)) + // Load versions rows, queryErr := s.db.QueryContext(ctx, ddl.QuerySecretVersions, path) if queryErr != nil { diff --git a/app/nexus/internal/state/backend/sqlite/persist/secret_load_sqlite_test.go b/app/nexus/internal/state/backend/sqlite/persist/secret_load_sqlite_test.go index 5f0518c8..f4af4335 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/secret_load_sqlite_test.go +++ b/app/nexus/internal/state/backend/sqlite/persist/secret_load_sqlite_test.go @@ -251,11 +251,14 @@ func TestDataStore_loadSecretInternal_EmptyVersionsResult(t *testing.T) { createdTime := time.Now().Add(-24 * time.Hour).Truncate(time.Second) updatedTime := time.Now().Add(-1 * time.Hour).Truncate(time.Second) - _, err := store.db.ExecContext(ctx, ddl.QueryUpdateSecretMetadata, - path, 1, 1, createdTime, updatedTime, 5) - if err != nil { - t.Fatalf("Failed to insert metadata: %v", err) + metadata := TestSecretMetadata{ + CurrentVersion: 1, + OldestVersion: 1, + MaxVersions: 5, + CreatedTime: createdTime, + UpdatedTime: updatedTime, } + insertEncryptedMetadata(ctx, t, store, path, metadata) // Execute the function secret, loadErr := store.loadSecretInternal(ctx, path) @@ -290,19 +293,22 @@ func TestDataStore_loadSecretInternal_CorruptedData(t *testing.T) { createdTime := time.Now().Add(-24 * time.Hour).Truncate(time.Second) updatedTime := time.Now().Add(-1 * time.Hour).Truncate(time.Second) - // Insert metadata - _, err := store.db.ExecContext(ctx, ddl.QueryUpdateSecretMetadata, - path, 1, 1, createdTime, updatedTime, 5) - if err != nil { - t.Fatalf("Failed to insert metadata: %v", err) + metadata := TestSecretMetadata{ + CurrentVersion: 1, + OldestVersion: 1, + MaxVersions: 5, + CreatedTime: createdTime, + UpdatedTime: updatedTime, } + // Insert metadata + insertEncryptedMetadata(ctx, t, store, path, metadata) // Insert corrupted version data (invalid nonce/encrypted combination) invalidNonce := make([]byte, store.Cipher.NonceSize()) invalidEncrypted := []byte("invalid encrypted data that will fail decryption") versionCreatedTime := createdTime.Add(1 * time.Hour) - _, err = store.db.ExecContext(ctx, ddl.QueryUpsertSecret, + _, err := store.db.ExecContext(ctx, ddl.QueryUpsertSecret, path, 1, invalidNonce, invalidEncrypted, versionCreatedTime, nil) if err != nil { t.Fatalf("Failed to insert corrupted version: %v", err) diff --git a/app/nexus/internal/state/backend/sqlite/persist/testing_helper.go b/app/nexus/internal/state/backend/sqlite/persist/testing_helper.go index 576c7f98..e359ac10 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/testing_helper.go +++ b/app/nexus/internal/state/backend/sqlite/persist/testing_helper.go @@ -12,6 +12,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "testing" "time" @@ -127,13 +128,7 @@ func storeTestSecretDirectly(t TestingInterface, store *DataStore, path string, versions map[int]map[string]string, metadata TestSecretMetadata) { ctx := context.Background() - // Insert metadata - _, metaErr := store.db.ExecContext(ctx, ddl.QueryUpdateSecretMetadata, - path, metadata.CurrentVersion, metadata.OldestVersion, - metadata.CreatedTime, metadata.UpdatedTime, metadata.MaxVersions) - if metaErr != nil { - t.Fatalf("Failed to insert metadata: %v", metaErr) - } + insertEncryptedMetadata(ctx, t, store, path, metadata) // Insert versions for version, data := range versions { @@ -171,3 +166,27 @@ func storeTestSecretDirectly(t TestingInterface, store *DataStore, path string, } } } + +// insertEncryptedMetadata encrypts the metadata fields with a shared nonce +// and inserts the encrypted values into the secret_metadata table +func insertEncryptedMetadata(ctx context.Context, t TestingInterface, store *DataStore, path string, metadata TestSecretMetadata) { + metaNonce := make([]byte, store.Cipher.NonceSize()) + if _, randErr := rand.Read(metaNonce); randErr != nil { + t.Fatalf("Failed to generate metadata nonce: %v", randErr) + } + // Encrypt metadata + encryptedCurrentversion := store.Cipher.Seal(nil, metaNonce, []byte(strconv.Itoa(metadata.CurrentVersion)), nil) + encryptedOldestVersion := store.Cipher.Seal(nil, metaNonce, []byte(strconv.Itoa(metadata.OldestVersion)), nil) + encryptedMaxVersions := store.Cipher.Seal(nil, metaNonce, []byte(strconv.Itoa(metadata.MaxVersions)), nil) + + encryptedCreatedTime := store.Cipher.Seal(nil, metaNonce, []byte(strconv.FormatInt(metadata.CreatedTime.Unix(), 10)), nil) + encryptedUpdatedTime := store.Cipher.Seal(nil, metaNonce, []byte(strconv.FormatInt(metadata.UpdatedTime.Unix(), 10)), nil) + + // Insert metadata + _, execErr := store.db.ExecContext(ctx, ddl.QueryUpdateSecretMetadata, + path, metaNonce, encryptedCurrentversion, encryptedOldestVersion, encryptedCreatedTime, encryptedUpdatedTime, encryptedMaxVersions, + ) + if execErr != nil { + t.Fatalf("Failed to insert metadata: %v", execErr) + } +} From 0a0d8cf587095206747c9f7889ffa296c700f1e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ya=C4=9Fmur=20=C3=87i=C3=A7ekda=C4=9F=C4=B1?= Date: Thu, 22 Jan 2026 16:36:20 +0000 Subject: [PATCH 2/2] fix(secret): use derived per field nonces for metadata encrypt/decrypt operations --- .../backend/sqlite/persist/field_nonce.go | 118 ++++++++++++++++++ .../state/backend/sqlite/persist/secret.go | 24 +++- .../backend/sqlite/persist/secret_load.go | 26 ++-- .../backend/sqlite/persist/testing_helper.go | 49 ++++++-- 4 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 app/nexus/internal/state/backend/sqlite/persist/field_nonce.go diff --git a/app/nexus/internal/state/backend/sqlite/persist/field_nonce.go b/app/nexus/internal/state/backend/sqlite/persist/field_nonce.go new file mode 100644 index 00000000..b3c4fd17 --- /dev/null +++ b/app/nexus/internal/state/backend/sqlite/persist/field_nonce.go @@ -0,0 +1,118 @@ +// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ +// \\\\\ Copyright 2024-present SPIKE contributors. +// \\\\\\\ SPDX-License-Identifier: Apache-2.0 + +package persist + +import ( + "fmt" + + sdkErrors "github.com/spiffe/spike-sdk-go/errors" +) + +const ( + nonceFieldSecretMetadataCurrentVersion string = "secret_metadata.current_version" + nonceFieldSecretMetadataOldestVersion string = "secret_metadata.oldest_version" + nonceFieldSecretMetadataCreatedTime string = "secret_metadata.created_time" + nonceFieldSecretMetadataUpdatedTime string = "secret_metadata.updated_time" + nonceFieldSecretMetadataMaxVersions string = "secret_metadata.max_versions" +) + +// fieldNonceSalts are fixed per-field salts (must match AES-GCM nonce size). +// A per-field nonce is derived as baseNonce XOR salt. +var fieldNonceSalts = map[string][]byte{ + nonceFieldSecretMetadataCurrentVersion: []byte("current_ver_"), // 12 bytes + nonceFieldSecretMetadataOldestVersion: []byte("oldest_ver__"), // 12 bytes + nonceFieldSecretMetadataCreatedTime: []byte("created_tim_"), // 12 bytes + nonceFieldSecretMetadataUpdatedTime: []byte("updated_tim_"), // 12 bytes + nonceFieldSecretMetadataMaxVersions: []byte("max_versions"), // 12 bytes +} + +// deriveFieldNonce derives a per-field AES-GCM nonce from a base nonce by +// XOR'ing it with a fixed, field-specific salt. This enables using a single +// per-row base nonce while still ensuring each encrypted field uses a distinct +// nonce. +// +// Parameters: +// - baseNonce: The base nonce to derive from. Its length must match the +// cipher's required nonce size and the salt length for the given field. +// - field: The field identifier used to select the derivation salt. +// +// Returns: +// - []byte: The derived nonce for the given field. +// - *sdkErrors.SDKError: An error if the field is unknown +// (ErrEntityInvalid) or if the nonce size does not match +// (ErrCryptoNonceSizeMismatch). Returns nil on success. +func deriveFieldNonce(baseNonce []byte, field string) ([]byte, *sdkErrors.SDKError) { + salt, ok := fieldNonceSalts[field] + if !ok { + failErr := *sdkErrors.ErrEntityInvalid.Clone() + failErr.Msg = fmt.Sprintf("unknown nonce derivation field: %q", field) + return nil, &failErr + } + + if len(baseNonce) != len(salt) { + failErr := *sdkErrors.ErrCryptoNonceSizeMismatch.Clone() + failErr.Msg = fmt.Sprintf( + "invalid nonce size for field %q: got %d, want %d", + field, len(baseNonce), len(salt), + ) + return nil, &failErr + } + + derived := make([]byte, len(baseNonce)) + for i := range baseNonce { + derived[i] = baseNonce[i] ^ salt[i] + } + return derived, nil +} + +// encryptWithDerivedNonce encrypts data using a nonce derived from the +// provided base nonce and field identifier. The derived nonce is produced by +// applying the field-specific XOR salt in deriveFieldNonce. +// +// Parameters: +// - s: The DataStore containing the AES-GCM cipher for encryption. +// - baseNonce: The base nonce used to derive the per-field nonce. +// - field: The field identifier used to select the derivation salt. +// - data: The plaintext data to encrypt. +// +// Returns: +// - []byte: The encrypted ciphertext. +// - *sdkErrors.SDKError: An error if nonce derivation fails or if encryption +// fails. Returns nil on success. +func encryptWithDerivedNonce( + s *DataStore, baseNonce []byte, field string, data []byte, +) ([]byte, *sdkErrors.SDKError) { + derivedNonce, deriveErr := deriveFieldNonce(baseNonce, field) + if deriveErr != nil { + return nil, deriveErr + } + + return encryptWithNonce(s, derivedNonce, data) +} + +// decryptWithDerivedNonce decrypts ciphertext using a nonce derived from the +// provided base nonce and field identifier. The derived nonce is produced by +// applying the field-specific XOR salt in deriveFieldNonce. +// +// Parameters: +// - s: The DataStore containing the AES-GCM cipher for decryption. +// - baseNonce: The base nonce used to derive the per-field nonce. +// - field: The field identifier used to select the derivation salt. +// - ciphertext: The encrypted data to decrypt. +// +// Returns: +// - []byte: The decrypted plaintext. +// - *sdkErrors.SDKError: An error if nonce derivation fails or if decryption +// fails. Returns nil on success. +func decryptWithDerivedNonce( + s *DataStore, baseNonce []byte, field string, ciphertext []byte, +) ([]byte, *sdkErrors.SDKError) { + derivedNonce, deriveErr := deriveFieldNonce(baseNonce, field) + if deriveErr != nil { + return nil, deriveErr + } + + return s.decrypt(ciphertext, derivedNonce) +} diff --git a/app/nexus/internal/state/backend/sqlite/persist/secret.go b/app/nexus/internal/state/backend/sqlite/persist/secret.go index 38e38a9d..0531a54a 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/secret.go +++ b/app/nexus/internal/state/backend/sqlite/persist/secret.go @@ -66,10 +66,12 @@ func (s *DataStore) StoreSecret( } } }(tx) + nonce, nonceErr := generateNonce(s) if nonceErr != nil { return sdkErrors.ErrCryptoNonceGenerationFailed.Wrap(nonceErr) } + // time.Time → []byte (Unix seconds as string) createdBytes := []byte(strconv.FormatInt(secret.Metadata.CreatedTime.Unix(), 10)) updatedBytes := []byte(strconv.FormatInt(secret.Metadata.UpdatedTime.Unix(), 10)) @@ -79,24 +81,34 @@ func (s *DataStore) StoreSecret( oldestVersionBytes := []byte(strconv.Itoa(secret.Metadata.OldestVersion)) maxVersionsBytes := []byte(strconv.Itoa(secret.Metadata.MaxVersions)) - // Encrypt metadata - encryptedCurrentVersion, encryptErr := encryptWithNonce(s, nonce, currentVersionBytes) + // Encrypt metadata using a derived per-field nonce. + encryptedCurrentVersion, encryptErr := encryptWithDerivedNonce( + s, nonce, nonceFieldSecretMetadataCurrentVersion, currentVersionBytes, + ) if encryptErr != nil { return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr) } - encryptedOldestVersion, encryptErr := encryptWithNonce(s, nonce, oldestVersionBytes) + encryptedOldestVersion, encryptErr := encryptWithDerivedNonce( + s, nonce, nonceFieldSecretMetadataOldestVersion, oldestVersionBytes, + ) if encryptErr != nil { return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr) } - encryptedMaxVersions, encryptErr := encryptWithNonce(s, nonce, maxVersionsBytes) + encryptedMaxVersions, encryptErr := encryptWithDerivedNonce( + s, nonce, nonceFieldSecretMetadataMaxVersions, maxVersionsBytes, + ) if encryptErr != nil { return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr) } - encryptedCreatedTime, encryptErr := encryptWithNonce(s, nonce, createdBytes) + encryptedCreatedTime, encryptErr := encryptWithDerivedNonce( + s, nonce, nonceFieldSecretMetadataCreatedTime, createdBytes, + ) if encryptErr != nil { return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr) } - encryptedUpdatedTime, encryptErr := encryptWithNonce(s, nonce, updatedBytes) + encryptedUpdatedTime, encryptErr := encryptWithDerivedNonce( + s, nonce, nonceFieldSecretMetadataUpdatedTime, updatedBytes, + ) if encryptErr != nil { return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr) } diff --git a/app/nexus/internal/state/backend/sqlite/persist/secret_load.go b/app/nexus/internal/state/backend/sqlite/persist/secret_load.go index 5b231aca..cbbf7dfe 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/secret_load.go +++ b/app/nexus/internal/state/backend/sqlite/persist/secret_load.go @@ -64,7 +64,7 @@ func (s *DataStore) loadSecretInternal( var secret kv.Value var ( - metaNonce []byte + nonce []byte encryptedCurrentVersion []byte encryptedOldestVersion []byte encryptedCreatedTime []byte @@ -74,7 +74,7 @@ func (s *DataStore) loadSecretInternal( // Load metadata metaErr := s.db.QueryRowContext(ctx, ddl.QuerySecretMetadata, path).Scan( - &metaNonce, + &nonce, &encryptedCurrentVersion, &encryptedOldestVersion, &encryptedCreatedTime, @@ -89,24 +89,34 @@ func (s *DataStore) loadSecretInternal( return nil, sdkErrors.ErrEntityLoadFailed } - // Decrypt metadata - currentVersionBytes, decryptErr := s.decrypt(encryptedCurrentVersion, metaNonce) + // Decrypt metadata using per-field derived nonces. + currentVersionBytes, decryptErr := decryptWithDerivedNonce( + s, nonce, nonceFieldSecretMetadataCurrentVersion, encryptedCurrentVersion, + ) if decryptErr != nil { return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr) } - oldestVersionBytes, decryptErr := s.decrypt(encryptedOldestVersion, metaNonce) + oldestVersionBytes, decryptErr := decryptWithDerivedNonce( + s, nonce, nonceFieldSecretMetadataOldestVersion, encryptedOldestVersion, + ) if decryptErr != nil { return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr) } - createdBytes, decryptErr := s.decrypt(encryptedCreatedTime, metaNonce) + createdBytes, decryptErr := decryptWithDerivedNonce( + s, nonce, nonceFieldSecretMetadataCreatedTime, encryptedCreatedTime, + ) if decryptErr != nil { return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr) } - updatedBytes, decryptErr := s.decrypt(encryptedUpdatedTime, metaNonce) + updatedBytes, decryptErr := decryptWithDerivedNonce( + s, nonce, nonceFieldSecretMetadataUpdatedTime, encryptedUpdatedTime, + ) if decryptErr != nil { return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr) } - maxVersionsBytes, decryptErr := s.decrypt(encryptedMaxVersions, metaNonce) + maxVersionsBytes, decryptErr := decryptWithDerivedNonce( + s, nonce, nonceFieldSecretMetadataMaxVersions, encryptedMaxVersions, + ) if decryptErr != nil { return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr) } diff --git a/app/nexus/internal/state/backend/sqlite/persist/testing_helper.go b/app/nexus/internal/state/backend/sqlite/persist/testing_helper.go index e359ac10..a91ef401 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/testing_helper.go +++ b/app/nexus/internal/state/backend/sqlite/persist/testing_helper.go @@ -167,24 +167,53 @@ func storeTestSecretDirectly(t TestingInterface, store *DataStore, path string, } } -// insertEncryptedMetadata encrypts the metadata fields with a shared nonce +// insertEncryptedMetadata is a helper function that encrypts the metadata fields with per-field nonces // and inserts the encrypted values into the secret_metadata table func insertEncryptedMetadata(ctx context.Context, t TestingInterface, store *DataStore, path string, metadata TestSecretMetadata) { - metaNonce := make([]byte, store.Cipher.NonceSize()) - if _, randErr := rand.Read(metaNonce); randErr != nil { + nonce := make([]byte, store.Cipher.NonceSize()) + if _, randErr := rand.Read(nonce); randErr != nil { t.Fatalf("Failed to generate metadata nonce: %v", randErr) } // Encrypt metadata - encryptedCurrentversion := store.Cipher.Seal(nil, metaNonce, []byte(strconv.Itoa(metadata.CurrentVersion)), nil) - encryptedOldestVersion := store.Cipher.Seal(nil, metaNonce, []byte(strconv.Itoa(metadata.OldestVersion)), nil) - encryptedMaxVersions := store.Cipher.Seal(nil, metaNonce, []byte(strconv.Itoa(metadata.MaxVersions)), nil) - - encryptedCreatedTime := store.Cipher.Seal(nil, metaNonce, []byte(strconv.FormatInt(metadata.CreatedTime.Unix(), 10)), nil) - encryptedUpdatedTime := store.Cipher.Seal(nil, metaNonce, []byte(strconv.FormatInt(metadata.UpdatedTime.Unix(), 10)), nil) + encryptedCurrentversion, encryptErr := encryptWithDerivedNonce( + store, nonce, nonceFieldSecretMetadataCurrentVersion, + []byte(strconv.Itoa(metadata.CurrentVersion)), + ) + if encryptErr != nil { + t.Fatalf("Failed to encrypt current version: %v", encryptErr) + } + encryptedOldestVersion, encryptErr := encryptWithDerivedNonce( + store, nonce, nonceFieldSecretMetadataOldestVersion, + []byte(strconv.Itoa(metadata.OldestVersion)), + ) + if encryptErr != nil { + t.Fatalf("Failed to encrypt oldest version: %v", encryptErr) + } + encryptedMaxVersions, encryptErr := encryptWithDerivedNonce( + store, nonce, nonceFieldSecretMetadataMaxVersions, + []byte(strconv.Itoa(metadata.MaxVersions)), + ) + if encryptErr != nil { + t.Fatalf("Failed to encrypt max versions: %v", encryptErr) + } + encryptedCreatedTime, encryptErr := encryptWithDerivedNonce( + store, nonce, nonceFieldSecretMetadataCreatedTime, + []byte(strconv.FormatInt(metadata.CreatedTime.Unix(), 10)), + ) + if encryptErr != nil { + t.Fatalf("Failed to encrypt created time: %v", encryptErr) + } + encryptedUpdatedTime, encryptErr := encryptWithDerivedNonce( + store, nonce, nonceFieldSecretMetadataUpdatedTime, + []byte(strconv.FormatInt(metadata.UpdatedTime.Unix(), 10)), + ) + if encryptErr != nil { + t.Fatalf("Failed to encrypt updated time: %v", encryptErr) + } // Insert metadata _, execErr := store.db.ExecContext(ctx, ddl.QueryUpdateSecretMetadata, - path, metaNonce, encryptedCurrentversion, encryptedOldestVersion, encryptedCreatedTime, encryptedUpdatedTime, encryptedMaxVersions, + path, nonce, encryptedCurrentversion, encryptedOldestVersion, encryptedCreatedTime, encryptedUpdatedTime, encryptedMaxVersions, ) if execErr != nil { t.Fatalf("Failed to insert metadata: %v", execErr)