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

22 KiB
Raw Blame History

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:

<key-type> SP <canonical-base64-key-blob>

Comments, leading/trailing whitespace, and additional fields are prohibited. Keys are identified by:

BLAKE3("sshsigchain.key-id.v1\0" || canonical_public_key)

Protocol identifiers are 1128 ASCII bytes containing letters, digits, ., -, _, :, or /.

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:

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.

"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:

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:

"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:

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:

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:

"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:

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.

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:

"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:

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:

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:

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:

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:

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