mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:51:32 +00:00
21aeed4f4e
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
366 lines
16 KiB
Go
366 lines
16 KiB
Go
// 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)
|
||
}
|