geth/docs/sshsigchain-v2.md

10 KiB
Raw Blame History

SSHSIGCHAIN v2

Status: Draft 0

This document specifies a deliberately small, generic append-only signature chain for applications that use OpenSSH sshsig signatures. 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.

The reference implementation lives in geth's geth-keychain crate. Geth uses the geth.keychain.v2 profile for identity-plane operations, but the format and verification core do not depend on geth data types.

1. Goals

SSHSIGCHAIN v2 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.

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.

2. Terminology and required inputs

An implementation has four separately configured trust inputs:

  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.

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

<key-type> SP <base64-encoded-OpenSSH-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.

4. Record model

A record has these logical fields:

Field Type Rule
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.

The profile identifier is 1128 ASCII bytes containing only letters, digits, ., -, and _. 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.

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. Signing bytes

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.

"SSCS"                 4 bytes
0x02                   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]

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:

BLAKE3("sshsigchain.record-hash.v2\\0" || signing_bytes || u32(signature_length) || signature)

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.

6. Verification algorithm

Given the configured trust input and an ordered candidate record list, a conforming verifier MUST:

  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.

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.

For OpenSSH interoperability, implementations use commands equivalent to:

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>

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.

7. Application profiles

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.v2. 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;
  • every signer to be an active admin public key in the preceding profile state;
  • 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 v2'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

SSHSIGCHAIN v2 has no compatibility mode with the older geth static JSONL sigchain. That format could bootstrap trust from its own download and ordered operations by mutable timestamps, so treating it as v2 would silently preserve the bugs this specification removes. Migration requires an explicitly pinned v2 trust tuple and a freshly signed v2 genesis sequence.

References