Skip to content

Keyrings & key delivery

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.

A keyring is a named recipient set with one admin role. Everything these routes serve is public by construction: a recipient key is a public key, a signing key is a public key, and an epoch wrap is a blob addressed to one of them. None of it discloses anything, which is why the reads are replica-safe and why keyring.read is a low bar.

Membership is not writable here. A keyring names @czytelnik role labels and its members are whoever holds those roles now. Which roster answers is detected from the @czytelnik values, never from the ring's name: a ring whose readers are moderator roles (mc/smc) resolves its members from nerthus.moderators.md, and every other ring resolves them from the committed contributor store. There is no list to append to, so adding a member is editing @rola in the roster the ring reads — a reviewed merge request the moderators own for a moderation ring, the narrators for the rest — and removing one is closing that range. The alternative would be a second membership list, which is the thing the role-derived model exists to delete.

Routes

Method Path Cmdlet Cap Write
GET /keyrings keyring.read
GET /keyrings/{name} keyring.read
POST /keyrings/{name}/epochs keyring.admin
POST /keys/claims keyring.admin
GET /keys/claim/{sha}

All five rows carried a cmdlet name that named nothing. Get-NerthusKeyring, Publish-NerthusKeyringEpoch, New-NerthusKeyClaim and Receive-NerthusKeyClaim are defined in no .ps1 in Nerthus.Core and exported by no manifest — the reference asserted a shell client this family never had. The cells are empty now, following the regulations page's precedent for a browser-only family, and ci/assert-cmdlet-names.py in this repository fails the build if one comes back.

Cap is the required capability ( = public, no token); Write () marks routes that pass the write gate. Paths are relative to /v1/api; the cross-cutting contract is on the API reference index.

Where a ring is declared and stored

A keyring is declared in a ## Keyringi section — in nerthus.contributors.md, or in the moderators repository's own nerthus.moderators.md. The declarations merge into the one keyring map every route reads; a ring name declared in both files keeps the nerthus.contributors.md one and files a DuplicateKeyring finding. The moderacja ring is declared beside its roster on purpose: @czytelnik and @admin decide who reads the sanction ledger, and holding that declaration in the lore repository would put the decision behind a narrator's merge button.

Epoch storage routes on the ring the same way. A ring whose readers are moderator roles keeps its epoch wraps in the moderators tree, beside the records they open; every other ring's epochs stay in the lore tree.

Recipients and signers

Each member on GET /keyrings/{name} carries two key lists: recipients, the published age keys an epoch is wrapped to, and signers, ssh-ed25519 public keys parsed from the roster's @klucz_podpis lines with their optional (od:do) range. They are two pairs on purpose — one answers who reads this, the other who signed this. signers is what a POST /moderation/records signature check verifies against, so a member with no current signing key still reads and has every record they submit refused; KeyringMemberWithoutSigningKey is the Warning that names them.

ModeratorsCorpusMissing (Error, once per moderation ring) is the resolution's fail-closed answer: a host whose moderators clone is absent must not report a healthy empty ring, because a new epoch published against it would be wrapped to nobody. An empty roster and a missing corpus are different answers.

The invariant

/keys/claim/{sha} moves bytes the daemon cannot decrypt. Anything that would give the daemon the ability to produce, unwrap, or verify the plaintext of a claim payload is a change to the trust model, not an optimisation.

The same sentence governs POST /keyrings/{name}/epochs. The body is what the client computed: a new epoch key already wrapped to every current member. The daemon stores wraps and generates none. What it can check without reading anything is that every current member has a wrap in the submission, and it does — a new epoch missing one member's wrap is one person quietly losing the ability to read, which is the failure KeyringMemberWithoutKey exists to catch a layer up and which would otherwise arrive here undetected.

keyring.admin is refined per keyring

The route table declares one capability, and the handler narrows it: each keyring names its own @admin role, and a caller who does not hold that role is refused 403 with required: "@admin: <role>". So the same capability lets one body administer lore and another administer moderacja without either reaching the other — a flat keyring.admin would let an SMC publish an epoch on lore.

The refinement resolves the caller through the contributor store, so a principal with no person behind it holds no role and administers nothing. The machine token is such a principal. It carries admin.all and passes the capability check, and it is still refused here, deliberately: its holder is whoever operates the host, and the contributor model already refuses the it role as a @czytelnik of any keyring (KeyringIncludesIt). A host operator who could publish an epoch would be a member of every keyring in the one way membership was designed to exclude.

POST /keyrings/{name}/epochs answers 422 EpochRefused when the body is well formed and the state refuses it — a member with no wrap, a republished number, an epoch below the published one. A 400 there would send somebody to look at their JSON, which is the one thing not wrong.

Key delivery

POST /keys/claims stashes an already-wrapped blob and returns its claim path; the issuer wrapped it to the recipient's published @klucz_szyfr before posting, so the daemon holds no plaintext at any point. It is modelled on POST /api-keys and differs in exactly that respect.

GET /keys/claim/{sha} is the one-time collection, and it carries no capability for the reason GET /api-keys/claim/{sha} carries none: the 64-hex sha in the path is the shared secret, the entry dies on first read, and that read is the only way to the bytes. It answers 404 alike for unknown, already-claimed and swept-stale, which is what stops a prober telling those apart.

The claim renames and unlinks a file, so it is the one GET in this surface that is not poolable: a pooled request is answered from the read generation's snapshot, an object a reader may hold and a writer may not touch, and a GET that mutates the filesystem does not belong on that thread.

The moderation export (Chat moderation) stages through this same stash and is collected here. There is deliberately no second claim route over it: one one-time-claim mechanism, one route, and one place for the rename-without-overwrite argument to live.