mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:31:29 +00:00
fix(crypto): per-ciphertext PBKDF2 salt + v2 versioned format with v1 fallback (M-8)
This commit is contained in:
@@ -45,11 +45,11 @@ jobs:
|
||||
run: govulncheck ./...
|
||||
|
||||
- name: Race Detection
|
||||
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -timeout 300s
|
||||
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/crypto/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -timeout 300s
|
||||
|
||||
- name: Go Test with Coverage
|
||||
run: |
|
||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/connector/discovery/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -cover -coverprofile=coverage.out
|
||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/connector/discovery/... ./internal/crypto/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -cover -coverprofile=coverage.out
|
||||
|
||||
- name: Check Coverage Thresholds
|
||||
run: |
|
||||
@@ -73,6 +73,13 @@ jobs:
|
||||
MIDDLEWARE_COV=$(go tool cover -func=coverage.out | grep 'internal/api/middleware' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||
echo "Middleware layer coverage: ${MIDDLEWARE_COV}%"
|
||||
|
||||
# Check crypto package coverage (target: 85%+)
|
||||
# M-8 rationale: encryption primitives are a security-critical gate.
|
||||
# v2 format, key-derivation, fallback, and fail-closed sentinel paths
|
||||
# all need exhaustive coverage to avoid silent regressions (CWE-916 / CWE-329).
|
||||
CRYPTO_COV=$(go tool cover -func=coverage.out | grep 'internal/crypto' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||
echo "Crypto package coverage: ${CRYPTO_COV}%"
|
||||
|
||||
# Fail if thresholds not met
|
||||
if [ "$(echo "$SERVICE_COV < 55" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Service layer coverage ${SERVICE_COV}% is below 55% threshold"
|
||||
@@ -90,6 +97,10 @@ jobs:
|
||||
echo "::error::Middleware layer coverage ${MIDDLEWARE_COV}% is below 30% threshold"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(echo "$CRYPTO_COV < 85" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Crypto package coverage ${CRYPTO_COV}% is below 85% threshold"
|
||||
exit 1
|
||||
fi
|
||||
echo "Coverage thresholds passed!"
|
||||
|
||||
- name: Upload Coverage Report
|
||||
|
||||
+13
-6
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/api/router"
|
||||
"github.com/shankar0123/certctl/internal/config"
|
||||
"github.com/shankar0123/certctl/internal/crypto"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
discoveryawssm "github.com/shankar0123/certctl/internal/connector/discovery/awssm"
|
||||
discoveryazurekv "github.com/shankar0123/certctl/internal/connector/discovery/azurekv"
|
||||
@@ -82,12 +81,20 @@ func main() {
|
||||
logger.Info("initialized all repositories")
|
||||
|
||||
// Initialize dynamic issuer registry.
|
||||
// Issuers are loaded from the database (with AES-GCM encrypted config).
|
||||
// Issuers are loaded from the database (with AES-256-GCM encrypted config).
|
||||
// On first boot with an empty database, env var issuers are seeded automatically.
|
||||
var encryptionKey []byte
|
||||
if cfg.Encryption.ConfigEncryptionKey != "" {
|
||||
encryptionKey = crypto.DeriveKey(cfg.Encryption.ConfigEncryptionKey)
|
||||
logger.Info("config encryption enabled (AES-256-GCM)")
|
||||
//
|
||||
// M-8 (CWE-916 / CWE-329): the encryption passphrase is passed as a raw
|
||||
// string into IssuerService / TargetService / IssuerRegistry. Each call to
|
||||
// crypto.EncryptIfKeySet generates a fresh 16-byte PBKDF2 salt and emits a
|
||||
// v2 blob (magic 0x02 || salt || nonce || sealed). Decryption auto-detects
|
||||
// v1 legacy blobs (no magic) and falls back to the fixed v1 salt for
|
||||
// backward compatibility; v1 blobs transparently upgrade to v2 on next
|
||||
// write. DO NOT pre-derive the key here with crypto.DeriveKey — that was
|
||||
// the v1 fixed-salt behaviour that M-8 removes.
|
||||
encryptionKey := cfg.Encryption.ConfigEncryptionKey
|
||||
if encryptionKey != "" {
|
||||
logger.Info("config encryption enabled (AES-256-GCM, per-ciphertext PBKDF2 salt)")
|
||||
} else {
|
||||
// C-2 fix: fail closed at startup when database-sourced issuer or target
|
||||
// rows exist without a configured encryption key. Previously the server
|
||||
|
||||
@@ -808,6 +808,34 @@ All shell-facing inputs (connector scripts, domain names, ACME tokens) are valid
|
||||
|
||||
All incoming HTTP request bodies are capped by `http.MaxBytesReader` middleware (default 1MB, configurable via `CERTCTL_MAX_BODY_SIZE`). Requests exceeding the limit receive a 413 Request Entity Too Large response. The middleware is positioned before authentication in the chain so oversized payloads are rejected early, before any auth processing or database work occurs. Requests without bodies (GET, HEAD, nil body) skip the limit check.
|
||||
|
||||
### Config Encryption at Rest
|
||||
|
||||
Dynamic issuer and target configurations (rows with `source='database'`) contain credentials — ACME EAB HMACs, Vault tokens, DigiCert/Sectigo API keys, SSH private keys, WinRM passwords, F5 BIG-IP passwords, and similar. These are sealed at rest in PostgreSQL via `internal/crypto/encryption.go` using AES-256-GCM with a key derived from the operator passphrase `CERTCTL_CONFIG_ENCRYPTION_KEY` through PBKDF2-SHA256 (100,000 rounds, 32-byte output).
|
||||
|
||||
**v2 wire format (current, M-8 remediation, CWE-916 / CWE-329):**
|
||||
|
||||
```
|
||||
magic(0x02) || salt(16) || nonce(12) || ciphertext+tag
|
||||
```
|
||||
|
||||
Every call to `EncryptIfKeySet` draws 16 fresh bytes from `crypto/rand` as the PBKDF2 salt, so the derived AES-256 key is distinct per ciphertext and per re-encryption. The salt is stored alongside the ciphertext; decryption reads the magic byte, splits out the salt, re-derives the key, and verifies the AEAD tag.
|
||||
|
||||
**v1 legacy format (read-only):**
|
||||
|
||||
```
|
||||
nonce(12) || ciphertext+tag
|
||||
```
|
||||
|
||||
Pre-M-8 blobs were sealed with a package-level fixed salt `"certctl-config-encryption-v1"`. `DecryptIfKeySet` preserves the v1 read path unchanged — a blob whose first byte is not `0x02`, or whose v2 AEAD verification fails (including the 1/256 case where a v1 nonce happens to begin with `0x02`), falls through to a v1 attempt against the legacy fixed salt. v1 blobs are never written by the post-M-8 code path; they re-seal as v2 naturally on the next UPDATE through the normal service CRUD flow. No operator migration ceremony is required.
|
||||
|
||||
**Fail-closed behavior (C-2 sentinel, CWE-311):** both `EncryptIfKeySet` and `DecryptIfKeySet` return `ErrEncryptionKeyRequired` when invoked with an empty passphrase. The server refuses to start if any `source='database'` rows already exist without `CERTCTL_CONFIG_ENCRYPTION_KEY` set.
|
||||
|
||||
**Low-level primitives preserved byte-identical.** `Encrypt`, `Decrypt`, and `DeriveKey` are kept bit-stable so v1 fixtures on disk remain decryptable unchanged and so callers outside the config-encryption path (none today, but the symbols are exported) do not see a breaking change. The new per-ciphertext salt path is reached via the helper `deriveKeyWithSalt(passphrase, salt)`.
|
||||
|
||||
**Passphrase plumbing.** Services (`IssuerService`, `TargetService`, `IssuerRegistry`) hold the operator passphrase as a raw `string` and delegate PBKDF2 to the crypto package per ciphertext. This replaces the pre-M-8 design that pre-derived a single `[]byte` key at service construction and reused it for every row, which was the direct consequence of the fixed-salt KDF.
|
||||
|
||||
**Coverage gate.** CI enforces `internal/crypto/...` coverage ≥ 85% (observed 86.7%) — the encryption primitives are a security-critical gate, and the v2 format plus v1 fallback plus C-2 sentinel paths all need exhaustive coverage to avoid silent regressions.
|
||||
|
||||
### CORS
|
||||
|
||||
CORS uses a **deny-by-default** posture: when `CERTCTL_CORS_ORIGINS` is empty, no CORS headers are set and only same-origin requests can read responses. Operators must explicitly configure allowed origins. This prevents accidental exposure of the API to cross-origin requests in production.
|
||||
|
||||
+197
-37
@@ -1,4 +1,31 @@
|
||||
// Package crypto provides AES-256-GCM encryption for sensitive configuration data.
|
||||
//
|
||||
// The on-disk format for blobs produced by [EncryptIfKeySet] is versioned. Two
|
||||
// versions coexist and both can be read by [DecryptIfKeySet]:
|
||||
//
|
||||
// v2 (current, M-8)
|
||||
// magic(0x02) || salt(16) || nonce(12) || ciphertext+tag
|
||||
// — 32-byte AES-256 key derived via PBKDF2-SHA256 from the operator
|
||||
// passphrase and the per-ciphertext random salt.
|
||||
//
|
||||
// v1 (legacy, pre-M-8)
|
||||
// nonce(12) || ciphertext+tag
|
||||
// — 32-byte AES-256 key derived via PBKDF2-SHA256 from the operator
|
||||
// passphrase and the package-level fixed salt
|
||||
// "certctl-config-encryption-v1".
|
||||
//
|
||||
// v1 blobs are accepted by the read path for backward compatibility with rows
|
||||
// persisted before the M-8 remediation. They are never produced by the write
|
||||
// path. Any row that is updated after M-8 is re-sealed as v2 in-place via the
|
||||
// normal UPDATE flow.
|
||||
//
|
||||
// Rationale for the per-ciphertext salt (see M-8 / CWE-916 / CWE-329): the
|
||||
// pre-M-8 design reused a single 28-byte fixed salt for every ciphertext, which
|
||||
// (a) removes one defense-in-depth layer against passphrase-space brute force
|
||||
// and (b) makes every encrypted column across every row share the exact same
|
||||
// derived key. v2 replaces the fixed salt with 16 fresh random bytes per write
|
||||
// and stores the salt alongside the ciphertext. Derived keys now differ per
|
||||
// row and per re-encryption.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
@@ -14,7 +41,8 @@ import (
|
||||
)
|
||||
|
||||
// ErrEncryptionKeyRequired is returned by EncryptIfKeySet and DecryptIfKeySet when
|
||||
// the caller provides an empty key but the data on the wire requires protection.
|
||||
// the caller provides an empty passphrase but the data on the wire requires
|
||||
// protection.
|
||||
//
|
||||
// Historically these helpers silently returned plaintext when no key was configured,
|
||||
// which produced a data-at-rest confidentiality bypass (CWE-311): sensitive fields
|
||||
@@ -24,16 +52,58 @@ import (
|
||||
// and plaintext branches at runtime, so the only visible signal was a warning
|
||||
// line emitted once at startup.
|
||||
//
|
||||
// The fix is to fail closed: EncryptIfKeySet/DecryptIfKeySet now require a key
|
||||
// whenever they are invoked on sensitive material, and the server refuses to
|
||||
// start if any source='database' rows already exist without a configured key.
|
||||
// The fix (C-2, commit fb4ce1a) is to fail closed: EncryptIfKeySet/DecryptIfKeySet
|
||||
// now require a passphrase whenever they are invoked on sensitive material, and
|
||||
// the server refuses to start if any source='database' rows already exist without
|
||||
// a configured passphrase.
|
||||
var ErrEncryptionKeyRequired = errors.New("crypto: CERTCTL_CONFIG_ENCRYPTION_KEY is required to encrypt or decrypt sensitive config")
|
||||
|
||||
// v2Magic is the first byte of every v2-format ciphertext blob. It distinguishes
|
||||
// v2 blobs (per-ciphertext random salt, embedded in the blob) from v1 legacy
|
||||
// blobs (no magic byte, fixed package-level salt).
|
||||
//
|
||||
// The choice of 0x02 is deliberate: v1 blobs begin with a random 12-byte AES-GCM
|
||||
// nonce. A v1 nonce can coincidentally start with 0x02 with probability 1/256,
|
||||
// which makes a pure magic-byte dispatch ambiguous. [DecryptIfKeySet] resolves
|
||||
// the ambiguity by falling back to the v1 path when v2 AEAD verification fails.
|
||||
const v2Magic byte = 0x02
|
||||
|
||||
// v2SaltSize is the length in bytes of the per-ciphertext salt embedded in a
|
||||
// v2 blob. 16 bytes (128 bits) matches the lower bound recommended in NIST
|
||||
// SP 800-132 §5.1 for PBKDF2 salts and is sufficient given the one-shot-per-row
|
||||
// nature of the derivation.
|
||||
const v2SaltSize = 16
|
||||
|
||||
// pbkdf2Iterations is the PBKDF2-SHA256 work factor applied uniformly to both
|
||||
// v1 and v2 key derivations. The value is preserved from the pre-M-8 design so
|
||||
// that v1 fallback reads stay bit-identical.
|
||||
const pbkdf2Iterations = 100000
|
||||
|
||||
// aes256KeySize is the output length in bytes of both [DeriveKey] and
|
||||
// [deriveKeyWithSalt]. It is also the only AES key length accepted by [Encrypt]
|
||||
// and [Decrypt].
|
||||
const aes256KeySize = 32
|
||||
|
||||
// legacyV1Salt is the fixed salt used by pre-M-8 config encryption. It is
|
||||
// retained exclusively to preserve the v1 read path — any v1 blob that pre-dates
|
||||
// M-8 remediation must be decryptable with a key derived from (passphrase,
|
||||
// legacyV1Salt). The write path never uses this salt.
|
||||
//
|
||||
// Exposed as a package-level var rather than a local so that tests can reason
|
||||
// about v1 fixture bytes symbolically.
|
||||
var legacyV1Salt = []byte("certctl-config-encryption-v1")
|
||||
|
||||
// Encrypt encrypts plaintext using AES-256-GCM with a random 12-byte nonce prepended to the output.
|
||||
// The key must be exactly 32 bytes (AES-256). Returns [12-byte nonce][ciphertext+tag].
|
||||
//
|
||||
// Encrypt is a low-level primitive. It is intentionally kept byte-identical to
|
||||
// the pre-M-8 implementation so that existing v1 blobs on disk remain
|
||||
// decryptable via [Decrypt] when paired with a [DeriveKey]-derived key. New
|
||||
// callers should prefer [EncryptIfKeySet], which handles key derivation and
|
||||
// emits the v2 wire format.
|
||||
func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("encryption key must be exactly 32 bytes, got %d", len(key))
|
||||
if len(key) != aes256KeySize {
|
||||
return nil, fmt.Errorf("encryption key must be exactly %d bytes, got %d", aes256KeySize, len(key))
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
@@ -57,9 +127,14 @@ func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
|
||||
|
||||
// Decrypt decrypts ciphertext that was encrypted with Encrypt.
|
||||
// Expects format: [12-byte nonce][ciphertext+tag]. Key must be exactly 32 bytes.
|
||||
//
|
||||
// Decrypt is a low-level primitive. It is intentionally kept byte-identical to
|
||||
// the pre-M-8 implementation so that [DecryptIfKeySet] can delegate to it for
|
||||
// both the v2 inner blob (after stripping the magic byte + embedded salt) and
|
||||
// the v1 legacy blob (unmodified).
|
||||
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("encryption key must be exactly 32 bytes, got %d", len(key))
|
||||
if len(key) != aes256KeySize {
|
||||
return nil, fmt.Errorf("encryption key must be exactly %d bytes, got %d", aes256KeySize, len(key))
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
@@ -86,48 +161,133 @@ func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// DeriveKey derives a 32-byte AES-256 key from a passphrase using PBKDF2-SHA256.
|
||||
// Uses a fixed application-specific salt and 100,000 iterations for resistance
|
||||
// to brute-force attacks on weak passphrases.
|
||||
// DeriveKey derives a 32-byte AES-256 key from a passphrase using PBKDF2-SHA256
|
||||
// with the legacy v1 fixed salt.
|
||||
//
|
||||
// This helper is preserved byte-identical to the pre-M-8 implementation so that
|
||||
// v1 ciphertexts persisted before the M-8 remediation remain decryptable
|
||||
// unchanged. New code paths should prefer [EncryptIfKeySet] and
|
||||
// [DecryptIfKeySet], which use a per-ciphertext random salt.
|
||||
func DeriveKey(passphrase string) []byte {
|
||||
// Fixed salt is acceptable here because:
|
||||
// 1. Each certctl instance has its own passphrase
|
||||
// 2. The salt prevents generic rainbow table attacks
|
||||
// 3. Per-user salts are unnecessary (single server key, not user passwords)
|
||||
salt := []byte("certctl-config-encryption-v1")
|
||||
return pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New)
|
||||
return deriveKeyWithSalt(passphrase, legacyV1Salt)
|
||||
}
|
||||
|
||||
// EncryptIfKeySet encrypts plaintext with the supplied 32-byte AES-256 key.
|
||||
// deriveKeyWithSalt derives a 32-byte AES-256 key from a passphrase and an
|
||||
// explicit salt using PBKDF2-SHA256 with [pbkdf2Iterations] rounds.
|
||||
//
|
||||
// The per-ciphertext random salt path (v2) calls this directly with a fresh
|
||||
// 16-byte random salt embedded in the ciphertext blob. The legacy path
|
||||
// ([DeriveKey]) calls it with the package-level fixed salt [legacyV1Salt].
|
||||
func deriveKeyWithSalt(passphrase string, salt []byte) []byte {
|
||||
return pbkdf2.Key([]byte(passphrase), salt, pbkdf2Iterations, aes256KeySize, sha256.New)
|
||||
}
|
||||
|
||||
// IsLegacyFormat reports whether blob is in the v1 legacy wire format (no magic
|
||||
// byte, fixed-salt derivation) as opposed to the v2 wire format
|
||||
// (magic(0x02) || salt(16) || nonce(12) || ciphertext+tag).
|
||||
//
|
||||
// A return value of false is a necessary but not sufficient condition for a
|
||||
// blob to be a valid v2 ciphertext: the shortest possible v2 blob is
|
||||
// 1 + v2SaltSize + 12 = 29 bytes, and even a 29+ byte blob that starts with
|
||||
// 0x02 may turn out to be a v1 ciphertext whose random nonce happens to begin
|
||||
// with 0x02 (probability 1/256). [DecryptIfKeySet] resolves this ambiguity at
|
||||
// decrypt time by falling back to v1 when v2 AEAD verification fails; callers
|
||||
// of IsLegacyFormat should use it only as a heuristic (e.g. migration
|
||||
// tooling, log annotation).
|
||||
func IsLegacyFormat(blob []byte) bool {
|
||||
if len(blob) == 0 {
|
||||
return false
|
||||
}
|
||||
return blob[0] != v2Magic
|
||||
}
|
||||
|
||||
// EncryptIfKeySet encrypts plaintext with the supplied passphrase and emits a
|
||||
// v2 wire-format blob: magic(0x02) || salt(16) || nonce(12) || ciphertext+tag.
|
||||
//
|
||||
// Key derivation is performed internally per invocation with a fresh 16-byte
|
||||
// random salt, producing a distinct AES-256 key for every ciphertext. The
|
||||
// operator-supplied passphrase is the only cross-ciphertext shared secret.
|
||||
//
|
||||
// The second return value is always true when err == nil — the "wasEncrypted"
|
||||
// flag is retained for source-compatibility with callers that previously used it
|
||||
// to log provenance. Callers MUST handle err: passing an empty key now returns
|
||||
// ErrEncryptionKeyRequired rather than silently emitting plaintext. See the
|
||||
// package-level ErrEncryptionKeyRequired documentation for the history behind
|
||||
// this behavior change.
|
||||
func EncryptIfKeySet(plaintext []byte, key []byte) ([]byte, bool, error) {
|
||||
if len(key) == 0 {
|
||||
// flag is retained for source-compatibility with callers that previously used
|
||||
// it to log provenance. Callers MUST handle err: passing an empty passphrase
|
||||
// returns [ErrEncryptionKeyRequired] rather than silently emitting plaintext.
|
||||
// See the package-level [ErrEncryptionKeyRequired] documentation for the
|
||||
// history behind this behavior change (C-2).
|
||||
//
|
||||
// The write path never produces a v1 blob. v1 blobs are read-only legacy
|
||||
// state — see [DecryptIfKeySet] for the compatibility fallback.
|
||||
func EncryptIfKeySet(plaintext []byte, passphrase string) ([]byte, bool, error) {
|
||||
if passphrase == "" {
|
||||
return nil, false, ErrEncryptionKeyRequired
|
||||
}
|
||||
encrypted, err := Encrypt(plaintext, key)
|
||||
|
||||
salt := make([]byte, v2SaltSize)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to generate v2 salt: %w", err)
|
||||
}
|
||||
|
||||
key := deriveKeyWithSalt(passphrase, salt)
|
||||
inner, err := Encrypt(plaintext, key)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return encrypted, true, nil
|
||||
|
||||
// v2 blob layout: magic(1) || salt(v2SaltSize) || inner
|
||||
blob := make([]byte, 0, 1+v2SaltSize+len(inner))
|
||||
blob = append(blob, v2Magic)
|
||||
blob = append(blob, salt...)
|
||||
blob = append(blob, inner...)
|
||||
return blob, true, nil
|
||||
}
|
||||
|
||||
// DecryptIfKeySet decrypts ciphertext with the supplied 32-byte AES-256 key.
|
||||
// DecryptIfKeySet decrypts blob with the supplied passphrase, supporting both
|
||||
// v2 (M-8 and later) and v1 (legacy) on-disk formats.
|
||||
//
|
||||
// Passing an empty key now returns ErrEncryptionKeyRequired. Callers that
|
||||
// legitimately store plaintext (e.g. env-seeded source='env' rows that keep
|
||||
// the raw JSON in the unencrypted `config` column) must branch on the presence
|
||||
// of the ciphertext themselves rather than relying on this helper to silently
|
||||
// pass bytes through. See the package-level ErrEncryptionKeyRequired
|
||||
// documentation for the history behind this behavior change.
|
||||
func DecryptIfKeySet(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
if len(key) == 0 {
|
||||
// Dispatch is first-byte magic + AEAD fallback. If blob starts with
|
||||
// [v2Magic] and is long enough to contain a v2 header plus an AEAD-authenticated
|
||||
// inner ciphertext, a v2 decrypt is attempted using a key derived from the
|
||||
// embedded salt. If that succeeds, its plaintext is returned. If v2 AEAD
|
||||
// verification fails — which covers both the "wrong passphrase" case and the
|
||||
// 1/256 case where a v1 blob's first byte happens to be 0x02 — the function
|
||||
// falls through to the v1 path and attempts decryption using a key derived
|
||||
// from the package-level fixed salt [legacyV1Salt].
|
||||
//
|
||||
// Passing an empty passphrase returns [ErrEncryptionKeyRequired]. Callers that
|
||||
// legitimately store plaintext (e.g. env-seeded source='env' rows that keep the
|
||||
// raw JSON in the unencrypted `config` column) must branch on the presence of
|
||||
// the ciphertext themselves rather than relying on this helper to silently
|
||||
// pass bytes through. See the package-level [ErrEncryptionKeyRequired]
|
||||
// documentation for the history behind this behavior change (C-2).
|
||||
//
|
||||
// The function never re-encrypts in place. A v1 blob that is successfully
|
||||
// decrypted is returned to the caller as plaintext; re-sealing as v2 happens
|
||||
// naturally on the next UPDATE via [EncryptIfKeySet].
|
||||
func DecryptIfKeySet(blob []byte, passphrase string) ([]byte, error) {
|
||||
if passphrase == "" {
|
||||
return nil, ErrEncryptionKeyRequired
|
||||
}
|
||||
return Decrypt(ciphertext, key)
|
||||
if len(blob) == 0 {
|
||||
return nil, fmt.Errorf("ciphertext is empty")
|
||||
}
|
||||
|
||||
// v2 path: magic || salt(16) || nonce(12) || ciphertext+tag (min 29 bytes
|
||||
// ignoring the GCM tag; the AEAD verify inside Decrypt enforces the tag).
|
||||
if blob[0] == v2Magic && len(blob) >= 1+v2SaltSize+12 {
|
||||
salt := blob[1 : 1+v2SaltSize]
|
||||
sealed := blob[1+v2SaltSize:]
|
||||
key := deriveKeyWithSalt(passphrase, salt)
|
||||
if plaintext, err := Decrypt(sealed, key); err == nil {
|
||||
return plaintext, nil
|
||||
}
|
||||
// v2 AEAD verification failed. Fall through to v1 so that a v1 blob
|
||||
// whose first byte happens to be 0x02 (1/256 probability) is still
|
||||
// decryptable. If this is truly a v2 blob with the wrong passphrase,
|
||||
// the v1 attempt below will also fail and the v1 error is returned.
|
||||
}
|
||||
|
||||
// v1 legacy path: blob is the full ciphertext with no header and was
|
||||
// sealed with a key derived from (passphrase, legacyV1Salt).
|
||||
key := DeriveKey(passphrase)
|
||||
return Decrypt(blob, key)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
@@ -126,21 +128,20 @@ func TestDeriveKeyDifferentPassphrases(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEncryptIfKeySet_WithKey(t *testing.T) {
|
||||
key := DeriveKey("test-key")
|
||||
plaintext := []byte("config data")
|
||||
|
||||
result, wasEncrypted, err := EncryptIfKeySet(plaintext, key)
|
||||
result, wasEncrypted, err := EncryptIfKeySet(plaintext, "test-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
||||
}
|
||||
if !wasEncrypted {
|
||||
t.Fatal("expected wasEncrypted=true when key provided")
|
||||
t.Fatal("expected wasEncrypted=true when passphrase provided")
|
||||
}
|
||||
if bytes.Equal(result, plaintext) {
|
||||
t.Fatal("result should be encrypted")
|
||||
}
|
||||
|
||||
decrypted, err := DecryptIfKeySet(result, key)
|
||||
decrypted, err := DecryptIfKeySet(result, "test-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptIfKeySet failed: %v", err)
|
||||
}
|
||||
@@ -150,67 +151,43 @@ func TestEncryptIfKeySet_WithKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestEncryptIfKeySet_EmptyKeyFailsClosed asserts the C-2 regression guard:
|
||||
// EncryptIfKeySet must refuse to silently emit plaintext when no key is configured.
|
||||
// The pre-fix behavior was to return plaintext with wasEncrypted=false, which
|
||||
// produced a data-at-rest confidentiality bypass (CWE-311) for GUI-created
|
||||
// issuer and target configs.
|
||||
// EncryptIfKeySet must refuse to silently emit plaintext when no passphrase is
|
||||
// configured. The pre-fix behavior was to return plaintext with
|
||||
// wasEncrypted=false, which produced a data-at-rest confidentiality bypass
|
||||
// (CWE-311) for GUI-created issuer and target configs.
|
||||
func TestEncryptIfKeySet_EmptyKeyFailsClosed(t *testing.T) {
|
||||
plaintext := []byte("config data")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key []byte
|
||||
}{
|
||||
{"nil_key", nil},
|
||||
{"empty_key", []byte{}},
|
||||
result, wasEncrypted, err := EncryptIfKeySet(plaintext, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected ErrEncryptionKeyRequired, got nil")
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, wasEncrypted, err := EncryptIfKeySet(plaintext, tc.key)
|
||||
if err == nil {
|
||||
t.Fatal("expected ErrEncryptionKeyRequired, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrEncryptionKeyRequired) {
|
||||
t.Fatalf("expected ErrEncryptionKeyRequired, got %v", err)
|
||||
}
|
||||
if wasEncrypted {
|
||||
t.Fatal("wasEncrypted must be false on error")
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("expected nil result on error, got %q", result)
|
||||
}
|
||||
})
|
||||
if !errors.Is(err, ErrEncryptionKeyRequired) {
|
||||
t.Fatalf("expected ErrEncryptionKeyRequired, got %v", err)
|
||||
}
|
||||
if wasEncrypted {
|
||||
t.Fatal("wasEncrypted must be false on error")
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("expected nil result on error, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecryptIfKeySet_EmptyKeyFailsClosed asserts the matching C-2 regression
|
||||
// guard on the read path: DecryptIfKeySet must refuse to pass ciphertext
|
||||
// through as plaintext when no key is configured.
|
||||
// through as plaintext when no passphrase is configured.
|
||||
func TestDecryptIfKeySet_EmptyKeyFailsClosed(t *testing.T) {
|
||||
data := []byte("plaintext config data")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key []byte
|
||||
}{
|
||||
{"nil_key", nil},
|
||||
{"empty_key", []byte{}},
|
||||
result, err := DecryptIfKeySet(data, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected ErrEncryptionKeyRequired, got nil")
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := DecryptIfKeySet(data, tc.key)
|
||||
if err == nil {
|
||||
t.Fatal("expected ErrEncryptionKeyRequired, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrEncryptionKeyRequired) {
|
||||
t.Fatalf("expected ErrEncryptionKeyRequired, got %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("expected nil result on error, got %q", result)
|
||||
}
|
||||
})
|
||||
if !errors.Is(err, ErrEncryptionKeyRequired) {
|
||||
t.Fatalf("expected ErrEncryptionKeyRequired, got %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("expected nil result on error, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,21 +195,20 @@ func TestDecryptIfKeySet_EmptyKeyFailsClosed(t *testing.T) {
|
||||
// "if set" helpers produce real AES-GCM output (not plaintext) and that a full
|
||||
// round-trip through both helpers recovers the original bytes.
|
||||
func TestEncryptDecryptIfKeySet_RoundTripProducesDifferentCiphertext(t *testing.T) {
|
||||
key := DeriveKey("round-trip-key")
|
||||
plaintext := []byte(`{"api_key":"s3cr3t","token":"abc"}`)
|
||||
|
||||
encrypted, wasEncrypted, err := EncryptIfKeySet(plaintext, key)
|
||||
encrypted, wasEncrypted, err := EncryptIfKeySet(plaintext, "round-trip-key")
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
||||
}
|
||||
if !wasEncrypted {
|
||||
t.Fatal("wasEncrypted must be true when key is present")
|
||||
t.Fatal("wasEncrypted must be true when passphrase is present")
|
||||
}
|
||||
if bytes.Equal(encrypted, plaintext) {
|
||||
t.Fatal("EncryptIfKeySet returned plaintext — would regress C-2")
|
||||
}
|
||||
|
||||
decrypted, err := DecryptIfKeySet(encrypted, key)
|
||||
decrypted, err := DecryptIfKeySet(encrypted, "round-trip-key")
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptIfKeySet failed: %v", err)
|
||||
}
|
||||
@@ -242,22 +218,24 @@ func TestEncryptDecryptIfKeySet_RoundTripProducesDifferentCiphertext(t *testing.
|
||||
}
|
||||
|
||||
// TestDecryptIfKeySet_RejectsTamperedCiphertext confirms the AEAD auth tag
|
||||
// still rejects modified ciphertext when routed through the helper.
|
||||
// still rejects modified ciphertext when routed through the helper. The v2
|
||||
// wire format is magic(1) || salt(16) || nonce(12) || ciphertext+tag, so
|
||||
// flipping a byte anywhere past offset 29 lands squarely inside the AEAD body.
|
||||
func TestDecryptIfKeySet_RejectsTamperedCiphertext(t *testing.T) {
|
||||
key := DeriveKey("tamper-test-key")
|
||||
plaintext := []byte("authenticated data")
|
||||
|
||||
encrypted, _, err := EncryptIfKeySet(plaintext, key)
|
||||
encrypted, _, err := EncryptIfKeySet(plaintext, "tamper-test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
||||
}
|
||||
// Flip a byte inside the GCM body (past the 12-byte nonce) to invalidate the tag.
|
||||
if len(encrypted) <= 13 {
|
||||
// Flip a byte past the v2 header (1 + 16 + 12 = 29) to invalidate the tag.
|
||||
const minV2HeaderLen = 1 + v2SaltSize + 12
|
||||
if len(encrypted) <= minV2HeaderLen {
|
||||
t.Fatalf("ciphertext too short to tamper: %d bytes", len(encrypted))
|
||||
}
|
||||
encrypted[13] ^= 0xFF
|
||||
encrypted[minV2HeaderLen] ^= 0xFF
|
||||
|
||||
if _, err := DecryptIfKeySet(encrypted, key); err == nil {
|
||||
if _, err := DecryptIfKeySet(encrypted, "tamper-test-key"); err == nil {
|
||||
t.Fatal("DecryptIfKeySet accepted tampered ciphertext — AEAD tag check bypassed")
|
||||
}
|
||||
}
|
||||
@@ -296,3 +274,217 @@ func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||
t.Fatal("encrypting same plaintext twice should produce different ciphertexts (random nonce)")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M-8 additions: per-ciphertext salt + v2 wire format + v1 backward compat.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestDeriveKey_DifferentSaltsProduceDifferentKeys asserts that
|
||||
// deriveKeyWithSalt fans out distinct 32-byte keys for the same passphrase
|
||||
// across different salts. This is the core M-8 defense-in-depth property: even
|
||||
// if an attacker obtains two v2 ciphertexts encrypted with the same master
|
||||
// passphrase, the derived AES keys differ, and a brute-force attempt on one
|
||||
// blob cannot be amortized across the other.
|
||||
func TestDeriveKey_DifferentSaltsProduceDifferentKeys(t *testing.T) {
|
||||
passphrase := "master-passphrase"
|
||||
saltA := bytes.Repeat([]byte{0xAA}, v2SaltSize)
|
||||
saltB := bytes.Repeat([]byte{0xBB}, v2SaltSize)
|
||||
|
||||
keyA := deriveKeyWithSalt(passphrase, saltA)
|
||||
keyB := deriveKeyWithSalt(passphrase, saltB)
|
||||
|
||||
if len(keyA) != aes256KeySize || len(keyB) != aes256KeySize {
|
||||
t.Fatalf("derived key length wrong: %d / %d", len(keyA), len(keyB))
|
||||
}
|
||||
if bytes.Equal(keyA, keyB) {
|
||||
t.Fatal("deriveKeyWithSalt must produce different keys for different salts")
|
||||
}
|
||||
|
||||
// Sanity-check that deterministic behaviour is preserved under a fixed salt.
|
||||
keyA2 := deriveKeyWithSalt(passphrase, saltA)
|
||||
if !bytes.Equal(keyA, keyA2) {
|
||||
t.Fatal("deriveKeyWithSalt must be deterministic for a fixed (passphrase, salt)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptIfKeySet_ProducesV2Format asserts the exact v2 wire-format bytes:
|
||||
// magic(0x02) || salt(16) || nonce(12) || ciphertext+tag.
|
||||
func TestEncryptIfKeySet_ProducesV2Format(t *testing.T) {
|
||||
blob, _, err := EncryptIfKeySet([]byte("hello"), "any-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
||||
}
|
||||
|
||||
const minLen = 1 + v2SaltSize + 12 + 16 // magic + salt + nonce + GCM tag (16)
|
||||
if len(blob) < minLen {
|
||||
t.Fatalf("v2 blob too short: got %d, want >= %d", len(blob), minLen)
|
||||
}
|
||||
if blob[0] != v2Magic {
|
||||
t.Fatalf("v2 blob must start with magic byte 0x%02x, got 0x%02x", v2Magic, blob[0])
|
||||
}
|
||||
if IsLegacyFormat(blob) {
|
||||
t.Fatal("IsLegacyFormat must return false for a freshly produced v2 blob")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptIfKeySet_SaltIsRandom asserts that two calls with the same
|
||||
// passphrase and plaintext produce distinct embedded salts.
|
||||
func TestEncryptIfKeySet_SaltIsRandom(t *testing.T) {
|
||||
plaintext := []byte("same plaintext")
|
||||
passphrase := "same-passphrase"
|
||||
|
||||
blob1, _, err := EncryptIfKeySet(plaintext, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptIfKeySet #1 failed: %v", err)
|
||||
}
|
||||
blob2, _, err := EncryptIfKeySet(plaintext, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptIfKeySet #2 failed: %v", err)
|
||||
}
|
||||
|
||||
salt1 := blob1[1 : 1+v2SaltSize]
|
||||
salt2 := blob2[1 : 1+v2SaltSize]
|
||||
if bytes.Equal(salt1, salt2) {
|
||||
t.Fatal("two EncryptIfKeySet invocations must produce distinct per-ciphertext salts")
|
||||
}
|
||||
if bytes.Equal(blob1, blob2) {
|
||||
t.Fatal("two v2 blobs with same (passphrase, plaintext) must differ end-to-end")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecryptIfKeySet_V1BackwardCompat builds a deterministic v1-format
|
||||
// ciphertext using the pre-M-8 recipe (DeriveKey with the fixed salt, then
|
||||
// Encrypt with an all-zero nonce for reproducibility) and asserts that
|
||||
// DecryptIfKeySet still decrypts it correctly. This is the migration guarantee:
|
||||
// v1 blobs persisted before M-8 must remain decryptable.
|
||||
func TestDecryptIfKeySet_V1BackwardCompat(t *testing.T) {
|
||||
passphrase := "legacy-passphrase"
|
||||
plaintext := []byte(`{"api_key":"legacy","org_id":"789"}`)
|
||||
|
||||
// Build a deterministic v1 blob directly: nonce(12 zero bytes) || ct+tag.
|
||||
// This matches the exact wire shape that Encrypt produces, minus the random
|
||||
// nonce, so the test is stable rather than 1/256 flaky.
|
||||
key := DeriveKey(passphrase) // fixed-salt derivation (pre-M-8 behavior)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatalf("aes.NewCipher: %v", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatalf("cipher.NewGCM: %v", err)
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize()) // all zeros → first byte != v2Magic
|
||||
v1Blob := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
if v1Blob[0] == v2Magic {
|
||||
t.Fatalf("fixture nonce collided with v2 magic byte — test design error")
|
||||
}
|
||||
|
||||
decrypted, err := DecryptIfKeySet(v1Blob, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptIfKeySet(v1) failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Fatalf("v1 decrypt mismatch: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
|
||||
// Cross-check: IsLegacyFormat should flag this as legacy.
|
||||
if !IsLegacyFormat(v1Blob) {
|
||||
t.Fatal("IsLegacyFormat must return true for a v1 blob whose first byte != v2Magic")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecryptIfKeySet_V1MagicByteCollisionFallsThrough covers the 1/256 edge
|
||||
// case where a v1 ciphertext's random 12-byte nonce happens to begin with
|
||||
// 0x02. The dispatch must attempt v2, see AEAD failure, and fall through to
|
||||
// v1 — never return a decrypt error when the passphrase is correct.
|
||||
func TestDecryptIfKeySet_V1MagicByteCollisionFallsThrough(t *testing.T) {
|
||||
passphrase := "collision-passphrase"
|
||||
plaintext := []byte("colliding v1 blob")
|
||||
|
||||
// Craft a v1 blob whose first byte equals v2Magic by choosing a nonce
|
||||
// starting with 0x02 and sealing manually.
|
||||
key := DeriveKey(passphrase)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatalf("aes.NewCipher: %v", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatalf("cipher.NewGCM: %v", err)
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
nonce[0] = v2Magic // force collision
|
||||
v1Blob := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
if v1Blob[0] != v2Magic {
|
||||
t.Fatal("fixture construction bug: first byte must equal v2Magic")
|
||||
}
|
||||
|
||||
decrypted, err := DecryptIfKeySet(v1Blob, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptIfKeySet must fall through to v1 on AEAD failure, got err: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Fatalf("v1-via-fallback decrypt mismatch: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecryptIfKeySet_V2WithWrongPassphraseFails asserts that a v2 blob
|
||||
// sealed under passphrase A cannot be decrypted under passphrase B. Both the
|
||||
// v2 AEAD verify (with salt from the blob + passphrase B) and the v1 fallback
|
||||
// (with fixed salt + passphrase B) must fail, and an error must be returned
|
||||
// rather than silently-corrupt plaintext.
|
||||
func TestDecryptIfKeySet_V2WithWrongPassphraseFails(t *testing.T) {
|
||||
blob, _, err := EncryptIfKeySet([]byte("secret"), "passphrase-A")
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
||||
}
|
||||
|
||||
got, err := DecryptIfKeySet(blob, "passphrase-B")
|
||||
if err == nil {
|
||||
t.Fatalf("DecryptIfKeySet must return error for wrong passphrase, got plaintext %q", got)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("result must be nil on decrypt error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecryptIfKeySet_TruncatedV2Blob asserts that a blob starting with the v2
|
||||
// magic byte but too short to contain a full v2 header does not trip an
|
||||
// out-of-bounds slice and does not succeed. It either returns an error (v1
|
||||
// fallback on the short bytes fails with "ciphertext too short") or at minimum
|
||||
// never returns plaintext.
|
||||
func TestDecryptIfKeySet_TruncatedV2Blob(t *testing.T) {
|
||||
truncated := []byte{v2Magic, 0x00, 0x01, 0x02, 0x03} // 5 bytes — well below the 29-byte v2 minimum
|
||||
got, err := DecryptIfKeySet(truncated, "any-passphrase")
|
||||
if err == nil {
|
||||
t.Fatalf("DecryptIfKeySet must reject a truncated v2 blob, got plaintext %q", got)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("result must be nil on decrypt error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsLegacyFormat covers the three branches of the public magic-byte
|
||||
// heuristic: v2 blob → false, v1 blob → true, empty blob → false.
|
||||
func TestIsLegacyFormat(t *testing.T) {
|
||||
v2Blob, _, err := EncryptIfKeySet([]byte("data"), "p")
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
||||
}
|
||||
if IsLegacyFormat(v2Blob) {
|
||||
t.Fatal("v2 blob must not be flagged as legacy")
|
||||
}
|
||||
|
||||
// Any blob whose first byte isn't v2Magic should be reported as legacy.
|
||||
v1Shape := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF}
|
||||
if !IsLegacyFormat(v1Shape) {
|
||||
t.Fatal("non-v2-magic blob must be flagged as legacy")
|
||||
}
|
||||
|
||||
if IsLegacyFormat(nil) {
|
||||
t.Fatal("nil blob must not be flagged as legacy (undefined)")
|
||||
}
|
||||
if IsLegacyFormat([]byte{}) {
|
||||
t.Fatal("empty blob must not be flagged as legacy (undefined)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
// without a configured CERTCTL_CONFIG_ENCRYPTION_KEY. Happy-path CRUD tests
|
||||
// must supply a real key so the encrypt path runs instead of returning
|
||||
// ErrEncryptionKeyRequired.
|
||||
testEncryptionKey := []byte("0123456789abcdef0123456789abcdef")
|
||||
testEncryptionKey := "0123456789abcdef0123456789abcdef"
|
||||
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, testEncryptionKey, slog.Default())
|
||||
|
||||
// Initialize handlers
|
||||
|
||||
@@ -62,7 +62,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
// without a configured CERTCTL_CONFIG_ENCRYPTION_KEY. Happy-path CRUD tests
|
||||
// must supply a real key so the encrypt path runs instead of returning
|
||||
// ErrEncryptionKeyRequired.
|
||||
testEncryptionKey := []byte("0123456789abcdef0123456789abcdef")
|
||||
testEncryptionKey := "0123456789abcdef0123456789abcdef"
|
||||
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, testEncryptionKey, logger)
|
||||
|
||||
certificateHandler := handler.NewCertificateHandler(certificateService)
|
||||
|
||||
@@ -194,7 +194,7 @@ func TestConcurrentTargetCRUD(t *testing.T) {
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
|
||||
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
targetSvc := NewTargetService(mockTargetRepo, nil, nil, "", slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
|
||||
var mu sync.Mutex
|
||||
createdTargets := make([]string, 0)
|
||||
@@ -403,7 +403,7 @@ func TestConcurrentMixedOperations(t *testing.T) {
|
||||
// Setup services
|
||||
auditSvc := &AuditService{auditRepo: mockAuditRepo}
|
||||
certSvc := NewCertificateService(mockCertRepo, nil, auditSvc)
|
||||
targetSvc := NewTargetService(mockTargetRepo, auditSvc, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
targetSvc := NewTargetService(mockTargetRepo, auditSvc, nil, "", slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, 30)
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestTargetService_ListWithCancelledContext(t *testing.T) {
|
||||
mockTargetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
targetSvc := NewTargetService(mockTargetRepo, nil, nil, "", slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
|
||||
_, _, err := targetSvc.List(ctx, 1, 50)
|
||||
|
||||
|
||||
@@ -17,20 +17,27 @@ import (
|
||||
)
|
||||
|
||||
// IssuerService provides business logic for certificate issuer management.
|
||||
//
|
||||
// The encryptionKey field holds the raw passphrase (not a pre-derived 32-byte
|
||||
// key). Per-ciphertext salt derivation is performed inside
|
||||
// [crypto.EncryptIfKeySet] / [crypto.DecryptIfKeySet] on each call. See M-8
|
||||
// in certctl-audit-report.md.
|
||||
type IssuerService struct {
|
||||
issuerRepo repository.IssuerRepository
|
||||
auditService *AuditService
|
||||
registry *IssuerRegistry
|
||||
encryptionKey []byte
|
||||
encryptionKey string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewIssuerService creates a new issuer service.
|
||||
// NewIssuerService creates a new issuer service. The encryptionKey is the raw
|
||||
// passphrase; it MUST NOT be pre-derived via crypto.DeriveKey (that was the
|
||||
// v1 behavior, replaced in M-8 with per-ciphertext random salt).
|
||||
func NewIssuerService(
|
||||
issuerRepo repository.IssuerRepository,
|
||||
auditService *AuditService,
|
||||
registry *IssuerRegistry,
|
||||
encryptionKey []byte,
|
||||
encryptionKey string,
|
||||
logger *slog.Logger,
|
||||
) *IssuerService {
|
||||
return &IssuerService{
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestBuildEnvVarSeeds_ACMEConfig(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
// Call buildEnvVarSeeds (unexported method, but testable from same package)
|
||||
seeds := service.buildEnvVarSeeds(cfg)
|
||||
@@ -82,7 +82,7 @@ func TestBuildEnvVarSeeds_VaultConfig(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
seeds := service.buildEnvVarSeeds(cfg)
|
||||
|
||||
@@ -136,7 +136,7 @@ func TestBuildEnvVarSeeds_NoConfig(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
seeds := service.buildEnvVarSeeds(cfg)
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestBuildEnvVarSeeds_MultipleConfigs(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
seeds := service.buildEnvVarSeeds(cfg)
|
||||
|
||||
@@ -232,7 +232,7 @@ func TestSeedFromEnvVars_Empty(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
// Call SeedFromEnvVars on empty repo
|
||||
service.SeedFromEnvVars(ctx, cfg)
|
||||
@@ -280,7 +280,7 @@ func TestSeedFromEnvVars_AlreadyExists(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
// Get count before seeding
|
||||
beforeSeeding, _ := repo.List(ctx)
|
||||
@@ -328,7 +328,7 @@ func TestBuildRegistry_Success(t *testing.T) {
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
|
||||
|
||||
// Call BuildRegistry
|
||||
err := service.BuildRegistry(ctx)
|
||||
@@ -351,7 +351,7 @@ func TestBuildRegistry_EmptyDatabase(t *testing.T) {
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
|
||||
|
||||
// Call BuildRegistry on empty database
|
||||
err := service.BuildRegistry(ctx)
|
||||
|
||||
@@ -72,7 +72,12 @@ func (r *IssuerRegistry) Len() int {
|
||||
// For each enabled issuer, it decrypts the config (if encryption key is set),
|
||||
// instantiates a connector via the factory, wraps it in an adapter, and
|
||||
// atomically swaps the entire map.
|
||||
func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey []byte) error {
|
||||
//
|
||||
// The encryption passphrase is passed as a string; per-ciphertext salt derivation
|
||||
// for v2 blobs is performed inside [crypto.DecryptIfKeySet]. Empty passphrase
|
||||
// fails closed via [crypto.ErrEncryptionKeyRequired] when encrypted configs
|
||||
// are encountered. See M-8 in certctl-audit-report.md.
|
||||
func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey string) error {
|
||||
newIssuers := make(map[string]IssuerConnector)
|
||||
var errors []string
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestIssuerRegistry_Rebuild_Enabled(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err := reg.Rebuild(configs, nil)
|
||||
err := reg.Rebuild(configs, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Rebuild failed: %v", err)
|
||||
}
|
||||
@@ -124,11 +124,12 @@ func TestIssuerRegistry_Rebuild_Enabled(t *testing.T) {
|
||||
func TestIssuerRegistry_Rebuild_WithEncryption(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
key := crypto.DeriveKey("test-key")
|
||||
configJSON := []byte(`{"ca_common_name":"Encrypted CA"}`)
|
||||
encrypted, err := crypto.Encrypt(configJSON, key)
|
||||
// M-8: EncryptIfKeySet now emits v2 (magic 0x02 || per-ciphertext salt || sealed).
|
||||
// IssuerRegistry.Rebuild accepts the raw passphrase and delegates PBKDF2 to crypto.DecryptIfKeySet.
|
||||
encrypted, _, err := crypto.EncryptIfKeySet(configJSON, "test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt failed: %v", err)
|
||||
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
||||
}
|
||||
|
||||
configs := []*domain.Issuer{
|
||||
@@ -141,7 +142,7 @@ func TestIssuerRegistry_Rebuild_WithEncryption(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err = reg.Rebuild(configs, key)
|
||||
err = reg.Rebuild(configs, "test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("Rebuild with encryption failed: %v", err)
|
||||
}
|
||||
@@ -165,10 +166,11 @@ func TestIssuerRegistry_Rebuild_NilKeyFallback(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// nil key should work — falls back to config column
|
||||
err := reg.Rebuild(configs, nil)
|
||||
// Empty passphrase is safe when no EncryptedConfig is present — falls back to config column.
|
||||
// The C-2 fail-closed sentinel only fires when EncryptedConfig is non-empty.
|
||||
err := reg.Rebuild(configs, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Rebuild with nil key failed: %v", err)
|
||||
t.Fatalf("Rebuild with empty key failed: %v", err)
|
||||
}
|
||||
|
||||
_, ok := reg.Get("iss-plain")
|
||||
@@ -198,7 +200,7 @@ func TestIssuerRegistry_Rebuild_InvalidConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should return an error indicating partial failure, but still load valid issuers
|
||||
err := reg.Rebuild(configs, nil)
|
||||
err := reg.Rebuild(configs, "")
|
||||
if err == nil {
|
||||
t.Fatal("Rebuild should return error when some issuers fail to load")
|
||||
}
|
||||
@@ -230,7 +232,7 @@ func TestIssuerRegistry_Rebuild_ReplacesExisting(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err := reg.Rebuild(configs, nil)
|
||||
err := reg.Rebuild(configs, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Rebuild failed: %v", err)
|
||||
}
|
||||
@@ -275,7 +277,7 @@ func TestIssuerRegistry_Rebuild_Empty(t *testing.T) {
|
||||
|
||||
reg.Set("iss-existing", &mockIssuerConnector{})
|
||||
|
||||
err := reg.Rebuild([]*domain.Issuer{}, nil)
|
||||
err := reg.Rebuild([]*domain.Issuer{}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Rebuild with empty configs failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestIssuerService_List(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
issuers, total, err := service.List(ctx, 1, 2)
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestIssuerService_List_DefaultPagination(t *testing.T) {
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
|
||||
|
||||
// Call with invalid page and perPage
|
||||
issuers, total, err := service.List(ctx, 0, 0)
|
||||
@@ -115,7 +115,7 @@ func TestIssuerService_List_RepositoryError(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
_, _, err := service.List(ctx, 1, 50)
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestIssuerService_List_EmptyResult(t *testing.T) {
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
|
||||
|
||||
issuers, total, err := service.List(ctx, 1, 50)
|
||||
|
||||
@@ -173,7 +173,7 @@ func TestIssuerService_Get(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
retrieved, err := service.Get(ctx, "iss-acme-prod")
|
||||
|
||||
@@ -199,7 +199,7 @@ func TestIssuerService_Get_NotFound(t *testing.T) {
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
|
||||
|
||||
_, err := service.Get(ctx, "nonexistent-issuer")
|
||||
|
||||
@@ -280,7 +280,7 @@ func TestIssuerService_Create_EmptyName(t *testing.T) {
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
|
||||
|
||||
issuer := &domain.Issuer{
|
||||
Name: "",
|
||||
@@ -314,7 +314,7 @@ func TestIssuerService_Create_RepositoryError(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
issuer := &domain.Issuer{
|
||||
Name: "Test Issuer",
|
||||
@@ -387,7 +387,7 @@ func TestIssuerService_Update_EmptyName(t *testing.T) {
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
|
||||
|
||||
issuer := &domain.Issuer{
|
||||
Name: "",
|
||||
@@ -415,7 +415,7 @@ func TestIssuerService_Delete(t *testing.T) {
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
|
||||
|
||||
err := service.Delete(ctx, "iss-to-delete", "user-frank")
|
||||
|
||||
@@ -447,7 +447,7 @@ func TestIssuerService_Delete_RepositoryError(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
err := service.Delete(ctx, "iss-bad-id", "user-grace")
|
||||
|
||||
@@ -482,7 +482,7 @@ func TestIssuerService_TestConnection_Success(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
svc := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
svc := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
err := svc.TestConnectionWithContext(ctx, "iss-test-conn")
|
||||
|
||||
@@ -500,7 +500,7 @@ func TestIssuerService_TestConnection_NotFound(t *testing.T) {
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
|
||||
|
||||
err := service.TestConnectionWithContext(ctx, "nonexistent-issuer")
|
||||
|
||||
@@ -540,7 +540,7 @@ func TestIssuerService_ListIssuers_HandlerInterface(t *testing.T) {
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
|
||||
|
||||
issuers, total, err := service.ListIssuers(1, 50)
|
||||
|
||||
@@ -606,7 +606,7 @@ func TestIssuerService_DeleteIssuer_HandlerInterface(t *testing.T) {
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
|
||||
|
||||
err := service.DeleteIssuer("iss-handler-delete")
|
||||
|
||||
|
||||
@@ -36,20 +36,27 @@ func isValidTargetType(t domain.TargetType) bool {
|
||||
}
|
||||
|
||||
// TargetService provides business logic for deployment target management.
|
||||
//
|
||||
// The encryptionKey field holds the raw passphrase (not a pre-derived 32-byte
|
||||
// key). Per-ciphertext salt derivation is performed inside
|
||||
// [crypto.EncryptIfKeySet] / [crypto.DecryptIfKeySet] on each call. See M-8
|
||||
// in certctl-audit-report.md.
|
||||
type TargetService struct {
|
||||
targetRepo repository.TargetRepository
|
||||
agentRepo repository.AgentRepository
|
||||
auditService *AuditService
|
||||
encryptionKey []byte
|
||||
encryptionKey string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewTargetService creates a new target service.
|
||||
// NewTargetService creates a new target service. The encryptionKey is the raw
|
||||
// passphrase; it MUST NOT be pre-derived via crypto.DeriveKey (that was the
|
||||
// v1 behavior, replaced in M-8 with per-ciphertext random salt).
|
||||
func NewTargetService(
|
||||
targetRepo repository.TargetRepository,
|
||||
auditService *AuditService,
|
||||
agentRepo repository.AgentRepository,
|
||||
encryptionKey []byte,
|
||||
encryptionKey string,
|
||||
logger *slog.Logger,
|
||||
) *TargetService {
|
||||
return &TargetService{
|
||||
|
||||
@@ -12,12 +12,15 @@ import (
|
||||
|
||||
var errNotFound = errors.New("not found")
|
||||
|
||||
// testEncryptionKey is a deterministic 32-byte AES-256 key for unit tests that
|
||||
// testEncryptionKey is a deterministic passphrase for unit tests that
|
||||
// exercise IssuerService/TargetService write paths. After the C-2 remediation
|
||||
// these services fail closed when no key is configured, so happy-path tests
|
||||
// must supply a real key. Using a constant keeps wire-format assertions stable
|
||||
// across runs and avoids flaky PBKDF2 timing.
|
||||
var testEncryptionKey = []byte("0123456789abcdef0123456789abcdef") // 32 bytes
|
||||
// must supply a real passphrase. M-8 reshaped the type from []byte to string
|
||||
// because services now hold the raw passphrase and delegate PBKDF2 to
|
||||
// crypto.EncryptIfKeySet / crypto.DecryptIfKeySet (which apply a fresh random
|
||||
// salt per ciphertext). Using a constant keeps wire-format assertions stable
|
||||
// across runs.
|
||||
var testEncryptionKey = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// mockCertRepo is a test implementation of CertificateRepository
|
||||
type mockCertRepo struct {
|
||||
|
||||
Reference in New Issue
Block a user