Instance secrets¶
Every Nerthus.Platform instance has one age identity and one sops store, and nerthus secrets
verify is the one run that says whether the estate's credentials are alive. All of it is inside the
package, so a laptop, a CI job and a host need neither age nor sops installed. What each class
of credential is and how the program keeps from printing one is
Credential classes; data that is not a credential and is still encrypted is
Sensitive data.
The identity¶
nerthus init mints it at <config dir>/identity.age, mode 0600; nerthus secrets keygen does the
same on its own. Neither ever replaces one. The identity is the instance's name and the only key
its store has: re-minting would orphan every secret the old key wrote.
nerthus secrets keygen --json
{"created": true, "path": "~/.config/nerthus/identity.age", "recipient": "age1…"}
A key file that exists and cannot be parsed is an error, not an absence. "No identity" sends a caller to the arm that works without one; a damaged key answered as "none" would quietly move a password store.
The store¶
<config dir>/secrets.sops.yaml, mode 0600, in the estate vault's shape (a material: table of
names), so sops itself can open, edit and re-key anything the package writes.
Every entry declares its encoding. A whole file is stored base64, never as text:
sops --extract drops a trailing newline, and an SSH private key one byte short is refused by every
consumer with a message about the key format.
nerthus secrets show lists names, encodings and notes, and never prints a value; nothing else
here does either.
The database password follows the identity¶
| The instance has | The password lives in | nerthus db status says |
|---|---|---|
| an age identity | secrets.sops.yaml, as DATABASE_SUPERUSER_PASSWORD |
password: sops, in … |
| no identity | <data dir>/postgres/password, mode 0600 |
password: file, … at 0600 |
The second arm is what makes uv sync && pytest work where nobody has run nerthus init, which is
every fresh clone and every CI job. initdb takes a --pwfile and nothing else, which keeps the
password off a command line where ps would show it, so the sops arm writes a 0600 file inside the
cluster root and deletes it the moment initdb has read it.
nerthus secrets verify¶
| Exit | Verdict | Meaning |
|---|---|---|
| 0 | agreed |
every remote has a credential, every credential a named holder, every probe answered |
| 2 | disagreed |
at least one finding |
| 3 | blind |
a control failed or a side could not be read; nothing is reported as alive |
The question is asked of the credential, never of a date. rotate_by is read and consulted by
nothing. Every verdict comes from an act performed with the value: the request git ls-remote
makes, a GET /user whose body is the identity document, the registry token exchange
docker login performs. A status code alone is not an answer, and a 200 from a credential that
can see nothing is not access.
Both controls run every time. A synthetic, well-formed, never-issued credential must come back
dead through the identical path, which proves dead is reachable; at least one real credential
must come back alive, because a run of nothing but dead is a broken caller far more often than a
dead estate.
A placeholder is refused before it is probed: a value nobody filled in is never sent to an
issuer. A password is unprobeable, because a wrong password often hangs instead of failing and
the timeout reads as the answer; live_check.kind: none must carry a reason, and a row without a
ruling beside it is a finding.
Every corpus the corpora manifest names with a credential must have that credential in the inventory.
The inventory¶
version: 1
remotes:
lore:
url: https://gitlab.com/margonem/nerthus/repozytorium-fabularne-prodkopia.git
credential: LORE_DEPLOY_TOKEN
credentials:
LORE_DEPLOY_TOKEN:
what: read-only deploy token for the lore corpus
kind: gitlab-deploy-token # decides the shape the value must have
rotate_by: 2027-08-26 # a note to a person; never evidence
live_check:
kind: git-ls-remote # git-ls-remote | gitlab-api | container-registry | none
target: https://gitlab.com/margonem/nerthus/repozytorium-fabularne-prodkopia.git
username: gitlab+deploy-token-1
holders:
- domain: vault # where the copy lives
at: ci/lore.yaml
who: the instance operator # required: somebody has to be askable
tier: vault # vault | root | minted | none
ceiling_days: 365 # required for a vault credential
issuer_account: nerthus-ci # the account that mints it
file: ci/lore.yaml # the one sops file that carries it
accounts:
nerthus-ci:
kind: service # service | person
issuer: gitlab
Every table refuses a key it does not know, and one refusal names every broken key at once: a typo produces two errors, the unknown key and the required one it was meant to be.
| Rule | Finding |
|---|---|
| every vault credential names a ceiling | credential-no-ceiling |
| a vault credential is minted by a service account | credential-personal-issuer; an issuer_account no accounts: row declares is credential-issuer-undeclared |
| one secret per sops file | sops-file-many-secrets, counted from the file's cleartext structure, so CI checks it holding no key |
a credential's file: is a file the inventory declares |
credential-file-missing |
An account's name says nothing about its kind, which is why accounts: declares it.
--recorded <file> answers from a JSON of recorded probes instead of the network; the suite and a
dry run use it, and nothing in the tests reaches gitlab.com.
What is deliberately not supported¶
- Encryption to anything but X25519 age recipients.
scryptandssh-*stanzas are refused with their type named, never skipped: "no identity matched" and "this tool does not implement your recipient" send an operator to different places. - A float in a sops document. Go and Python render one differently, so the document would MAC differently in the two tools.