add linked sshsigchain v2 core

This commit is contained in:
Eric Wendland 2026-07-18 20:22:58 +02:00
commit 8780350d41
8 changed files with 1305 additions and 0 deletions

2
Cargo.lock generated
View file

@ -1676,9 +1676,11 @@ dependencies = [
name = "geth-keychain"
version = "0.1.0"
dependencies = [
"base64",
"blake3",
"geth-codec",
"geth-types",
"hex",
"serde",
"serde_json",
"thiserror 2.0.18",

View file

@ -6,7 +6,9 @@ rust-version.workspace = true
license.workspace = true
[dependencies]
base64.workspace = true
blake3.workspace = true
hex.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true

View file

@ -15,6 +15,17 @@
//! create their own profile with `KeychainProfile::for_application` or
//! `KeychainProfile::new` so signed payload namespaces do not overlap.
mod sshsigchain;
pub use sshsigchain::{
ChainId as SshSigchainChainId, Digest as SshSigchainDigest, KEYCHAIN_V2_PAYLOAD_VERSION,
KEYCHAIN_V2_PROFILE, KeychainV2Policy, KeychainV2State, KeychainV2Verification,
SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy,
SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier,
decode_keychain_v2_payload, keychain_v2_payload, keychain_v2_unsigned_record,
verify_keychain_v2_sigchain, verify_sshsigchain,
};
use geth_types::{
AgentId, AuthOpId, Capability, DeviceId, KeyId, NodeId, ResourceId, UnixMillis, UserId,
};

View file

@ -0,0 +1,991 @@
//! Reference implementation of the small, generic SSHSIGCHAIN v2 core.
//!
//! The core deliberately knows nothing about geth's resource or identity
//! model. It verifies one linear, linked sequence against an explicitly
//! configured root key, then delegates authorization and state transitions to
//! an application profile. `KeychainV2Policy` below is geth's first profile.
//! See `docs/sshsigchain-v2.md` for the interoperable format.
use base64::{Engine as _, engine::general_purpose};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::{KeychainOp, KeychainOpKind, KeychainView, admin_key_fingerprint, reduce_keychain_ops};
use geth_types::{AgentId, AuthOpId, DeviceId, KeyId, NodeId, UnixMillis, UserId};
pub const SSH_SIGCHAIN_VERSION: u8 = 2;
pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v2";
pub const KEYCHAIN_V2_PROFILE: &str = "geth.keychain.v2";
pub const KEYCHAIN_V2_PAYLOAD_VERSION: u16 = 1;
pub const MAX_PROFILE_BYTES: usize = 128;
pub const MAX_PUBLIC_KEY_BYTES: usize = 16 * 1024;
pub const MAX_PAYLOAD_BYTES: usize = 1024 * 1024;
pub const MAX_SIGNATURE_BYTES: usize = 64 * 1024;
pub const MAX_RECORDS: usize = 100_000;
const SIGNING_MAGIC: &[u8] = b"SSCS";
const RECORD_HASH_DOMAIN: &[u8] = b"sshsigchain.record-hash.v2\0";
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ChainId(pub [u8; 32]);
impl ChainId {
#[must_use]
pub fn to_hex(self) -> String {
hex::encode(self.0)
}
pub fn from_hex(value: &str) -> Result<Self, SshSigchainError> {
let bytes = hex::decode(value).map_err(|_| SshSigchainError::InvalidChainId)?;
let array: [u8; 32] = bytes
.try_into()
.map_err(|_| SshSigchainError::InvalidChainId)?;
Ok(Self(array))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Digest(pub [u8; 32]);
impl Digest {
#[must_use]
pub fn to_hex(self) -> String {
hex::encode(self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshSigchainTrust {
pub chain_id: ChainId,
pub profile: String,
pub namespace: String,
pub root_public_key: String,
}
impl SshSigchainTrust {
pub fn new(
chain_id: ChainId,
profile: impl Into<String>,
namespace: impl Into<String>,
root_public_key: impl AsRef<str>,
) -> Result<Self, SshSigchainError> {
let profile = validate_profile(profile.into())?;
let namespace = validate_namespace(namespace.into())?;
let root_public_key = canonical_ssh_public_key(root_public_key.as_ref())?;
Ok(Self {
chain_id,
profile,
namespace,
root_public_key,
})
}
}
/// A JSON-serializable transport envelope. Its JSON representation is not
/// signed; the exact bytes from [`SshSigchainRecord::signing_bytes`] are.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshSigchainRecord {
pub chain_id: ChainId,
pub profile: String,
pub sequence: u64,
pub previous: Option<Digest>,
pub payload: Vec<u8>,
pub signer_public_key: String,
pub signature: Vec<u8>,
}
impl SshSigchainRecord {
pub fn unsigned(
chain_id: ChainId,
profile: impl Into<String>,
sequence: u64,
previous: Option<Digest>,
payload: Vec<u8>,
signer_public_key: impl AsRef<str>,
) -> Result<Self, SshSigchainError> {
let record = Self {
chain_id,
profile: validate_profile(profile.into())?,
sequence,
previous,
payload,
signer_public_key: canonical_ssh_public_key(signer_public_key.as_ref())?,
signature: Vec::new(),
};
record.validate(false)?;
Ok(record)
}
pub fn with_signature(mut self, signature: Vec<u8>) -> Result<Self, SshSigchainError> {
self.signature = signature;
self.validate(true)?;
Ok(self)
}
/// The exact bytes passed to `ssh-keygen -Y sign` / `-Y verify`.
pub fn signing_bytes(&self) -> Result<Vec<u8>, SshSigchainError> {
self.validate(false)?;
let mut bytes = Vec::with_capacity(
SIGNING_MAGIC.len()
+ 1
+ 32
+ 8
+ 1
+ 32
+ 2
+ self.profile.len()
+ 4
+ self.payload.len()
+ 2
+ self.signer_public_key.len(),
);
bytes.extend_from_slice(SIGNING_MAGIC);
bytes.push(SSH_SIGCHAIN_VERSION);
bytes.extend_from_slice(&self.chain_id.0);
bytes.extend_from_slice(&self.sequence.to_be_bytes());
match self.previous {
Some(previous) => {
bytes.push(1);
bytes.extend_from_slice(&previous.0);
}
None => bytes.push(0),
}
push_u16_bytes(&mut bytes, self.profile.as_bytes())?;
push_u32_bytes(&mut bytes, &self.payload)?;
push_u16_bytes(&mut bytes, self.signer_public_key.as_bytes())?;
Ok(bytes)
}
/// The hash which the next record must name as its `previous` field.
pub fn record_hash(&self) -> Result<Digest, SshSigchainError> {
self.validate(true)?;
let signed = self.signing_bytes()?;
let mut input =
Vec::with_capacity(RECORD_HASH_DOMAIN.len() + signed.len() + 4 + self.signature.len());
input.extend_from_slice(RECORD_HASH_DOMAIN);
input.extend_from_slice(&signed);
push_u32_bytes(&mut input, &self.signature)?;
Ok(Digest(*blake3::hash(&input).as_bytes()))
}
fn validate(&self, signature_required: bool) -> Result<(), SshSigchainError> {
validate_profile(self.profile.clone())?;
if self.payload.len() > MAX_PAYLOAD_BYTES {
return Err(SshSigchainError::PayloadTooLarge(self.payload.len()));
}
if canonical_ssh_public_key(&self.signer_public_key)? != self.signer_public_key {
return Err(SshSigchainError::NonCanonicalPublicKey);
}
if self.signature.len() > MAX_SIGNATURE_BYTES {
return Err(SshSigchainError::SignatureTooLarge(self.signature.len()));
}
if signature_required && self.signature.is_empty() {
return Err(SshSigchainError::MissingSignature);
}
Ok(())
}
}
pub trait SshSigchainVerifier {
fn verify(&self, namespace: &str, message: &[u8], public_key: &str, signature: &[u8]) -> bool;
}
/// Application-specific authorization and reducer hooks. The initial state is
/// derived from the out-of-band trust object, never from a downloaded record.
pub trait SshSigchainPolicy {
type State;
fn initial_state(&self, trust: &SshSigchainTrust) -> Result<Self::State, String>;
fn authorize(&self, state: &Self::State, record: &SshSigchainRecord) -> Result<(), String>;
fn apply(&self, state: &mut Self::State, record: &SshSigchainRecord) -> Result<(), String>;
}
#[derive(Debug)]
pub struct SshSigchainVerification<S> {
pub state: S,
pub records: usize,
pub head: Digest,
}
pub fn verify_sshsigchain<P, V>(
records: &[SshSigchainRecord],
trust: &SshSigchainTrust,
verifier: &V,
policy: &P,
) -> Result<SshSigchainVerification<P::State>, SshSigchainError>
where
P: SshSigchainPolicy,
V: SshSigchainVerifier + ?Sized,
{
if records.is_empty() {
return Err(SshSigchainError::EmptyChain);
}
if records.len() > MAX_RECORDS {
return Err(SshSigchainError::TooManyRecords(records.len()));
}
let mut state = policy
.initial_state(trust)
.map_err(SshSigchainError::Policy)?;
let mut previous = None;
for (index, record) in records.iter().enumerate() {
record.validate(true)?;
let expected_sequence =
u64::try_from(index).map_err(|_| SshSigchainError::SequenceOverflow)?;
if record.chain_id != trust.chain_id {
return Err(SshSigchainError::WrongChainId { index });
}
if record.profile != trust.profile {
return Err(SshSigchainError::WrongProfile { index });
}
if record.sequence != expected_sequence {
return Err(SshSigchainError::UnexpectedSequence {
index,
expected: expected_sequence,
actual: record.sequence,
});
}
if record.previous != previous {
return Err(SshSigchainError::UnexpectedPrevious { index });
}
if index == 0 && record.signer_public_key != trust.root_public_key {
return Err(SshSigchainError::RootSignerMismatch);
}
let message = record.signing_bytes()?;
if !verifier.verify(
&trust.namespace,
&message,
&record.signer_public_key,
&record.signature,
) {
return Err(SshSigchainError::InvalidSignature { index });
}
policy
.authorize(&state, record)
.map_err(SshSigchainError::Policy)?;
policy
.apply(&mut state, record)
.map_err(SshSigchainError::Policy)?;
previous = Some(record.record_hash()?);
}
Ok(SshSigchainVerification {
state,
records: records.len(),
head: previous.expect("non-empty chain has a head"),
})
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct KeychainV2Policy;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeychainV2State {
initialized: bool,
admin_public_keys: BTreeMap<KeyId, String>,
ops: Vec<KeychainOp>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeychainV2Verification {
pub view: KeychainView,
pub records: usize,
pub head: Digest,
}
pub fn keychain_v2_payload(op: &KeychainOp) -> Result<Vec<u8>, SshSigchainError> {
geth_codec::encode_canonical(&KeychainV2Payload {
version: KEYCHAIN_V2_PAYLOAD_VERSION,
op: CanonicalKeychainOp::from(op),
})
.map_err(|error| SshSigchainError::PayloadEncoding(error.to_string()))
}
pub fn decode_keychain_v2_payload(bytes: &[u8]) -> Result<KeychainOp, SshSigchainError> {
let decoded = geth_codec::decode_canonical::<KeychainV2Payload>(bytes)
.map_err(|error| SshSigchainError::PayloadDecoding(error.to_string()))?;
if decoded.version != KEYCHAIN_V2_PAYLOAD_VERSION {
return Err(SshSigchainError::UnsupportedPayloadVersion(decoded.version));
}
let op = KeychainOp::from(decoded.op);
let canonical = keychain_v2_payload(&op)?;
if canonical != bytes {
return Err(SshSigchainError::NonCanonicalPayload);
}
Ok(op)
}
pub fn keychain_v2_unsigned_record(
trust: &SshSigchainTrust,
sequence: u64,
previous: Option<Digest>,
op: &KeychainOp,
signer_public_key: impl AsRef<str>,
) -> Result<SshSigchainRecord, SshSigchainError> {
if trust.profile != KEYCHAIN_V2_PROFILE {
return Err(SshSigchainError::WrongKeychainProfile(
trust.profile.clone(),
));
}
SshSigchainRecord::unsigned(
trust.chain_id,
KEYCHAIN_V2_PROFILE,
sequence,
previous,
keychain_v2_payload(op)?,
signer_public_key,
)
}
pub fn verify_keychain_v2_sigchain<V>(
records: &[SshSigchainRecord],
trust: &SshSigchainTrust,
verifier: &V,
) -> Result<KeychainV2Verification, SshSigchainError>
where
V: SshSigchainVerifier + ?Sized,
{
if trust.profile != KEYCHAIN_V2_PROFILE {
return Err(SshSigchainError::WrongKeychainProfile(
trust.profile.clone(),
));
}
let verified = verify_sshsigchain(records, trust, verifier, &KeychainV2Policy)?;
Ok(KeychainV2Verification {
view: reduce_keychain_ops(&verified.state.ops),
records: verified.records,
head: verified.head,
})
}
impl SshSigchainPolicy for KeychainV2Policy {
type State = KeychainV2State;
fn initial_state(&self, trust: &SshSigchainTrust) -> Result<Self::State, String> {
let root =
canonical_ssh_public_key(&trust.root_public_key).map_err(|error| error.to_string())?;
Ok(KeychainV2State {
initialized: false,
admin_public_keys: BTreeMap::from([(KeyId::new(admin_key_fingerprint(&root)), root)]),
ops: Vec::new(),
})
}
fn authorize(&self, state: &Self::State, record: &SshSigchainRecord) -> Result<(), String> {
if state
.admin_public_keys
.values()
.any(|key| key == &record.signer_public_key)
{
Ok(())
} else {
Err("record signer is not an active admin key at this chain position".to_owned())
}
}
fn apply(&self, state: &mut Self::State, record: &SshSigchainRecord) -> Result<(), String> {
let op = decode_keychain_v2_payload(&record.payload).map_err(|error| error.to_string())?;
match &op.kind {
KeychainOpKind::KeychainInit => {
if state.initialized || record.sequence != 0 {
return Err("KeychainInit is valid only as sequence 0".to_owned());
}
state.initialized = true;
}
KeychainOpKind::AdminKeyAdd {
key,
public_key,
valid_after_ms,
valid_before_ms,
..
} => {
if !state.initialized {
return Err("keychain must start with KeychainInit".to_owned());
}
if valid_after_ms.is_some() || valid_before_ms.is_some() {
return Err(
"v2 does not accept key validity windows as security policy; use a causally ordered revocation record"
.to_owned(),
);
}
let public_key = public_key.as_deref().ok_or_else(|| {
"AdminKeyAdd requires its canonical public key in v2".to_owned()
})?;
let public_key =
canonical_ssh_public_key(public_key).map_err(|error| error.to_string())?;
if KeyId::new(admin_key_fingerprint(&public_key)) != *key {
return Err("AdminKeyAdd key ID does not match its public key".to_owned());
}
state.admin_public_keys.insert(key.clone(), public_key);
}
KeychainOpKind::AdminKeyRevoke { key } => {
if !state.initialized {
return Err("keychain must start with KeychainInit".to_owned());
}
state.admin_public_keys.remove(key);
}
_ => {
if !state.initialized {
return Err("keychain must start with KeychainInit".to_owned());
}
}
}
state.ops.push(op);
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct KeychainV2Payload {
version: u16,
op: CanonicalKeychainOp,
}
/// `KeychainOpKind` uses a human-facing internally tagged JSON enum. Postcard
/// deliberately cannot deserialize that representation, so v2 uses this
/// profile-local externally tagged mirror for its signed binary payload.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct CanonicalKeychainOp {
id: AuthOpId,
created_at: UnixMillis,
kind: CanonicalKeychainOpKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
enum CanonicalKeychainOpKind {
KeychainInit,
AdminKeyAdd {
key: KeyId,
public_key: Option<String>,
principal: Option<String>,
valid_after_ms: Option<i64>,
valid_before_ms: Option<i64>,
},
AdminKeyRevoke {
key: KeyId,
},
UserAdd {
user: UserId,
name: String,
},
UserRename {
user: UserId,
name: String,
},
UserRevoke {
user: UserId,
},
DeviceAdd {
device: DeviceId,
user: UserId,
},
DeviceRevoke {
device: DeviceId,
},
DeviceKeyAdd {
device: DeviceId,
key: KeyId,
},
DeviceKeyRevoke {
device: DeviceId,
key: KeyId,
},
NodeAdd {
node: NodeId,
device: DeviceId,
name: String,
},
NodeRename {
node: NodeId,
name: String,
},
NodeRevoke {
node: NodeId,
},
NodeEndpointAdd {
node: NodeId,
endpoint: String,
},
NodeEndpointRevoke {
node: NodeId,
endpoint: String,
},
AgentBind {
agent: AgentId,
node: NodeId,
},
}
impl From<&KeychainOp> for CanonicalKeychainOp {
fn from(value: &KeychainOp) -> Self {
Self {
id: value.id.clone(),
created_at: value.created_at,
kind: CanonicalKeychainOpKind::from(&value.kind),
}
}
}
impl From<CanonicalKeychainOp> for KeychainOp {
fn from(value: CanonicalKeychainOp) -> Self {
Self {
id: value.id,
created_at: value.created_at,
kind: KeychainOpKind::from(value.kind),
}
}
}
impl From<&KeychainOpKind> for CanonicalKeychainOpKind {
fn from(value: &KeychainOpKind) -> Self {
match value {
KeychainOpKind::KeychainInit => Self::KeychainInit,
KeychainOpKind::AdminKeyAdd {
key,
public_key,
principal,
valid_after_ms,
valid_before_ms,
} => Self::AdminKeyAdd {
key: key.clone(),
public_key: public_key.clone(),
principal: principal.clone(),
valid_after_ms: *valid_after_ms,
valid_before_ms: *valid_before_ms,
},
KeychainOpKind::AdminKeyRevoke { key } => Self::AdminKeyRevoke { key: key.clone() },
KeychainOpKind::UserAdd { user, name } => Self::UserAdd {
user: user.clone(),
name: name.clone(),
},
KeychainOpKind::UserRename { user, name } => Self::UserRename {
user: user.clone(),
name: name.clone(),
},
KeychainOpKind::UserRevoke { user } => Self::UserRevoke { user: user.clone() },
KeychainOpKind::DeviceAdd { device, user } => Self::DeviceAdd {
device: device.clone(),
user: user.clone(),
},
KeychainOpKind::DeviceRevoke { device } => Self::DeviceRevoke {
device: device.clone(),
},
KeychainOpKind::DeviceKeyAdd { device, key } => Self::DeviceKeyAdd {
device: device.clone(),
key: key.clone(),
},
KeychainOpKind::DeviceKeyRevoke { device, key } => Self::DeviceKeyRevoke {
device: device.clone(),
key: key.clone(),
},
KeychainOpKind::NodeAdd { node, device, name } => Self::NodeAdd {
node: node.clone(),
device: device.clone(),
name: name.clone(),
},
KeychainOpKind::NodeRename { node, name } => Self::NodeRename {
node: node.clone(),
name: name.clone(),
},
KeychainOpKind::NodeRevoke { node } => Self::NodeRevoke { node: node.clone() },
KeychainOpKind::NodeEndpointAdd { node, endpoint } => Self::NodeEndpointAdd {
node: node.clone(),
endpoint: endpoint.clone(),
},
KeychainOpKind::NodeEndpointRevoke { node, endpoint } => Self::NodeEndpointRevoke {
node: node.clone(),
endpoint: endpoint.clone(),
},
KeychainOpKind::AgentBind { agent, node } => Self::AgentBind {
agent: agent.clone(),
node: node.clone(),
},
}
}
}
impl From<CanonicalKeychainOpKind> for KeychainOpKind {
fn from(value: CanonicalKeychainOpKind) -> Self {
match value {
CanonicalKeychainOpKind::KeychainInit => Self::KeychainInit,
CanonicalKeychainOpKind::AdminKeyAdd {
key,
public_key,
principal,
valid_after_ms,
valid_before_ms,
} => Self::AdminKeyAdd {
key,
public_key,
principal,
valid_after_ms,
valid_before_ms,
},
CanonicalKeychainOpKind::AdminKeyRevoke { key } => Self::AdminKeyRevoke { key },
CanonicalKeychainOpKind::UserAdd { user, name } => Self::UserAdd { user, name },
CanonicalKeychainOpKind::UserRename { user, name } => Self::UserRename { user, name },
CanonicalKeychainOpKind::UserRevoke { user } => Self::UserRevoke { user },
CanonicalKeychainOpKind::DeviceAdd { device, user } => Self::DeviceAdd { device, user },
CanonicalKeychainOpKind::DeviceRevoke { device } => Self::DeviceRevoke { device },
CanonicalKeychainOpKind::DeviceKeyAdd { device, key } => {
Self::DeviceKeyAdd { device, key }
}
CanonicalKeychainOpKind::DeviceKeyRevoke { device, key } => {
Self::DeviceKeyRevoke { device, key }
}
CanonicalKeychainOpKind::NodeAdd { node, device, name } => {
Self::NodeAdd { node, device, name }
}
CanonicalKeychainOpKind::NodeRename { node, name } => Self::NodeRename { node, name },
CanonicalKeychainOpKind::NodeRevoke { node } => Self::NodeRevoke { node },
CanonicalKeychainOpKind::NodeEndpointAdd { node, endpoint } => {
Self::NodeEndpointAdd { node, endpoint }
}
CanonicalKeychainOpKind::NodeEndpointRevoke { node, endpoint } => {
Self::NodeEndpointRevoke { node, endpoint }
}
CanonicalKeychainOpKind::AgentBind { agent, node } => Self::AgentBind { agent, node },
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SshSigchainError {
#[error("SSH sigchain chain ID must be 32 bytes encoded as lowercase or uppercase hexadecimal")]
InvalidChainId,
#[error("SSH sigchain profile must be non-empty ASCII and at most {MAX_PROFILE_BYTES} bytes")]
InvalidProfile,
#[error("SSH sigchain namespace must be non-empty printable ASCII without whitespace")]
InvalidNamespace,
#[error("SSH public key must be a canonical two-field OpenSSH public key")]
InvalidPublicKey,
#[error("SSH public key is not canonical")]
NonCanonicalPublicKey,
#[error("SSH sigchain payload is {0} bytes, over the {MAX_PAYLOAD_BYTES}-byte limit")]
PayloadTooLarge(usize),
#[error("SSH sigchain signature is {0} bytes, over the {MAX_SIGNATURE_BYTES}-byte limit")]
SignatureTooLarge(usize),
#[error("SSH sigchain record has no signature")]
MissingSignature,
#[error("SSH sigchain cannot be empty")]
EmptyChain,
#[error("SSH sigchain has {0} records, over the {MAX_RECORDS}-record limit")]
TooManyRecords(usize),
#[error("SSH sigchain sequence counter overflow")]
SequenceOverflow,
#[error("record {index} names a different chain ID")]
WrongChainId { index: usize },
#[error("record {index} names a different profile")]
WrongProfile { index: usize },
#[error("record {index} has sequence {actual}, expected {expected}")]
UnexpectedSequence {
index: usize,
expected: u64,
actual: u64,
},
#[error("record {index} does not link to the immediately previous record")]
UnexpectedPrevious { index: usize },
#[error("genesis record signer does not equal the explicit root public key")]
RootSignerMismatch,
#[error("record {index} has an invalid OpenSSH signature")]
InvalidSignature { index: usize },
#[error("SSH sigchain policy rejected record: {0}")]
Policy(String),
#[error("failed to encode keychain v2 payload: {0}")]
PayloadEncoding(String),
#[error("failed to decode keychain v2 payload: {0}")]
PayloadDecoding(String),
#[error("unsupported keychain v2 payload version {0}")]
UnsupportedPayloadVersion(u16),
#[error("keychain v2 payload is not its unique canonical encoding")]
NonCanonicalPayload,
#[error("expected geth keychain v2 profile, got {0}")]
WrongKeychainProfile(String),
#[error("length does not fit the SSH sigchain wire format")]
LengthOverflow,
}
fn push_u16_bytes(out: &mut Vec<u8>, value: &[u8]) -> Result<(), SshSigchainError> {
let length = u16::try_from(value.len()).map_err(|_| SshSigchainError::LengthOverflow)?;
out.extend_from_slice(&length.to_be_bytes());
out.extend_from_slice(value);
Ok(())
}
fn push_u32_bytes(out: &mut Vec<u8>, value: &[u8]) -> Result<(), SshSigchainError> {
let length = u32::try_from(value.len()).map_err(|_| SshSigchainError::LengthOverflow)?;
out.extend_from_slice(&length.to_be_bytes());
out.extend_from_slice(value);
Ok(())
}
fn validate_profile(value: String) -> Result<String, SshSigchainError> {
if value.is_empty()
|| value.len() > MAX_PROFILE_BYTES
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
{
return Err(SshSigchainError::InvalidProfile);
}
Ok(value)
}
fn validate_namespace(value: String) -> Result<String, SshSigchainError> {
if value.is_empty()
|| value.len() > MAX_PROFILE_BYTES
|| !value.bytes().all(|byte| byte.is_ascii_graphic())
{
return Err(SshSigchainError::InvalidNamespace);
}
Ok(value)
}
fn canonical_ssh_public_key(value: &str) -> Result<String, SshSigchainError> {
if value.len() > MAX_PUBLIC_KEY_BYTES || value.trim() != value {
return Err(SshSigchainError::InvalidPublicKey);
}
let mut fields = value.split_ascii_whitespace();
let key_type = fields.next().ok_or(SshSigchainError::InvalidPublicKey)?;
let encoded = fields.next().ok_or(SshSigchainError::InvalidPublicKey)?;
if fields.next().is_some()
|| key_type.is_empty()
|| !key_type
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'@')
{
return Err(SshSigchainError::InvalidPublicKey);
}
let decoded = general_purpose::STANDARD
.decode(encoded)
.or_else(|_| general_purpose::STANDARD_NO_PAD.decode(encoded))
.map_err(|_| SshSigchainError::InvalidPublicKey)?;
if decoded.is_empty() {
return Err(SshSigchainError::InvalidPublicKey);
}
Ok(format!(
"{key_type} {}",
general_purpose::STANDARD.encode(decoded)
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{KeychainOpKind, admin_key_fingerprint};
use geth_types::{AuthOpId, UnixMillis};
const ROOT_KEY: &str = "ssh-ed25519 AQID";
const SECOND_KEY: &str = "ssh-ed25519 BAUG";
struct TestVerifier;
impl SshSigchainVerifier for TestVerifier {
fn verify(
&self,
namespace: &str,
message: &[u8],
public_key: &str,
signature: &[u8],
) -> bool {
signature == test_signature(namespace, message, public_key)
}
}
fn test_signature(namespace: &str, message: &[u8], public_key: &str) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(namespace.as_bytes());
bytes.push(0);
bytes.extend_from_slice(message);
bytes.push(0);
bytes.extend_from_slice(public_key.as_bytes());
blake3::hash(&bytes).as_bytes().to_vec()
}
fn trust() -> SshSigchainTrust {
SshSigchainTrust::new(
ChainId([7; 32]),
KEYCHAIN_V2_PROFILE,
SSH_SIGCHAIN_NAMESPACE,
ROOT_KEY,
)
.expect("trust")
}
fn op(id: &str, created_at: i64, kind: KeychainOpKind) -> KeychainOp {
KeychainOp {
id: AuthOpId::new(id),
created_at: UnixMillis(created_at),
kind,
}
}
fn signed_record(
trust: &SshSigchainTrust,
sequence: u64,
previous: Option<Digest>,
op: &KeychainOp,
signer: &str,
) -> SshSigchainRecord {
let record = keychain_v2_unsigned_record(trust, sequence, previous, op, signer)
.expect("unsigned record");
let signature = test_signature(
&trust.namespace,
&record.signing_bytes().expect("bytes"),
signer,
);
record.with_signature(signature).expect("signed record")
}
fn root_add_op() -> KeychainOp {
op(
"op:add-root",
2,
KeychainOpKind::AdminKeyAdd {
key: KeyId::new(admin_key_fingerprint(ROOT_KEY)),
public_key: Some(ROOT_KEY.to_owned()),
principal: Some("admin".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
)
}
#[test]
fn v2_accepts_a_linked_rooted_keychain() {
let trust = trust();
let init = signed_record(
&trust,
0,
None,
&op("op:init", 1, KeychainOpKind::KeychainInit),
ROOT_KEY,
);
let add = signed_record(
&trust,
1,
Some(init.record_hash().expect("hash")),
&root_add_op(),
ROOT_KEY,
);
let user = signed_record(
&trust,
2,
Some(add.record_hash().expect("hash")),
&op(
"op:user",
3,
KeychainOpKind::UserAdd {
user: "user:alice".into(),
name: "Alice".to_owned(),
},
),
ROOT_KEY,
);
let verified = verify_keychain_v2_sigchain(&[init, add, user], &trust, &TestVerifier)
.expect("valid chain");
assert_eq!(verified.records, 3);
assert_eq!(
verified.view.admin_keys,
vec![KeyId::new(admin_key_fingerprint(ROOT_KEY))]
);
assert!(verified.view.users.contains_key(&"user:alice".into()));
}
#[test]
fn v2_requires_an_explicit_root_instead_of_self_bootstrap() {
let trust = trust();
let init = signed_record(
&trust,
0,
None,
&op("op:init", 1, KeychainOpKind::KeychainInit),
SECOND_KEY,
);
assert!(matches!(
verify_keychain_v2_sigchain(&[init], &trust, &TestVerifier),
Err(SshSigchainError::RootSignerMismatch)
));
}
#[test]
fn v2_rejects_a_non_linked_fork() {
let trust = trust();
let init = signed_record(
&trust,
0,
None,
&op("op:init", 1, KeychainOpKind::KeychainInit),
ROOT_KEY,
);
let add = signed_record(&trust, 1, None, &root_add_op(), ROOT_KEY);
assert!(matches!(
verify_keychain_v2_sigchain(&[init, add], &trust, &TestVerifier),
Err(SshSigchainError::UnexpectedPrevious { index: 1 })
));
}
#[test]
fn v2_revocation_is_causal_not_timestamp_ordered() {
let trust = trust();
let init = signed_record(
&trust,
0,
None,
&op("op:init", 100, KeychainOpKind::KeychainInit),
ROOT_KEY,
);
let add = signed_record(
&trust,
1,
Some(init.record_hash().expect("hash")),
&root_add_op(),
ROOT_KEY,
);
let revoke = signed_record(
&trust,
2,
Some(add.record_hash().expect("hash")),
&op(
"op:revoke-root",
300,
KeychainOpKind::AdminKeyRevoke {
key: KeyId::new(admin_key_fingerprint(ROOT_KEY)),
},
),
ROOT_KEY,
);
// The attacker backdates this payload, but cannot move it before the
// revocation because its sequence and parent are immutable.
let forged = signed_record(
&trust,
3,
Some(revoke.record_hash().expect("hash")),
&op(
"op:backdated-user",
200,
KeychainOpKind::UserAdd {
user: "user:mallory".into(),
name: "Mallory".to_owned(),
},
),
ROOT_KEY,
);
assert!(matches!(
verify_keychain_v2_sigchain(&[init, add, revoke, forged], &trust, &TestVerifier),
Err(SshSigchainError::Policy(_))
));
}
#[test]
fn v2_payload_rejects_trailing_or_noncanonical_bytes() {
let payload =
keychain_v2_payload(&op("op:init", 1, KeychainOpKind::KeychainInit)).expect("payload");
let mut noncanonical = payload;
noncanonical.push(0);
assert!(matches!(
decode_keychain_v2_payload(&noncanonical),
Err(SshSigchainError::NonCanonicalPayload)
));
}
}

View 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.

View file

@ -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

View file

@ -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
View 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 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.
```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)