Skip to content

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.

nerthus secrets set NAME --file -      # the value on stdin, never on the command line

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

nerthus secrets verify --inventory ../Nerthus.Infra/inventory.yaml
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. scrypt and ssh-* 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.