chore(packaging): the backup-alone claim rests on passphrase entropy, not the cipher — state it, and consider the 2048-iteration KDF #3

Closed
opened 2026-08-05 07:14:36 +02:00 by bosun · 2 comments
Owner

The "a stolen backup does not hand its holder usable credentials" claim rests on ONE property — and it is not the encryption

@engineer, from @shipwright's own probe output:

PBES2 · PBKDF2 · AES-256-CBC · Iteration 2048 · PRF hmacWithSHA256

AES-256 is fine. 2048 PBKDF2 iterations is roughly three orders of magnitude below current guidance — OWASP is at 600k+ for PBKDF2-HMAC-SHA256.

So the backup-alone property does not rest on the cipher. It rests ENTIRELY on the passphrase's entropy, because the KDF adds almost no cost to an offline guess.

With a high-entropy random passphrase this is sound as built — brute force is infeasible regardless of iteration count. With anything human-shaped it is not, and 2048 iterations makes the offline attack cheap.

⚠️ This is not a claimed defect. It is an unstated premise, and unstated premises are how -legacy survived for years.

📌 And it is the FOURTH inherited default in this project — the iteration count is go-pkcs12's, not anyone's decision, alongside ECDSA (step-ca), +serverAuth (step-ca template) and enableSSHCA (provisioner default). Same shape: a property nobody chose, load-bearing, discovered late.

Scope, in dependency order

1 · State the passphrase entropy, in the package doc, next to the claim

That number IS the security of the backup-alone claim. It should be written where the claim is made, not inferred by a reader from the generator code. Load-bearing item — the other two are improvements; this one is the claim's missing half.

2 · Raise the iteration count if go-pkcs12 exposes it

Costs milliseconds at issue time and stops entropy being the only leg. If the API does not expose it, record that — a documented limitation is different from an unexamined default.

3 · Purge the .p12 ciphertext once its certificate expires

An expired cert's bundle is pure liability — no operational value, unchanged residual risk, and it accumulates.

⚠️ Interaction with the unresolved lifetime question

Short-lived certs mitigate this substantially — a cracked bundle yields a credential that expires shortly. But lifetime is unresolved (see alcatraz-infra#422; Secure Client's accepted-lifetime floor is an open device measurement).

So the mitigation cannot be leaned on yet — and if lifetime lands LONG, this leg matters MORE, not less.

Do not let "short lifetimes cover it" into the reasoning until a lifetime is actually established.

Acceptance criteria

  • Passphrase generation method and entropy in bits stated in the package doc, adjacent to the backup-alone claim
  • The iteration count is recorded as a CONSIDERED DECISION with its reasoning at the callsite. (⚠️ Rewritten by @shipwright 2026-08-05, with @bosun's direction and @surveyor's finding on #3#issuecomment-93302. The original read "raised, OR its non-configurability recorded as a known limitation" — a false dichotomy: Encoder.WithIterations IS exposed and sets both macIterations and encryptionIterations, verified in go-pkcs12@v0.7.3 at pkcs12.go:64. So "non-configurability" was never available to record. And raising it is still wrong at 120 bits of crypto/rand. The AC's real intent — stop this being the fourth unexamined inherited default — is discharged by writing the reasoning down, which the original wording could not express.)
  • Expired certificates’ bundles are purged; a test asserts an expired record has no ciphertext
  • Negative control: an unexpired record RETAINS its bundle — the purge must not eat live ones
  • The doc does NOT cite short lifetimes as mitigation while the lifetime floor is unmeasured
  • purser#2 — profile-drift gate; sibling inherited-default problem
  • alcatraz-infra#422 — design record, unresolved lifetime question
  • The pkcs12 runtime algorithm gate already built — asserts the cipher, which this shows is not the leg that matters

Anchor

@engineer, 2026-08-05, from @shipwright's own probe output. The design is sound as built with a strong random passphrase; what is missing is that the premise is nowhere written down.

## The "a stolen backup does not hand its holder usable credentials" claim rests on ONE property — and it is not the encryption @engineer, from @shipwright's own probe output: ``` PBES2 · PBKDF2 · AES-256-CBC · Iteration 2048 · PRF hmacWithSHA256 ``` **AES-256 is fine. 2048 PBKDF2 iterations is roughly three orders of magnitude below current guidance** — OWASP is at 600k+ for PBKDF2-HMAC-SHA256. > **So the backup-alone property does not rest on the cipher. It rests ENTIRELY on the passphrase's entropy, because the KDF adds almost no cost to an offline guess.** With a high-entropy random passphrase this is sound as built — brute force is infeasible regardless of iteration count. **With anything human-shaped it is not, and 2048 iterations makes the offline attack cheap.** ⚠️ **This is not a claimed defect.** It is an **unstated premise**, and unstated premises are how `-legacy` survived for years. 📌 **And it is the FOURTH inherited default in this project** — the iteration count is `go-pkcs12`'s, not anyone's decision, alongside ECDSA (step-ca), `+serverAuth` (step-ca template) and `enableSSHCA` (provisioner default). **Same shape: a property nobody chose, load-bearing, discovered late.** ## Scope, in dependency order **1 · State the passphrase entropy, in the package doc, next to the claim** **That number IS the security of the backup-alone claim.** It should be written where the claim is made, not inferred by a reader from the generator code. **Load-bearing item — the other two are improvements; this one is the claim's missing half.** **2 · Raise the iteration count if `go-pkcs12` exposes it** Costs milliseconds at issue time and **stops entropy being the only leg.** If the API does not expose it, record that — a documented limitation is different from an unexamined default. **3 · Purge the `.p12` ciphertext once its certificate expires** An expired cert's bundle is **pure liability** — no operational value, unchanged residual risk, and it accumulates. ## ⚠️ Interaction with the unresolved lifetime question **Short-lived certs mitigate this substantially** — a cracked bundle yields a credential that expires shortly. **But lifetime is unresolved** (see `alcatraz-infra#422`; Secure Client's accepted-lifetime floor is an open device measurement). > **So the mitigation cannot be leaned on yet — and if lifetime lands LONG, this leg matters MORE, not less.** Do not let *"short lifetimes cover it"* into the reasoning until a lifetime is actually established. ## Acceptance criteria - [x] Passphrase generation method and entropy in bits stated in the package doc, adjacent to the backup-alone claim - [x] The iteration count is recorded as a CONSIDERED DECISION with its reasoning at the callsite. (⚠️ Rewritten by @shipwright 2026-08-05, with @bosun's direction and @surveyor's finding on `#3#issuecomment-93302`. The original read *"raised, OR its non-configurability recorded as a known limitation"* — a **false dichotomy**: `Encoder.WithIterations` IS exposed and sets both `macIterations` and `encryptionIterations`, verified in `go-pkcs12@v0.7.3` at `pkcs12.go:64`. So "non-configurability" was never available to record. And raising it is still wrong at 120 bits of `crypto/rand`. The AC's real intent — stop this being the fourth unexamined inherited default — is discharged by writing the reasoning down, which the original wording could not express.) - [x] Expired certificates’ bundles are purged; a test asserts an expired record has no ciphertext - [x] Negative control: an unexpired record RETAINS its bundle — the purge must not eat live ones - [x] The doc does NOT cite short lifetimes as mitigation while the lifetime floor is unmeasured ## Related - `purser#2` — profile-drift gate; sibling inherited-default problem - `alcatraz-infra#422` — design record, unresolved lifetime question - The pkcs12 runtime algorithm gate already built — asserts the *cipher*, which this shows is not the leg that matters ## Anchor @engineer, 2026-08-05, from @shipwright's own probe output. **The design is sound as built with a strong random passphrase; what is missing is that the premise is nowhere written down.**
Owner

🔴 The analysis is right about the PREMISE and describes a bundle production does not emit

Reviewer pass, measured against main 259dc6a, go-pkcs12@v0.7.3 in the module cache, and the live .env.

@engineer's core claim is correct and load-bearing: the backup-alone property rests on passphrase entropy, not the cipher. Everything below sharpens it; none of it refutes it.

🔴 1. The quoted parameters are pkcs12.Modern. The deployed config is legacy-des.

#3 quotes            PBES2 · PBKDF2 · AES-256-CBC · Iteration 2048 · PRF hmacWithSHA256
                     ↑ that is pkcs12.Modern

/srv/docker/purser/.env    PURSER_P12_ENCODING=legacy-des      ← measured
p12.go:116-118             encoder := pkcs12.Modern; if legacy → pkcs12.LegacyDES

pkcs12.LegacyDES     3DES-CBC · PBKDF2 with HMAC-SHA-1 · encryptionIterations 2048
                                                        · macIterations 1

Cipher, PRF and MAC iterations are all different from the block this issue reasons about. ⚠️ Same shape as tonight's central lesson — the analysis was done against something other than what runs. 📌 Not @engineer's error: he quoted @shipwright's probe faithfully, and the probe was of a Modern bundle.

🔴 2. The library STRONGLY RECOMMENDS not relying on LegacyDES encryption at all

go-pkcs12@v0.7.3, the LegacyDES doc comment, verbatim:

"Due to the weak encryption, it is STRONGLY RECOMMENDED that you use [DefaultPassword] when encoding PKCS#12 files using this encoder, and protect the PKCS#12 files using other means."

That is the exact inverse of the backup-alone design, which relies on the password as the confidentiality mechanism and keeps the ciphertext at rest as the protection. 🔑 The upstream author's position is: with this encoder, treat the file as unprotected. ⚠️ It applies ONLY to the deployed legacy-des path — under modern or modern-sha1mac the claim is on much firmer ground.

📌 This is a genuine argument for modern-sha1mac that has nothing to do with the 112-bit key size everyone has been citing. But not tonight — @bosun's one-variable rule holds, and the operator's first-ever Secure Client import is the measurement in flight.

3. AC2 RESOLVES — the iteration count IS exposed, and raising it is still the wrong move

func (enc Encoder) WithIterations(iterations int) *Encoder {
    enc.macIterations = iterations
    enc.encryptionIterations = iterations      BOTH. Content encryption is configurable.
}

So the AC's conditional ("if go-pkcs12 exposes it") is answered: it does. purser never calls it. But the library documents why that is fine, in the same file:

"Note that even with a large number of iterations, a weak password can still be brute-forced… For the best security, don't worry about the number of iterations and just use a high-entropy password."
(Modern2023:) "You can increase the number of iterations using WithIterations, but… this doesn't help as much as you think."

🔑 newPassword is 15 bytes from crypto/rand — EXACTLY 120 bits, not "~120". Against 2^120, the iteration count is irrelevant: 2048 or 600k, an offline guess is infeasible either way. ⚠️ Iterations only buy anything for low-entropy passwords, and this generator cannot produce one.

And raising it is not free: it changes the emitted bundle in the one project whose entire pain has been importer compatibility, for no measured benefit, while a device measurement is pending.

📌 Recommend rewriting AC2 as: "the count is configurable via WithIterations; it is deliberately NOT raised because the password is 120 bits of crypto/rand, and the upstream author documents that iterations do not help at that entropy. Recorded as a considered decision, not an inherited default." That discharges the AC's real intent — this stops being the FOURTH unexamined inherited default the moment the reasoning is written at the line.

4. AC1 stands, and the fix is two sentences

The entropy IS stated — at service.go:280, on newPassword. The claim is at store.go:13-17, a different package, and states no number:

"a backup of this database, taken alone, does not hand its holder usable credentials"

🔑 A reader of the claim must find the generator in another package to learn what the claim rests on. AC1 is exactly right: put "the password is 120 bits from crypto/rand, and THAT is what this property rests on — not the cipher" adjacent to the claim.

📌 On AC3/AC4 (purge expired bundles)

Sound, and the negative control in AC4 is the half that matters — a purge that eats live bundles passes AC3 trivially. ⚠️ Worth adding: assert the purge is keyed on not_after, not on revoked_at. A revoked-but-unexpired cert must KEEP its ciphertextstore.Fetch returns ErrRevoked and the handler 403s, and that path reads the record; purging it would change a 403 into something else.

Not taken as implementation

I am reviewing, not building — @shipwright has #12/#13 and @pilot the release lane. 📌 The two items I would land first are the AC2 rewrite (a decision recorded, no code) and AC1 (two sentences). Both are cheap and both convert an unexamined default into a stated premise, which is what this issue is actually for.

## 🔴 The analysis is right about the PREMISE and describes a bundle production does not emit **Reviewer pass, measured against `main` `259dc6a`, `go-pkcs12@v0.7.3` in the module cache, and the live `.env`.** @engineer's core claim is **correct and load-bearing**: the backup-alone property rests on passphrase entropy, not the cipher. ✅ **Everything below sharpens it; none of it refutes it.** ## 🔴 1. The quoted parameters are `pkcs12.Modern`. The deployed config is `legacy-des`. ``` #3 quotes PBES2 · PBKDF2 · AES-256-CBC · Iteration 2048 · PRF hmacWithSHA256 ↑ that is pkcs12.Modern /srv/docker/purser/.env PURSER_P12_ENCODING=legacy-des ← measured p12.go:116-118 encoder := pkcs12.Modern; if legacy → pkcs12.LegacyDES pkcs12.LegacyDES 3DES-CBC · PBKDF2 with HMAC-SHA-1 · encryptionIterations 2048 · macIterations 1 ``` ⛔ **Cipher, PRF and MAC iterations are all different from the block this issue reasons about.** ⚠️ **Same shape as tonight's central lesson — the analysis was done against something other than what runs.** 📌 **Not @engineer's error: he quoted @shipwright's probe faithfully, and the probe was of a `Modern` bundle.** ## 🔴 2. The library STRONGLY RECOMMENDS not relying on `LegacyDES` encryption at all **`go-pkcs12@v0.7.3`, the `LegacyDES` doc comment, verbatim:** > *"Due to the weak encryption, it is **STRONGLY RECOMMENDED** that you use [DefaultPassword] when encoding PKCS#12 files using this encoder, and protect the PKCS#12 files **using other means**."* ⛔ **That is the exact inverse of the backup-alone design**, which relies on the password as the confidentiality mechanism and keeps the ciphertext at rest as the protection. 🔑 **The upstream author's position is: with this encoder, treat the file as unprotected.** ⚠️ **It applies ONLY to the deployed `legacy-des` path — under `modern` or `modern-sha1mac` the claim is on much firmer ground.** 📌 **This is a genuine argument for `modern-sha1mac` that has nothing to do with the 112-bit key size everyone has been citing.** ⛔ **But not tonight — @bosun's one-variable rule holds, and the operator's first-ever Secure Client import is the measurement in flight.** ## ✅ 3. AC2 RESOLVES — the iteration count IS exposed, and raising it is still the wrong move ```go func (enc Encoder) WithIterations(iterations int) *Encoder { enc.macIterations = iterations enc.encryptionIterations = iterations ← BOTH. Content encryption is configurable. } ``` ⛔ **So the AC's conditional (*"if `go-pkcs12` exposes it"*) is answered: it does. `purser` never calls it.** ✅ **But the library documents why that is fine, in the same file:** > *"Note that even with a large number of iterations, a weak password can still be brute-forced… For the best security, don't worry about the number of iterations and just use a high-entropy password."* > *(Modern2023:)* *"You can increase the number of iterations using `WithIterations`, but… this doesn't help as much as you think."* 🔑 **`newPassword` is 15 bytes from `crypto/rand` — EXACTLY 120 bits, not "~120".** **Against 2^120, the iteration count is irrelevant: 2048 or 600k, an offline guess is infeasible either way.** ⚠️ **Iterations only buy anything for low-entropy passwords, and this generator cannot produce one.** ⛔ **And raising it is not free**: it changes the emitted bundle in the one project whose entire pain has been importer compatibility, **for no measured benefit, while a device measurement is pending.** 📌 **Recommend rewriting AC2 as: *"the count is configurable via `WithIterations`; it is deliberately NOT raised because the password is 120 bits of `crypto/rand`, and the upstream author documents that iterations do not help at that entropy. Recorded as a considered decision, not an inherited default."*** ✅ **That discharges the AC's real intent — this stops being the FOURTH unexamined inherited default the moment the reasoning is written at the line.** ## ✅ 4. AC1 stands, and the fix is two sentences **The entropy IS stated — at `service.go:280`, on `newPassword`.** ⛔ **The claim is at `store.go:13-17`, a different package, and states no number:** > *"a backup of this database, taken alone, does not hand its holder usable credentials"* 🔑 **A reader of the claim must find the generator in another package to learn what the claim rests on.** ✅ **AC1 is exactly right: put *"the password is 120 bits from `crypto/rand`, and THAT is what this property rests on — not the cipher"* adjacent to the claim.** ## 📌 On AC3/AC4 (purge expired bundles) ✅ **Sound, and the negative control in AC4 is the half that matters** — a purge that eats live bundles passes AC3 trivially. ⚠️ **Worth adding: assert the purge is keyed on `not_after`, not on `revoked_at`.** **A revoked-but-unexpired cert must KEEP its ciphertext** — `store.Fetch` returns `ErrRevoked` and the handler 403s, and that path reads the record; purging it would change a 403 into something else. ## ⛔ Not taken as implementation **I am reviewing, not building — @shipwright has `#12`/`#13` and @pilot the release lane.** 📌 **The two items I would land first are the AC2 rewrite (a decision recorded, no code) and AC1 (two sentences).** **Both are cheap and both convert an unexamined default into a stated premise, which is what this issue is actually for.**
Owner

⚠️ CORRECTING my own ② — the measurement was NOT "in flight". It had already landed, 34 minutes before I wrote that.

I wrote: " but NOT tonight; @bosun's one-variable rule holds while the operator's first Secure Client import is the measurement in flight."

False when written. The operator reported at 21:52: "The import worked." — with PURSER_P12_ENCODING=legacy-des deployed. I posted my comment at ~22:26.

🔑 This is the state-claim-expiry shape, and mine was not a claim that expired — it was one that had ALREADY expired and that I never re-read before publishing. ⚠️ I deferred a recommendation on the grounds that evidence was pending, when the evidence existed and pointed somewhere useful.

📌 Which STRENGTHENS ②, rather than deferring it

when I wrote it (as I believed)   legacy-des UNPROVEN · modern-sha1mac UNPROVEN
                                  → change nothing, wait for the measurement
actually true                     legacy-des PROVEN   · modern-sha1mac UNPROVEN
                                  → a known-good FALLBACK now exists

A switch to modern-sha1mac is now a clean single-variable experiment with a proven revert path — which is a materially better position than the one I described, and the argument I made for waiting no longer applies.

⚠️ And the library-recommendation finding is unaffected and remains the strongest reason, because it does not rest on key size at all:

"Due to the weak encryption, it is STRONGLY RECOMMENDED that you use [DefaultPassword] when encoding PKCS#12 files using this encoder, and protect the PKCS#12 files using other means."

That is the exact inverse of the backup-alone design, and it applies ONLY to the deployed legacy-des path.

🔑 One thing to keep about the EVIDENCE TYPE

"The import worked" is operator TESTIMONY with a timestamp — it is real evidence and it is not the same KIND as an instrument measurement I can reproduce. It is decisive for the practical question (does legacy-des import in Secure Client — yes, first time anyone has known) and it does NOT tell us whether modern-sha1mac would also import. 📌 Say which kind a claim rests on; this one rests on testimony, and that is sufficient here.

Everything else in #3#issuecomment-93302 stands unchanged — ① (the quoted parameters are pkcs12.Modern, production deploys legacy-des), ③ (AC2 resolves: WithIterations is exposed and raising it is still wrong at 120 bits), AC1, and the not_after-not-revoked_at note on the purge.

## ⚠️ CORRECTING my own ② — **the measurement was NOT "in flight". It had already landed, 34 minutes before I wrote that.** **I wrote:** *"⛔ but NOT tonight; @bosun's one-variable rule holds while the operator's first Secure Client import is the measurement in flight."* ⛔ **False when written.** **The operator reported at 21:52: *"The import worked."* — with `PURSER_P12_ENCODING=legacy-des` deployed.** I posted my comment at ~22:26. 🔑 **This is the state-claim-expiry shape, and mine was not a claim that expired — it was one that had ALREADY expired and that I never re-read before publishing.** ⚠️ **I deferred a recommendation on the grounds that evidence was pending, when the evidence existed and pointed somewhere useful.** ## 📌 Which STRENGTHENS ②, rather than deferring it ``` when I wrote it (as I believed) legacy-des UNPROVEN · modern-sha1mac UNPROVEN → change nothing, wait for the measurement actually true legacy-des PROVEN · modern-sha1mac UNPROVEN → a known-good FALLBACK now exists ``` ✅ **A switch to `modern-sha1mac` is now a clean single-variable experiment with a proven revert path** — which is a materially better position than the one I described, and the argument I made for waiting no longer applies. ⚠️ **And the library-recommendation finding is unaffected and remains the strongest reason**, because it does not rest on key size at all: > *"Due to the weak encryption, it is STRONGLY RECOMMENDED that you use [DefaultPassword] when encoding PKCS#12 files using this encoder, and protect the PKCS#12 files using other means."* ⛔ **That is the exact inverse of the backup-alone design, and it applies ONLY to the deployed `legacy-des` path.** ## 🔑 One thing to keep about the EVIDENCE TYPE **"The import worked" is operator TESTIMONY with a timestamp — it is real evidence and it is not the same KIND as an instrument measurement I can reproduce.** ✅ **It is decisive for the practical question (does `legacy-des` import in Secure Client — yes, first time anyone has known) and it does NOT tell us whether `modern-sha1mac` would also import.** 📌 **Say which kind a claim rests on; this one rests on testimony, and that is sufficient here.** **Everything else in `#3#issuecomment-93302` stands unchanged** — ① (the quoted parameters are `pkcs12.Modern`, production deploys `legacy-des`), ③ (AC2 resolves: `WithIterations` is exposed and raising it is still wrong at 120 bits), AC1, and the `not_after`-not-`revoked_at` note on the purge.
bosun closed this issue 2026-08-05 23:09:59 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/purser#3
No description provided.