The secret tree¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: Instance secrets.
sejf/ in nerthus.core.infra.common is the estate's credential list. This page is what it is and how to work with it; the tree's own sejf/README.md is the authority on the rules and is not restated here.
It exists because of a failure that was not a leak. A Worker secret that was supposed to exist did not, wrangler.toml and the runbook both said it did, and eleven months of fleet alerts went nowhere because nothing compared the two. So the requirement was never somewhere safe to put secrets. It is one list, reconciled.
The shape¶
sejf/
├── .sops.yaml who can read what — the whole access-control surface
├── inventory.yaml THE list — cleartext, and no value appears in it
├── keys/ a test recipient, private half committed on purpose
├── ci/<project>.yaml what each GitLab project's variables should be
├── edge/<worker>.yaml what each Worker's secrets should be
├── hosts/<town>.yaml per-town material
└── services/<name>.yaml a SERVICE that is not a town — and it may hold
MORE THAN ONE of that service's credentials
inventory.yaml is cleartext and holds no value, ever. That is what lets two things work without a key: the expiry watch reads every rotate_by while being unable to decrypt anything, and a person answering what do we hold, and who holds it needs no key for a question whose answer contains no secret. A value in inventory.yaml is a defect, not a convenience.
A services/ document is one service, not one credential. services/runner.yaml holds
three glrt- tokens — the ordinary runner's, the annotation runner's and the laptop arch
runner's — because they are one service, run by one piece of tooling, and a second file per runner
would multiply the document count without separating anything.
The per-runner key convention, so the next reader does not invent one
The base name is the inventory's name for the first one, and each additional instance is that name plus an explicit suffix naming the instance:
RUNNER_AUTH_TOKEN the ordinary runner — carries `env: NERTHUS_RUNNER_TOKEN`
RUNNER_AUTH_TOKEN_LANG the annotation runner — NO `env:` alias
RUNNER_AUTH_TOKEN_ARM64 the laptop arch runner — NO `env:` alias
Only one entry may carry a given env: alias. The resolver refuses a name two entries claim
rather than picking one, which is right — three entries aliased to NERTHUS_RUNNER_TOKEN would
make every lookup an ambiguity error. The consumer declares which entry is its own instead, in
the config file that already knows which instance it is (NERTHUS_RUNNER_TOKEN_SEJF_NAME). A
declaration at the consumer, never a guess from a directory name.
And the suffix names what the instance is, not where it sits. RUNNER_AUTH_TOKEN_ARM64
lives in a directory called amd64-runner on disk, because the runner was renamed and the
directory was not; naming the vault entry after the directory would have carried the old lie
into the one list the estate trusts.
hosts/ is towns and services/ is everything else, and CI enforces the split. A town may hold lore and may be promoted to primary; a service may do neither. restoration is in schema/reserved-names.txt and not in schema/towns.txt, and the suites hold the two lists disjoint — so filing a service under hosts/ would put a name the fleet contract refuses into the directory whose filenames it validates against.
hosts/<town>.yaml is the home for any credential that belongs to that machine, not only its tunnel token. Most towns hold one entry, TUNNEL_TOKEN, with via: secrets/<town>.tunnel.age — the token is committed as ciphertext in the fleet repo and the vault row is the second copy. A rented host also has an out-of-band console, and the root password that opens it lives in the same file: eder.yaml carries NERTHUS_EDER_ROOT_PASSWORD, whose via is /etc/shadow on the box and the provider's console, because there is no committed ciphertext for it anywhere. That entry is the only copy of the value, which is what makes losing the vault the same act as losing the machine. Commission a rented host is where it gets minted.
The two private keys bootstrap.sh generates go the other way. /etc/nerthus/agent.key and /etc/nerthus/lore.key stay on the device and get a host_file holder row in inventory.yaml rather than a material entry. Copying the age key off the device buys nothing for recovery — the documented repair is Invoke-FleetRotate.ps1, which re-encrypts to a new key — and costs a second copy of the one key that turns committed ciphertext into a live tunnel token.
Only a field literally named value is encrypted. Project names, variable names, holders, expiry dates and notes stay readable, so a git diff shows which secret changed without showing what it changed to, and a reviewer approves a rotation without decrypting anything.
The four jobs¶
| job | what it answers | needs a key? |
|---|---|---|
sejf |
every file decrypts, no real value sits behind a committed key, the inventory and the path files agree | yes — the committed test key, or the recipient's |
sejf:verify |
does the tree agree with GitLab and Cloudflare, by names and presence | no |
sejf:expiry |
which credentials are expired, close, or undated | no |
sejf:apply |
make the estate match the tree | yes |
sejf:verify and sejf:expiry read only cleartext fields, so they need no sops and no identity. sejf:verify asks GitLab's GraphQL API for key, masked and protected rather than the REST variable list, which would return every value in the body — there is nothing to redact because nothing is fetched.
sejf:verify has three exit codes and they are not interchangeable: 0 agreed, 2 disagreed, 3 could not read a side. A red that means the job is blind and a red that means the estate is wrong call for different acts, and a status that collapses them teaches people to ignore both.
Every remote the fleet authenticates to needs a row¶
sejf:verify also reconciles the two files this repository owns against each other, offline: for each remote in schema/remotes.yml — the corpora and the container registry — the entry must name a credential, inventory.yaml must carry a row for it, and that row must name at least one holder. Any of the three missing is a remote_uninventoried finding.
This rule exists because of a failure the rest of the tree could not have caught. The credential every host pulled the daemon image with lived in a laptop keychain and appeared in no inventory at all — not a row, not a holder — because the inventory was counted against the GitLab and Cloudflare APIs, and a keychain entry is invisible to that. When it expired, every host failed to converge, every /health stayed green, and nothing said why for two days.
It runs whether or not either API answers, so it is not one of the things a blind run cannot report on. And it is scoped to the manifest's own population deliberately: what must be inventoried is the set of remotes the fleet authenticates to, not every credential the estate will ever hold.
A host_file or laptop holder still counts. Nothing in CI can look at a file on somebody's machine, so those rows are reported unknown rather than checked — but a holder nobody wrote down is indistinguishable from a holder nobody checked, and only one of those is recoverable.
Add a credential¶
- A row in
inventory.yaml— the name, what it is, how it rotates, and every place a copy lives. No value, ever. Give it arotate_by; what a real one means is below. - A material entry in the right path file, with
sops setand never a hand edit:
sops set sejf/ci/nerthus.core.yaml \
'["variables"]["NEW_TOKEN"]' \
'{"credential":true,"masked":true,"protected":true,"value":"the-value"}'
- Run the selftest and read what it says:
Two traps, by name¶
SOPS MACs the whole document, cleartext fields included. A text edit to an encrypted file produces MAC mismatch and the file stops decrypting. sops set is a five-line diff; a decrypt/re-encrypt round trip rewrites every neighbouring ciphertext and buries the change in noise.
A : inside an unquoted note breaks the YAML. Notes are long and cleartext by design. Quote them.
The selftest fails if a credential: true entry in a path file has no inventory.yaml row — a credential nobody is tracking. It is the check that catches the thing this tree exists for, so read its output rather than its exit code.
It could not see an entry whose credential: was not the first sub-key
Until 2026-08-21 that sweep matched the entry name and required credential: true on the very
next line. That is a fact about key order and not about the entry: sops set writes a
mapping in the order it is handed, and an entry that opens with note: reads exactly the same to
a person. Measured: services/runner.yaml carried three entries marked credential: true and
the sweep found none of them, so "a credential nobody is tracking" could not have fired for
any of them. The sweep walks the block now, and the printed count moved 34 → 37 — exactly the
three that were invisible, which is the positive control that the fix is a fix.
Consume a credential¶
Adding one and rotating one were on this page from the start; getting one out was not, because
until 2026-08-21 the answer was «read it from your .env». Project 76 moved the estate's live
laptop credentials in here, and these are the three ways a tool now gets one.
Nothing writes a secret to disk that does not also delete it. That is the whole design, and it
is why a standing «temporary» .env was considered and rejected — it is the old .env with extra
steps.
1. Invoke-SejfEnv.ps1 — the default¶
Decrypts the names you ask for into a child process's environment and runs your command there. The value never touches disk and never lands in the shell you typed from.
sejf/Invoke-SejfEnv.ps1 -Names DISCORD_BOT_TOKEN -Command pwsh -Arguments 'daemon/Start-NerthusDaemon.ps1'
sejf/Invoke-SejfEnv.ps1 -Names GITLAB_TRIGGER_TOKEN,DISCORD_BOT_TOKEN -Command pwsh -Arguments '-File','vm/Set-NerthusGitLabPipelines.ps1'
sops exec-env has this shape natively and takes one file; the wrapper's job is resolving a
name to its document across the whole tree, so two credentials from two documents reach one command.
It resolves an entry name first, then a declared env: alias — GITLAB_TRIGGER_TOKEN finds
GL_TRIGGER_TOKEN, CF_TOKEN finds CF_API_TOKEN. The alias is declared in the entry, never
inferred: matching two names by similarity would be a guess that reads as a measurement.
It refuses rather than guessing when a name is in two documents that both carry a real value —
pass -From ci/nerthus.core.infra.dev.yaml. It refuses a placeholder, and it refuses an empty
entry, because handing either to a caller fails somewhere further away where the cause is invisible.
There is no -- in those lines: PowerShell consumes -- before a script sees it, so the command
is -Command plus -Arguments.
2. Invoke-SejfRender.ps1 — when the consumer demands a file¶
docker compose --env-file and gitlab-runner's config.toml cannot read a process environment.
This renders 0600, runs the command, and removes the file in a finally — a render deleted
only on success leaves a cleartext credential behind exactly when something went wrong, which is
when nobody is looking at the directory. It refuses to render over a file it did not create, and
with -Replace it puts the original back afterwards.
This is the fleet's own precedent rather than a new idea: nerthus-agent.sh renders the host
.env every tick and rewrites it the next, which is what the host_file domain describes. The
rendered file is a derivative artefact — never edited, never backed up, never inventoried
beyond the note on its source row.
3. The loader fallback — why adoption needed no flag day¶
Nerthus.Core/vm/Import-NerthusEnv.ps1 and Nerthus.Infra.Runner/Import-NerthusEnv.ps1 look a
required key up in the tree when .env does not carry it. Precedence: the environment, then
.env, then the vault. So a secret set in .env still overrides — a local experiment against a
throwaway credential works exactly as it did — and a tool adopts one deletion at a time.
Three outcomes, and they are three because they call for different acts:
| what it means | what to do | |
|---|---|---|
| the key is filled | .env did not carry it and the tree did |
nothing |
| «not in .env and X is in no document of this tree» | nobody holds it anywhere | mint it, or fix the name |
| «not in .env and no age identity at …» | the vault is unreadable here | publish an age key and get added as a recipient |
Set NERTHUS_SEJF_ROOT if your checkouts are not siblings under margonem/nerthus/it.
What none of them will do¶
Print a value — not on success, not in a warning, not in an error. tests/SejfEnv.Tests.ps1
asserts that over every message-emitting line of all three scripts, because a refusal to echo is a
promise until something checks it. What runs where is the usual split: that suite decrypts nothing
and runs in pester, and sejf/Invoke-SejfEnvSelftest.ps1 does the decrypting half in the sejf
job, on an image where sops is not emulated.
Rotate a credential¶
The holders list is why this is worth a page. A rotation is one act at the issuer followed by one act per holder, and the list is what makes that cost visible before you start. DISCORD_BOT_TOKEN has six.
- Read the entry's
holders. That is the work. - Mint the new credential at the issuer.
- Set it at every holder. Where the holder is a fleet host, Rotate secrets owns the mechanics.
- Update the tree with
sops set, and put a real futurerotate_byon the row. - Revoke the old credential — last, so a half-done rotation degrades rather than breaks.
- Run
sejf:verify. It is what says the estate and the list now agree.
A credential delivered at boot needs a restart. NERTHUS_GITLAB_TOKEN is read once when the daemon starts, so a host that has the new value in its .env and has not restarted is still using the old one — and a host with neither answers GitLabNoCredential on /gitlab/* and is unaffected everywhere else. That makes a rotation degrade-then-restore rather than an outage, and it is worth knowing which of your holders behave that way before you revoke.
What rotate_by means¶
Three kinds of value, and the tree tells them apart because they call for different acts:
| value | meaning |
|---|---|
| a date | the entry expires then. sejf:expiry classifies it expired, expiring (within 30 days) or ok |
unset |
nobody has chosen a date. Not this does not expire |
mechanism |
re-minted by something that is not a calendar — fleet:rotate on every host pin, bootstrap.sh on the device. A date here would be arbitrary |
Anything else — including a word like overdue — reads as unknown and is a value somebody should fix. sejf/EXPIRY.md records where each entry's date came from and which ones are a policy rather than a fact the issuer enforces.
An entry with no date is never reported as healthy. A sweep that counted undated credentials as fine would print a clean report over exactly the case the watch was built to catch.
Where the values actually live today¶
The tree is the register of what should exist, and since 2026-08-21 it is also where the estate's laptop credentials are. Project 76 moved them: the moderators-estate bot's three, the Discord bot token, the Cloudflare token, the pipeline trigger token, the runner's glrt- token, the lore repository's repo-wide Discord webhook, and the dev moderation keyring's private halves. inventory.yaml names the document for each.
Project 86 added the two runner tokens that migration could not see, because they lived in
~/.nerthus/infra/*/.env — outside both repositories, which is the one place a repository sweep
cannot read. Both were live and unvaulted for as long as their runners had existed; a glrt- token
has no expiry and ends only when its runner is unregistered, so neither would have decayed into
harmlessness on its own.
Two entries carry a note worth reading before you trust them. GL_TRIGGER_TOKEN has apply: never — it holds a real value whose estate copy nobody has been able to compare it against, so sejf:apply skips it rather than writing an unidentified token onto a live Worker. REPO_WEBHOOK is expired and stays that way: removing a URL from a tracked file does not un-publish it, and only re-creating the webhook at Discord does.
Where an entry's holders are still not sejf, a device is given its copy the way Set up credentials describes — and that page is a local copy, not a source.