Skip to content

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

  1. A row in inventory.yaml — the name, what it is, how it rotates, and every place a copy lives. No value, ever. Give it a rotate_by; what a real one means is below.
  2. A material entry in the right path file, with sops set and never a hand edit:
sops set sejf/ci/nerthus.core.yaml \
  '["variables"]["NEW_TOKEN"]' \
  '{"credential":true,"masked":true,"protected":true,"value":"the-value"}'
  1. Run the selftest and read what it says:
pwsh -NoProfile -File sejf/Invoke-SejfSelftest.ps1

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.

  1. Read the entry's holders. That is the work.
  2. Mint the new credential at the issuer.
  3. Set it at every holder. Where the holder is a fleet host, Rotate secrets owns the mechanics.
  4. Update the tree with sops set, and put a real future rotate_by on the row.
  5. Revoke the old credential — last, so a half-done rotation degrades rather than breaks.
  6. 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/*/.envoutside 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.