make sshsigchain the only portable sigchain format

This commit is contained in:
Eric Wendland 2026-07-18 21:00:06 +02:00
commit 73500e1944
15 changed files with 812 additions and 1603 deletions

270
docs/sshsigchain.md Normal file
View file

@ -0,0 +1,270 @@
# SSHSIGCHAIN v1
Status: Draft 1 (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.
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.
## 1. Goals
SSHSIGCHAIN v1 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 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:
```text
<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 `_`. The namespace is 1128 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.
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.
```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]
```
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:
```text
BLAKE3("sshsigchain.record-hash.v1\\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.
### JSONL transport
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.
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.
### Base test vector
This unsigned record fixes the field grammar independently of OpenSSH key
generation or signature randomness:
```text
chain_id 32 zero bytes
profile "example.test"
sequence 0
previous absent
payload 01 02
signer_public_key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2"
```
Its signing bytes, encoded as lowercase hexadecimal, MUST be:
```text
53534353010000000000000000000000000000000000000000000000000000000000000000000000000000000000000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332
```
## 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:
```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>
```
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.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.
## 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)