geth/docs/sshsigchain.md
Eric Wendland 76eb785ee2
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 5s
CodeQL / Analyze Rust (push) Failing after 4s
Security / RustSec cargo-audit (push) Failing after 5s
CI / test (macos-latest) (push) Has been cancelled
CI / test (windows-latest) (push) Has been cancelled
specify selective disclosure and anchor policy
2026-07-18 22:05:57 +02:00

382 lines
15 KiB
Markdown
Raw 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. JSONL transport and limits
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.
## 10. 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.
## 11. 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.
- 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)