geth/docs/sshsigchain.md
Eric Wendland 538b52bdb6
Some checks failed
CI / fmt, clippy, docs (push) Failing after 5s
CI / test (ubuntu-latest) (push) Failing after 5s
CI / iroh integration smoke tests (push) Failing after 4s
CodeQL / Analyze Rust (push) Failing after 5s
Security / RustSec cargo-audit (push) Failing after 4s
CI / test (macos-latest) (push) Has been cancelled
CI / test (windows-latest) (push) Has been cancelled
simplify sigchain http distribution
2026-07-19 03:43:06 +02:00

537 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SSHSIGCHAIN v1
Status: Draft 2 (pre-deployment)
SSHSIGCHAIN is a transport-independent, OpenSSH-signed authority chain. It is
intended to be implementable outside geth. A chain can be carried by JSONL,
HTTP, an object store, Iroh, or another transport; SSH is a signature mechanism,
not a transport.
Version 1 is the first and only format. There is no legacy grammar or downgrade
mode.
## 1. Security goals and non-goals
SSHSIGCHAIN provides:
- an explicit out-of-band `(chain_id, namespace, root_public_key)` trust tuple;
- deterministic signing bytes and causal parent hashes, with no sequence number
or timestamp ordering;
- a mandatory public authority state machine for devices, keys, causal
revocation, permissions, delegation, and anchor policy;
- proof-of-possession when a key is added;
- salted commitments for selectively disclosed application-profile payloads;
- signed head claims and a generic receipt policy for independent anchor
backends; and
- fail-closed rollback and fork detection relative to a cached head or multiple
verified histories.
It does not provide consensus, guaranteed publication, trusted time, anonymity,
zero-knowledge disclosure, payload encryption, or automatic compromise
recovery. An authorized key can perform every transition its effective
permissions allow. Outer links reveal the chain ID, signer, authority
transition, profile IDs, and payload commitments even when payloads are hidden.
## 2. Trust inputs and identifiers
A verifier obtains these values through an authenticated channel, never from a
downloaded chain:
1. `chain_id`: 32 independently generated random bytes;
2. `namespace`: the 1128 byte printable, non-whitespace SSHSIG namespace
(`sshsigchain.v1` by default); and
3. `root_public_key`: canonical OpenSSH public-key text.
Canonical key text is exactly:
```text
<key-type> SP <canonical-base64-key-blob>
```
Comments, leading/trailing whitespace, and additional fields are prohibited.
Keys are identified by:
```text
BLAKE3("sshsigchain.key-id.v1\0" || canonical_public_key)
```
Protocol identifiers are 1128 ASCII bytes containing letters, digits, `.`,
`-`, `_`, `:`, or `/`.
## 3. Outer-link model
A record contains:
| Field | Meaning |
| --- | --- |
| `chain_id` | Exact configured 32-byte chain ID. |
| `previous` | Absent only for genesis; otherwise the preceding record hash. |
| `signer_key_id` | Hash-derived ID of `signer_public_key`. |
| `signer_public_key` | Canonical OpenSSH public key used for SSHSIG verification. |
| `authority` | Exactly one public authority transition. |
| `extensions` | Strictly profile-ID-sorted payload commitments and optional disclosures. |
| `signature` | OpenSSH SSHSIG signature over the canonical signing bytes. |
There is deliberately no sequence field. A record's position and causality are
fully determined by genesis plus the exact `previous` link. Record counts are
local indexing metadata and are not signed protocol state.
The record hash named by its child is:
```text
BLAKE3("sshsigchain.record-hash.v1\0" || signing_bytes)
```
The signature is verified but excluded from record identity. This prevents two
valid encodings of a signature over identical outer content from creating
different chain identities.
## 4. Binary signing grammar
Integers are unsigned big-endian. `string16` and `bytes16` are a `u16` byte
length followed by that many bytes; `bytes32` uses a `u32` length. Lists begin
with a `u16` element count. No alignment or terminator bytes are present.
```text
"SSCS" 4 bytes
0x01 protocol version
chain_id 32 bytes
has_previous u8: 0 or 1
previous 32 bytes when present
signer_key_id 32 bytes
signer_public_key string16
authority_transition transition (section 5)
extension_count u16
repeated extension_count times:
profile_id string16
payload_commitment 32 bytes
```
Transport-only disclosure bytes and the SSHSIG signature are not part of these
bytes. The `signer_key_id` MUST match the canonical public key. Extensions MUST
be strictly sorted by profile ID with no duplicates.
### 4.1 Base signing vector
For an unsigned structural link with a zero `chain_id`, absent `previous`,
signer `ssh-ed25519 AQID`, `Noop` authority, and no extensions, the signing
bytes are:
```text
5353435301000000000000000000000000000000000000000000000000000000000000000000eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a200107373682d656432353531392041514944080000
```
The derived signer key ID in that vector is
`eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a2`.
This vector fixes the outer grammar; it is not a valid chain genesis because a
valid first record must carry `Genesis` authority.
## 5. Mandatory Authority v1 state machine
Authority is part of the protocol suite, not an optional application profile.
Every record contains exactly one transition. Transition tags and fields are:
| Tag | Transition | Fields after tag |
| --- | --- | --- |
| `0` | `Genesis` | device ID, root authority key, anchor policy |
| `1` | `DeviceAdd` | device ID, permission ceiling |
| `2` | `DeviceRevoke` | device ID |
| `3` | `KeyAdd` | device ID, authority key, proof `bytes32` |
| `4` | `KeyRevoke` | key ID |
| `5` | `PermissionGrant` | key ID, permissions, delegable permissions |
| `6` | `PermissionRevoke` | key ID, permissions, delegable permissions |
| `7` | `AnchorPolicySet` | anchor policy |
| `8` | `Noop` | no fields |
An authority key encodes canonical public-key `string16`, permissions, then
delegable permissions. Permission lists MUST be strictly sorted according to
their encoded `(tag, profile ID)` ordering and contain no duplicates.
### 5.1 Genesis
Genesis is the only record without `previous`. Its record signer and embedded
root key MUST equal the pinned root. The root key starts on the named root
device and MUST have both `All` authority and delegable `All` authority. Its
outer signature proves root-key possession. Genesis selects the initial anchor
policy.
Another genesis is invalid. The pinned root has no permanent bypass: it can be
revoked like another key after genesis.
### 5.2 Devices and keys
Device IDs are stable opaque identifiers. `DeviceAdd` creates a device once and
sets its immutable permission ceiling. `DeviceRevoke` is causal and permanent;
it also disables every key attached to that device. IDs and keys are never
reused or replaced in place.
`KeyAdd` declares the new key's device, active permissions, delegable
permissions, and a proof-of-possession SSHSIG made by the new key. The proof
uses namespace `sshsigchain.key-proof.v1` and these bytes:
```text
"SSKP" || 0x01 || chain_id || device_id:string16 || key_id:32 ||
public_key:string16 || permissions || delegable_permissions
```
The adding signer must be allowed to add a key to its own device or any device,
as applicable. Every granted and delegable permission must be within both the
signer's delegation scope and the target device ceiling. `KeyRevoke` is causal
and permanent.
### 5.3 Permissions
Permission tags are:
| Tag | Permission |
| --- | --- |
| `0` | `All` |
| `1` | `DeviceAdd` |
| `2` | `DeviceRevoke` |
| `3` | `KeyAddSelf` |
| `4` | `KeyAddAny` |
| `5` | `KeyRevokeSelf` |
| `6` | `KeyRevokeAny` |
| `7` | `ManagePermissions` |
| `8` | `AnchorPolicy` |
| `9` | `AnchorAttest` |
| `10` | `ProfileWrite(profile_id)` |
| `11` | `ProfileDelegate(profile_id)` |
`All` satisfies every permission and is intended for bootstrap/recovery keys.
An effective key is active only when both it and its device are active.
`PermissionGrant` requires `ManagePermissions`; every new permission must also
be within the signer's delegable set and the device ceiling. A delegable
`ProfileDelegate(x)` authorizes delegation of `ProfileWrite(x)` without
granting write access by itself. Revocation requires `ManagePermissions` and
takes effect before the next record is authorized.
An implementation MUST authorize each record from the state after its parent,
never from a final-state key set or a payload timestamp.
## 6. Selective disclosure
Each extension contains:
```text
profile_id
commitment = BLAKE3(
"sshsigchain.payload.v1\0" ||
profile_id:string16 || nonce:32 || payload:bytes32
)
optional transport disclosure { nonce, payload }
```
The 32-byte nonce MUST be generated unpredictably for each payload. It prevents
offline dictionary attacks against small hidden values; it is not encryption.
A disclosure verifies only when recomputation equals the signed commitment.
Removing or adding a valid disclosure does not change signing bytes or the
record hash.
Authority transitions are always public. Profile payloads MUST NOT create,
remove, revoke, authorize, or grant permissions to devices or keys. A verifier
can therefore verify chain authority without disclosures. If any needed
disclosure is absent, the affected profile state is `incomplete`; absence MUST
NOT be interpreted as an empty payload, deletion, or successful full replay.
## 7. Anchor policies and head claims
Anchoring makes rollback or equivocation observable under a chosen policy; it
does not make a backend a consensus system.
An anchor policy contains separately weighted attesters and publication
backends. Both lists and the required-class list are strictly sorted and unique:
```text
attester_threshold u16
attester_count u16
per attester:
key_id 32 bytes
weight u16 (non-zero)
required u8: 0 or 1
required_class_count u16
required classes repeated string16
backend_threshold u16
backend_count u16
per backend:
backend_id string16
class string16
locator string16
weight u16 (non-zero)
required u8: 0 or 1
```
Attester key IDs and backend IDs are unique, and neither threshold can exceed
its corresponding total weight. Required attesters must publish valid claims;
required backends must have valid receipts independently of thresholds. At
least one valid receipt must come from every required backend class. Classes
such as `transparency`, `nostr`, `blockchain`, and `http` are policy labels;
adapter-specific receipt verification defines their evidence. An empty
attester list with threshold zero permits any active `AnchorAttest` key, but at
least one valid claim is still required for an anchored history.
A key with `AnchorAttest` signs a `HeadClaim` under namespace
`sshsigchain.anchor.v1`:
```text
"SSAH" || 0x01 || chain_id:32 || head:32 || signer_key_id:32 ||
signer_public_key:string16
```
Every claim signer must be active and authorized at the claimed head. Distinct
signers are counted once against the attester policy. A receipt binds a backend
ID, one claim hash, and backend-defined evidence. A backend is counted once if
it has a valid receipt for any accepted claim. Claim identity is
`BLAKE3("sshsigchain.head-claim.v1\0" || claim_signing_bytes)`.
Adapters conceptually implement:
```text
publish(HeadClaim) -> AnchorReceipt
fetch(chain_id) -> HeadClaim[]
verify_receipt(policy_backend, HeadClaim, AnchorReceipt) -> bool
```
They do not choose a canonical head. After verifying claims, chains, authority,
and receipts, a client:
1. satisfies required/weighted attesters and backend evidence under the policy
which governs that head;
2. rejects histories that do not contain its cached accepted head;
3. selects the furthest valid descendant;
4. rejects incomparable valid histories as a fork; and
5. persists the newly accepted head before trusting later descendants.
The exact head containing `AnchorPolicySet` MUST satisfy the previous policy.
The new policy applies to its descendants. This prevents an authority from
weakening anchoring and treating the weakening record as already witnessed only
under the new policy.
Nostr replaceable/addressable events and ordinary mutable HTTP resources are
discovery conveniences, not strong rollback evidence by themselves. Prefer
immutable events plus the SSH-signed claim, local head caching, independent
witnesses, or a transparency log with verifiable inclusion/consistency proofs.
A blockchain receipt proves inclusion under the configured backend rules; it
does not decide between forked SSHSIGCHAIN histories.
## 8. Verification algorithm
A conforming verifier MUST:
1. reject an empty chain, over-limit inputs, noncanonical keys/lists, and
malformed commitments;
2. require the exact configured chain ID, absent genesis parent, and exact
parent hash thereafter;
3. require genesis to install the pinned root and initial authority state;
4. verify every outer SSHSIG using the configured namespace;
5. authorize the signer and transition from only the parent authority state;
6. verify new-key possession proofs and apply the authority transition;
7. require `ProfileWrite(profile_id)` for every extension and verify every
supplied disclosure; and
8. compute the record hash for the next link.
Records are never reordered by timestamps, transport arrival, identifiers, or
record counts. A verifier without all trust inputs fails closed.
## 9. Canonical objects and on-disk bundle
The canonical on-disk and full-snapshot distribution format is a single
SSHSIGCHAIN Canonical Bundle (`.sscb`). It contains records, disclosures, head
claims, and anchor receipts while deliberately omitting the root public key.
The root remains an out-of-band trust input.
All objects start with four magic bytes and version `0x01`. `bytes32` means a
big-endian `u32` length followed by that many bytes.
```text
record object:
"SSCR" || 0x01 || record_signing_bytes:bytes32 || signature:bytes32
disclosure object:
"SSCD" || 0x01 || record_hash:32 || profile_id:string16 ||
nonce:32 || payload:bytes32
head-claim object:
"SSHC" || 0x01 || claim_signing_bytes:bytes32 || signature:bytes32
anchor-receipt object:
"SSRC" || 0x01 || backend_id:string16 || claim_hash:32 ||
evidence:bytes32
```
The bundle grammar is:
```text
"SSCB" 4 bytes
0x01 bundle version
chain_id 32 bytes
namespace string16
record_count u32
records record_count objects, each wrapped as bytes32
disclosure_count u32
disclosures disclosure_count objects, each wrapped as bytes32
claim_count u32
claims claim_count objects, each wrapped as bytes32
receipt_count u32
receipts receipt_count objects, each wrapped as bytes32
```
Canonical ordering and uniqueness rules are:
- records occur in causal genesis-to-head order and every parent must equal the
preceding record hash;
- disclosures are sorted by `(record_hash, profile_id)` and that pair is unique;
- claims are sorted by claim hash and claim hashes are unique;
- receipts are sorted by `(claim_hash, backend_id)` and that pair is unique;
- disclosures must open a commitment in the named bundled record;
- claims must name a record in the bundled history; and
- receipts must name a bundled claim.
A decoder MUST reject trailing bytes, out-of-order objects, duplicate objects,
noncanonical embedded signing bytes, mixed chain IDs, unknown references, or a
bundle which does not encode byte-for-byte identically when re-encoded. The
namespace is self-description only and MUST exactly match the locally configured
trust tuple before record verification.
Bundle identity is:
```text
BLAKE3("sshsigchain.bundle.v1\0" || canonical_bundle_bytes)
```
Bundle identity is not chain identity: adding a valid disclosure or receipt
changes the bundle hash without changing any record hash. A canonical bundle is
therefore suitable as the canonical single-file on-disk snapshot, attachment,
cache object, or static-server artifact. Implementations may maintain indexes
or databases internally, but export/import MUST round-trip through this format.
The reference maximum bundle size is 256 MiB and the maximum embedded object
size is 5 MiB. Implementations MUST enforce bounds while streaming, before
allocating lengths supplied by an untrusted bundle.
### 9.1 Base bundle vector
The following structural vector contains the section 4.1 zero-chain `Noop`
record, signature bytes `01`, namespace `sshsigchain.v1`, and no disclosures,
claims, or receipts:
```text
53534342010000000000000000000000000000000000000000000000000000000000000000000e737368736967636861696e2e7631000000010000006953534352010000005b5353435301000000000000000000000000000000000000000000000000000000000000000000eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a200107373682d6564323535313920415149440800000000000101000000000000000000000000
```
It fixes bundle and record-object framing. Like the section 4.1 outer vector,
it is not a valid verified chain because the first transition is not `Genesis`
and `01` is not an OpenSSH SSHSIG signature.
## 10. Distribution profiles
Distribution is untrusted availability and routing. It does not grant
authority, choose a canonical head, replace the root trust tuple, or satisfy an
anchor policy merely because a file was fetched successfully.
A distribution endpoint has a local/configured `backend_id`, `class`, and
opaque `locator`. Backends conceptually implement:
```text
fetch_bundle(endpoint, chain_id, accepted_head) -> none | canonical_bundle_bytes
```
The `accepted_head` permits an incremental P2P backend to optimize its response,
but every returned result must decode to a complete verifiable snapshot that
contains the accepted head. HTTP, Iroh, removable-media, object-store, IPFS, or
application-specific backends can implement the same interface. Fetch failures
affect availability only. Results enter the same signature, authority,
anchoring, cached-head, and fork checks.
### 10.1 Static HTTP profile
The static HTTP profile needs no server application, directory listing,
well-known path, or discovery protocol. Its `locator` is the exact arbitrary
HTTP(S) URL of one canonical `.sscb` file. Implementations MUST NOT derive the
URL from the chain ID, treat a URL as root-key discovery, or use a successful
fetch as trust-on-first-use. The chain ID, namespace, and root public key are
already pinned independently of the URL.
The response media type SHOULD be:
```text
application/vnd.sshsigchain.bundle.v1
```
A client performs an ordinary `GET` of the exact configured URL, accepts a
successful full response, caps bytes while streaming, decodes the canonical
bundle, requires its chain ID and namespace to match local trust, verifies the
chain, verifies applicable head claims and receipts, then applies
cached-head/fork rules. `404` means the source currently has no bundle. Servers
SHOULD replace the file atomically.
Servers SHOULD expose the lowercase bundle hash as a strong `ETag`; clients
SHOULD use `If-None-Match`. A mutable latest-version URL SHOULD use
`Cache-Control: no-cache` or a suitably short freshness lifetime so ordinary
HTTP caches and CDNs can store the file and revalidate it. HTTP cache validators
are performance hints, not trust. HTTPS protects privacy, locators, and
availability against network interference, but SSHSIGCHAIN verification
remains mandatory even over authenticated HTTPS. Plain HTTP remains
integrity-safe only in the narrow sense that tampering is detected; it offers
no confidentiality or reliable availability.
Servers MAY support byte ranges, and clients MAY use them to resume or
parallelize transfer of the single file. The v1 bundle has no authenticated
random-access index: a client MUST assemble, decode, hash, and verify the
complete canonical bundle before accepting any state. A `206 Partial Content`
response is therefore a download optimization, not a partial-chain or
per-object protocol. This profile deliberately remains one file.
## 11. JSONL interchange
The reference transport uses one JSON object per nonblank line with exactly the
record fields in section 3. Byte arrays use JSON arrays of octets; enum objects
use the names in this document. Object order and whitespace are irrelevant.
Unknown or duplicate fields are rejected.
Reference limits are: 16 KiB canonical key, 64 KiB signature/proof, 1 MiB one
profile payload, 1,024 extensions per record, 100,000 records, 5 MiB one JSONL
line, and 64 MiB one JSONL input. Implementations may impose smaller limits.
JSONL is a human-facing interchange representation, not the canonical on-disk
format. It carries records and optional disclosures only; canonical bundles are
required to preserve head claims and receipts.
## 12. OpenSSH interoperability
Outer records use commands equivalent to:
```sh
ssh-keygen -Y sign -f <private-key> -n sshsigchain.v1 <signing-bytes-file>
ssh-keygen -Y verify -f <one-key-allowed-signers> -I sshsigchain \
-n sshsigchain.v1 -s <signature-file> < <signing-bytes-file>
```
The one-key `allowed_signers` file is only an input to cryptographic signature
verification. It never establishes authorization or root trust.
## 13. Security considerations
- Use independent random chain IDs and protocol-specific SSHSIG namespaces.
- A stolen active key retains its scoped authority until a causally prior
revocation is in the accepted history. There is no trusted signature time.
- Device ceilings and delegation checks prevent permission amplification but
cannot protect against a legitimately authorized `All` key.
- Selective disclosure leaks outer metadata and payload equality only when a
nonce is reused. Always use a fresh unpredictable nonce.
- Anchor strength is no greater than the configured independent backends and
receipt verifiers. Cached-head checks remain mandatory.
- Distribution sources and HTTP cache metadata are untrusted. A fetched bundle
must contain the cached head and pass the complete verifier before use.
- Resource bearer secrets, discovery records, and profile payloads cannot
mutate SSHSIGCHAIN authority.
- Implementations should apply a local OpenSSH algorithm policy and reject
unsupported keys or SSHSIG algorithms.
## References
- [OpenSSH `PROTOCOL.sshsig`](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig)
- [OpenBSD `ssh-keygen(1)`](https://man.openbsd.org/ssh-keygen.1)
- [Nostr NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md)
- [Sigstore Rekor](https://github.com/sigstore/rekor)