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.