make sshsigchain the only portable sigchain format

This commit is contained in:
Eric Wendland 2026-07-18 21:00:06 +02:00
commit 73500e1944
15 changed files with 812 additions and 1603 deletions

View file

@ -1,202 +1,103 @@
# Geth Keychain Sigchain Design
# Geth Keychain And SSHSIGCHAIN
## Purpose
The geth keychain is a signed operation log for mesh identity state. It is the
source of truth for admin SSH keys, users, devices, nodes, agents, and endpoint
bindings. Peers do not trust a mutable keychain snapshot. They replay signed
operations and reduce the accepted log into the current keychain view.
The geth keychain is the local identity-plane operation log for admin SSH keys,
users, devices, nodes, agents, and endpoint bindings. The daemon stores these
operations in SQLite and synchronizes authorized records over Iroh. Reducing
the operation log produces the current keychain view used by node management,
authorization, and OpenSSH `allowed_signers` projection.
This is intentionally similar to the `git-skm` pattern:
[`sshsigchain.md`](sshsigchain.md) specifies SSHSIGCHAIN, the only portable
signed-chain format in this repository. It is deliberately separate from the
current local keychain operation log: SSHSIGCHAIN starts with an explicit
out-of-band trust tuple, has a strict sequence and hash link, and never uses a
downloaded `allowed_signers` file as a trust root. The previous test-only
static JSONL publication format was removed.
- `git-skm` treats a committed `allowed_signers` file as trusted only if every
commit that changed it can be verified from a prior trusted state.
- geth treats a keychain operation as trusted only if it is signed by an admin
key that was trusted in the previously accepted keychain view.
## Local keychain model
## Data Model
The durable local log contains `KeychainOp` records and their
`KeychainOpSignature` records. Every operation has a deterministic canonical
binary signing payload under the `geth.keychain.v1@geth.local` namespace.
JSON is an API and storage representation, never the signed payload.
The reusable model lives in the `geth-keychain` crate. The daemon stores it in
SQLite and syncs it over Iroh, but the crate does not depend on SQLite, Iroh, or
the geth daemon. Other projects can publish the same keychain as a static
sigchain file, append it to object storage, embed it in a document, or transport
it by any other mechanism.
The durable log is `KeychainOp[]` plus `KeychainOpSignature[]`. Each operation
has a deterministic canonical signing payload under the
`geth.keychain.v1@geth.local` namespace.
The namespace is part of a `KeychainProfile`. geth uses:
- `geth.keychain.v1@geth.local`
- `geth.node-enrollment-request.v1@geth.local`
- default admin principal `admin`
Other applications should create their own profile with explicit namespaces or
`KeychainProfile::for_application("<app>", "<domain>")`. For example,
`for_application("acme-notes", "example.com")` produces
`acme-notes.keychain.v1@example.com`. This prevents signatures from one
application's keychain from being replayed into another application's
keychain.
Important operation kinds:
Important operation kinds are:
- `KeychainInit`
- `AdminKeyAdd`
- `AdminKeyRevoke`
- `AdminKeyAdd`, `AdminKeyRevoke`
- `UserAdd`, `UserRename`, `UserRevoke`
- `DeviceAdd`, `DeviceRevoke`
- `DeviceKeyAdd`, `DeviceKeyRevoke`
- `DeviceAdd`, `DeviceRevoke`, `DeviceKeyAdd`, `DeviceKeyRevoke`
- `NodeAdd`, `NodeRename`, `NodeRevoke`
- `NodeEndpointAdd`, `NodeEndpointRevoke`
- `AgentBind`
`AdminKeyAdd` records the admin key fingerprint and, for new operations, the
OpenSSH public key material, optional principal, and optional validity metadata.
The public key is part of the signed operation so the key registry can be
reconstructed from the sigchain itself. Older local data may only have the
fingerprint; geth can use stored signature public-key material as a fallback
when exporting the current allowed signers view.
`AdminKeyAdd` carries the OpenSSH public key material used to reconstruct the
active `allowed_signers` view. Local operation and signature identities are
append-only: an existing identity with different bytes is rejected rather than
replaced.
## Signature Rules
## Local signatures
Each signed operation has a `KeychainOpSignature`:
- `op_id`
- signer key fingerprint
- signer OpenSSH public key
- namespace
- OpenSSH signature bytes
- creation time
Signatures are produced with:
An operation signature contains its operation ID, signer key fingerprint,
signer public key, SSHSIG namespace, OpenSSH signature bytes, and creation
time. geth signs through OpenSSH, for example:
```sh
ssh-keygen -Y sign -n geth.keychain.v1@geth.local -f <signing-key> <payload>
```
`<signing-key>` can be:
`<signing-key>` may be a local private key, an OpenSSH security-key stub, or a
public key whose private half is available through `ssh-agent`. The daemon does
not store private keys or passphrases.
- a local private OpenSSH key file,
- a FIDO/YubiKey OpenSSH security-key stub,
- a public key whose private half is already loaded in `ssh-agent`, or
- a public key backed by a PKCS#11 token loaded into `ssh-agent` with
`ssh-add -s <provider>`.
Local verification accepts an operation only when a signature from an admin
key in the previously accepted local view verifies over the canonical payload.
This is useful for the local Iroh-synchronized operation log, but it is not a
portable SSHSIGCHAIN history and MUST NOT be presented as one.
Encrypted private key files should normally be unlocked into `ssh-agent` before
running geth control commands. The daemon never stores private keys or
passphrases. Direct PKCS#11 signing is intentionally not a first-class geth
backend because portable `ssh-keygen -Y sign` flows do not expose the same
provider flag as OpenSSH certificate signing.
## SSHSIGCHAIN keychain profile
Verification uses:
Geth's SSHSIGCHAIN profile identifier is `geth.keychain.sshsigchain.v1` and its
SSHSIG namespace is `sshsigchain.v1`. Its canonical binary payload is a
versioned mirror of a keychain operation; it is separate from the
human-facing, internally tagged JSON API type so a decoder can prove one unique
payload encoding.
The profile requires:
- sequence zero to be `KeychainInit` signed by the operator-pinned root key;
- sequence one to be an `AdminKeyAdd` recording that root key;
- every following signer to be an active admin key in the causally prior
profile state;
- every keychain operation ID to occur only once in the chain;
- each added admin key to have canonical key material matching its declared key
fingerprint; and
- causal add/revoke records rather than validity windows or payload timestamps
as authorization policy.
The root key seeds authorization at genesis only. A causally valid revocation
removes it like any other admin key.
To inspect an SSHSIGCHAIN JSONL transport file, pin the chain ID and root public
key locally:
```sh
ssh-keygen -Y verify \
-f <allowed-signers> \
-I <signer-principal> \
-n geth.keychain.v1@geth.local \
-s <signature>
geth keychain verify-sigchain \
--in geth.sshsigchain.v1.jsonl \
--chain-id <64-hex-character-chain-id> \
--root-key ~/.ssh/geth-root.pub
```
The signed payload is canonical binary encoding, not JSON.
This verifier is experimental while geth adds its own record creation,
publication, import, accepted-head persistence, and independently generated
wire vectors. Those later workflows must preserve the same explicit trust
tuple; they must not introduce a compatibility route for the removed static
format.
## Verification Algorithm
## Local commands
For a candidate log ordered by `(created_at_ms, op_id)`:
1. Start from a local trust anchor. In the bootstrap implementation this is a
locally initialized `KeychainInit` plus an admin key added through
`geth init --admin-key ... --signing-key ...` or `geth keychain init
--admin-key ...`.
2. Maintain an accepted operation prefix and reduce it into the current
keychain view.
3. For each next operation, collect its signatures.
4. Accept the operation only if at least one signature:
- is from a key fingerprint present in the previous accepted view,
- carries public key material that hashes to that fingerprint,
- verifies over the canonical operation payload with OpenSSH, and
- uses the keychain namespace.
5. After accepting the operation, append it to the accepted prefix and reduce
again. This lets a valid `AdminKeyAdd` authorize later operations, and a
valid `AdminKeyRevoke` stop later authorization by that key.
6. Reject unsigned, invalidly signed, conflicting, or out-of-authority
operations.
This mirrors `git-skm`'s "verify from the previously trusted
allowed_signers" model, but the transport and storage are geth/Iroh/SQLite
instead of Git commits.
The `geth-keychain` crate exposes this as a transport-neutral verifier. Callers
provide:
- ordered or unordered `KeychainOp[]`
- `KeychainOpSignature[]`
- a `KeychainProfile`
- a `KeychainSignatureVerifier`
The verifier is responsible for the cryptographic backend, such as
`ssh-keygen -Y verify`, WebCrypto, an HSM, a service-side verifier, or a test
verifier. The crate owns the replay order, bootstrap rule, profile namespace
check, previous-view authorization rule, `allowed_signers` projection, and
reduced keychain view.
Minimal reusable Rust shape:
```rust
let profile = geth_keychain::KeychainProfile::for_application(
"acme-notes",
"example.com",
)?;
let entries = geth_keychain::decode_sigchain_jsonl(sigchain_text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = geth_keychain::verify_sigchain_with_profile(
&ops,
&signatures,
&profile,
&my_verifier,
);
```
## Static Sigchain File
For static hosting and range-friendly distribution, `geth-keychain` defines a
JSONL sigchain representation:
```json
{"op":{...},"signatures":[...]}
{"op":{...},"signatures":[...]}
```
Each line is a complete `KeychainSigchainEntry`. This keeps the file appendable
and cacheable:
- A publisher can append new entries to the end of the file.
- HTTP clients can use `ETag`, `Last-Modified`, and byte range requests to fetch
only new bytes.
- A client can decode complete trailing lines, ignore a partial final line until
the next fetch, and replay verification from its last accepted checkpoint.
- The canonical signed payload remains the `KeychainOp`; JSONL is only the
publication container.
The crate provides helpers to encode/decode JSONL and flatten entries back into
`KeychainOp[]` plus `KeychainOpSignature[]`.
geth writes checkpoint records with `geth keychain publish-bundle`. The
checkpoint contains the accepted head, operation/signature counts, byte length,
BLAKE3 hashes for the sigchain and allowed signers projection, a reduced-view
hash, generation time, and the discovery base URL. The checkpoint is signed as
`geth.sigchain.checkpoint.json.sig` so static clients can detect rollback or
truncation before importing updates. `geth keychain fetch --import` stores the
last accepted checkpoint for a source URL and rejects older checkpoints from the
same source. The fetch URL is a retrieval location and may be a `file://` mirror
for testing; the checkpoint's signed `base_url` remains the advertised static
publication location. Use `geth keychain verify-checkpoint --base-url <url>` when
a consumer needs to pin that advertised value explicitly.
## Commands
Owner bootstrap:
Bootstrap an owner node:
```sh
geth init \
@ -205,100 +106,25 @@ geth init \
--node-name laptop
```
Add a new admin key:
Manage the current local keychain and its OpenSSH projection:
```sh
geth keychain admin-add \
--admin-key ~/.ssh/new_admin.pub \
--signing-key ~/.ssh/current_admin_sk \
--principal admin
```
Revoke an admin key:
```sh
geth keychain admin-revoke <key-fingerprint> \
--signing-key ~/.ssh/current_admin_sk
```
Export the reduced active admin key registry in OpenSSH `allowed_signers`
format:
```sh
geth keychain allowed-signers > allowed_signers
geth keychain allowed-signers --out allowed_signers
geth keychain sign-file \
--in authorized_keys \
--out authorized_keys.sig \
geth keychain sign-file --in authorized_keys --out authorized_keys.sig \
--signing-key ~/.ssh/id_ed25519_sk
geth keychain verify-file \
--in authorized_keys \
--signature authorized_keys.sig
geth keychain sigchain --out geth.sigchain.jsonl
geth keychain publish-bundle \
--out public/.well-known/sshsigchain \
--signing-key ~/.ssh/id_ed25519_sk \
--snapshot authorized_keys=authorized_keys
geth keychain verify-checkpoint \
--checkpoint geth.sigchain.checkpoint.json \
--signature geth.sigchain.checkpoint.json.sig \
--sigchain geth.sigchain.jsonl \
--allowed-signers allowed_signers
geth keychain fetch --url https://example.com/.well-known/sshsigchain/ --import
geth keychain verify-sigchain --in geth.sigchain.jsonl
geth keychain import-sigchain --in geth.sigchain.jsonl
geth keychain verify-file --in authorized_keys --signature authorized_keys.sig
geth keychain explain <op-id>
geth keychain explain-signer <key-id>
```
The default static discovery base URL used by `publish-bundle` is
`https://example.com/.well-known/sshsigchain/`. The keychain does not manage
`authorized_keys` policy. It signs an arbitrary
snapshot you provide, which lets other projects keep their own SSH login policy
while rooting snapshot approval in the keychain. By default `sign-file` uses the
personal identity namespace `geth.authorized-keys.v1@eric.wendland.dev`; pass
`--namespace` for project-specific snapshots. The signer must be an active key
in the current `allowed_signers` projection, so website consumers can fetch the
snapshot, signature, and allowed signers and verify:
```sh
ssh-keygen -Y verify \
-f allowed_signers \
-I admin \
-n geth.authorized-keys.v1@eric.wendland.dev \
-s authorized_keys.sig < authorized_keys
```
Replay and verify the local sigchain:
```sh
geth keychain verify
```
## Differences From Git-SKM
`git-skm` uses Git commit history as the append-only log and a trusted commit
hash as the checkpoint. geth uses `KeychainOp[]` as the append-only log and the
locally initialized owner/admin key as the bootstrap trust anchor.
The current geth prototype does not yet provide:
- transparency-log style append proofs
- anti-rollback protection beyond local state, HTTP cache validators, and sync
conflict checks
- delegated admin scopes or threshold admin signatures
- strong compromise-recovery semantics
Those are future hardening items. The current prototype is intended to make the
key registry self-describing, replayable, and testable.
## Security Invariants
- The admin SSH private key is never copied into geth state.
- Admin key changes are keychain operations, not mutable ACL edits.
- Public key material needed to reconstruct active admin signers is stored in
the signed operation log.
- Import from peers accepts only operations signed by currently trusted admin
keys.
- A discovered peer card or Iroh EndpointID never grants keychain authority.
- Bearer secrets do not grant keychain mutation rights.
The keychain does not manage host `authorized_keys` policy. It can sign a
snapshot supplied by the operator, allowing another system to retain its own
SSH login policy while usefully checking approval against an active local admin
key.