15 KiB
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:
chain_id: 32 independently generated random bytes;namespace: the 1–128 byte printable, non-whitespace SSHSIG namespace (sshsigchain.v1by default); androot_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 1–128 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:
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:
- satisfies required/weighted attesters and backend evidence under the policy which governs that head;
- rejects histories that do not contain its cached accepted head;
- selects the furthest valid descendant;
- rejects incomparable valid histories as a fork; and
- 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:
- reject an empty chain, over-limit inputs, noncanonical keys/lists, and malformed commitments;
- require the exact configured chain ID, absent genesis parent, and exact parent hash thereafter;
- require genesis to install the pinned root and initial authority state;
- verify every outer SSHSIG using the configured namespace;
- authorize the signer and transition from only the parent authority state;
- verify new-key possession proofs and apply the authority transition;
- require
ProfileWrite(profile_id)for every extension and verify every supplied disclosure; and - 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:
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
Allkey. - 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.