Credential lifecycle — standing, rotation, and the road to leased secrets
How a submitted credential lives after it is fulfilled, and how it is rotated (issue #64, epic
42). For the retrieval path itself see runbook.md (Runtime retrieval via OIDC);
for the zero-knowledge guarantees see zero-knowledge-proof.md.
Standing credentials (the default, N-051)
A fulfilled intake is standing: the submitted value is retained in Vault and is readable by a
bound reader at any time through the audited service path. This is deliberate — CI jobs and
containers fetch the same credential across many runs, so it must persist. The operator still
can never read it (Vault deny on the inbound path); persistence changes nothing about
zero-knowledge.
Nothing extra is needed to "keep" a credential: once status = fulfilled, version = 1 and reads
return the value until it is rotated or the intake is revoked.
Rotation (re-submission)
When the underlying secret changes at its source (a key is rolled, a token reissued), the operator
rotates the intake instead of creating a new one — the id, display name, fields, and
allowed_readers are preserved, so every bound reader keeps working without reconfiguration.
POST /api/v1/intakes/:id/rotate (operator session)
→ issues a fresh submission token, sets status back to `pending`, version += 1
→ emails the recipient a new submission link
→ returns { id, intake_url, expires_at, version }
Flow:
- Re-open. The intake flips
fulfilled → pendingwith a new JTI and a fresh 24h submission window. The old value stays in Vault (KV v2 keeps prior versions) until it is overwritten. - Re-submit. The recipient submits the new value through the portal exactly as the first
time. On success the value overwrites the Vault path,
status → fulfilled, androtated_atis stamped. - Readers learn of it. Every service read returns
versionandrotated_at. A reader that caches a credential comparesversionand re-fetches when it increases — no prior value is ever exposed to detect the change.
Availability note. While a rotation is mid-flight (re-opened, not yet re-submitted) the service read returns
404— the new value isn't in yet and the old one is being replaced. Rotate during a maintenance window, or (future work) serve the prior KV version until the new one lands.
Reader notification
Bound readers are machines, so "notification" is an auditable hook, not an email. On rotate the
broker emits one reader.notify event per bound reader (metadata: { reader, version, reason:
'rotation' }) alongside the intake.rotate event. A deployment wires these to a webhook / SIEM /
chatops channel to actively push the change; absent that, readers discover it via the version
bump on their next read. The recipient (the human who holds the secret) is emailed the
re-submission link.
Audit trail for one rotation:
intake.rotate result=success metadata={version:2}
reader.notify result=success metadata={reader:"ci", version:2, reason:"rotation"}
intake.submit result=success (when the recipient re-submits)
Future: dynamic / leased secrets (sketch, not built)
Standing + rotation covers credentials that live at a source system. The next step is credentials Sepulchre mints on demand with a TTL, so there is nothing standing to steal:
- Back an intake with a Vault dynamic secrets engine (database, AWS STS, PKI) instead of a static KV value. A reader's fetch triggers a short-lived lease; expiry/renewal is Vault's job.
- The service read path returns a lease (value +
lease_id+ TTL) rather than a stored value; the reader renews or re-fetches.versiongeneralises to a lease generation. - Revocation becomes immediate and per-lease (revoke the Vault lease), and the "standing value at rest" disappears entirely for these credential types.
This is tracked as a later phase; the version / rotated_at fields and the reader.notify hook
are the forward-compatible seams that leased credentials will reuse.