229 lines
10 KiB
Markdown
229 lines
10 KiB
Markdown
# 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:
|
||
|
||
```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 1–128 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.
|
||
|
||
```text
|
||
"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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```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.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
|
||
|
||
- [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)
|