feat(dashboard): choose a certificate's expiry at issue time, up to a configured maximum #18

Closed
opened 2026-08-05 21:44:19 +02:00 by bosun · 1 comment
Owner

What

Today the lifetime is a single deployment-wide value (PURSER_CERT_LIFETIME, read once from the environment). Every certificate this instance issues gets the same one.

Operator direction, 2026-08-05:

provisioner maximum   365 days   (step-ca maxTLSCertDuration — needs CA admin, purser#4)
purser default         7 days    (what you get if you do not choose)
per-issuance           free choice, up to the maximum

🔑 A safe default with a deliberate exception, rather than one compromise value that is wrong for both cases. The immediate driver: certificates for family devices that should last a specific trip, without making every routine certificate long-lived.

Why this is a separate tracker from purser#4

#4 is a VALUE decision — pick a number, record the reasoning. It closes when the value is chosen and documented.
This is a FEATURE — per-request lifetime plumbing plus a UI control. It closes on working code.

📌 Filing them together would mean one tracker that cannot close until both are done, and #4's half is what unblocks the operator's trip.

Scope

  • Service.Issue takes a lifetime per request rather than reading Cfg.CertLifetime
  • The dashboard offers an expiry control; the default is PURSER_CERT_LIFETIME and requires no interaction
  • Refuse anything above the configured maximum IN PURSER, with the limit named in the refusal. ⚠️ Do not rely on step-ca clamping — a silent clamp is what #4 measured, and it hands back a certificate that is not the one requested while reporting success
  • Negative control: a request above the maximum is REFUSED, not clamped; and a request below it is honoured. Both arms.
  • The issued not_after is read back from the SIGNED CERTIFICATE, not from what was requested — the store must record what the CA actually granted
  • docs/operations.md configuration table updated

⚠️ Depends on

A long lifetime is only safe once revocation works — see the CRL tracker in alcatraz-infra. Today NotAfter is the only enforcement, so a 365-day certificate cannot be withdrawn.

Anchor

Operator direction 2026-08-05, after establishing that unattended renewal is not available: Cisco Secure Client keeps its own certificate store (not the system Keychain) and is provisioned unattended only by EMM/MDM, which is disproportionate here. The lifetime is the only lever, so it should be a good one. Filed by @bosun.

## What **Today the lifetime is a single deployment-wide value** (`PURSER_CERT_LIFETIME`, read once from the environment). Every certificate this instance issues gets the same one. **Operator direction, 2026-08-05:** ``` provisioner maximum 365 days (step-ca maxTLSCertDuration — needs CA admin, purser#4) purser default 7 days (what you get if you do not choose) per-issuance free choice, up to the maximum ``` 🔑 **A safe default with a deliberate exception, rather than one compromise value that is wrong for both cases.** ✅ **The immediate driver: certificates for family devices that should last a specific trip, without making every routine certificate long-lived.** ## Why this is a separate tracker from `purser#4` ⛔ **`#4` is a VALUE decision** — pick a number, record the reasoning. **It closes when the value is chosen and documented.** ✅ **This is a FEATURE** — per-request lifetime plumbing plus a UI control. **It closes on working code.** 📌 **Filing them together would mean one tracker that cannot close until both are done, and `#4`'s half is what unblocks the operator's trip.** ## Scope - [x] `Service.Issue` takes a lifetime per request rather than reading `Cfg.CertLifetime` - [x] The dashboard offers an expiry control; the default is `PURSER_CERT_LIFETIME` and requires no interaction - [x] **Refuse anything above the configured maximum IN PURSER, with the limit named in the refusal.** ⚠️ Do not rely on step-ca clamping — a silent clamp is what `#4` measured, and it hands back a certificate that is not the one requested while reporting success - [x] Negative control: a request above the maximum is REFUSED, not clamped; and a request below it is honoured. **Both arms.** - [x] The issued `not_after` is read back from the SIGNED CERTIFICATE, not from what was requested — the store must record what the CA actually granted - [x] `docs/operations.md` configuration table updated ## ⚠️ Depends on **A long lifetime is only safe once revocation works** — see the CRL tracker in `alcatraz-infra`. Today `NotAfter` is the only enforcement, so a 365-day certificate cannot be withdrawn. ## Anchor Operator direction 2026-08-05, after establishing that unattended renewal is not available: Cisco Secure Client keeps its own certificate store (not the system Keychain) and is provisioned unattended only by EMM/MDM, which is disproportionate here. **The lifetime is the only lever, so it should be a good one.** Filed by @bosun.
bosun closed this issue 2026-08-06 15:24:40 +02:00
Author
Owner

Six ACs ticked — each re-derived from main at 9f991484

Closed on #49's merge with all six unticked. Same close-without-pre-tick gap that hit #29 an hour ago; caught immediately this time rather than by the operator.

These are state-asserting ACs, so the tick is a claim about how the world is. Verified against the merged code, not inferred from the merge:

AC evidence on main
Service.Issue takes a lifetime per request service.go:82Issue(ctx, cn, issuedBy string, lifetime time.Duration)
Dashboard offers the control; default is PURSER_CERT_LIFETIME, no interaction needed server.go:174,192d.CertLifetime = s.Cfg.CertLifetime, lifetime := s.Cfg.CertLifetime; LifetimeIsFixed at :176; TestDashboard_ExpiryControlIsHiddenWhenThereIsNoChoice
Refuse above the maximum in purser, limit named in the refusal service.go:261"purser: refusing a certificate lifetime of %s — the maximum this …"
Negative control: refused not clamped, and below is honoured — both arms TestIssue_LifetimeAboveTheMaximumIsRefusedNotClamped + TestIssue_LifetimeBelowTheMaximumIsHonoured, plus boundary arms at exactly the maximum and exactly the download window
not_after read back from the SIGNED certificate service.go:164NotAfter: res.Leaf.NotAfter, off the signed leaf rather than the request
docs/operations.md configuration table updated :83 table row, plus the opt-in explanation at :190–194 and the why not checked against the provisioner note at :214

Two worth naming beyond the tick.

The refusal names the limit, and the boundary arms are at the exact edge — at-maximum accepted, at-download-window refused. That is the dimension rather than a point either side of it, which is what AC4 was asking for.

NotAfter comes off res.Leaf. The store records what the CA actually granted, not what was asked for — which is the whole point, since a silent clamp is what #4 measured and it returns success while handing back a different certificate.

Deployment status, stated because merged is not deployed

PURSER_MAX_CERT_LIFETIME now has a supplier (alcatraz-infra#470, merged) and a reader (this PR, merged). It is not yet effective on the host: the operator has not set it in .env, and the container has not been recreated. Unset, it equals the default and the expiry control stays hidden — so this feature grants nothing until it is deliberately turned on, which is what the docs say it does.

## Six ACs ticked — each re-derived from `main` at `9f991484` Closed on #49's merge with all six unticked. Same close-without-pre-tick gap that hit #29 an hour ago; caught immediately this time rather than by the operator. These are **state-asserting** ACs, so the tick is a claim about how the world is. Verified against the merged code, not inferred from the merge: | AC | evidence on `main` | |---|---| | `Service.Issue` takes a lifetime per request | `service.go:82` — `Issue(ctx, cn, issuedBy string, lifetime time.Duration)` | | Dashboard offers the control; default is `PURSER_CERT_LIFETIME`, no interaction needed | `server.go:174,192` — `d.CertLifetime = s.Cfg.CertLifetime`, `lifetime := s.Cfg.CertLifetime`; `LifetimeIsFixed` at `:176`; `TestDashboard_ExpiryControlIsHiddenWhenThereIsNoChoice` | | **Refuse above the maximum in purser, limit named in the refusal** | `service.go:261` — `"purser: refusing a certificate lifetime of %s — the maximum this …"` | | **Negative control: refused not clamped, and below is honoured — both arms** | `TestIssue_LifetimeAboveTheMaximumIsRefusedNotClamped` + `TestIssue_LifetimeBelowTheMaximumIsHonoured`, plus boundary arms at exactly the maximum and exactly the download window | | `not_after` read back from the SIGNED certificate | `service.go:164` — `NotAfter: res.Leaf.NotAfter`, off the signed leaf rather than the request | | `docs/operations.md` configuration table updated | `:83` table row, plus the opt-in explanation at `:190–194` and the *why not checked against the provisioner* note at `:214` | Two worth naming beyond the tick. **The refusal names the limit, and the boundary arms are at the exact edge** — at-maximum accepted, at-download-window refused. That is the dimension rather than a point either side of it, which is what AC4 was asking for. **`NotAfter` comes off `res.Leaf`.** The store records what the CA actually granted, not what was asked for — which is the whole point, since a silent clamp is what #4 measured and it returns success while handing back a different certificate. ### Deployment status, stated because merged is not deployed `PURSER_MAX_CERT_LIFETIME` now has a supplier (alcatraz-infra#470, merged) and a reader (this PR, merged). It is **not yet effective on the host**: the operator has not set it in `.env`, and the container has not been recreated. Unset, it equals the default and the expiry control stays hidden — so this feature grants nothing until it is deliberately turned on, which is what the docs say it does.
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#18
No description provided.