2026-07-18 21:00:06 +02:00
|
|
|
|
# SSHSIGCHAIN v1
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
Status: Draft 2 (pre-deployment)
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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.
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
Version 1 is the first and only format. There is no legacy grammar or downgrade
|
|
|
|
|
|
mode.
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
## 1. Security goals and non-goals
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
SSHSIGCHAIN provides:
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
- 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.
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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.
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
## 2. Trust inputs and identifiers
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
A verifier obtains these values through an authenticated channel, never from a
|
|
|
|
|
|
downloaded chain:
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
1. `chain_id`: 32 independently generated random bytes;
|
|
|
|
|
|
2. `namespace`: the 1–128 byte printable, non-whitespace SSHSIG namespace
|
|
|
|
|
|
(`sshsigchain.v1` by default); and
|
|
|
|
|
|
3. `root_public_key`: canonical OpenSSH public-key text.
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
Canonical key text is exactly:
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
```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 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:
|
|
|
|
|
|
|
|
|
|
|
|
```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.
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
### 4.1 Base signing vector
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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:
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
|
|
|
|
|
```text
|
2026-07-18 22:05:57 +02:00
|
|
|
|
5353435301000000000000000000000000000000000000000000000000000000000000000000eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a200107373682d656432353531392041514944080000
|
2026-07-18 20:22:58 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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.
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
## 5. Mandatory Authority v1 state machine
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
Authority is part of the protocol suite, not an optional application profile.
|
|
|
|
|
|
Every record contains exactly one transition. Transition tags and fields are:
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
| Tag | Transition | Fields after tag |
|
2026-07-18 20:22:58 +02:00
|
|
|
|
| --- | --- | --- |
|
2026-07-18 22:05:57 +02:00
|
|
|
|
| `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:
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
|
|
|
|
|
```text
|
2026-07-18 22:05:57 +02:00
|
|
|
|
"SSKP" || 0x01 || chain_id || device_id:string16 || key_id:32 ||
|
|
|
|
|
|
public_key:string16 || permissions || delegable_permissions
|
2026-07-18 20:22:58 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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:
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
|
|
|
|
|
```text
|
2026-07-18 22:05:57 +02:00
|
|
|
|
profile_id
|
|
|
|
|
|
commitment = BLAKE3(
|
|
|
|
|
|
"sshsigchain.payload.v1\0" ||
|
|
|
|
|
|
profile_id:string16 || nonce:32 || payload:bytes32
|
|
|
|
|
|
)
|
|
|
|
|
|
optional transport disclosure { nonce, payload }
|
2026-07-18 20:22:58 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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.
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
## 7. Anchor policies and head claims
|
2026-07-18 21:00:06 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
Anchoring makes rollback or equivocation observable under a chosen policy; it
|
|
|
|
|
|
does not make a backend a consensus system.
|
2026-07-18 21:00:06 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
An anchor policy contains separately weighted attesters and publication
|
|
|
|
|
|
backends. Both lists and the required-class list are strictly sorted and unique:
|
2026-07-18 21:00:06 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
```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
|
|
|
|
|
|
```
|
2026-07-18 21:00:06 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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`:
|
2026-07-18 21:00:06 +02:00
|
|
|
|
|
|
|
|
|
|
```text
|
2026-07-18 22:05:57 +02:00
|
|
|
|
"SSAH" || 0x01 || chain_id:32 || head:32 || signer_key_id:32 ||
|
|
|
|
|
|
signer_public_key:string16
|
2026-07-18 21:00:06 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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:
|
2026-07-18 21:00:06 +02:00
|
|
|
|
|
|
|
|
|
|
```text
|
2026-07-18 22:05:57 +02:00
|
|
|
|
publish(HeadClaim) -> AnchorReceipt
|
|
|
|
|
|
fetch(chain_id) -> HeadClaim[]
|
|
|
|
|
|
verify_receipt(policy_backend, HeadClaim, AnchorReceipt) -> bool
|
2026-07-18 21:00:06 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-07-19 03:16:14 +02:00
|
|
|
|
## 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
|
|
|
|
|
|
|
2026-07-19 03:43:06 +02:00
|
|
|
|
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.
|
2026-07-19 03:16:14 +02:00
|
|
|
|
|
|
|
|
|
|
The response media type SHOULD be:
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
application/vnd.sshsigchain.bundle.v1
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-19 03:43:06 +02:00
|
|
|
|
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.
|
2026-07-19 03:16:14 +02:00
|
|
|
|
|
|
|
|
|
|
## 11. JSONL interchange
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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.
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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.
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-19 03:16:14 +02:00
|
|
|
|
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
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
Outer records use commands equivalent to:
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
|
|
|
|
|
```sh
|
2026-07-18 22:05:57 +02:00
|
|
|
|
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>
|
2026-07-18 20:22:58 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-18 22:05:57 +02:00
|
|
|
|
The one-key `allowed_signers` file is only an input to cryptographic signature
|
|
|
|
|
|
verification. It never establishes authorization or root trust.
|
|
|
|
|
|
|
2026-07-19 03:16:14 +02:00
|
|
|
|
## 13. Security considerations
|
2026-07-18 22:05:57 +02:00
|
|
|
|
|
|
|
|
|
|
- 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.
|
2026-07-19 03:16:14 +02:00
|
|
|
|
- Distribution sources and HTTP cache metadata are untrusted. A fetched bundle
|
|
|
|
|
|
must contain the cached head and pass the complete verifier before use.
|
2026-07-18 22:05:57 +02:00
|
|
|
|
- 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.
|
2026-07-18 20:22:58 +02:00
|
|
|
|
|
|
|
|
|
|
## References
|
|
|
|
|
|
|
|
|
|
|
|
- [OpenSSH `PROTOCOL.sshsig`](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig)
|
2026-07-18 22:05:57 +02:00
|
|
|
|
- [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)
|