specify selective disclosure and anchor policy
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
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
This commit is contained in:
parent
492a37c020
commit
76eb785ee2
11 changed files with 834 additions and 387 deletions
|
|
@ -1,270 +1,382 @@
|
|||
# SSHSIGCHAIN v1
|
||||
|
||||
Status: Draft 1 (pre-deployment)
|
||||
Status: Draft 2 (pre-deployment)
|
||||
|
||||
This document specifies a deliberately small, generic append-only signature
|
||||
chain for applications that use OpenSSH `sshsig` signatures. Version 1 is the
|
||||
first and only defined version of SSHSIGCHAIN. It is transport
|
||||
independent: a chain may be carried by a file, object store, HTTP, database, or
|
||||
mesh protocol. It does not make SSH a transport.
|
||||
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.
|
||||
|
||||
The reference implementation lives in geth's `geth-keychain` crate. Geth uses
|
||||
the `geth.keychain.sshsigchain.v1` profile for identity-plane operations, but the format and
|
||||
verification core do not depend on geth data types.
|
||||
Version 1 is the first and only format. There is no legacy grammar or downgrade
|
||||
mode.
|
||||
|
||||
## 1. Goals
|
||||
## 1. Security goals and non-goals
|
||||
|
||||
SSHSIGCHAIN v1 provides:
|
||||
SSHSIGCHAIN provides:
|
||||
|
||||
- a fixed, deterministic byte sequence for every signed record;
|
||||
- an explicit, out-of-band root public key rather than trust bootstrapped from
|
||||
downloaded chain content;
|
||||
- one causally ordered, hash-linked history, so a later record cannot be made
|
||||
earlier by changing a timestamp;
|
||||
- application-defined authorization and state transitions evaluated at each
|
||||
chain position;
|
||||
- bounded field and chain sizes suitable for processing untrusted transport
|
||||
input; and
|
||||
- direct use of OpenSSH's `ssh-keygen -Y sign` / `-Y verify` SSHSIG format.
|
||||
- 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 quorum, transparency, equivocation detection, secure clock
|
||||
attestation, encrypted payloads, or a distributed consensus protocol. A valid
|
||||
signature from an authorized key is still authority to make whatever change the
|
||||
application profile permits.
|
||||
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. Terminology and required inputs
|
||||
## 2. Trust inputs and identifiers
|
||||
|
||||
An implementation has four separately configured trust inputs:
|
||||
A verifier obtains these values through an authenticated channel, never from a
|
||||
downloaded chain:
|
||||
|
||||
1. `chain_id`: exactly 32 random bytes, rendered as 64 hexadecimal characters
|
||||
when displayed.
|
||||
2. `profile`: an ASCII identifier naming the application payload rules.
|
||||
3. `namespace`: the OpenSSH SSHSIG namespace passed to `ssh-keygen -Y`.
|
||||
4. `root_public_key`: one canonical OpenSSH public-key line.
|
||||
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.
|
||||
|
||||
These values are a trust anchor. They must come from local configuration,
|
||||
enrollment material, release metadata, or another authenticated out-of-band
|
||||
channel. A fetched `allowed_signers` file, checkpoint, or first record MUST NOT
|
||||
be used to discover or replace them.
|
||||
|
||||
The v1 core accepts a single root key. A threshold or witness scheme is a
|
||||
separate protocol extension and must bind the same `chain_id`, profile, and
|
||||
head digest; it is not implied by multiple signatures attached to a record.
|
||||
|
||||
## 3. Canonical public-key text
|
||||
|
||||
The public-key field is exactly two ASCII whitespace-separated fields:
|
||||
Canonical key text is exactly:
|
||||
|
||||
```text
|
||||
<key-type> SP <base64-encoded-OpenSSH-key-blob>
|
||||
<key-type> SP <canonical-base64-key-blob>
|
||||
```
|
||||
|
||||
Comments, leading/trailing whitespace, and extra fields are prohibited. The
|
||||
base64 form is the standard canonical encoding of the decoded key blob. An
|
||||
implementation MUST compare canonical text, not an operator-supplied comment,
|
||||
when binding a record signer to policy state. The SSHSIG verifier MUST still
|
||||
verify the actual OpenSSH key and signature; textual validation alone is not a
|
||||
cryptographic verification.
|
||||
Comments, leading/trailing whitespace, and additional fields are prohibited.
|
||||
Keys are identified by:
|
||||
|
||||
## 4. Record model
|
||||
```text
|
||||
BLAKE3("sshsigchain.key-id.v1\0" || canonical_public_key)
|
||||
```
|
||||
|
||||
A record has these logical fields:
|
||||
Protocol identifiers are 1–128 ASCII bytes containing letters, digits, `.`,
|
||||
`-`, `_`, `:`, or `/`.
|
||||
|
||||
| Field | Type | Rule |
|
||||
## 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 |
|
||||
| --- | --- | --- |
|
||||
| `chain_id` | 32 bytes | Must equal the configured chain ID. |
|
||||
| `profile` | UTF-8 ASCII identifier | Must equal the configured profile. |
|
||||
| `sequence` | unsigned 64-bit integer | Starts at zero and increases by exactly one. |
|
||||
| `previous` | absent or 32 bytes | Absent only at sequence zero; otherwise the preceding record digest. |
|
||||
| `payload` | opaque byte string | Profile-defined canonical payload, at most 1 MiB. |
|
||||
| `signer_public_key` | canonical key text | The key presented to SSHSIG verification. |
|
||||
| `signature` | byte string | An OpenSSH SSHSIG signature over the signing bytes below. |
|
||||
| `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 |
|
||||
|
||||
The profile identifier is 1–128 ASCII bytes containing only letters, digits,
|
||||
`.`, `-`, and `_`. The namespace is 1–128 printable ASCII bytes without
|
||||
whitespace. Canonical public-key text is at most 16 KiB, signatures are at most
|
||||
64 KiB, and a verifier MUST reject a chain over 100,000 records before
|
||||
performing unbounded work. Implementations may set smaller limits.
|
||||
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.
|
||||
|
||||
Timestamps are intentionally not fields in the generic ordering mechanism.
|
||||
Applications may put timestamps in their payload, but MUST treat them as signed
|
||||
metadata rather than a way to order, revoke, or retroactively authorize
|
||||
records. Sequence and `previous` define causal order.
|
||||
### 5.1 Genesis
|
||||
|
||||
## 5. Signing bytes
|
||||
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.
|
||||
|
||||
The signing byte string is the following binary grammar. `u16` and `u32` are
|
||||
unsigned big-endian lengths. `u64` is an unsigned big-endian integer. `bytes[n]`
|
||||
contains exactly `n` bytes; no implicit terminator or alignment is present.
|
||||
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
|
||||
"SSCS" 4 bytes
|
||||
0x01 1 byte (protocol version)
|
||||
chain_id 32 bytes
|
||||
sequence u64
|
||||
has_previous 1 byte: 0 or 1
|
||||
previous 32 bytes, only when has_previous is 1
|
||||
profile_length u16
|
||||
profile bytes[profile_length]
|
||||
payload_length u32
|
||||
payload bytes[payload_length]
|
||||
signer_key_length u16
|
||||
signer_public_key bytes[signer_key_length]
|
||||
"SSKP" || 0x01 || chain_id || device_id:string16 || key_id:32 ||
|
||||
public_key:string16 || permissions || delegable_permissions
|
||||
```
|
||||
|
||||
The `signature` field is not in the signing bytes because SSHSIG signs those
|
||||
bytes. The record digest, which binds the signature into the next link, is:
|
||||
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
|
||||
BLAKE3("sshsigchain.record-hash.v1\\0" || signing_bytes || u32(signature_length) || signature)
|
||||
profile_id
|
||||
commitment = BLAKE3(
|
||||
"sshsigchain.payload.v1\0" ||
|
||||
profile_id:string16 || nonce:32 || payload:bytes32
|
||||
)
|
||||
optional transport disclosure { nonce, payload }
|
||||
```
|
||||
|
||||
`previous` in record `n + 1` MUST equal this digest for record `n`. A JSON or
|
||||
JSONL transport envelope is allowed for convenience, but JSON bytes MUST NOT
|
||||
be signed or hashed as the record representation.
|
||||
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.
|
||||
|
||||
### JSONL transport
|
||||
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.
|
||||
|
||||
This specification defines JSONL as a convenient interchange transport. Each
|
||||
non-empty physical line contains exactly one JSON object with these fields:
|
||||
`chain_id`, `profile`, `sequence`, `previous`, `payload`,
|
||||
`signer_public_key`, and `signature`. `chain_id`, `previous` when present,
|
||||
`payload`, and `signature` are JSON arrays of unsigned octets; `previous` is
|
||||
`null` when absent. `profile` and `signer_public_key` are JSON strings, and
|
||||
`sequence` is an unsigned integer. Unknown or duplicate fields MUST be
|
||||
rejected. Object-member order and JSON whitespace carry no meaning.
|
||||
## 7. Anchor policies and head claims
|
||||
|
||||
Blank lines MAY be ignored. A JSONL line is limited to 5 MiB and one complete
|
||||
input to 64 MiB. Implementations reading an arbitrary stream MUST enforce those
|
||||
limits before buffering an unbounded line or input. These limits are transport
|
||||
limits in addition to the record limits above.
|
||||
Anchoring makes rollback or equivocation observable under a chosen policy; it
|
||||
does not make a backend a consensus system.
|
||||
|
||||
### Base test vector
|
||||
|
||||
This unsigned record fixes the field grammar independently of OpenSSH key
|
||||
generation or signature randomness:
|
||||
An anchor policy contains separately weighted attesters and publication
|
||||
backends. Both lists and the required-class list are strictly sorted and unique:
|
||||
|
||||
```text
|
||||
chain_id 32 zero bytes
|
||||
profile "example.test"
|
||||
sequence 0
|
||||
previous absent
|
||||
payload 01 02
|
||||
signer_public_key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2"
|
||||
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
|
||||
```
|
||||
|
||||
Its signing bytes, encoded as lowercase hexadecimal, MUST be:
|
||||
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
|
||||
53534353010000000000000000000000000000000000000000000000000000000000000000000000000000000000000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332
|
||||
"SSAH" || 0x01 || chain_id:32 || head:32 || signer_key_id:32 ||
|
||||
signer_public_key:string16
|
||||
```
|
||||
|
||||
## 6. Verification algorithm
|
||||
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)`.
|
||||
|
||||
Given the configured trust input and an ordered candidate record list, a
|
||||
conforming verifier MUST:
|
||||
Adapters conceptually implement:
|
||||
|
||||
1. Reject an empty or over-limit chain.
|
||||
2. For record `i`, require `sequence == i`, the configured chain ID and
|
||||
profile, and the exact predecessor digest (or absent predecessor for `i=0`).
|
||||
3. Require the sequence-zero record's canonical signer key to equal the
|
||||
configured root public key. This is the only bootstrap rule.
|
||||
4. Invoke OpenSSH SSHSIG verification using the configured namespace, the
|
||||
canonical signing bytes, the record's public key, and its signature. Reject
|
||||
on failure.
|
||||
5. Ask the profile whether that signer is authorized by the state resulting
|
||||
from records `0..i-1`. Reject on failure.
|
||||
6. Apply the profile's deterministic transition. Reject on failure.
|
||||
7. Hash the complete signed record and use that hash as the required
|
||||
predecessor for the next record.
|
||||
```text
|
||||
publish(HeadClaim) -> AnchorReceipt
|
||||
fetch(chain_id) -> HeadClaim[]
|
||||
verify_receipt(policy_backend, HeadClaim, AnchorReceipt) -> bool
|
||||
```
|
||||
|
||||
Records are never re-sorted by payload timestamp, identifier, signature time,
|
||||
or transport arrival time. A verifier that cannot obtain its configured trust
|
||||
input MUST fail closed.
|
||||
They do not choose a canonical head. After verifying claims, chains, authority,
|
||||
and receipts, a client:
|
||||
|
||||
For OpenSSH interoperability, implementations use commands equivalent to:
|
||||
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 <namespace> <signing-bytes-file>
|
||||
ssh-keygen -Y verify -f <one-key-allowed-signers> -I <principal> \
|
||||
-n <namespace> -s <signature-file> < <signing-bytes-file>
|
||||
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 allowed-signers file used for this individual cryptographic check contains
|
||||
only the record's already-canonical signer key. Authorization remains the
|
||||
profile's job; a downloaded allowed-signers projection is never a root of
|
||||
trust.
|
||||
The one-key `allowed_signers` file is only an input to cryptographic signature
|
||||
verification. It never establishes authorization or root trust.
|
||||
|
||||
## 7. Application profiles
|
||||
## 11. Security considerations
|
||||
|
||||
A profile defines its payload codec and reducer. It MUST document its profile
|
||||
identifier, payload versioning, signer authorization rules, transition rules,
|
||||
and any limits beyond this base specification. A profile MUST reject a payload
|
||||
that decodes successfully but does not round-trip to exactly the same canonical
|
||||
bytes.
|
||||
|
||||
### geth keychain profile
|
||||
|
||||
Geth's identifier is `geth.keychain.sshsigchain.v1`. Its payload is a versioned canonical
|
||||
binary mirror of a keychain operation. The mirror is deliberately separate from
|
||||
the human-facing, internally tagged JSON API type so that a decoder can prove a
|
||||
unique payload byte sequence. Version 1 requires:
|
||||
|
||||
- sequence zero to be `KeychainInit`;
|
||||
- sequence one to be an `AdminKeyAdd` that records the configured root signer;
|
||||
- every signer to be an active admin public key in the preceding profile state;
|
||||
- every keychain operation ID to occur only once in the chain;
|
||||
- each `AdminKeyAdd` to carry a canonical public key whose BLAKE3 key ID
|
||||
matches the declared key ID;
|
||||
- `AdminKeyRevoke` to remove that key before any later record is authorized;
|
||||
and
|
||||
- no `valid_after_ms` or `valid_before_ms` policy fields. Key lifecycle is
|
||||
causally represented by add and revoke records, not untrusted timestamps.
|
||||
|
||||
The configured root key seeds the profile's initial admin authorization state.
|
||||
It can be revoked by a valid record; it is not a permanent bypass after genesis.
|
||||
|
||||
## 8. Publication and rollback
|
||||
|
||||
This base format proves only one supplied history. A publisher that controls a
|
||||
root key can present different valid descendants to different readers. Clients
|
||||
that need rollback or fork detection SHOULD persist the accepted `(chain_id,
|
||||
head digest, sequence)` and only accept a later bundle after proving that it is
|
||||
an extension of the stored head. A signed checkpoint alone is insufficient
|
||||
unless its signer is an already pinned trust anchor and its claimed ancestry is
|
||||
verified.
|
||||
|
||||
For stronger equivocation evidence, publish heads to independently operated
|
||||
witnesses or a transparency log. This is intentionally outside SSHSIGCHAIN's
|
||||
small core.
|
||||
|
||||
## 9. Security considerations
|
||||
|
||||
- SSHSIG namespaces are mandatory domain separation. Reusing a namespace for a
|
||||
different protocol is unsafe.
|
||||
- The root key, chain ID, profile, and namespace form one trust tuple. Changing
|
||||
any member starts a different trust domain and needs an explicit operator
|
||||
action.
|
||||
- A compromised active admin key can still append records until a causally
|
||||
later revocation is accepted. This is inherent to single-key authority, not a
|
||||
claim of compromise recovery.
|
||||
- SSHSIG signatures do not give a trusted signing time. Do not make expiry or
|
||||
ordering decisions from payload timestamps without a separate, authenticated
|
||||
time design.
|
||||
- This protocol does not encrypt payloads and does not make bearer secrets or
|
||||
untrusted discovery data into node identity.
|
||||
- Verifiers should use OpenSSH versions that support `ssh-keygen -Y` and should
|
||||
reject unsupported algorithms according to their local OpenSSH policy.
|
||||
|
||||
## 10. Compatibility
|
||||
|
||||
This specification defines one format: SSHSIGCHAIN version 1. It has no
|
||||
compatibility mode, downgrade path, or alternate legacy record grammar.
|
||||
Implementations MUST reject another protocol version. Geth removed its
|
||||
pre-standard test-only static JSONL workflow rather than treating it as an
|
||||
SSHSIGCHAIN variant.
|
||||
- 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)` manual](https://man.openbsd.org/ssh-keygen.1)
|
||||
- [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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue