Files
certctl/internal/crypto/encryption.go
T
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

366 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
// Package crypto provides AES-256-GCM encryption for sensitive configuration data.
//
// The on-disk format for blobs produced by [EncryptIfKeySet] is versioned.
// Three versions coexist; the write path always emits v3, the read path
// (DecryptIfKeySet) accepts all three:
//
// v3 (current, Bundle B / M-001)
// magic(0x03) || salt(16) || nonce(12) || ciphertext+tag
// — 32-byte AES-256 key derived via PBKDF2-SHA256 (600,000 rounds)
// from the operator passphrase and the per-ciphertext random salt.
// OWASP 2024 recommends 600,000 rounds for SHA-256 PBKDF2; this is
// a 6× increase over v2.
//
// v2 (legacy, M-8)
// magic(0x02) || salt(16) || nonce(12) || ciphertext+tag
// — 32-byte AES-256 key derived via PBKDF2-SHA256 (100,000 rounds)
// 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 (100,000 rounds)
// from the operator passphrase and the package-level fixed salt
// "certctl-config-encryption-v1".
//
// v1 and v2 blobs are accepted by the read path for backward compatibility
// with rows persisted before each remediation. They are never produced by the
// write path. Any row that is updated after Bundle B is re-sealed as v3
// in-place via the normal UPDATE flow.
//
// Rationale for the iteration bump (see Bundle B / Audit M-001 / CWE-916):
// PBKDF2 work factor is the only knob that bounds an attacker's ability to
// brute-force a leaked passphrase + ciphertext pair. OWASP's December-2023
// Password Storage Cheat Sheet raises the SHA-256 PBKDF2 floor to 600,000;
// 100k was the 2018-era floor. v3 brings certctl onto the current floor at
// the cost of ~6× more boot-time CPU on the encryption code path (a
// configuration-load operation, so amortized across the entire process
// lifetime).
//
// Rationale for the per-ciphertext salt (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/v3 replace the fixed salt with 16 fresh
// random bytes per write and store the salt alongside the ciphertext.
// Derived keys differ per row and per re-encryption.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"golang.org/x/crypto/pbkdf2"
)
// ErrEncryptionKeyRequired is returned by EncryptIfKeySet and DecryptIfKeySet when
// 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
// in dynamically-configured issuer and target records (source='database') were
// persisted to PostgreSQL without any encryption whenever the operator forgot to
// set CERTCTL_CONFIG_ENCRYPTION_KEY. Callers could not distinguish the encrypted
// and plaintext branches at runtime, so the only visible signal was a warning
// line emitted once at startup.
//
// 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 / v3Magic are the first byte of every v2/v3-format ciphertext blob.
// Magic bytes distinguish each version from v1 legacy blobs (no magic byte,
// fixed package-level salt) and from each other (different PBKDF2 work
// factors).
//
// The choice of 0x02 / 0x03 is deliberate: v1 blobs begin with a random
// 12-byte AES-GCM nonce. A v1 nonce can coincidentally start with 0x02 or
// 0x03 with probability 1/256 each, which makes a pure magic-byte dispatch
// ambiguous. [DecryptIfKeySet] resolves the ambiguity by falling back
// through the version chain on AEAD verification failure
// (v3 → v2 → v1).
const (
v2Magic byte = 0x02
v3Magic byte = 0x03
)
// v2SaltSize / v3SaltSize is the length in bytes of the per-ciphertext salt
// embedded in v2/v3 blobs. 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. The two versions use
// the same salt size — only the iteration count changes.
const (
v2SaltSize = 16
v3SaltSize = 16
)
// pbkdf2IterationsV1V2 is the PBKDF2-SHA256 work factor for v1 and v2 blobs
// (100,000 rounds, the 2018-era OWASP recommendation). Preserved byte-for-byte
// so legacy fallback reads stay deterministic.
//
// pbkdf2IterationsV3 is the work factor for newly-written v3 blobs (600,000
// rounds, the OWASP 2024 recommendation per the Password Storage Cheat Sheet).
// Bundle B / Audit M-001 / CWE-916.
const (
pbkdf2IterationsV1V2 = 100000
pbkdf2IterationsV3 = 600000
)
// pbkdf2Iterations is preserved as an alias for v1V2 so existing internal
// references and downstream tests that compute v1 bytes manually keep working.
// New code should reference pbkdf2IterationsV3 explicitly.
const pbkdf2Iterations = pbkdf2IterationsV1V2
// 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) != aes256KeySize {
return nil, fmt.Errorf("encryption key must be exactly %d bytes, got %d", aes256KeySize, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
// 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) != aes256KeySize {
return nil, fmt.Errorf("encryption key must be exactly %d bytes, got %d", aes256KeySize, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short: %d bytes", len(ciphertext))
}
nonce, ciphertextBody := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertextBody, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt: %w", err)
}
return plaintext, nil
}
// 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 {
return deriveKeyWithSalt(passphrase, legacyV1Salt)
}
// deriveKeyWithSalt derives a 32-byte AES-256 key from a passphrase and an
// explicit salt using PBKDF2-SHA256 with [pbkdf2Iterations] rounds (= the
// v1/v2 work factor). v3 blobs use [deriveKeyWithSaltV3] instead.
//
// 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)
}
// deriveKeyWithSaltV3 derives a 32-byte AES-256 key from a passphrase and
// an explicit salt using PBKDF2-SHA256 with [pbkdf2IterationsV3] rounds
// (the OWASP 2024 floor of 600,000). Bundle B / Audit M-001 / CWE-916.
func deriveKeyWithSaltV3(passphrase string, salt []byte) []byte {
return pbkdf2.Key([]byte(passphrase), salt, pbkdf2IterationsV3, aes256KeySize, sha256.New)
}
// IsLegacyFormat reports whether blob is in the v1 legacy wire format (no
// magic byte, fixed-salt derivation) as opposed to a v2 or v3 wire format
// (magic byte || 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/v3 ciphertext: the shortest possible v2/v3 blob
// is 1 + saltSize + 12 = 29 bytes, and even a 29+ byte blob that starts
// with 0x02/0x03 may turn out to be a v1 ciphertext whose random nonce
// happens to begin with that byte (probability 1/256 each).
// [DecryptIfKeySet] resolves this ambiguity at decrypt time by falling
// back through the version chain when 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
}
first := blob[0]
return first != v2Magic && first != v3Magic
}
// EncryptIfKeySet encrypts plaintext with the supplied passphrase and emits
// a v3 wire-format blob: magic(0x03) || 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 work factor is [pbkdf2IterationsV3] (600,000) — Bundle B / Audit M-001
// / CWE-916 / OWASP 2024.
//
// 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
// 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 v1 or v2 blobs. They 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
}
salt := make([]byte, v3SaltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, false, fmt.Errorf("failed to generate v3 salt: %w", err)
}
key := deriveKeyWithSaltV3(passphrase, salt)
inner, err := Encrypt(plaintext, key)
if err != nil {
return nil, false, err
}
// v3 blob layout: magic(1) || salt(v3SaltSize) || inner
blob := make([]byte, 0, 1+v3SaltSize+len(inner))
blob = append(blob, v3Magic)
blob = append(blob, salt...)
blob = append(blob, inner...)
return blob, true, nil
}
// DecryptIfKeySet decrypts blob with the supplied passphrase, supporting v3
// (Bundle B and later), v2 (M-8 era), and v1 (pre-M-8 legacy) on-disk
// formats.
//
// Dispatch is first-byte magic + AEAD fallback. If blob starts with
// [v3Magic] / [v2Magic] and is long enough to contain a header plus an
// AEAD-authenticated inner ciphertext, the matching version is attempted
// using a key derived from the embedded salt at the version's PBKDF2 work
// factor. If AEAD verification fails — which covers both the "wrong
// passphrase" case and the 1/256 case where a different-version blob
// happens to start with that magic byte — the function falls through to
// the next version. The order is v3 → v2 → v1.
//
// A v1 blob that is successfully decrypted is returned as plaintext;
// re-sealing as v3 happens naturally on the next UPDATE via
// [EncryptIfKeySet]. The function never re-encrypts in place.
//
// 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).
func DecryptIfKeySet(blob []byte, passphrase string) ([]byte, error) {
if passphrase == "" {
return nil, ErrEncryptionKeyRequired
}
if len(blob) == 0 {
return nil, fmt.Errorf("ciphertext is empty")
}
// v3 path: Bundle B / M-001 — magic(0x03) || salt(16) || nonce(12) || ct+tag.
// 600,000 PBKDF2 rounds.
if blob[0] == v3Magic && len(blob) >= 1+v3SaltSize+12 {
salt := blob[1 : 1+v3SaltSize]
sealed := blob[1+v3SaltSize:]
key := deriveKeyWithSaltV3(passphrase, salt)
if plaintext, err := Decrypt(sealed, key); err == nil {
return plaintext, nil
}
// v3 AEAD failed. Fall through — could be a v2 blob whose first
// byte happens to be 0x03 (1/256), or a v1 nonce-prefix collision,
// or a wrong-passphrase v3.
}
// v2 path: M-8 — magic(0x02) || salt(16) || nonce(12) || ct+tag.
// 100,000 PBKDF2 rounds.
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 failed. Fall through to v1.
}
// v1 legacy path: blob is the full ciphertext with no header and was
// sealed with a key derived from (passphrase, legacyV1Salt) at 100k
// rounds. If both v2/v3 attempts above failed and this also fails, the
// returned error is the v1 attempt's error — which is the most likely
// "wrong passphrase" surface for an operator on a recent install (no
// pre-M-8 v1 rows, so the first two paths are the actual write format
// and only v1 has a chance to surface a meaningful error).
key := DeriveKey(passphrase)
return Decrypt(blob, key)
}