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,9 +1,10 @@
|
|||
# ADR 0018: Linked SSHSIGCHAIN v2
|
||||
# ADR 0018: Linked SSHSIGCHAIN v1
|
||||
|
||||
## Status
|
||||
|
||||
Accepted for the new generic core and geth keychain profile. Legacy static
|
||||
sigchain publication is deprecated pending explicit v2 command migration.
|
||||
Accepted for the generic core and geth keychain profile. The pre-standard
|
||||
test-only static workflow is removed; it is not an alternate format or a
|
||||
compatibility path.
|
||||
|
||||
## Context
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ rollback, and fork semantics inadequate for a trust foundation.
|
|||
|
||||
## Decision
|
||||
|
||||
Geth adopts SSHSIGCHAIN v2 as the replacement design:
|
||||
Geth adopts SSHSIGCHAIN version 1 as its one portable signed-chain design:
|
||||
|
||||
- root trust is an explicit `(chain ID, profile, namespace, root key)` tuple;
|
||||
- records are linearly ordered by sequence and linked by a digest over their
|
||||
|
|
@ -31,11 +32,11 @@ not a geth transport.
|
|||
|
||||
## Consequences
|
||||
|
||||
The v2 core can be published and implemented by applications without importing
|
||||
The core can be published and implemented by applications without importing
|
||||
geth's resource model. Geth's keychain profile is intentionally narrow and
|
||||
tested against self-bootstrap, fork, and backdated-revocation attacks.
|
||||
|
||||
Existing static v1 bundles cannot safely migrate in place. Operators must pin a
|
||||
new v2 trust tuple and create a new genesis chain. Head persistence and later
|
||||
witness/transparency support remain follow-up work; neither is implied by a
|
||||
single signed checkpoint.
|
||||
The old static bundle code and commands are deleted rather than supported beside
|
||||
SSHSIGCHAIN. There is no migration format because the repository has not been
|
||||
deployed. Head persistence and later witness/transparency support remain
|
||||
follow-up work; neither is implied by a single signed checkpoint.
|
||||
|
|
@ -451,23 +451,20 @@ admin-revoke` operations; `AdminKeyAdd` carries the public key material needed
|
|||
to reconstruct an OpenSSH `allowed_signers` view. `geth keychain verify` replays
|
||||
the log against the previously accepted admin-key view, mirroring the `git-skm`
|
||||
pattern of verifying key-registry changes from a prior trusted state. The
|
||||
transport-neutral replay rules, application-specific signature namespaces,
|
||||
allowed-signers projection, and JSONL sigchain helpers live in `geth-keychain`
|
||||
so other applications can reuse the same identity-log model without depending
|
||||
on the daemon, SQLite, Iroh, or local control. The CLI can export the same
|
||||
reduced key registry as OpenSSH `allowed_signers` or as appendable JSONL
|
||||
sigchain data for website publication. It can also sign and verify arbitrary
|
||||
snapshots, such as externally managed `authorized_keys`, with an active
|
||||
keychain signer under an explicit OpenSSH namespace. `geth keychain
|
||||
publish-bundle` writes a website-ready bundle for
|
||||
`https://example.com/.well-known/sshsigchain/`, including `allowed_signers`,
|
||||
`geth.sigchain.jsonl`, a signed checkpoint, and optional signed snapshots.
|
||||
`geth keychain fetch --import` verifies the checkpoint and records the last
|
||||
accepted checkpoint per retrieval source URL to reject older bundles. The
|
||||
retrieval source may be a local mirror; the checkpoint still carries the signed
|
||||
advertised publication base URL, and explicit checkpoint verification can pin it. `geth keychain
|
||||
explain` and `explain-signer` provide basic auditability for why a keychain
|
||||
operation or signer is trusted. `geth keychain sync <node>` pulls keychain
|
||||
local replay rules, application-specific signature namespaces, and
|
||||
allowed-signers projection live in `geth-keychain`. The CLI can export the
|
||||
reduced key registry as OpenSSH `allowed_signers` and can sign and verify
|
||||
arbitrary snapshots, such as externally managed `authorized_keys`, with an
|
||||
active keychain signer under an explicit OpenSSH namespace. The prior static
|
||||
JSONL export, publication, import, checkpoint, and fetch commands were removed:
|
||||
their downloaded `allowed_signers` projection could bootstrap its own trust.
|
||||
`geth keychain verify-sigchain --in <file> --chain-id <id> --root-key
|
||||
<public-key>` is the inspection entry point for the standalone SSHSIGCHAIN
|
||||
format, verified against a pin supplied by the operator. SSHSIGCHAIN signing,
|
||||
persistent heads, publication, and import remain follow-up work rather than a
|
||||
compatibility fallback. `geth keychain explain`
|
||||
and `explain-signer` provide basic auditability for why a keychain operation or
|
||||
signer is trusted. `geth keychain sync <node>` pulls keychain
|
||||
operations and signatures from an imported peer over Iroh and imports only
|
||||
operations with a valid OpenSSH signature from a currently trusted admin key
|
||||
over the canonical payload. See `docs/sigchain-keychain.md` for the detailed
|
||||
|
|
@ -479,16 +476,16 @@ identities are immutable in local storage. An attempted re-import with an
|
|||
existing identity but different bytes is rejected before it can replace local
|
||||
trust state; idempotent repeats leave the original bytes unchanged.
|
||||
|
||||
The replacement static-publication design is specified in
|
||||
[`sshsigchain-v2.md`](sshsigchain-v2.md). Its reusable core has an explicit
|
||||
The portable signed-chain design is specified in
|
||||
[`sshsigchain.md`](sshsigchain.md). Its reusable core has an explicit
|
||||
out-of-band `(chain ID, profile, SSHSIG namespace, root public key)` trust
|
||||
tuple, a strict sequence plus hash link, bounded fields, and a profile reducer
|
||||
that authorizes each record from only the causally preceding state. Geth's
|
||||
`geth.keychain.v2` profile rejects timestamp validity windows as authorization
|
||||
policy and treats key revocation as a causal record. The legacy static JSONL
|
||||
commands have not yet been migrated to this core and are not a safe bootstrap
|
||||
for new trust; Iroh keychain sync remains the supported replicated path while
|
||||
the explicit v2 command workflow is completed.
|
||||
`geth.keychain.sshsigchain.v1` profile rejects timestamp validity windows as authorization
|
||||
policy and treats key revocation as a causal record. The prior test-only static
|
||||
workflow was removed rather than migrated. Iroh keychain sync remains the
|
||||
current replicated local operation-log path while explicit SSHSIGCHAIN
|
||||
production and import workflows are completed.
|
||||
|
||||
New devices can use the node enrollment flow instead of hand-editing keychain
|
||||
state. `geth node enroll join` explicitly imports an owner admin public key as
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ The following command families are intended to be stable automation surfaces:
|
|||
- `geth peer export|import|list|ping|auth-check`
|
||||
- `geth keychain init|status|admin-add|admin-revoke|allowed-signers|verify`
|
||||
- `geth keychain sign-file|verify-file`
|
||||
- `geth keychain sigchain|verify-sigchain|import-sigchain|verify-checkpoint|fetch|explain|explain-signer`
|
||||
- `geth keychain explain|explain-signer`
|
||||
- `geth auth explain|grant|revoke|sync`
|
||||
- `geth sync status|now`
|
||||
- `geth wait daemon|peer|sync`
|
||||
|
|
@ -61,6 +61,7 @@ settles:
|
|||
- `geth overlay status|plan|join|leave|interface-plan|up|down|peers|send|recv`
|
||||
- `geth cas add-private`
|
||||
- `geth cas get-private`
|
||||
- `geth keychain verify-sigchain`
|
||||
|
||||
Migration expectation: overlay membership records and authorization resources
|
||||
should remain readable, but packet runtime flags, platform activation details,
|
||||
|
|
@ -71,6 +72,11 @@ properties are explicitly out of scope. Prototype BLAKE3-XOR envelopes from
|
|||
earlier pre-deployment builds are rejected with a clear error and should be
|
||||
recreated from plaintext.
|
||||
|
||||
The SSHSIGCHAIN verifier is experimental while SSHSIGCHAIN signing, storage,
|
||||
publication, import, head persistence, and independently generated wire vectors
|
||||
are completed. The prior test-only static commands were removed and are not a
|
||||
compatibility path.
|
||||
|
||||
## Adding Commands
|
||||
|
||||
New commands should enter this file in the same change that introduces the CLI
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ admin devices without depending on one always-online coordination server.
|
|||
resource data.
|
||||
- `[ ]` The control plane has documented conflict semantics for concurrent
|
||||
joins, renames, IP conflicts, route changes, and revocations.
|
||||
- `[ ]` Nodes can converge from static sigchain publication, peer live-sync,
|
||||
- `[ ]` Nodes can converge from SSHSIGCHAIN publication, peer live-sync,
|
||||
and Iroh-connected peers without requiring a central coordinator.
|
||||
- `[ ]` Stale or partitioned nodes are detectable in `geth overlay status`
|
||||
and `geth sync status --json`.
|
||||
|
|
@ -181,7 +181,7 @@ admin devices without depending on one always-online coordination server.
|
|||
- `[ ]` Fault-tolerant peer and path selection.
|
||||
Acceptance criteria:
|
||||
- `[ ]` Nodes maintain multiple candidate addresses from Iroh relays, local
|
||||
discovery, imported peer cards, and static publication.
|
||||
discovery, imported peer cards, and configured bootstrap metadata.
|
||||
- `[ ]` Packet routing retries healthy paths and backs off failed paths
|
||||
without granting trust from discovery metadata.
|
||||
- `[ ]` Relay use, direct connections, LAN discovery, and path failures are
|
||||
|
|
@ -555,23 +555,33 @@ resource-scoped capability decisions.
|
|||
- JSON is not used as the signed representation.
|
||||
- Tests verify equivalent operations hash/sign identically across runs.
|
||||
|
||||
- `[~]` Replace the legacy static keychain sigchain with SSHSIGCHAIN v2.
|
||||
- `[~]` Implement SSHSIGCHAIN version 1 as the only portable keychain
|
||||
sigchain format.
|
||||
Acceptance criteria:
|
||||
- `[x]` Publish a transport-neutral, deterministic SSHSIGCHAIN v2 record
|
||||
- `[x]` Publish a transport-neutral, deterministic SSHSIGCHAIN v1 record
|
||||
format with explicit trust-anchor, chain-link, size-limit, and
|
||||
non-claim documentation.
|
||||
- `[x]` Provide a reusable verifier core and a geth keychain profile that
|
||||
rejects self-bootstrap, non-linked forks, non-canonical payloads, and
|
||||
post-revocation timestamp replay.
|
||||
- `[ ]` Add explicit CLI storage, signing, verification, publication, and
|
||||
import workflows for a pinned v2 trust tuple.
|
||||
- `[ ]` Persist accepted v2 heads and require proof of extension before a
|
||||
static source can advance.
|
||||
- `[ ]` Disable the legacy static publish/fetch/import workflow by default
|
||||
and provide an operator-visible migration path that creates a fresh v2
|
||||
genesis chain.
|
||||
- `[ ]` Add OpenSSH integration tests and independently generated wire test
|
||||
- `[~]` Add explicit CLI storage, signing, verification, publication, and
|
||||
import workflows for a pinned SSHSIGCHAIN trust tuple.
|
||||
- `[x]` `geth keychain verify-sigchain` verifies a JSONL transport file
|
||||
against an operator-pinned chain ID and OpenSSH root public key.
|
||||
- `[ ]` Local record storage, signing, publication, import, and
|
||||
head-advance workflows are complete.
|
||||
- `[ ]` Persist accepted heads and require proof of extension before a source
|
||||
can advance.
|
||||
- `[x]` Delete the previous test-only static export, publication,
|
||||
verification, import, checkpoint, and fetch workflow. It is not a
|
||||
compatibility target because it was never deployed.
|
||||
- `[~]` Add OpenSSH integration tests and independently generated wire test
|
||||
vectors for the published standard.
|
||||
- `[x]` An OpenSSH `ssh-keygen -Y` integration test signs and verifies a
|
||||
linked root/init chain.
|
||||
- `[x]` The specification and reference implementation share a base
|
||||
signing-byte test vector.
|
||||
- `[ ]` Add independently generated cross-implementation vectors.
|
||||
|
||||
- `[x]` SSH-admin-rooted keychain initialization.
|
||||
Acceptance criteria:
|
||||
|
|
@ -602,24 +612,11 @@ resource-scoped capability decisions.
|
|||
- `[x]` `geth keychain verify-file --in <path> --signature <sig>` verifies a
|
||||
snapshot signature against the current keychain-derived `allowed_signers`
|
||||
projection or a supplied `--allowed-signers` file.
|
||||
- `[x]` `geth keychain sigchain --out <path>` writes the appendable JSONL
|
||||
sigchain suitable for static website publication.
|
||||
- `[x]` `geth keychain publish-bundle --out <dir>` writes a static website
|
||||
bundle rooted at `https://example.com/.well-known/sshsigchain/` by default.
|
||||
- `[x]` Publication bundles include `allowed_signers`, `geth.sigchain.jsonl`,
|
||||
`geth.sigchain.checkpoint.json`, and a detached checkpoint signature.
|
||||
- `[x]` Publication bundles can copy and sign external snapshots with
|
||||
`--snapshot <name>=<path>` without making the keychain own their contents.
|
||||
- `[x]` `geth keychain verify-sigchain --in <path>` verifies a JSONL sigchain
|
||||
file by replaying operations and signatures.
|
||||
- `[x]` `geth keychain import-sigchain --in <path>` imports a JSONL sigchain
|
||||
only if replay verification rejects no operations.
|
||||
- `[x]` `geth keychain verify-checkpoint` verifies checkpoint signatures,
|
||||
checkpoint hashes, base URL, and sigchain head consistency.
|
||||
- `[x]` `geth keychain fetch --url <base> --import` fetches static bundles
|
||||
with `curl` for HTTP(S) or filesystem reads for local/file URLs.
|
||||
- `[x]` Static fetch/import records the last accepted checkpoint per source
|
||||
URL and rejects older checkpoints for rollback resistance.
|
||||
- `[x]` Previous test-only static JSONL export, publication, verification,
|
||||
import, checkpoint, and fetch commands were deleted after review found that
|
||||
their downloaded allowed-signers projection could self-bootstrap trust and
|
||||
their timestamp ordering could not prove causal history. They are not a
|
||||
compatibility target; SSHSIGCHAIN is the only portable format.
|
||||
- `[x]` `geth keychain explain <op-id>` and `explain-signer <key-id>` provide
|
||||
basic audit output for operations and admin signers.
|
||||
- `[x]` Agent/FIDO signing is supported through OpenSSH by passing a public
|
||||
|
|
@ -629,9 +626,9 @@ resource-scoped capability decisions.
|
|||
previously accepted admin-key view.
|
||||
- `[x]` Reusable sigchain mechanics live in `geth-keychain`, not in daemon
|
||||
orchestration code.
|
||||
- `[x]` `geth-keychain` exposes transport-neutral allowed-signers projection,
|
||||
replay verification with an injected verifier, and appendable JSONL
|
||||
sigchain encode/decode helpers for static hosting or alternate transports.
|
||||
- `[x]` `geth-keychain` exposes a transport-neutral SSHSIGCHAIN verifier,
|
||||
bounded JSONL transport codec, and an explicit trust-tuple model alongside
|
||||
local allowed-signers projection and replay verification.
|
||||
- `[x]` `geth-keychain` exposes `KeychainProfile` so non-geth applications
|
||||
can use distinct signature namespaces and default principals.
|
||||
- `[x]` `geth-keychain` exposes a `KeychainSignatureVerifier` trait so
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
# SSHSIGCHAIN v2
|
||||
# SSHSIGCHAIN v1
|
||||
|
||||
Status: Draft 0
|
||||
Status: Draft 1 (pre-deployment)
|
||||
|
||||
This document specifies a deliberately small, generic append-only signature
|
||||
chain for applications that use OpenSSH `sshsig` signatures. It is transport
|
||||
chain for applications that use OpenSSH `sshsig` signatures. Version 1 is the
|
||||
first and only defined version of SSHSIGCHAIN. It is transport
|
||||
independent: a chain may be carried by a file, object store, HTTP, database, or
|
||||
mesh protocol. It does not make SSH a transport.
|
||||
|
||||
The reference implementation lives in geth's `geth-keychain` crate. Geth uses
|
||||
the `geth.keychain.v2` profile for identity-plane operations, but the format and
|
||||
the `geth.keychain.sshsigchain.v1` profile for identity-plane operations, but the format and
|
||||
verification core do not depend on geth data types.
|
||||
|
||||
## 1. Goals
|
||||
|
||||
SSHSIGCHAIN v2 provides:
|
||||
SSHSIGCHAIN v1 provides:
|
||||
|
||||
- a fixed, deterministic byte sequence for every signed record;
|
||||
- an explicit, out-of-band root public key rather than trust bootstrapped from
|
||||
|
|
@ -46,7 +47,7 @@ enrollment material, release metadata, or another authenticated out-of-band
|
|||
channel. A fetched `allowed_signers` file, checkpoint, or first record MUST NOT
|
||||
be used to discover or replace them.
|
||||
|
||||
The v2 core accepts a single root key. A threshold or witness scheme is a
|
||||
The v1 core accepts a single root key. A threshold or witness scheme is a
|
||||
separate protocol extension and must bind the same `chain_id`, profile, and
|
||||
head digest; it is not implied by multiple signatures attached to a record.
|
||||
|
||||
|
|
@ -80,8 +81,9 @@ A record has these logical fields:
|
|||
| `signature` | byte string | An OpenSSH SSHSIG signature over the signing bytes below. |
|
||||
|
||||
The profile identifier is 1–128 ASCII bytes containing only letters, digits,
|
||||
`.`, `-`, and `_`. Canonical public-key text is at most 16 KiB, signatures are
|
||||
at most 64 KiB, and a verifier MUST reject a chain over 100,000 records before
|
||||
`.`, `-`, and `_`. The namespace is 1–128 printable ASCII bytes without
|
||||
whitespace. Canonical public-key text is at most 16 KiB, signatures are at most
|
||||
64 KiB, and a verifier MUST reject a chain over 100,000 records before
|
||||
performing unbounded work. Implementations may set smaller limits.
|
||||
|
||||
Timestamps are intentionally not fields in the generic ordering mechanism.
|
||||
|
|
@ -97,7 +99,7 @@ contains exactly `n` bytes; no implicit terminator or alignment is present.
|
|||
|
||||
```text
|
||||
"SSCS" 4 bytes
|
||||
0x02 1 byte (protocol version)
|
||||
0x01 1 byte (protocol version)
|
||||
chain_id 32 bytes
|
||||
sequence u64
|
||||
has_previous 1 byte: 0 or 1
|
||||
|
|
@ -114,13 +116,49 @@ The `signature` field is not in the signing bytes because SSHSIG signs those
|
|||
bytes. The record digest, which binds the signature into the next link, is:
|
||||
|
||||
```text
|
||||
BLAKE3("sshsigchain.record-hash.v2\\0" || signing_bytes || u32(signature_length) || signature)
|
||||
BLAKE3("sshsigchain.record-hash.v1\\0" || signing_bytes || u32(signature_length) || signature)
|
||||
```
|
||||
|
||||
`previous` in record `n + 1` MUST equal this digest for record `n`. A JSON or
|
||||
JSONL transport envelope is allowed for convenience, but JSON bytes MUST NOT
|
||||
be signed or hashed as the record representation.
|
||||
|
||||
### JSONL transport
|
||||
|
||||
This specification defines JSONL as a convenient interchange transport. Each
|
||||
non-empty physical line contains exactly one JSON object with these fields:
|
||||
`chain_id`, `profile`, `sequence`, `previous`, `payload`,
|
||||
`signer_public_key`, and `signature`. `chain_id`, `previous` when present,
|
||||
`payload`, and `signature` are JSON arrays of unsigned octets; `previous` is
|
||||
`null` when absent. `profile` and `signer_public_key` are JSON strings, and
|
||||
`sequence` is an unsigned integer. Unknown or duplicate fields MUST be
|
||||
rejected. Object-member order and JSON whitespace carry no meaning.
|
||||
|
||||
Blank lines MAY be ignored. A JSONL line is limited to 5 MiB and one complete
|
||||
input to 64 MiB. Implementations reading an arbitrary stream MUST enforce those
|
||||
limits before buffering an unbounded line or input. These limits are transport
|
||||
limits in addition to the record limits above.
|
||||
|
||||
### Base test vector
|
||||
|
||||
This unsigned record fixes the field grammar independently of OpenSSH key
|
||||
generation or signature randomness:
|
||||
|
||||
```text
|
||||
chain_id 32 zero bytes
|
||||
profile "example.test"
|
||||
sequence 0
|
||||
previous absent
|
||||
payload 01 02
|
||||
signer_public_key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2"
|
||||
```
|
||||
|
||||
Its signing bytes, encoded as lowercase hexadecimal, MUST be:
|
||||
|
||||
```text
|
||||
53534353010000000000000000000000000000000000000000000000000000000000000000000000000000000000000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332
|
||||
```
|
||||
|
||||
## 6. Verification algorithm
|
||||
|
||||
Given the configured trust input and an ordered candidate record list, a
|
||||
|
|
@ -167,13 +205,15 @@ bytes.
|
|||
|
||||
### geth keychain profile
|
||||
|
||||
Geth's identifier is `geth.keychain.v2`. Its payload is a versioned canonical
|
||||
Geth's identifier is `geth.keychain.sshsigchain.v1`. Its payload is a versioned canonical
|
||||
binary mirror of a keychain operation. The mirror is deliberately separate from
|
||||
the human-facing, internally tagged JSON API type so that a decoder can prove a
|
||||
unique payload byte sequence. Version 1 requires:
|
||||
|
||||
- sequence zero to be `KeychainInit`;
|
||||
- sequence one to be an `AdminKeyAdd` that records the configured root signer;
|
||||
- every signer to be an active admin public key in the preceding profile state;
|
||||
- every keychain operation ID to occur only once in the chain;
|
||||
- each `AdminKeyAdd` to carry a canonical public key whose BLAKE3 key ID
|
||||
matches the declared key ID;
|
||||
- `AdminKeyRevoke` to remove that key before any later record is authorized;
|
||||
|
|
@ -195,7 +235,8 @@ unless its signer is an already pinned trust anchor and its claimed ancestry is
|
|||
verified.
|
||||
|
||||
For stronger equivocation evidence, publish heads to independently operated
|
||||
witnesses or a transparency log. This is intentionally outside v2's small core.
|
||||
witnesses or a transparency log. This is intentionally outside SSHSIGCHAIN's
|
||||
small core.
|
||||
|
||||
## 9. Security considerations
|
||||
|
||||
|
|
@ -217,11 +258,11 @@ witnesses or a transparency log. This is intentionally outside v2's small core.
|
|||
|
||||
## 10. Compatibility
|
||||
|
||||
SSHSIGCHAIN v2 has no compatibility mode with the older geth static JSONL
|
||||
sigchain. That format could bootstrap trust from its own download and ordered
|
||||
operations by mutable timestamps, so treating it as v2 would silently preserve
|
||||
the bugs this specification removes. Migration requires an explicitly pinned
|
||||
v2 trust tuple and a freshly signed v2 genesis sequence.
|
||||
This specification defines one format: SSHSIGCHAIN version 1. It has no
|
||||
compatibility mode, downgrade path, or alternate legacy record grammar.
|
||||
Implementations MUST reject another protocol version. Geth removed its
|
||||
pre-standard test-only static JSONL workflow rather than treating it as an
|
||||
SSHSIGCHAIN variant.
|
||||
|
||||
## References
|
||||
|
||||
Loading…
Reference in a new issue