add linked sshsigchain v2 core
This commit is contained in:
parent
cb8c4e6fd4
commit
8780350d41
8 changed files with 1305 additions and 0 deletions
41
docs/adr/0018-sshsigchain-v2.md
Normal file
41
docs/adr/0018-sshsigchain-v2.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# ADR 0018: Linked SSHSIGCHAIN v2
|
||||
|
||||
## Status
|
||||
|
||||
Accepted for the new generic core and geth keychain profile. Legacy static
|
||||
sigchain publication is deprecated pending explicit v2 command migration.
|
||||
|
||||
## Context
|
||||
|
||||
The prior static JSONL keychain flow replayed records after sorting mutable
|
||||
timestamps and used a downloaded allowed-signers projection to verify its own
|
||||
checkpoint. That permits self-bootstrapping trust and makes revocation,
|
||||
rollback, and fork semantics inadequate for a trust foundation.
|
||||
|
||||
## Decision
|
||||
|
||||
Geth adopts SSHSIGCHAIN v2 as the replacement design:
|
||||
|
||||
- root trust is an explicit `(chain ID, profile, namespace, root key)` tuple;
|
||||
- records are linearly ordered by sequence and linked by a digest over their
|
||||
signed content and SSHSIG signature;
|
||||
- the initial root is only a genesis requirement, not a permanent bypass;
|
||||
- profile authorization runs against the causally preceding state;
|
||||
- key lifecycle is represented by causal add/revoke records, not timestamp
|
||||
validity fields; and
|
||||
- payloads use deterministic binary codecs with a strict round-trip check.
|
||||
|
||||
The generic core is transport-independent. Geth continues using Iroh for all
|
||||
node-to-node communication; SSH remains an identity/signature integration and
|
||||
not a geth transport.
|
||||
|
||||
## Consequences
|
||||
|
||||
The v2 core can be published and implemented by applications without importing
|
||||
geth's resource model. Geth's keychain profile is intentionally narrow and
|
||||
tested against self-bootstrap, fork, and backdated-revocation attacks.
|
||||
|
||||
Existing static v1 bundles cannot safely migrate in place. Operators must pin a
|
||||
new v2 trust tuple and create a new genesis chain. Head persistence and later
|
||||
witness/transparency support remain follow-up work; neither is implied by a
|
||||
single signed checkpoint.
|
||||
|
|
@ -474,6 +474,17 @@ over the canonical payload. See `docs/sigchain-keychain.md` for the detailed
|
|||
sigchain design. This is currently a pull-based signed operation log, not a
|
||||
CRDT or Keyhive-style convergent authority.
|
||||
|
||||
The replacement static-publication design is specified in
|
||||
[`sshsigchain-v2.md`](sshsigchain-v2.md). Its reusable core has an explicit
|
||||
out-of-band `(chain ID, profile, SSHSIG namespace, root public key)` trust
|
||||
tuple, a strict sequence plus hash link, bounded fields, and a profile reducer
|
||||
that authorizes each record from only the causally preceding state. Geth's
|
||||
`geth.keychain.v2` profile rejects timestamp validity windows as authorization
|
||||
policy and treats key revocation as a causal record. The legacy static JSONL
|
||||
commands have not yet been migrated to this core and are not a safe bootstrap
|
||||
for new trust; Iroh keychain sync remains the supported replicated path while
|
||||
the explicit v2 command workflow is completed.
|
||||
|
||||
New devices can use the node enrollment flow instead of hand-editing keychain
|
||||
state. `geth node enroll join` explicitly imports an owner admin public key as
|
||||
the new node's trust anchor, imports the signed peer card only as untrusted
|
||||
|
|
|
|||
|
|
@ -555,6 +555,24 @@ resource-scoped capability decisions.
|
|||
- JSON is not used as the signed representation.
|
||||
- Tests verify equivalent operations hash/sign identically across runs.
|
||||
|
||||
- `[~]` Replace the legacy static keychain sigchain with SSHSIGCHAIN v2.
|
||||
Acceptance criteria:
|
||||
- `[x]` Publish a transport-neutral, deterministic SSHSIGCHAIN v2 record
|
||||
format with explicit trust-anchor, chain-link, size-limit, and
|
||||
non-claim documentation.
|
||||
- `[x]` Provide a reusable verifier core and a geth keychain profile that
|
||||
rejects self-bootstrap, non-linked forks, non-canonical payloads, and
|
||||
post-revocation timestamp replay.
|
||||
- `[ ]` Add explicit CLI storage, signing, verification, publication, and
|
||||
import workflows for a pinned v2 trust tuple.
|
||||
- `[ ]` Persist accepted v2 heads and require proof of extension before a
|
||||
static source can advance.
|
||||
- `[ ]` Disable the legacy static publish/fetch/import workflow by default
|
||||
and provide an operator-visible migration path that creates a fresh v2
|
||||
genesis chain.
|
||||
- `[ ]` Add OpenSSH integration tests and independently generated wire test
|
||||
vectors for the published standard.
|
||||
|
||||
- `[x]` SSH-admin-rooted keychain initialization.
|
||||
Acceptance criteria:
|
||||
- `[x]` `geth keychain init` records a local `KeychainInit`.
|
||||
|
|
|
|||
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