Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 20 additions & 17 deletions app/nexus/internal/state/backend/sqlite/ddl/statement.go
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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`
Expand All @@ -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
Copy link
Contributor

@v0lkan v0lkan Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a single nonce per field would be cryptographically weak and it can cause warnings in security audits.

While I do realize that the data here itself is not super-sensitive, auditors typically want to "check boxes", rather than having a pragmatic perspective on security.

My suggestion is as follows:

Derive per-field nonces from the stored base nonce without changing the schema:

  var fieldNonceSalts = map[string][]byte{
      "current_version": []byte("current_ver_"),  // 12 bytes each
      "oldest_version":  []byte("oldest_ver__"),
      "created_time":    []byte("created_tim_"),
      // ...
  }

  func deriveFieldNonce(baseNonce []byte, field string) []byte {
      derived := make([]byte, len(baseNonce))
      salt := fieldNonceSalts[field]
      for i := range baseNonce {
          derived[i] = baseNonce[i] ^ salt[i]
      }
      return derived
  }
                                                                                                                                                                                                          This gives each field a unique nonce while keeping a single nonce column in the DB. Decryption remains deterministic since the derivation is reversible.

One related question would be "what if an attacker knows the per-field key" (since they have access to the source code, as the code is public).
Kerckhoffs's principle states that "A cryptosystem should be secure even if everything except the key is public knowledge".

In AES-GCM:

  • Key → must be secret (this is your root key)
  • Nonce → must be unique per encryption, but can be public

The nonce is typically stored in plaintext right next to the ciphertext (which we are already doing). An attacker seeing the nonce doesn't help them—they still can't decrypt without the key.

The per-field salts being in the source code is fine because:

  1. The base nonce is random (generated via crypto/rand)
  2. XORing with known constants just ensures each field's nonce differs
  3. The actual security comes entirely from the encryption key

As an aside, nonces don't need to be secret—they only need to be unique.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review!

My initial thought was to generate unique nonces per field, but as you mentioned, this would be an overkill, so I opted for the single nonce approach—referring to how policies are encrypted in app/nexus/internal/state/backend/sqlite/persist/policy.go.

I'm also just curious: Is there a specific reason why single nonce per field approach is used in policies? Wouldn't that be cryptographically weak as well?

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
`
Expand Down Expand Up @@ -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 = ?
`

Expand Down
118 changes: 118 additions & 0 deletions app/nexus/internal/state/backend/sqlite/persist/field_nonce.go
Original file line number Diff line number Diff line change
@@ -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)
}
55 changes: 50 additions & 5 deletions app/nexus/internal/state/backend/sqlite/persist/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
//
Expand Down Expand Up @@ -66,16 +67,60 @@ 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 using a derived per-field nonce.
encryptedCurrentVersion, encryptErr := encryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataCurrentVersion, currentVersionBytes,
)
if encryptErr != nil {
return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr)
}
encryptedOldestVersion, encryptErr := encryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataOldestVersion, oldestVersionBytes,
)
if encryptErr != nil {
return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr)
}
encryptedMaxVersions, encryptErr := encryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataMaxVersions, maxVersionsBytes,
)
if encryptErr != nil {
return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr)
}
encryptedCreatedTime, encryptErr := encryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataCreatedTime, createdBytes,
)
if encryptErr != nil {
return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr)
}
encryptedUpdatedTime, encryptErr := encryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataUpdatedTime, 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)
Expand Down
70 changes: 63 additions & 7 deletions app/nexus/internal/state/backend/sqlite/persist/secret_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"strconv"
"time"

sdkErrors "github.com/spiffe/spike-sdk-go/errors"
Expand All @@ -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.
//
Expand All @@ -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
Expand All @@ -61,14 +63,24 @@ func (s *DataStore) loadSecretInternal(
validation.NonNilContextOrDie(ctx, fName)

var secret kv.Value
var (
nonce []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)
&nonce,
&encryptedCurrentVersion,
&encryptedOldestVersion,
&encryptedCreatedTime,
&encryptedUpdatedTime,
&encryptedMaxVersions,
)
if metaErr != nil {
if errors.Is(metaErr, sql.ErrNoRows) {
return nil, sdkErrors.ErrEntityNotFound
Expand All @@ -77,6 +89,50 @@ func (s *DataStore) loadSecretInternal(
return nil, sdkErrors.ErrEntityLoadFailed
}

// 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 := decryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataOldestVersion, encryptedOldestVersion,
)
if decryptErr != nil {
return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr)
}
createdBytes, decryptErr := decryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataCreatedTime, encryptedCreatedTime,
)
if decryptErr != nil {
return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr)
}
updatedBytes, decryptErr := decryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataUpdatedTime, encryptedUpdatedTime,
)
if decryptErr != nil {
return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr)
}
maxVersionsBytes, decryptErr := decryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataMaxVersions, encryptedMaxVersions,
)
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 {
Expand Down
Loading
Loading