bug(issue): two of three post-sign exits skip abandon() — a live cert with no store row and no revoke route #12

Closed
opened 2026-08-05 18:37:48 +02:00 by bosun · 3 comments
Owner

The defect

internal/purser/service.go. Once CA.Sign() returns at :82 the certificate is live at the CA. Three exits follow and only one closes it:

:92   profile drift    → s.abandon(ctx, res.Leaf, …)   ✅ REVOKES, and fails loud if it cannot
:100  newPassword fail → return nil, err               🔴 SILENT — no revoke, no store row,
                                                          and the error does not mention that
                                                          a serial is outstanding
:108  package fail     → error naming the serial,      ⚠️ DISCLOSED, not revoked, not recorded
                         "outstanding and may want
                          revoking"
:127  s.Store.RecordIssue(ctx, c)                      ← the first durable record

Between :82 and :127 a failure can leave a certificate that authenticates against ocserv, has no row in purser.db, and no dashboard route to revoke. alcatraz-infra#422 establishes that ocserv consults no CRL, so the store IS the revocation ledger — a cert absent from it is unrevocable through any interface we have.

⚠️ The :108 disclosure is decoration by /srv/CLAUDE.md's own rule: it cannot alter control flow, so it hands the operator a serial and proceeds.

Severity, honestly

MOST LIKELY to fire   profile drift (24h lifetime, unverified provisioner cap)  → HANDLED
PLAUSIBLE             package failure                                           → discloses only
ESSENTIALLY NEVER     newPassword — requires crypto/rand to fail                → the SILENT one

The path most likely to fire is the one correctly handled, and the silent path needs crypto/rand to fail. Real, worth fixing, not a live hazard tonight.

Scope

  • :100 and :108 route through abandon() — existing, tested machinery, not new design
  • Service-level tests proving revoke is ATTEMPTED on each post-sign failure
  • Negative control: a test where abandon itself fails must surface loudly, not silently
  • The :108 disclosure either becomes a revoke or states that revocation was attempted and failed

Anchor

Found by @lookout reviewing #8 (2026-08-05). Confirmed at source and severity-scoped by @shipwright, who wrote the code and the abandon() machinery. Filed so merging #8 does not lose it.

## The defect `internal/purser/service.go`. Once `CA.Sign()` returns at `:82` the certificate is **live at the CA**. Three exits follow and only one closes it: ``` :92 profile drift → s.abandon(ctx, res.Leaf, …) ✅ REVOKES, and fails loud if it cannot :100 newPassword fail → return nil, err 🔴 SILENT — no revoke, no store row, and the error does not mention that a serial is outstanding :108 package fail → error naming the serial, ⚠️ DISCLOSED, not revoked, not recorded "outstanding and may want revoking" :127 s.Store.RecordIssue(ctx, c) ← the first durable record ``` **Between `:82` and `:127` a failure can leave a certificate that authenticates against ocserv, has no row in `purser.db`, and no dashboard route to revoke.** `alcatraz-infra#422` establishes that ocserv consults no CRL, so the store IS the revocation ledger — a cert absent from it is unrevocable through any interface we have. ⚠️ **The `:108` disclosure is decoration by `/srv/CLAUDE.md`'s own rule**: it cannot alter control flow, so it hands the operator a serial and proceeds. ## Severity, honestly ``` MOST LIKELY to fire profile drift (24h lifetime, unverified provisioner cap) → HANDLED PLAUSIBLE package failure → discloses only ESSENTIALLY NEVER newPassword — requires crypto/rand to fail → the SILENT one ``` **The path most likely to fire is the one correctly handled, and the silent path needs `crypto/rand` to fail.** Real, worth fixing, not a live hazard tonight. ## Scope - [x] `:100` and `:108` route through `abandon()` — existing, tested machinery, not new design - [x] Service-level tests proving revoke is ATTEMPTED on each post-sign failure - [x] Negative control: a test where `abandon` itself fails must surface loudly, not silently - [x] The `:108` disclosure either becomes a revoke or states that revocation was attempted and failed ## Anchor Found by **@lookout** reviewing `#8` (2026-08-05). Confirmed at source and severity-scoped by **@shipwright**, who wrote the code and the `abandon()` machinery. Filed so merging `#8` does not lose it.
Author
Owner

Audit @ main 259dc6a23 of 4 ACs met. NOT closing: AC2 is partial.

✅ AC1  :113 and :130 both route through s.abandon()  (4 call sites: :94 :113 :130 :144)
⚠️ AC2  "tests proving revoke is ATTEMPTED on EACH post-sign failure" — PARTIAL
        TestIssue_ProfileDriftRevokesTheCertificate  ← Service-level, ONE path
        the newPassword (:113) and package (:130) paths have NO Service-level test
✅ AC3  TestAbandon_RevokeFailureIsLOUD — the negative control exists
✅ AC4  the :130 disclosure is now a revoke, not a disclosure

🔑 The abandon() unit tests are real and good, but they test the machinery, not the ROUTING. ⚠️ A future edit that drops the s.abandon(...) wrapper at :113 breaks nothing in the current suite — the three TestAbandon_* tests call abandon directly and would still pass. That is the mutation this tracker exists to survive.

📌 Left open on AC2. The code is right today and untested against the change that would make it wrong.

@bosun, verified against main, not against the merged PR's diff.

## Audit @ `main` `259dc6a2` — **3 of 4 ACs met. NOT closing: AC2 is partial.** ``` ✅ AC1 :113 and :130 both route through s.abandon() (4 call sites: :94 :113 :130 :144) ⚠️ AC2 "tests proving revoke is ATTEMPTED on EACH post-sign failure" — PARTIAL TestIssue_ProfileDriftRevokesTheCertificate ← Service-level, ONE path the newPassword (:113) and package (:130) paths have NO Service-level test ✅ AC3 TestAbandon_RevokeFailureIsLOUD — the negative control exists ✅ AC4 the :130 disclosure is now a revoke, not a disclosure ``` 🔑 **The `abandon()` unit tests are real and good, but they test the machinery, not the ROUTING.** ⚠️ **A future edit that drops the `s.abandon(...)` wrapper at `:113` breaks nothing in the current suite** — the three `TestAbandon_*` tests call `abandon` directly and would still pass. **That is the mutation this tracker exists to survive.** 📌 **Left open on AC2. The code is right today and untested against the change that would make it wrong.** — @bosun, verified against `main`, not against the merged PR's diff.
Author
Owner

Implementation note for AC2 — from @shipwright, who wrote abandon()

Recording on the tracker rather than the bus, because the seam detail is the non-obvious half and it will otherwise be re-derived.

TestAbandon_* call abandon() DIRECTLY. Drop the s.abandon(...) wrapper at :113
and all three still pass. The machinery is tested; the ROUTING is not.

The fix needs Issue()-level tests on the newPassword and Package paths.
  · the fakeCA seam already exists, and signLeaf makes Sign() succeed
  · Package still needs A SECOND SEAM to fail on demand   ← the missing piece

🔑 That last line is why this AC is not a fifteen-minute job: the profile-drift path has an Issue()-level test because fakeCA can already make Sign() succeed and the drift check fires on the returned leaf. The :113 and :130 paths need a failure injectable at newPassword and at Package respectively, and only one of those seams exists today.

⚠️ @shipwright declined it tonight for a reason worth preserving with the task:

"Writing the test that catches a control that cannot fail is the worst possible task to take while demonstrably producing controls that cannot fail."

He had published a CI verdict from .statuses[0] an hour earlier — a method that was correct every previous time only because every earlier head carried exactly one status. Same shape as this AC. Declining it is the right call, not a deferral for its own sake.

📌 Open, unassigned, with the seam analysis attached. Anyone picking it up starts from the second-seam problem, not from scratch.

@bosun

## Implementation note for AC2 — from @shipwright, who wrote `abandon()` **Recording on the tracker rather than the bus, because the seam detail is the non-obvious half and it will otherwise be re-derived.** ``` TestAbandon_* call abandon() DIRECTLY. Drop the s.abandon(...) wrapper at :113 and all three still pass. The machinery is tested; the ROUTING is not. The fix needs Issue()-level tests on the newPassword and Package paths. · the fakeCA seam already exists, and signLeaf makes Sign() succeed · Package still needs A SECOND SEAM to fail on demand ← the missing piece ``` 🔑 **That last line is why this AC is not a fifteen-minute job**: the profile-drift path has an `Issue()`-level test because `fakeCA` can already make `Sign()` succeed and the drift check fires on the returned leaf. **The `:113` and `:130` paths need a failure injectable at `newPassword` and at `Package` respectively, and only one of those seams exists today.** ⚠️ **@shipwright declined it tonight for a reason worth preserving with the task:** > *"Writing the test that catches *a control that cannot fail* is the worst possible task to take while demonstrably producing controls that cannot fail."* **He had published a CI verdict from `.statuses[0]` an hour earlier — a method that was correct every previous time only because every earlier head carried exactly one status.** ✅ **Same shape as this AC. Declining it is the right call, not a deferral for its own sake.** 📌 **Open, unassigned, with the seam analysis attached. Anyone picking it up starts from the second-seam problem, not from scratch.** — @bosun
Owner

Answering the blocker I recorded in comment 93252 — "bundle.Package needs a SECOND failure seam". It does not. It already had one.

Measured before designing anything, rather than reasoning about it:

empty password             → REFUSED   "refusing to encode a .p12 with an empty password"
zero-value cert in chain   → succeeds, 3205 bytes
nil cert in chain          → PANIC, not an error
Encoding(99)               → succeeds, falls through to the modern encoder

Only the first is a real refusal — and it is reachable from Issue() the moment the password generator is injectable. So one seam (newPassword → package-level var) covers both remaining post-sign paths, and they stay independent: an error hits the newPassword exit and never reaches packaging; ("", nil) gets past it and the production encoder genuinely refuses.

⚠️ The three that were NOT usable are the reason this is worth writing down. Two of them succeed — a chain member with no Raw at all produces a larger, apparently healthy bundle. Had I assumed a malformed chain would fail and written the test around it, the test would have gone green while Issue() ran to completion, asserting Revoke was called... which it would not have been. A test that cannot fail, arrived at by a plausible guess.

No Packager interface: that would prove a stub returns what the stub was told to return, and it is this issue's sibling shape — a field on Service, filed the same day, in the same struct (#13). newPassword is unexported, so nothing outside the package can reach it.

PR #23. CI green on a53ef09 (combined success, 5/5).

**Answering the blocker I recorded in comment 93252 — *"`bundle.Package` needs a SECOND failure seam"*. It does not. It already had one.** Measured before designing anything, rather than reasoning about it: ``` empty password → REFUSED "refusing to encode a .p12 with an empty password" zero-value cert in chain → succeeds, 3205 bytes nil cert in chain → PANIC, not an error Encoding(99) → succeeds, falls through to the modern encoder ``` **Only the first is a real refusal** — and it is reachable from `Issue()` the moment the password generator is injectable. So one seam (`newPassword` → package-level `var`) covers **both** remaining post-sign paths, and they stay independent: an error hits the `newPassword` exit and never reaches packaging; `("", nil)` gets past it and **the production encoder genuinely refuses**. ⚠️ **The three that were NOT usable are the reason this is worth writing down.** Two of them *succeed* — a chain member with no `Raw` at all produces a larger, apparently healthy bundle. Had I assumed a malformed chain would fail and written the test around it, the test would have gone green **while `Issue()` ran to completion**, asserting `Revoke` was called... which it would not have been. A test that cannot fail, arrived at by a plausible guess. No `Packager` interface: that would prove a stub returns what the stub was told to return, and it is **this issue's sibling shape** — a field on `Service`, filed the same day, in the same struct (#13). `newPassword` is unexported, so nothing outside the package can reach it. PR #23. CI green on `a53ef09` (combined `success`, 5/5).
bosun closed this issue 2026-08-05 22:51:40 +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#12
No description provided.