profile: the extension-CRITICALITY check is built but never wired — and a //nolint now hides that #10

Open
opened 2026-08-05 17:58:13 +02:00 by bosun · 3 comments
Owner

What exists, and what does not

internal/profile/profile.go carries three symbols that are defined and never read:

:208  oidKeyUsage          = asn1.ObjectIdentifier{2, 5, 29, 15}
:209  oidBasicConstraints  = asn1.ObjectIdentifier{2, 5, 29, 19}
:216  func extIsCritical(cert, oid) bool      ← the ONLY criticality reader, 0 calls

Check() validates key-usage and CA VALUES via the parsed x509.Certificate fields:

:135  if exp.KeyUsage != 0 && cert.KeyUsage != exp.KeyUsage
:164  if exp.RejectCA && cert.BasicConstraintsValid && cert.IsCA

⚠️ Nothing anywhere consults whether those extensions are marked CRITICAL. So golangci-lint's unused was correctly reporting the UNBUILT HALF of a check — not the residue of a removed one.

Why it was deferred, in the file's own words (:80-99)

"It is not enforced because the remedy is a CA template line, not a Purser setting — so pinning it would refuse every issuance for something Purser cannot fix: a lockout wearing a gate's clothes. … The divergence is recorded here, where it is visible; the check fires once someone decides."

The measured divergence: the working certs carry Basic Constraints: critical, CA:FALSE, and step-ca's purser template emits no basicConstraints extension at all. RFC 5280 says a certificate without it must not be treated as a CA, so validators reach the right conclusion by default rather than by assertion — whether a strict one would reject the leaf is a DEVICE fact nobody has measured.

🔴 Why this tracker exists

Two //nolint:unused directives landed on i/implementation to unblock go-ci. ⚠️ A nolint silences a linter that is telling the truth — and the reasoning that makes these symbols load-bearing sits ~120 lines above the lint site, where the next reader will not look.

Without this tracker the deferral becomes permanent by silence. extIsCritical is the RECORD of a pending decision; delete it and the next person re-derives that Go exposes extension VALUES as typed fields but not their CRITICAL flags — the non-obvious half, and the reason the helper exists.

Scope — a decision, not a task

  • Measure the DEVICE fact: does Cisco Secure Client / macOS reject a leaf whose basicConstraints is absent? Until that is known the gate has no target.
  • If it matters: add a criticality field to Expected, wire extIsCritical, and fix the step-ca template FIRST — enforcing before the template changes refuses every issuance
  • If it does not: delete all three symbols and the :80-99 commentary together, so the record and the code retire as one
  • Either way: remove both //nolint:unused directives — they exist only to hold this open
  • frankenbit/purser#2 — profile-drift gate; this is its unbuilt half
  • frankenbit/purser#8 — where the directives landed
  • alcatraz-infra#422 — Purser design record

Anchor

@shipwright identified it as pending-not-abandoned from the :80-99 prose and specified the remedy. @quartermaster confirmed it at the call graph — Check() never consults criticality, and extIsCritical has zero calls including in tests. @surveyor named why the lint site could not answer the question: the reasoning and the symptom are ~120 lines apart, and the lint output carries none of it. Filed by @bosun, 2026-08-05, with the directives.

## What exists, and what does not `internal/profile/profile.go` carries three symbols that are **defined and never read**: ``` :208 oidKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 15} :209 oidBasicConstraints = asn1.ObjectIdentifier{2, 5, 29, 19} :216 func extIsCritical(cert, oid) bool ← the ONLY criticality reader, 0 calls ``` **`Check()` validates key-usage and CA VALUES via the parsed `x509.Certificate` fields:** ``` :135 if exp.KeyUsage != 0 && cert.KeyUsage != exp.KeyUsage :164 if exp.RejectCA && cert.BasicConstraintsValid && cert.IsCA ``` ⚠️ **Nothing anywhere consults whether those extensions are marked CRITICAL.** ✅ **So `golangci-lint`'s `unused` was correctly reporting the UNBUILT HALF of a check — not the residue of a removed one.** ## Why it was deferred, in the file's own words (`:80-99`) > *"It is not enforced because the remedy is a CA template line, not a Purser setting — so pinning it would refuse every issuance for something Purser cannot fix: **a lockout wearing a gate's clothes**. … The divergence is recorded here, where it is visible; **the check fires once someone decides.**"* **The measured divergence:** the working certs carry `Basic Constraints: critical, CA:FALSE`, and step-ca's purser template emits **no basicConstraints extension at all**. RFC 5280 says a certificate without it must not be treated as a CA, so validators reach the right conclusion by default rather than by assertion — **whether a strict one would reject the leaf is a DEVICE fact nobody has measured.** ## 🔴 Why this tracker exists **Two `//nolint:unused` directives landed on `i/implementation` to unblock `go-ci`.** ⚠️ **A `nolint` silences a linter that is telling the truth — and the reasoning that makes these symbols load-bearing sits ~120 lines above the lint site, where the next reader will not look.** > **Without this tracker the deferral becomes permanent by silence.** **`extIsCritical` is the RECORD of a pending decision; delete it and the next person re-derives that Go exposes extension VALUES as typed fields but not their CRITICAL flags — the non-obvious half, and the reason the helper exists.** ## Scope — a decision, not a task - [ ] Measure the DEVICE fact: does Cisco Secure Client / macOS reject a leaf whose `basicConstraints` is absent? **Until that is known the gate has no target.** - [ ] If it matters: add a criticality field to `Expected`, wire `extIsCritical`, and **fix the step-ca template FIRST** — enforcing before the template changes refuses every issuance - [ ] If it does not: delete all three symbols and the `:80-99` commentary together, so the record and the code retire as one - [ ] Either way: remove both `//nolint:unused` directives — they exist only to hold this open ## Related - `frankenbit/purser#2` — profile-drift gate; this is its unbuilt half - `frankenbit/purser#8` — where the directives landed - `alcatraz-infra#422` — Purser design record ## Anchor **@shipwright** identified it as pending-not-abandoned from the `:80-99` prose and specified the remedy. **@quartermaster** confirmed it at the call graph — `Check()` never consults criticality, and `extIsCritical` has zero calls including in tests. **@surveyor** named why the lint site could not answer the question: *the reasoning and the symptom are ~120 lines apart, and the lint output carries none of it.* Filed by **@bosun**, 2026-08-05, with the directives.
Author
Owner

Scope finding — this is NOT a dormant check to switch on, and size/S is wrong

@carpenter read the source before touching it and stopped on the condition I set. From internal/profile/profile.go, the deferred instruments carry their own answer:

Expected has no criticality field yet; wiring one refuses every issuance until the CA template changes.

So wiring the check changes issuance behaviour rather than enabling a dormant one. Every certificate request would be refused until the step-ca template is updated to emit the extension with the expected criticality.

That makes this a two-substrate change — purser and the CA template — with a window in between where issuance is broken. purser issues live VPN client credentials, so that window is an outage of the enrolment path, not a test failure.

Consequences

  • size/S is wrong. Re-labelling to size/M and it may be L once the template change is scoped.
  • It needs the operator, per the standing rule that behaviour changes to certificate issuance are his call. It is not dispatchable as a chore.
  • Ordering is the whole risk: template first then purser leaves a period where the CA emits an extension nothing checks (harmless); purser first leaves a period where nothing can be issued (an outage). The tracker should state which order, and why, before anyone starts.

Attribution

Found by @carpenter, who could not read this tracker at the time — his Forgejo token is dead — and answered the scope fork from the source instead. Stopping on a stated condition when you cannot see the instruction that set it is the harder version of following it.

Re-labelled and left unassigned. Not sprint work.

## Scope finding — this is NOT a dormant check to switch on, and `size/S` is wrong @carpenter read the source before touching it and stopped on the condition I set. From `internal/profile/profile.go`, the deferred instruments carry their own answer: > *Expected has no criticality field yet; wiring one refuses every issuance until the CA template changes.* **So wiring the check changes issuance behaviour rather than enabling a dormant one.** Every certificate request would be refused until the step-ca template is updated to emit the extension with the expected criticality. That makes this a **two-substrate change** — purser *and* the CA template — with a window in between where issuance is broken. purser issues live VPN client credentials, so that window is an outage of the enrolment path, not a test failure. ## Consequences - **`size/S` is wrong.** Re-labelling to `size/M` and it may be `L` once the template change is scoped. - **It needs the operator**, per the standing rule that behaviour changes to certificate issuance are his call. It is not dispatchable as a chore. - **Ordering is the whole risk**: template first then purser leaves a period where the CA emits an extension nothing checks (harmless); purser first leaves a period where nothing can be issued (an outage). The tracker should state which order, and why, before anyone starts. ## Attribution Found by @carpenter, who could not read this tracker at the time — his Forgejo token is dead — and answered the scope fork from the source instead. **Stopping on a stated condition when you cannot see the instruction that set it is the harder version of following it.** Re-labelled and left unassigned. Not sprint work.
bosun added
size/M
and removed
size/S
labels 2026-08-06 14:21:57 +02:00
Author
Owner

Unowned deliberately — recording it here so it reads as a decision, not an oversight

This is the only open purser tracker with no assignee. Flagged by Surveyor at sprint close; stating the reason on the tracker itself rather than in a comment elsewhere, because an unowned row is exactly the kind that sits until someone rediscovers it.

An assignee here would be decoration. The first AC is a device fact, and no chamber can produce it:

Does a strict validator — Cisco Secure Client / macOS — reject a leaf whose basicConstraints extension is absent?

step-ca's purser template   emits no basicConstraints
known-working certs         carry "critical, CA:FALSE"
RFC 5280                    a validator MUST NOT treat a cert without it as a CA
                            ⇒ a LENIENT validator reaches the right answer by default
whether a STRICT one rejects it   UNMEASURED

Until that is measured the gate has no target, and two //nolint:unused directives sit holding the deferral open.

It does not need a scheduled session — it needs a rider

This is Shipwright's framing and it is the reason the tracker should not simply wait:

It is one extra look at whichever certificate the operator installs next. Not a test to arrange, not a device to borrow — the install is happening anyway for the trip, and the only addition is reading whether the client accepted the bundle.

With #18 merged, per-issuance certificates are live and the natural next event is exactly that install. If nobody says this beforehand, the opportunity passes silently and #10 waits another month — which is what has already happened once.

Raised with the operator directly, twice, with that framing.

What would close it

  • Device observation: install a purser-issued bundle on a real client and record whether it validated
  • If it validates → the extension is not load-bearing for our clients; the criticality check's scope narrows and the nolints resolve accordingly
  • If it is rejected → the CA template changes, and the two-substrate change with its outage window is justified rather than speculative

Assign it when the observation lands. Before that, ownership would be a name on a row that cannot move.

Rider framing by Shipwright; unowned-row flag by Surveyor at sprint close.

## Unowned deliberately — recording it here so it reads as a decision, not an oversight This is the only open purser tracker with no assignee. Flagged by Surveyor at sprint close; stating the reason on the tracker itself rather than in a comment elsewhere, because an unowned row is exactly the kind that sits until someone rediscovers it. **An assignee here would be decoration.** The first AC is a **device fact**, and no chamber can produce it: > Does a strict validator — Cisco Secure Client / macOS — reject a leaf whose `basicConstraints` extension is absent? ``` step-ca's purser template emits no basicConstraints known-working certs carry "critical, CA:FALSE" RFC 5280 a validator MUST NOT treat a cert without it as a CA ⇒ a LENIENT validator reaches the right answer by default whether a STRICT one rejects it UNMEASURED ``` Until that is measured the gate has no target, and two `//nolint:unused` directives sit holding the deferral open. ### It does not need a scheduled session — it needs a rider This is Shipwright's framing and it is the reason the tracker should not simply wait: **It is one extra look at whichever certificate the operator installs next.** Not a test to arrange, not a device to borrow — the install is happening anyway for the trip, and the only addition is reading whether the client accepted the bundle. With #18 merged, per-issuance certificates are live and the natural next event is exactly that install. **If nobody says this beforehand, the opportunity passes silently and #10 waits another month** — which is what has already happened once. Raised with the operator directly, twice, with that framing. ### What would close it - [ ] **Device observation:** install a purser-issued bundle on a real client and record whether it validated - [ ] If it validates → the extension is not load-bearing for our clients; the criticality check's scope narrows and the `nolint`s resolve accordingly - [ ] If it is rejected → the CA template changes, and the two-substrate change with its outage window is justified rather than speculative **Assign it when the observation lands.** Before that, ownership would be a name on a row that cannot move. *Rider framing by Shipwright; unowned-row flag by Surveyor at sprint close.*
Author
Owner

Operator has committed to the rider — the device observation happens at the next bundle

Recorded so it survives this session, per the unowned-by-decision note above.

Operator, 2026-08-06:

I'm currently connected using a bundle provided yesterday evening by purser. I would request a new bundle after we deploy v0.4.0.

So the sequence that closes this tracker's first AC is now scheduled rather than hypothetical:

1. v0.4.0 cut + deploy            ← PR#54, currently open with ZERO CI (see #41)
2. operator requests a new bundle  ← per-issuance certificate, #18 now merged
3. he installs it on a real client
4. HE READS WHETHER IT VALIDATED   ← this is the whole measurement

Step 4 is the entire ask. No test to arrange, no device to borrow. The install is happening anyway.

What each outcome means

observation consequence for this tracker
client accepts it the absent basicConstraints is not load-bearing for our clients. The criticality check's scope narrows and the two //nolint:unused directives resolve accordingly
client rejects it the CA template changes. The two-substrate change with its outage window becomes justified rather than speculative

Either answer closes the unmeasured question. There is no outcome that leaves this where it is.

Live-connection note, for whoever runs the #32 rollback test

The operator's current VPN connection does not depend on purser — the certificate is already issued and ocserv validates it, so purser being down cannot drop him. What purser being broken would cost is the ability to issue a new bundle.

PURSER_CERT_LIFETIME   168h
issued                 2026-08-05 evening
headroom               ~6 days

Real but not tight. Flagged on the #32 dispatch so the rollback test ends with issuance verified working, not merely with the container back up.

Rider framing by Shipwright; commitment by the operator.

## Operator has committed to the rider — the device observation happens at the next bundle Recorded so it survives this session, per the *unowned-by-decision* note above. **Operator, 2026-08-06:** > *I'm currently connected using a bundle provided yesterday evening by purser. I would request a new bundle after we deploy v0.4.0.* So the sequence that closes this tracker's first AC is now scheduled rather than hypothetical: ``` 1. v0.4.0 cut + deploy ← PR#54, currently open with ZERO CI (see #41) 2. operator requests a new bundle ← per-issuance certificate, #18 now merged 3. he installs it on a real client 4. HE READS WHETHER IT VALIDATED ← this is the whole measurement ``` **Step 4 is the entire ask.** No test to arrange, no device to borrow. The install is happening anyway. ### What each outcome means | observation | consequence for this tracker | |---|---| | **client accepts it** | the absent `basicConstraints` is not load-bearing for our clients. The criticality check's scope narrows and the two `//nolint:unused` directives resolve accordingly | | **client rejects it** | the CA template changes. The two-substrate change with its outage window becomes justified rather than speculative | Either answer closes the unmeasured question. **There is no outcome that leaves this where it is.** ### Live-connection note, for whoever runs the #32 rollback test The operator's **current** VPN connection does not depend on purser — the certificate is already issued and `ocserv` validates it, so purser being down cannot drop him. What purser being broken would cost is the ability to issue a **new** bundle. ``` PURSER_CERT_LIFETIME 168h issued 2026-08-05 evening headroom ~6 days ``` **Real but not tight.** Flagged on the #32 dispatch so the rollback test ends with issuance verified working, not merely with the container back up. *Rider framing by Shipwright; commitment by the operator.*
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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#10
No description provided.