Skip to content

The life of a credential

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: Credential classes and the redaction filter.

How a credential is born, filed, delivered, proved, rotated and retired in this estate, and what checks each of those. Rotate secrets is the procedure for one rotation; this is the model behind it, and the page to read before minting anything new.

Read at nerthus.core.infra.common 7cb02cb, sejf/inventory.yaml at 18b0057 (2026-08-28).

The vault is a map, not a keyring

sejf/ is a committed, encrypted tree plus one inventory that is deliberately cleartext: sejf/inventory.yaml, 1 118 lines, declaring 52 credentials and 131 holder rows. A holder row says where one copy of one credential lives.

Holder domain Rows What it means
laptop 44 a file on a contributor's machine
sejf 27 an entry in the encrypted tree itself
gitlab_ci 26 a CI/CD variable on a project
host_file 16 a file on a fleet host
age_git 13 an age-encrypted file committed to a fleet repository
cf_worker 4 a Cloudflare Worker secret
git_history 1 a value that was committed once and is therefore permanent

17 rows say THE ONLY COPY. Losing that machine loses that credential, and the recovery for each is minting a new one rather than reading it back from somewhere.

The names are cleartext by design. That is what lets a check run on every pipeline without holding a key, and it is the whole reason the map is worth keeping.

What checks what, and where each stops

Nine sejf:* jobs run in Nerthus.Core.Infra.Common's pipeline. Four of them are the lifecycle:

Job The question it answers
sejf:verify does the tree agree with the estate - is every credential the map declares actually there, and is anything there that the map does not declare
sejf:liveness does the credential still authenticate
sejf:expiry when does it stop working, and who says so
sejf:apply make the estate match the tree

sejf:verify cannot see two holder domains, and the estate closed that with an offline check rather than by widening it. It compares gitlab_ci and cf_worker against the live APIs and reports host_file and laptop as unknown. sejf and age_git rows appeared in neither branch - not compared, and not counted among the unknowns either, so they produced no row at all. Two offline checks in Invoke-SejfSelftest.ps1 now assert that every such holder names a file that exists. Neither needs a key.

sejf:liveness asks the credential, not the calendar, and it runs two controls every time or exits without a verdict: its own token against a real endpoint, and a synthetic never-issued token against the same path. The negative control is synthetic on purpose - it is the only proof that dead is a reachable answer, it needs nothing from the estate, and it cannot rot on the day somebody deletes the real dead credential.

A password is unprobeable by construction. A wrong password does not fail, it hangs: this estate ran an SSH negative control that timed out instead of being refused, and the timeout read as the answer. Anything that cannot be probed is reported as unprobed rather than assumed alive.

sejf:expiry separates a date somebody typed from a date somebody read. A stated expiry with no issuer record behind it is policy-only - a number nothing has ever compared to anything - and it is reported apart from a measured one. That distinction is not decoration: this estate has had a credential whose policy date said it was good for another year and which was already dead.

sejf:apply never creates. It writes a new value over a name the estate already carries and skips any name it does not, because creating one means minting or inventing a value, and that is a person's act. So filing something new in the tree reddens the checker; it does not conjure a live credential.

Its journal records the digest of what was written and never the value, so a reader can tell a retried rotation from a second, different one without the journal itself becoming a copy.

What they say today, and why the pipeline is green anyway

Measured on the scheduled run of 2026-08-28 at 17:08 UTC, pipeline 2800504765 on Nerthus.Core.Infra.Common@main:

Job Verdict Exit
sejf:verify the tree and the estate agree 0
sejf:liveness blind - 1 row unread. This is NOT a live estate 3
sejf:expiry 0 expired, 3 awaiting an operator act, 0 expiring 2
sejf:keyring · sejf:keyring-epoch red non-zero

Four of those jobs are allow_failure: true, so the pipeline is green while they are red. That is worth knowing before you read a green badge as a healthy vault. Only sejf and sejf:verdict can colour it.

Two of the reds are the instruments working rather than failing:

  • sejf:expiry is red because three credentials are waiting on a person. The state awaiting-operator-act was carved out of expired deliberately, and it stays red, because a state that went green on a ruling would be the map answering for the territory.
  • sejf:liveness reports blind rather than a pass when a row could not be read at all. Nine rows authenticating and one unread is not nine-tenths of an answer; it is an answer with a hole in it, and the job says so and exits non-zero.

And every alive means authenticates, not works. An API answering 200 and a 403 from the one repository a write-scoped token exists to push to are both consistent with a live token. The job prints that sentence in its own log rather than leaving a reader to infer it.

What it costs

Nothing per run: every check above is text and API calls on a pipeline that already runs. The cost is at the other end. A credential in this estate is minted by a person, filed by a person, and its holder rows are maintained by a person - and a holder row that goes stale is invisible until something fails.

The blast radius of a lost laptop is 44 holder rows, 17 of which are the only copy.

The gap this page will not paper over

fleet:roster-audit exists and does not run. It is in Nerthus.Core.Infra.Common@main at ci/fleet.gitlab-ci.yml:104, and it is not in v0.9.7, which is the newest tag and the ref Nerthus.Core.Infra.Dev pins. main is 113 commits ahead of that tag, measured 2026-08-28.

A job that exists in a submodule nobody has pinned yet is not a check that runs. It starts running when the contract pin advances through a new release tag, and not before - The fleet owns how a contract release reaches a host.

See also