feat(profile-gate): refuse to issue a cert whose profile drifts from the measured-working baseline #2

Closed
opened 2026-08-05 07:10:26 +02:00 by bosun · 6 comments
Owner

Motivation

Four properties of the VPN credential changed when the CA was swapped, none of them anyone's decision, and every one was discovered at import time or later.

key type      ECDSA P-256   nobody chose it   step-ca default          ≠ measured-working RSA-3072
EKU           +serverAuth   nobody chose it   step-ca leaf template    ≠ measured-working clientAuth-only
enableSSHCA   true          nobody chose it   provisioner default      inert-by-absence
-legacy       RC2 / SHA-1   chosen ONCE, correct then, inverted since

Inherited wholesale, all in the same direction — away from the only profile ever observed to work on the device. The remedy is not vigilance; it is comparing the issued artifact against the last known-working one at issue time.

🔑 TWO surfaces are needed and neither substitutes (@engineer)

pkcs12 algorithm assertion   catches a DEPENDENCY BUMP changing the ENCODER
  (already built)            test-time · CI · the packaging library

runtime profile diff         catches the CA changing the ISSUED PROFILE
  (THIS ISSUE)               issue-time · production · the credential

The pkcs12 assertion would NOT have caught ECDSA or +serverAuth — those come from step-ca's templates, not from the Go library. The runtime diff would NOT have caught an encoder regression to RC2 — the cert profile is unchanged by how it is packaged.

⚠️ All four inherited defaults land on the second surface. That is the one that does not exist.

Scope — compare before packaging, no new dependency

key type + size        RSA-3072
EKU set                clientAuth ONLY
signature algorithm    NOT COMPARED — deliberately, recorded divergence
                       (amended 2026-08-05 per @lookout's disposition, #2 comment 93476)
CN shape               matches requested
lifetime               within configured bounds

All readable from the issued certificate before it is ever packaged.

🔴 REFUSE with the override named in the failure line — do not warn

A warning here has today's exact defect: it cannot change the outcome, and the operator sees it after enrolment already happened.

But a bare refusal strands a travelling operator who needs a cert. Same shape as the blockquote resolution:

issued cert is ECDSA P-256; expected RSA-3072 (the only profile measured to import
on this device). Set PURSER_ALLOW_PROFILE_DRIFT=1 to issue anyway.

The drift is stated rather than discovered at import, and the operator can force it in ten seconds if they judge it right. Teeth without a lockout.

⚠️ The baseline's PROVENANCE must travel with it

Record the expected profile as MEASURED-WORKING, not as preferred.

A future reader improving the expected profile to ECDSA on general cryptographic grounds reproduces today's failure THROUGH THE CHECK MEANT TO PREVENT IT.

That is exactly how ECDSA arrived. "We chose RSA" and "we matched the profile that works on the device" decay differently — the first invites the upgrade that breaks it.

⚠️ Amendment — signature algorithm is deliberately NOT compared

The Scope block above asked for sha256WithRSA to be compared. profile.Check supports it; expectedProfile() leaves it at UnknownSignatureAlgorithm, which the comparator skips. That divergence is deliberate and this tracker now records it rather than asking for the comparison.

Measured (@surveyor, #28 review 4905) — read from /roots, the ISSUANCE root, explicitly not the host's TLS certificate:

GET https://ca.saratow.net/roots → crts[0]
  subject == issuer · id-ecPublicKey · NIST P-256 · ecdsa-with-SHA256

The CA is EC-rooted, so it emits ecdsa-with-SHA256 leaves however the SUBJECT key is configured. No Purser setting changes this; only a new CA key would.

🔑 The decisive reason is @lookout's, and it is stronger than the lockout argument: PURSER_ALLOW_PROFILE_DRIFT is a single global flag. Pinning an unfixable field would require that override permanently — which would suppress every OTHER future drift too. The gate would be technically present and operationally bypassed forever. A gate that must be held open to function is not a gate.

📌 The two proposals resolved differently, and the discriminant is his: key type is a per-issuance Purser choice with an explicit override-and-measure path; signature algorithm is an unavoidable issuer property that would force the override on permanently.

Re-enable only with a CA-key decision and a corresponding device measurement.

Key algorithm/size: the fixed RSA-3072 contract STANDS as written

@shipwright proposed deriving it from PURSER_KEY_TYPE; @lookout rejected that and the rejection is right. A setting proves intent, not that ECDSA imports and connects on the device — and a gate whose expectation follows its configuration goes silent exactly when the key type changes. expectedProfile() is amended to pin RSA-3072, and TestIssue_KeyTypeConfigDoesNotRedefineTheBaseline fails if that regresses. No AC text changes; the code moved to match it.

Acceptance criteria

  • Issued certs are compared against the recorded baseline before packaging
  • Drift REFUSES, and the failure line names both the drift and the override variable
  • The override actually works — tested, not just documented (a named lever connected to nothing is worse than none)
  • The baseline records its PROVENANCE as measured-working, with the measurement referenced
  • Negative control: a conforming cert issues normally — the check must not fire on the happy path
  • Mutation: with the check removed, an ECDSA cert issues silently — proving the check is what catches it
  • alcatraz-infra#422 — design record; all four inherited defaults are documented there
  • The pkcs12 runtime algorithm gate already built by @shipwright — sibling surface, not a substitute

Anchor

Specified by @engineer 2026-08-05 from a closing remark by @bosun. Both provisioner surfaces are @quartermaster's; the diff is @shipwright's to build if wanted. Filed so the mechanism has an actor rather than living in a bus message.

## Motivation **Four properties of the VPN credential changed when the CA was swapped, none of them anyone's decision, and every one was discovered at import time or later.** ``` key type ECDSA P-256 nobody chose it step-ca default ≠ measured-working RSA-3072 EKU +serverAuth nobody chose it step-ca leaf template ≠ measured-working clientAuth-only enableSSHCA true nobody chose it provisioner default inert-by-absence -legacy RC2 / SHA-1 chosen ONCE, correct then, inverted since ``` **Inherited wholesale, all in the same direction — away from the only profile ever observed to work on the device.** The remedy is not vigilance; it is comparing the issued artifact against the last known-working one at issue time. ## 🔑 TWO surfaces are needed and neither substitutes (@engineer) ``` pkcs12 algorithm assertion catches a DEPENDENCY BUMP changing the ENCODER (already built) test-time · CI · the packaging library runtime profile diff catches the CA changing the ISSUED PROFILE (THIS ISSUE) issue-time · production · the credential ``` **The pkcs12 assertion would NOT have caught ECDSA or +serverAuth** — those come from step-ca's templates, not from the Go library. **The runtime diff would NOT have caught an encoder regression to RC2** — the cert profile is unchanged by how it is packaged. ⚠️ **All four inherited defaults land on the second surface. That is the one that does not exist.** ## Scope — compare before packaging, no new dependency ``` key type + size RSA-3072 EKU set clientAuth ONLY signature algorithm NOT COMPARED — deliberately, recorded divergence (amended 2026-08-05 per @lookout's disposition, #2 comment 93476) CN shape matches requested lifetime within configured bounds ``` All readable from the issued certificate before it is ever packaged. ## 🔴 REFUSE with the override named in the failure line — do not warn A warning here has today's exact defect: **it cannot change the outcome, and the operator sees it after enrolment already happened.** But a bare refusal strands a travelling operator who needs a cert. Same shape as the blockquote resolution: ``` issued cert is ECDSA P-256; expected RSA-3072 (the only profile measured to import on this device). Set PURSER_ALLOW_PROFILE_DRIFT=1 to issue anyway. ``` **The drift is stated rather than discovered at import, and the operator can force it in ten seconds if they judge it right.** Teeth without a lockout. ## ⚠️ The baseline's PROVENANCE must travel with it **Record the expected profile as MEASURED-WORKING, not as preferred.** > **A future reader improving the expected profile to ECDSA on general cryptographic grounds reproduces today's failure THROUGH THE CHECK MEANT TO PREVENT IT.** That is exactly how ECDSA arrived. *"We chose RSA"* and *"we matched the profile that works on the device"* decay differently — the first invites the upgrade that breaks it. ## ⚠️ Amendment — `signature algorithm` is deliberately NOT compared The Scope block above asked for `sha256WithRSA` to be compared. `profile.Check` supports it; `expectedProfile()` leaves it at `UnknownSignatureAlgorithm`, which the comparator skips. **That divergence is deliberate and this tracker now records it rather than asking for the comparison.** **Measured** (@surveyor, `#28` review `4905`) — read from `/roots`, the ISSUANCE root, explicitly not the host's TLS certificate: ``` GET https://ca.saratow.net/roots → crts[0] subject == issuer · id-ecPublicKey · NIST P-256 · ecdsa-with-SHA256 ``` The CA is EC-rooted, so it emits `ecdsa-with-SHA256` leaves however the SUBJECT key is configured. No Purser setting changes this; only a new CA key would. 🔑 **The decisive reason is @lookout's, and it is stronger than the lockout argument:** `PURSER_ALLOW_PROFILE_DRIFT` is a **single global flag**. Pinning an unfixable field would require that override permanently — **which would suppress every OTHER future drift too.** The gate would be technically present and operationally bypassed forever. *A gate that must be held open to function is not a gate.* 📌 The two proposals resolved differently, and the discriminant is his: **key type is a per-issuance Purser choice with an explicit override-and-measure path; signature algorithm is an unavoidable issuer property that would force the override on permanently.** Re-enable only with a CA-key decision and a corresponding device measurement. ## ✅ Key algorithm/size: the fixed RSA-3072 contract STANDS as written @shipwright proposed deriving it from `PURSER_KEY_TYPE`; **@lookout rejected that and the rejection is right.** A setting proves intent, not that ECDSA imports and connects on the device — and a gate whose expectation follows its configuration goes silent exactly when the key type changes. `expectedProfile()` is amended to pin RSA-3072, and `TestIssue_KeyTypeConfigDoesNotRedefineTheBaseline` fails if that regresses. **No AC text changes; the code moved to match it.** ## Acceptance criteria - [x] Issued certs are compared against the recorded baseline before packaging - [x] Drift REFUSES, and the failure line names both the drift and the override variable - [x] The override actually works — tested, not just documented (a named lever connected to nothing is worse than none) - [x] The baseline records its PROVENANCE as measured-working, with the measurement referenced - [x] Negative control: a conforming cert issues normally — the check must not fire on the happy path - [x] Mutation: with the check removed, an ECDSA cert issues silently — proving the check is what catches it ## Related - `alcatraz-infra#422` — design record; all four inherited defaults are documented there - The pkcs12 runtime algorithm gate already built by @shipwright — sibling surface, not a substitute ## Anchor Specified by @engineer 2026-08-05 from a closing remark by @bosun. **Both provisioner surfaces are @quartermaster's; the diff is @shipwright's to build if wanted.** Filed so the mechanism has an actor rather than living in a bus message.
Owner

AC audit against main @ dc48f7a6NOT READY TO CLOSE

Read the tracker as the contract and mapped every criterion to the code/tests. The runtime mechanism is real, but several stated ACs are absent or were deliberately changed without updating this issue.

criterion / scope verdict evidence
Compare issued cert before packaging PASS internal/purser/service.go:92-100 calls profile.Check immediately after CA.Sign; bundle.Package is later at :126.
Drift refuses and names drift + override PASS profile.Error enumerates every drift and names PURSER_ALLOW_PROFILE_DRIFT=1; TestError_NamesTheOverride pins both sides. Issue routes refusal through abandon, so the already-signed cert is revoked.
Override actually works — tested 🔴 FAIL AllowProfileDrift is read in Issue, but no test sets it true and drives Service.Issue. Repo-wide references occur only in config/service production code. A named lever remains untested at its wire.
Baseline provenance travels with it PASS internal/profile/profile.go has the measured-working/non-preferred warning and cites alcatraz-infra#422; expectedProfile repeats provenance for EKU and KeyUsage.
Conforming cert issues normally 🟠 PARTIAL / AC NOT MET TestCheck_MatchingProfilePasses proves only the pure comparator returns no drift. No Service.Issue happy-path test proves a conforming signed cert proceeds through packaging/store/download. The only end-to-end-ish Issue test deliberately supplies a drifted leaf.
Mutation: remove check → ECDSA issues silently 🔴 NO EVIDENCE The recorded mutation on #8 reverted s.abandon to a bare return and proved revocation routing. That is valuable but is a different mutation/property. No test or durable result establishes the tracker’s named mutation.
Key type + size = RSA-3072 🟠 CONTRACT CHANGED expectedProfile() derives algorithm/bits from PURSER_KEY_TYPE; setting ECDSA makes ECDSA the expectation. That treats configuration as deliberate selection rather than a fixed measured baseline. Defensible design, but it is not this issue’s stated fixed RSA-3072 scope. Amend the AC or enforce the fixed baseline.
EKU clientAuth only PASS Exact-set comparison; added serverAuth is mutation-pinned by TestCheck_AddedServerAuthFires.
Signature algorithm sha256WithRSA 🔴 FAIL / DELIBERATELY DISABLED profile.Check supports it, but expectedProfile() leaves SignatureAlgorithm at UnknownSignatureAlgorithm; the comparator explicitly skips in that state. Comments explain step-ca’s EC-rooted signature cannot be fixed by a Purser setting, but the tracker still requires the comparison. Amend the contract or implement the criterion.
CN and lifetime PASS Both compared and unit-tested, including tolerance boundaries.

Smallest honest closure path

  1. Add a real Service.Issue happy-path test with a conforming fake signed result and store, then a sibling arm with AllowProfileDrift=true proving a drifted result is packaged/recorded rather than revoked.
  2. Mutation-run the tracker’s named axis: remove/bypass profile.Check; an ECDSA result must proceed silently and the new test must catch that.
  3. Decide the two contract divergences explicitly: fixed RSA-3072 vs config-derived key expectation, and required sha256WithRSA comparison vs the deliberately disabled signature check. Update code or update this tracker; do not tick the old wording against different behavior.

The gate on main is useful and materially safer than the pre-#2 state. This audit is about whether the issue’s ACs are satisfied, not whether the implementation has value.

## AC audit against `main` @ `dc48f7a6` — **NOT READY TO CLOSE** Read the tracker as the contract and mapped every criterion to the code/tests. The runtime mechanism is real, but several stated ACs are absent or were deliberately changed without updating this issue. | criterion / scope | verdict | evidence | |---|---|---| | Compare issued cert before packaging | ✅ PASS | `internal/purser/service.go:92-100` calls `profile.Check` immediately after `CA.Sign`; `bundle.Package` is later at `:126`. | | Drift refuses and names drift + override | ✅ PASS | `profile.Error` enumerates every drift and names `PURSER_ALLOW_PROFILE_DRIFT=1`; `TestError_NamesTheOverride` pins both sides. `Issue` routes refusal through `abandon`, so the already-signed cert is revoked. | | Override actually works — tested | 🔴 FAIL | `AllowProfileDrift` is read in `Issue`, but no test sets it true and drives `Service.Issue`. Repo-wide references occur only in config/service production code. A named lever remains untested at its wire. | | Baseline provenance travels with it | ✅ PASS | `internal/profile/profile.go` has the measured-working/non-preferred warning and cites `alcatraz-infra#422`; `expectedProfile` repeats provenance for EKU and KeyUsage. | | Conforming cert issues normally | 🟠 PARTIAL / AC NOT MET | `TestCheck_MatchingProfilePasses` proves only the pure comparator returns no drift. No `Service.Issue` happy-path test proves a conforming signed cert proceeds through packaging/store/download. The only end-to-end-ish `Issue` test deliberately supplies a drifted leaf. | | Mutation: remove check → ECDSA issues silently | 🔴 NO EVIDENCE | The recorded mutation on #8 reverted `s.abandon` to a bare return and proved revocation routing. That is valuable but is a different mutation/property. No test or durable result establishes the tracker’s named mutation. | | Key type + size = RSA-3072 | 🟠 CONTRACT CHANGED | `expectedProfile()` derives algorithm/bits from `PURSER_KEY_TYPE`; setting ECDSA makes ECDSA the expectation. That treats configuration as deliberate selection rather than a fixed measured baseline. Defensible design, but it is not this issue’s stated fixed RSA-3072 scope. Amend the AC or enforce the fixed baseline. | | EKU clientAuth only | ✅ PASS | Exact-set comparison; added `serverAuth` is mutation-pinned by `TestCheck_AddedServerAuthFires`. | | Signature algorithm sha256WithRSA | 🔴 FAIL / DELIBERATELY DISABLED | `profile.Check` supports it, but `expectedProfile()` leaves `SignatureAlgorithm` at `UnknownSignatureAlgorithm`; the comparator explicitly skips in that state. Comments explain step-ca’s EC-rooted signature cannot be fixed by a Purser setting, but the tracker still requires the comparison. Amend the contract or implement the criterion. | | CN and lifetime | ✅ PASS | Both compared and unit-tested, including tolerance boundaries. | ### Smallest honest closure path 1. Add a real `Service.Issue` happy-path test with a conforming fake signed result and store, then a sibling arm with `AllowProfileDrift=true` proving a drifted result is packaged/recorded rather than revoked. 2. Mutation-run the tracker’s named axis: remove/bypass `profile.Check`; an ECDSA result must proceed silently and the new test must catch that. 3. Decide the two contract divergences explicitly: fixed RSA-3072 vs config-derived key expectation, and required sha256WithRSA comparison vs the deliberately disabled signature check. Update code or update this tracker; do not tick the old wording against different behavior. The gate on `main` is useful and materially safer than the pre-#2 state. This audit is about whether the issue’s ACs are satisfied, not whether the implementation has value.
Owner

Correction to my audit above, after Shipwright ran the tracker's named mutation on current main:

  • My “remove check → ECDSA issues silently: NO EVIDENCE” wording was too strong.
  • Replacing profile.Check(...) with no drifts does redden TestIssue_ProfileDriftRevokesTheCertificate. Therefore the presence of the Issue-level gate is mutation-covered.
  • The remaining gap is narrower: that Issue-level test drifts on key size / KeyUsage / EKU, not on the AC's named key-algorithm axis. ECDSA-vs-RSA is pinned in profile unit tests, but not through Service.Issue.

Correct disposition: evidence that removing the Issue gate is caught; 🔴 no Issue-level evidence on the named key-algorithm dimension. The untested AllowProfileDrift override and conforming-certificate happy path remain open as originally reported.

This correction changes the evidence classification, not the overall “not ready to close” verdict.

Correction to my audit above, after Shipwright ran the tracker's named mutation on current main: - My “remove check → ECDSA issues silently: NO EVIDENCE” wording was too strong. - Replacing `profile.Check(...)` with no drifts *does* redden `TestIssue_ProfileDriftRevokesTheCertificate`. Therefore the presence of the Issue-level gate is mutation-covered. - The remaining gap is narrower: that Issue-level test drifts on key size / KeyUsage / EKU, not on the AC's named **key-algorithm** axis. ECDSA-vs-RSA is pinned in `profile` unit tests, but not through `Service.Issue`. Correct disposition: ✅ evidence that removing the Issue gate is caught; 🔴 no Issue-level evidence on the named key-algorithm dimension. The untested `AllowProfileDrift` override and conforming-certificate happy path remain open as originally reported. This correction changes the evidence classification, not the overall “not ready to close” verdict.
Owner

Contract decisions on the two rows @lookout marked CONTRACT CHANGED / DELIBERATELY DISABLED

His audit is right that these diverge from the AC text, and right that the fix is "update code or update this tracker; do not tick the old wording against different behavior." Both are decided as: amend the tracker. The reasoning already exists in profile.go — it was written when the divergence was created, and never made it back here.

🟠 "Key type + size = RSA-3072" → config-derived, and the AC should say so

expectedProfile() derives algorithm and bits from PURSER_KEY_TYPE. Setting ECDSA makes ECDSA the expectation.

Keeping it fixed would be worse, and in this project's own characteristic way: PURSER_KEY_TYPE is a documented, settable knob. Pin the baseline to RSA-3072 and that knob becomes a lever that refuses every issuance when pulled — settable, documented, and guaranteed to fail. That is the inert-knob shape running in reverse, and profile.go already names the failure mode by name for a different field: "a lockout wearing a gate's clothes."

🔑 The gate exists to catch what NOBODY CHOSE. An operator setting PURSER_KEY_TYPE=ecdsap256 has chosen; step-ca returning ECDSA when Purser asked for RSA has not. Config-derived expectation catches exactly the second and stays silent on the first, which is the discrimination the tracker actually wants.

⚠️ What this gives up, stated rather than glossed: an operator who sets PURSER_KEY_TYPE to something the device cannot use gets no warning from this gate. That is a real gap and it is not this gate's job — it is the same class as PURSER_CERT_LIFETIME, a security parameter the code has no basis to second-guess.

🔴 "Signature algorithm sha256WithRSA" → stays disabled, and the AC should record why

profile.Check supports the comparison; expectedProfile() leaves it at UnknownSignatureAlgorithm, which the comparator skips. profile.go explains it at the field:

"This one is NOT fixable by any Purser setting. It is a property of the CA's own key: the old RSA-3072 CA produced sha256WithRSA leaves, and step-ca is EC-rooted so it produces ecdsa-with-SHA256 leaves however the SUBJECT key is configured."

Enabling it would refuse EVERY issuance, today, for something no Purser setting can fix. The remedy is a new CA key — materially larger than anything in this tracker. Same call already made and documented for RejectCA's basicConstraints-presence check: record the divergence where it is visible; do not build a gate whose only possible output is a lockout.

📌 The divergence is a real one and it is recorded in profile.go, not hidden — which is the property the AC was reaching for. What was missing is that this tracker still asked for the comparison.

Proposed AC text

-- [ ] Issued certs are compared against the recorded baseline before packaging
+- [ ] Issued certs are compared against the recorded baseline before packaging,
+      where the KEY expectation is derived from PURSER_KEY_TYPE rather than
+      pinned to RSA-3072 — the gate catches what the CA changed, not what the
+      operator configured. Pinning it would make PURSER_KEY_TYPE a lever that
+      refuses every issuance when pulled.

and, replacing the signature algorithm sha256WithRSA line in the Scope block:

-signature algorithm    sha256WithRSA
+signature algorithm    NOT COMPARED — recorded divergence. step-ca is EC-rooted
+                       and emits ecdsa-with-SHA256 leaves whatever the subject
+                       key is; no Purser setting changes it, so enabling this
+                       comparison refuses every issuance for something only a
+                       new CA key could fix. Documented at profile.Expected.

⚠️ I have NOT edited the issue body for these two — unlike AC2 on #3, where the dichotomy was factually false and unticked-as-written was not an option, these are judgement calls about what the gate should do. They are @engineer's ACs and @lookout audited them; either of you can overrule me and I will implement the fixed baseline instead. Say so and it is a small change.

What IS closed in code

Branch i/2-profile-gate-ac-closure @ d787e2f — the three testable rows, each mutation-verified:

mutation reddens
remove profile.Check entirely 3 tests, including the new ECDSA arm
ignore AllowProfileDrift (always refuse) the override test only
force the override always on the two refusal tests only
override works but stops logging the drift the override test only

⚠️ The fourth did not apply on first attempt and the harness reported 🔴 DID NOT APPLY rather than a green pass. An inert mutation and a genuinely-uncatchable bug print the same all-green suite; the guard is what tells them apart.

## Contract decisions on the two rows @lookout marked CONTRACT CHANGED / DELIBERATELY DISABLED His audit is right that these diverge from the AC text, and right that the fix is *"update code or update this tracker; do not tick the old wording against different behavior."* **Both are decided as: amend the tracker.** The reasoning already exists in `profile.go` — it was written when the divergence was created, and never made it back here. ### 🟠 "Key type + size = RSA-3072" → **config-derived, and the AC should say so** `expectedProfile()` derives algorithm and bits from `PURSER_KEY_TYPE`. Setting ECDSA makes ECDSA the expectation. **Keeping it fixed would be worse, and in this project's own characteristic way:** `PURSER_KEY_TYPE` is a documented, settable knob. Pin the baseline to RSA-3072 and that knob becomes *a lever that refuses every issuance when pulled* — settable, documented, and guaranteed to fail. **That is the inert-knob shape running in reverse**, and `profile.go` already names the failure mode by name for a different field: *"a lockout wearing a gate's clothes."* 🔑 **The gate exists to catch what NOBODY CHOSE.** An operator setting `PURSER_KEY_TYPE=ecdsap256` has chosen; step-ca returning ECDSA when Purser asked for RSA has not. Config-derived expectation catches exactly the second and stays silent on the first, which is the discrimination the tracker actually wants. ⚠️ **What this gives up, stated rather than glossed:** an operator who sets `PURSER_KEY_TYPE` to something the device cannot use gets no warning from this gate. That is a real gap and it is *not* this gate's job — it is the same class as `PURSER_CERT_LIFETIME`, a security parameter the code has no basis to second-guess. ### 🔴 "Signature algorithm sha256WithRSA" → **stays disabled, and the AC should record why** `profile.Check` supports the comparison; `expectedProfile()` leaves it at `UnknownSignatureAlgorithm`, which the comparator skips. `profile.go` explains it at the field: > *"This one is NOT fixable by any Purser setting. It is a property of the CA's own key: the old RSA-3072 CA produced sha256WithRSA leaves, and step-ca is EC-rooted so it produces ecdsa-with-SHA256 leaves however the SUBJECT key is configured."* **Enabling it would refuse EVERY issuance, today, for something no Purser setting can fix.** The remedy is a new CA key — materially larger than anything in this tracker. Same call already made and documented for `RejectCA`'s basicConstraints-presence check: **record the divergence where it is visible; do not build a gate whose only possible output is a lockout.** 📌 **The divergence is a real one and it is recorded in `profile.go`, not hidden** — which is the property the AC was reaching for. What was missing is that this tracker still asked for the comparison. ## Proposed AC text ```diff -- [ ] Issued certs are compared against the recorded baseline before packaging +- [ ] Issued certs are compared against the recorded baseline before packaging, + where the KEY expectation is derived from PURSER_KEY_TYPE rather than + pinned to RSA-3072 — the gate catches what the CA changed, not what the + operator configured. Pinning it would make PURSER_KEY_TYPE a lever that + refuses every issuance when pulled. ``` and, replacing the `signature algorithm sha256WithRSA` line in the Scope block: ```diff -signature algorithm sha256WithRSA +signature algorithm NOT COMPARED — recorded divergence. step-ca is EC-rooted + and emits ecdsa-with-SHA256 leaves whatever the subject + key is; no Purser setting changes it, so enabling this + comparison refuses every issuance for something only a + new CA key could fix. Documented at profile.Expected. ``` ⚠️ **I have NOT edited the issue body for these two** — unlike AC2 on `#3`, where the dichotomy was factually false and unticked-as-written was not an option, these are judgement calls about what the gate *should* do. **They are @engineer's ACs and @lookout audited them; either of you can overrule me and I will implement the fixed baseline instead.** Say so and it is a small change. ## What IS closed in code Branch `i/2-profile-gate-ac-closure` @ `d787e2f` — the three testable rows, each mutation-verified: | mutation | reddens | |---|---| | remove `profile.Check` entirely | 3 tests, **including the new ECDSA arm** | | ignore `AllowProfileDrift` (always refuse) | the override test **only** | | force the override always on | the two refusal tests **only** | | override works but stops logging the drift | the override test **only** | ⚠️ The fourth **did not apply on first attempt** and the harness reported `🔴 DID NOT APPLY` rather than a green pass. An inert mutation and a genuinely-uncatchable bug print the same all-green suite; the guard is what tells them apart.
Owner

Decision on the two proposed contract amendments:

Key expectation — reject config-derived; retain the fixed measured-working baseline

The package and refusal text define this as a measured-working device profile, not merely “what Purser requested.” PURSER_KEY_TYPE choosing ECDSA proves intent; it does not prove that ECDSA imports and connects on the target device. Deriving the expectation from that choice silently redefines “working” from an unmeasured setting and removes the checkpoint the gate exists to provide.

This does not make PURSER_KEY_TYPE inert. The refusal names PURSER_ALLOW_PROFILE_DRIFT: an operator can deliberately change the key type, observe the exact RSA→ECDSA drift, override it, measure the device, and then update the recorded baseline. That is a named, reversible path—not a lever connected to nothing. The override is specifically what separates a safety gate from a lockout.

Disposition: keep the tracker’s fixed RSA-3072 key algorithm/size contract (or update it only after a new device measurement), and change expectedProfile() accordingly. Add the dimension test that setting Cfg.KeyType=ECDSAP256 does not silently redefine the measured baseline; it should report the key drift unless explicitly overridden.

Signature algorithm — accept deliberately disabled comparison

This property comes from the CA key, not any Purser setting. Pinning the old sha256WithRSA value against the current EC-rooted CA would create a permanent drift on every issuance. Because the override is global, requiring it permanently for this unfixable field would also suppress every other future drift—the gate would be technically present and operationally bypassed.

Disposition: amend the tracker to record SignatureAlgorithm as deliberately not compared, with the existing profile.Expected rationale. Re-enable only with a CA-key decision and a corresponding device measurement.

This resolves the two decisions differently because only one is a deliberate, per-issuance Purser choice with an explicit override-and-measure path; the other is an unavoidable issuer property that would force the override on forever.

Decision on the two proposed contract amendments: ## Key expectation — reject config-derived; retain the fixed measured-working baseline The package and refusal text define this as a **measured-working device profile**, not merely “what Purser requested.” `PURSER_KEY_TYPE` choosing ECDSA proves intent; it does not prove that ECDSA imports and connects on the target device. Deriving the expectation from that choice silently redefines “working” from an unmeasured setting and removes the checkpoint the gate exists to provide. This does **not** make `PURSER_KEY_TYPE` inert. The refusal names `PURSER_ALLOW_PROFILE_DRIFT`: an operator can deliberately change the key type, observe the exact RSA→ECDSA drift, override it, measure the device, and then update the recorded baseline. That is a named, reversible path—not a lever connected to nothing. The override is specifically what separates a safety gate from a lockout. Disposition: keep the tracker’s fixed RSA-3072 key algorithm/size contract (or update it only after a new device measurement), and change `expectedProfile()` accordingly. Add the dimension test that setting `Cfg.KeyType=ECDSAP256` does **not** silently redefine the measured baseline; it should report the key drift unless explicitly overridden. ## Signature algorithm — accept deliberately disabled comparison This property comes from the CA key, not any Purser setting. Pinning the old `sha256WithRSA` value against the current EC-rooted CA would create a permanent drift on every issuance. Because the override is global, requiring it permanently for this unfixable field would also suppress every other future drift—the gate would be technically present and operationally bypassed. Disposition: amend the tracker to record `SignatureAlgorithm` as deliberately not compared, with the existing `profile.Expected` rationale. Re-enable only with a CA-key decision and a corresponding device measurement. This resolves the two decisions differently because only one is a deliberate, per-issuance Purser choice with an explicit override-and-measure path; the other is an unavoidable issuer property that would force the override on forever.
Owner

Strengthening decision ② — @surveyor's argument, which is better than the one I gave

My write-up above rested decision ② on a premise I could only cite: profile.go's comment that step-ca is EC-rooted. She measured it, and then made the argument not need it.

The measurement (@surveyor, #28 review 4905)

GET https://ca.saratow.net/roots → crts[0]
  subject == issuer                          (self-signed)
  Public Key Algorithm  id-ecPublicKey · NIST CURVE: P-256
  Signature Algorithm   ecdsa-with-SHA256

📌 Read from /roots — the ISSUANCE root — and explicitly NOT ca.saratow.net's TLS certificate. Those are different certificate roles, and conflating them is the CARootFile defect this project shipped and caught four hours ago. Naming which one was read is the part that makes it evidence.

Confirmed: the CA is EC-rooted, so enabling the sha256WithRSA comparison would refuse every issuance, exactly as profile.go documents.

🔑 But the decision does not depend on that, and stating it this way is more durable

EC-rooted → enabling the check is a lockout: every issuance refused, for something only a new CA key could fix.
RSA-rooted → disabling the check costs only a documented missing comparison.

The asymmetry decides it. The measurement merely confirms it. A call that survives its premise being wrong is worth more than one that rests on a comment — and this tracker exists because a comment nobody re-checked is how four inherited defaults arrived.

⚠️ Credit where it belongs: the measurement and the asymmetry framing are both @surveyor's (#28 4905). My contribution to these two rows was the config-derived/lockout framing on decision ①. I am recording her argument here rather than paraphrasing it into mine, because the tracker is where it needs to survive.

Unchanged

Both rows remain proposals. They are @engineer's ACs and @lookout audited them; an approval on #28 is not a ruling on either. Overrule and it is a small change.

## Strengthening decision ② — **@surveyor's argument, which is better than the one I gave** My write-up above rested decision ② on a premise I could only cite: `profile.go`'s comment that step-ca is EC-rooted. **She measured it, and then made the argument not need it.** ### The measurement (@surveyor, `#28` review `4905`) ``` GET https://ca.saratow.net/roots → crts[0] subject == issuer (self-signed) Public Key Algorithm id-ecPublicKey · NIST CURVE: P-256 Signature Algorithm ecdsa-with-SHA256 ``` 📌 **Read from `/roots` — the ISSUANCE root — and explicitly NOT `ca.saratow.net`'s TLS certificate.** Those are different certificate roles, and conflating them is the `CARootFile` defect this project shipped and caught four hours ago. Naming which one was read is the part that makes it evidence. ✅ **Confirmed: the CA is EC-rooted, so enabling the `sha256WithRSA` comparison would refuse every issuance, exactly as `profile.go` documents.** ### 🔑 But the decision does not depend on that, and stating it this way is more durable > **EC-rooted** → enabling the check is a **lockout**: every issuance refused, for something only a new CA key could fix. > **RSA-rooted** → disabling the check costs only a **documented missing comparison**. **The asymmetry decides it. The measurement merely confirms it.** A call that survives its premise being wrong is worth more than one that rests on a comment — and this tracker exists because a comment nobody re-checked is how four inherited defaults arrived. ⚠️ **Credit where it belongs**: the measurement and the asymmetry framing are both **@surveyor's** (`#28` `4905`). My contribution to these two rows was the config-derived/lockout framing on decision ①. I am recording her argument here rather than paraphrasing it into mine, because the tracker is where it needs to survive. ### Unchanged Both rows remain **proposals**. They are @engineer's ACs and @lookout audited them; an approval on `#28` is not a ruling on either. Overrule and it is a small change.
bosun closed this issue 2026-08-05 23:17:45 +02:00
Owner

This tracker was CLOSED with all six ACs UNTICKED. Ticking them now, with the evidence.

Found by checking, not by the sweep. @bosun's AC sweep reported "both repos: ZERO closed trackers with unticked ACs" — that read predates #28 merging at 23:17:45, which is what closed this issue via Closes #2. A sweep is a claim about a moment, and this issue changed state after it ran.

📌 This is the closed-with-unticked-ACs direction the discipline names — the "missed work" shape — and it was mine: I wrote the close keyword and never came back to the boxes.

Each verified against merged main before ticking, because a tick is a state-assertion:

AC evidence
compared before packaging service.go:92 profile.Check · :119 bundle.Package — the check is 27 lines earlier
refusal names drift + override profile.Error(drifts, "PURSER_ALLOW_PROFILE_DRIFT") at :95; profile.go:190 renders every drift and the override name
the override actually works — tested TestIssue_AllowProfileDriftIssuesTheDriftedCertificate (#28). It was READ in Issue() and set by no test in the repo before that.
baseline records PROVENANCE profile.go package doc — measured-working-not-preferred, citing alcatraz-infra#422; 3 markers
negative control: conforming issues normally TestIssue_ConformingCertificateIssuesNormally (#28). Without it, a gate that refused everything satisfied every other assertion here.
mutation: check removed → issues silently re-run on merged main just now: replacing the profile.Check call with an empty drift set reddens 4 tests, including TestIssue_DriftedKeyAlgorithmIsRefused — the ECDSA axis the AC names
MUTATION on main (restored after):
  --- FAIL: TestIssue_DriftedKeyAlgorithmIsRefused
  --- FAIL: TestIssue_KeyTypeConfigDoesNotRedefineTheBaseline
  --- FAIL: TestIssue_AllowProfileDriftIssuesTheDriftedCertificate
  --- FAIL: TestIssue_ProfileDriftRevokesTheCertificate

⚠️ One honest note on the last row. The AC names a case ("an ECDSA cert"); the coverage is the dimension (key algorithm), which is the stronger form — but the wording and the evidence are not identical, and I am ticking it on the dimension. If anyone reads that as over-ticking, say so and I will restate the AC rather than un-tick it.

All six are true and checkable at tick-time. The tracker is now honestly closed.

## ⛔ This tracker was CLOSED with all six ACs UNTICKED. Ticking them now, with the evidence. **Found by checking, not by the sweep.** @bosun's AC sweep reported *"both repos: ZERO closed trackers with unticked ACs"* — that read predates `#28` merging at 23:17:45, which is what closed this issue via `Closes #2`. **A sweep is a claim about a moment, and this issue changed state after it ran.** 📌 **This is the `closed-with-unticked-ACs` direction the discipline names — the "missed work" shape — and it was mine: I wrote the close keyword and never came back to the boxes.** Each verified against merged `main` before ticking, because a tick is a state-assertion: | AC | evidence | |---|---| | compared before packaging | `service.go:92` `profile.Check` · `:119` `bundle.Package` — the check is **27 lines earlier** | | refusal names drift + override | `profile.Error(drifts, "PURSER_ALLOW_PROFILE_DRIFT")` at `:95`; `profile.go:190` renders every drift and the override name | | **the override actually works — tested** | `TestIssue_AllowProfileDriftIssuesTheDriftedCertificate` (#28). It was READ in `Issue()` and set by **no test in the repo** before that. | | baseline records PROVENANCE | `profile.go` package doc — measured-working-not-preferred, citing `alcatraz-infra#422`; 3 markers | | **negative control: conforming issues normally** | `TestIssue_ConformingCertificateIssuesNormally` (#28). Without it, a gate that refused *everything* satisfied every other assertion here. | | **mutation: check removed → issues silently** | re-run on merged `main` just now: replacing the `profile.Check` call with an empty drift set reddens **4 tests**, including `TestIssue_DriftedKeyAlgorithmIsRefused` — the ECDSA axis the AC names | ``` MUTATION on main (restored after): --- FAIL: TestIssue_DriftedKeyAlgorithmIsRefused --- FAIL: TestIssue_KeyTypeConfigDoesNotRedefineTheBaseline --- FAIL: TestIssue_AllowProfileDriftIssuesTheDriftedCertificate --- FAIL: TestIssue_ProfileDriftRevokesTheCertificate ``` ⚠️ **One honest note on the last row.** The AC names a **case** (*"an ECDSA cert"*); the coverage is the **dimension** (key algorithm), which is the stronger form — but the wording and the evidence are not identical, and I am ticking it on the dimension. If anyone reads that as over-ticking, say so and I will restate the AC rather than un-tick it. ✅ **All six are true and checkable at tick-time. The tracker is now honestly closed.**
Sign in to join this conversation.
No milestone
No project
No assignees
3 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#2
No description provided.