mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
Bundle B: Auth & transport surface tightening — 5 findings closed
Closes M-001 + M-002 + M-013 + M-018 + M-025 from
comprehensive-audit-2026-04-25.
M-001 (CWE-916) — PBKDF2 100k -> 600k via v3 blob format
internal/crypto/encryption.go:
- New v3Magic (0x03), pbkdf2IterationsV3 (600,000 — OWASP 2024
Password Storage Cheat Sheet floor), v3SaltSize (16 bytes),
deriveKeyWithSaltV3 helper.
- EncryptIfKeySet now unconditionally writes v3:
magic(0x03) || salt(16) || nonce(12) || ciphertext+tag
- DecryptIfKeySet falls through v3 -> v2 -> v1 with AEAD verification
at each step. Wrong-passphrase v3 reads cannot be silently
misattributed to v2/v1.
- IsLegacyFormat updated to recognize 0x03 as non-legacy.
internal/crypto/encryption_v3_test.go (NEW, 7 tests):
V3 round-trip / V2 read-fallback against deterministic v2 fixture /
V3 wrong-passphrase fails / V3-vs-V2 dispatch order / V2 vs V3 keys
differ for same (passphrase, salt) / iteration-count pin at OWASP
2024 floor / IsLegacyFormat-recognises-V3.
Coverage internal/crypto: 86.7% -> 88.2%.
M-002 (CWE-862) — Auth-exempt allowlist constants + AST regression test
Recon found auth-exempt surface spans TWO layers (audit's claim was
incomplete):
Layer 1 (router.go direct r.mux.Handle):
GET /health, GET /ready, GET /api/v1/auth/info, GET /api/v1/version
Layer 2 (cmd/server/main.go::buildFinalHandler URL-prefix dispatch):
/.well-known/pki/*, /.well-known/est/*, /scep[/...]*
internal/api/router/router.go:
- New AuthExemptRouterRoutes constant with per-entry justifications.
- New AuthExemptDispatchPrefixes constant.
internal/api/router/auth_exempt_test.go (NEW, 2 tests):
AST-walks router.go for every direct mux.Handle call and asserts
set equals AuthExemptRouterRoutes; reads source bytes of Register /
RegisterFunc and asserts they still wrap with middleware.Chain.
cmd/server/auth_exempt_test.go (NEW, 2 tests):
14-case table test on buildFinalHandler asserting documented
prefixes route to noAuthHandler and authenticated routes route to
apiHandler; inverse-overlap pin proves no documented bypass shadows
an authenticated prefix.
M-013 (CWE-942) — CORS deny-by-default verified-already-clean + pin
Audit claim 'default allows all origins if env-var unset' was WRONG.
internal/api/middleware/middleware.go::NewCORS already denies cross-
origin requests when len(cfg.AllowedOrigins) == 0 (no
Access-Control-Allow-Origin header is emitted, same-origin policy
applies).
internal/api/middleware/cors_test.go: +TestNewCORS_NilOriginsDeniesAll
+ TestNewCORS_M013_ContractDocumentedInOrder (5-case table test
pinning the 3-arm dispatch contract).
M-018 (CWE-319 / PCI-DSS Req 4) — Postgres TLS opt-in toggle
deploy/helm/certctl/values.yaml: new postgresql.tls.{mode,caSecretRef}
operator-facing knobs. Default 'disable' preserves in-cluster pod-
network behavior; PCI-scoped operators set verify-full.
deploy/helm/certctl/templates/_helpers.tpl: certctl.databaseURL helper
pipes postgresql.tls.mode into ?sslmode=.
deploy/helm/certctl/templates/server-secret.yaml: uses the helper
instead of hardcoded sslmode=disable.
deploy/docker-compose.yml: CERTCTL_DATABASE_URL is now
${CERTCTL_DATABASE_URL:-...} so operators override without editing.
docs/database-tls.md (NEW): operator runbook covering 4 deployment
shapes, RDS verify-full example with PGSSLROOTCERT mount, and
pg_stat_ssl verification query.
helm template + helm lint clean.
M-025 (OWASP ASVS L2 §11.2.1) — Per-key rate limiting
internal/api/middleware/middleware.go::NewRateLimiter rewritten from
a single global tokenBucket to a keyedRateLimiter map keyed on
'user:'+GetUser(ctx) for authenticated callers
'ip:'+RemoteAddr-host for unauthenticated
- Empty UserKey strings treated as unauthenticated.
- X-Forwarded-For intentionally NOT consulted (header-spoofing risk).
- Create-on-demand bucket allocation under sync.RWMutex with double-
check pattern.
RateLimitConfig.PerUserRPS / PerUserBurstSize fields with env vars
CERTCTL_RATE_LIMIT_PER_USER_RPS / CERTCTL_RATE_LIMIT_PER_USER_BURST
allow per-user budgets distinct from per-IP.
internal/api/middleware/ratelimit_keyed_test.go (NEW, 5 tests):
TwoIPsHaveIndependentBuckets / SameUserDifferentIPsShareBucket /
TwoUsersHaveIndependentBuckets / PerUserBudgetOverride /
EmptyUserKeyTreatedAsAnonymous.
Coverage internal/api/middleware: 82.1% -> 83.7%.
Audit deliverables:
cowork/comprehensive-audit-2026-04-25/audit-report.md: score
25/55 -> 30/55 closed (High 7/9, Medium 7/27 -> 12/27, Low 8/19).
cowork/comprehensive-audit-2026-04-25/findings.yaml: 5 status flips
open -> closed with closure notes citing the Bundle B mechanism.
certctl/CHANGELOG.md: Bundle B section under [unreleased].
Verification:
go test -count=1 -short ./... all green
staticcheck on changed packages no new SA*/ST* hits
(the 4 pre-existing SA1019 sites in cmd/server/main_test.go are
Bundle 9 / M-028 partial closure leftovers tracked in Bundle C)
helm template + helm lint clean
internal/repository/postgres setup-fail sandbox disk pressure,
same on master HEAD before this branch — environmental, not Bundle B
This commit is contained in:
+155
-86
@@ -1,31 +1,48 @@
|
||||
// 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]:
|
||||
// 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:
|
||||
//
|
||||
// v2 (current, M-8)
|
||||
// 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 from the operator
|
||||
// passphrase and the per-ciphertext random salt.
|
||||
// — 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 from the operator
|
||||
// passphrase and the package-level fixed salt
|
||||
// — 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 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.
|
||||
// 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 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.
|
||||
// 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 (
|
||||
@@ -58,26 +75,48 @@ import (
|
||||
// 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).
|
||||
// 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 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
|
||||
// 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 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
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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]
|
||||
@@ -173,7 +212,8 @@ func DeriveKey(passphrase string) []byte {
|
||||
}
|
||||
|
||||
// deriveKeyWithSalt derives a 32-byte AES-256 key from a passphrase and an
|
||||
// explicit salt using PBKDF2-SHA256 with [pbkdf2Iterations] rounds.
|
||||
// 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
|
||||
@@ -182,87 +222,100 @@ 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).
|
||||
// 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 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
|
||||
// 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
|
||||
}
|
||||
return blob[0] != v2Magic
|
||||
first := blob[0]
|
||||
return first != v2Magic && first != v3Magic
|
||||
}
|
||||
|
||||
// EncryptIfKeySet encrypts plaintext with the supplied passphrase and emits a
|
||||
// v2 wire-format blob: magic(0x02) || salt(16) || nonce(12) || ciphertext+tag.
|
||||
// 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).
|
||||
// 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
|
||||
// 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, v2SaltSize)
|
||||
salt := make([]byte, v3SaltSize)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to generate v2 salt: %w", err)
|
||||
return nil, false, fmt.Errorf("failed to generate v3 salt: %w", err)
|
||||
}
|
||||
|
||||
key := deriveKeyWithSalt(passphrase, salt)
|
||||
key := deriveKeyWithSaltV3(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)
|
||||
// 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 both
|
||||
// v2 (M-8 and later) and v1 (legacy) on-disk formats.
|
||||
// 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
|
||||
// [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].
|
||||
// [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.
|
||||
//
|
||||
// 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).
|
||||
// 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.
|
||||
//
|
||||
// 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].
|
||||
// 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
|
||||
@@ -271,8 +324,22 @@ func DecryptIfKeySet(blob []byte, passphrase string) ([]byte, error) {
|
||||
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).
|
||||
// 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:]
|
||||
@@ -280,14 +347,16 @@ func DecryptIfKeySet(blob []byte, passphrase string) ([]byte, error) {
|
||||
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.
|
||||
// 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).
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user