mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:11:32 +00:00
294 lines
13 KiB
Go
294 lines
13 KiB
Go
// 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 (
|
|
"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 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) != 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 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 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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 blob with the supplied passphrase, supporting both
|
|
// v2 (M-8 and later) and v1 (legacy) on-disk formats.
|
|
//
|
|
// 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
|
|
}
|
|
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)
|
|
}
|