make sshsigchain the only portable sigchain format
This commit is contained in:
parent
9e5871d02a
commit
73500e1944
15 changed files with 812 additions and 1603 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue