add linked sshsigchain v2 core
This commit is contained in:
parent
cb8c4e6fd4
commit
8780350d41
8 changed files with 1305 additions and 0 deletions
229
docs/sshsigchain-v2.md
Normal file
229
docs/sshsigchain-v2.md
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
# 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)
|
||||
Loading…
Reference in a new issue