diff --git a/Cargo.lock b/Cargo.lock index 8dcd678..f7fadf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/geth-keychain/Cargo.toml b/crates/geth-keychain/Cargo.toml index 1ca14d5..5573534 100644 --- a/crates/geth-keychain/Cargo.toml +++ b/crates/geth-keychain/Cargo.toml @@ -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 diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index 74525d3..4d67c6f 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -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, }; diff --git a/crates/geth-keychain/src/sshsigchain.rs b/crates/geth-keychain/src/sshsigchain.rs new file mode 100644 index 0000000..176ed63 --- /dev/null +++ b/crates/geth-keychain/src/sshsigchain.rs @@ -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 { + 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, + namespace: impl Into, + root_public_key: impl AsRef, + ) -> Result { + 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, + pub payload: Vec, + pub signer_public_key: String, + pub signature: Vec, +} + +impl SshSigchainRecord { + pub fn unsigned( + chain_id: ChainId, + profile: impl Into, + sequence: u64, + previous: Option, + payload: Vec, + signer_public_key: impl AsRef, + ) -> Result { + 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) -> Result { + 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, 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 { + 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; + 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 { + pub state: S, + pub records: usize, + pub head: Digest, +} + +pub fn verify_sshsigchain( + records: &[SshSigchainRecord], + trust: &SshSigchainTrust, + verifier: &V, + policy: &P, +) -> Result, 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, + ops: Vec, +} + +#[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, 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 { + let decoded = geth_codec::decode_canonical::(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, + op: &KeychainOp, + signer_public_key: impl AsRef, +) -> Result { + 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( + records: &[SshSigchainRecord], + trust: &SshSigchainTrust, + verifier: &V, +) -> Result +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 { + 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, + principal: Option, + valid_after_ms: Option, + valid_before_ms: Option, + }, + 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 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 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, 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, 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 { + 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 { + 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 { + 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 { + 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, + 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) + )); + } +} diff --git a/docs/adr/0018-sshsigchain-v2.md b/docs/adr/0018-sshsigchain-v2.md new file mode 100644 index 0000000..19cc69f --- /dev/null +++ b/docs/adr/0018-sshsigchain-v2.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 89dc2ae..7c9fd27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 37bb92d..ca790c1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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`. diff --git a/docs/sshsigchain-v2.md b/docs/sshsigchain-v2.md new file mode 100644 index 0000000..e0a1ab9 --- /dev/null +++ b/docs/sshsigchain-v2.md @@ -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 + SP +``` + +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 -n +ssh-keygen -Y verify -f -I \ + -n -s < +``` + +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)