diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 2034c60..1ce33de 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -4109,20 +4109,22 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> { chain_id, records, head, - active_admin_keys, - users, + active_keys, devices, - nodes, + disclosed_profiles, + incomplete_profiles, + anchor_threshold, note, } => { println!("sigchain: {}", input.display()); println!("chain_id: {chain_id}"); println!("records: {records}"); println!("head: {head}"); - println!("active_admin_keys: {active_admin_keys}"); - println!("users: {users}"); + println!("active_keys: {active_keys}"); println!("devices: {devices}"); - println!("nodes: {nodes}"); + println!("disclosed_profiles: {disclosed_profiles}"); + println!("incomplete_profiles: {incomplete_profiles}"); + println!("anchor_threshold: {anchor_threshold}"); eprintln!("note: {note}"); } ControlResponse::KeychainExplained { subject, lines } => { diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index c05b239..0c42bb4 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -746,10 +746,11 @@ pub enum ControlResponse { chain_id: String, records: usize, head: String, - active_admin_keys: usize, - users: usize, + active_keys: usize, devices: usize, - nodes: usize, + disclosed_profiles: usize, + incomplete_profiles: usize, + anchor_threshold: u16, note: String, }, KeychainExplained { diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index 0cf5c3b..6da7262 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -13,15 +13,16 @@ mod sshsigchain; pub use sshsigchain::{ - ChainId as SshSigchainChainId, Digest as SshSigchainDigest, - KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION, KEYCHAIN_SSH_SIGCHAIN_PROFILE, - KeychainSshSigchainPolicy, KeychainSshSigchainState, KeychainSshSigchainVerification, - MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, MAX_NAMESPACE_BYTES, SSH_SIGCHAIN_NAMESPACE, - SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy, - SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier, - decode_keychain_sshsigchain_payload, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, - keychain_sshsigchain_payload, keychain_sshsigchain_unsigned_record, - verify_keychain_sshsigchain, verify_sshsigchain, + AnchorBackendPolicy, AnchorPolicy, AnchorReceipt, AnchorReceiptVerifier, AnchoredHistory, + AuthorityDeviceState, AuthorityKey, AuthorityKeyId, AuthorityKeyState, AuthorityState, + AuthorityTransition, ChainId as SshSigchainChainId, Digest as SshSigchainDigest, HeadClaim, + MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure, + ProfileExtension, SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, + SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, + SshSigchainError, SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, + SshSigchainVerifier, authority_key_id, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, + key_proof_signing_bytes, profile_payload_commitment, select_anchored_head, + verify_anchor_receipts, verify_head_claim, verify_sshsigchain, }; use geth_types::{ diff --git a/crates/geth-keychain/src/sshsigchain.rs b/crates/geth-keychain/src/sshsigchain.rs index 1648005..f5a4156 100644 --- a/crates/geth-keychain/src/sshsigchain.rs +++ b/crates/geth-keychain/src/sshsigchain.rs @@ -1,34 +1,36 @@ -//! Reference implementation of the small, generic SSHSIGCHAIN v1 core. +//! SSHSIGCHAIN v1: a sequence-free SSH-signed authority chain. //! -//! 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. `KeychainSshSigchainPolicy` below is geth's first profile. -//! See `docs/sshsigchain.md` for the interoperable format. +//! Every link carries one public authority transition and zero or more +//! profile-scoped commitments. Application payloads can be disclosed without +//! changing the signed chain. The authority reducer is mandatory: profiles +//! cannot create keys, grant permissions, or otherwise mutate trust. use base64::{Engine as _, engine::general_purpose}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; -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 = 1; pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v1"; +pub const SSH_SIGCHAIN_KEY_PROOF_NAMESPACE: &str = "sshsigchain.key-proof.v1"; +pub const SSH_SIGCHAIN_ANCHOR_NAMESPACE: &str = "sshsigchain.anchor.v1"; pub const SSH_SIGCHAIN_VERIFIER_PRINCIPAL: &str = "sshsigchain"; -pub const KEYCHAIN_SSH_SIGCHAIN_PROFILE: &str = "geth.keychain.sshsigchain.v1"; -pub const KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION: u16 = 1; -pub const MAX_PROFILE_BYTES: usize = 128; +pub const MAX_IDENTIFIER_BYTES: usize = 128; pub const MAX_NAMESPACE_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_EXTENSIONS: usize = 1024; pub const MAX_RECORDS: usize = 100_000; pub const MAX_JSONL_LINE_BYTES: usize = 5 * 1024 * 1024; pub const MAX_JSONL_BYTES: usize = 64 * 1024 * 1024; const SIGNING_MAGIC: &[u8] = b"SSCS"; +const KEY_PROOF_MAGIC: &[u8] = b"SSKP"; +const ANCHOR_MAGIC: &[u8] = b"SSAH"; const RECORD_HASH_DOMAIN: &[u8] = b"sshsigchain.record-hash.v1\0"; +const KEY_ID_DOMAIN: &[u8] = b"sshsigchain.key-id.v1\0"; +const PAYLOAD_COMMITMENT_DOMAIN: &[u8] = b"sshsigchain.payload.v1\0"; +const CLAIM_HASH_DOMAIN: &[u8] = b"sshsigchain.head-claim.v1\0"; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ChainId(pub [u8; 32]); @@ -41,10 +43,11 @@ impl ChainId { 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)) + Ok(Self( + bytes + .try_into() + .map_err(|_| SshSigchainError::InvalidChainId)?, + )) } } @@ -58,10 +61,19 @@ impl Digest { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct AuthorityKeyId(pub Digest); + +impl AuthorityKeyId { + #[must_use] + pub fn to_hex(self) -> String { + self.0.to_hex() + } +} + #[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, } @@ -69,36 +81,873 @@ pub struct SshSigchainTrust { 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, + namespace: validate_namespace(namespace.into())?, + root_public_key: canonical_ssh_public_key(root_public_key.as_ref())?, }) } + + pub fn root_key_id(&self) -> Result { + authority_key_id(&self.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, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Permission { + /// The bootstrap authority wildcard. Applications should grant narrower permissions. + All, + DeviceAdd, + DeviceRevoke, + KeyAddSelf, + KeyAddAny, + KeyRevokeSelf, + KeyRevokeAny, + PermissionManage, + AnchorPolicy, + AnchorAttest, + ProfileWrite(String), + ProfileDelegate(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuthorityKey { + pub public_key: String, + pub permissions: Vec, + pub delegable_permissions: Vec, +} + +impl AuthorityKey { + pub fn key_id(&self) -> Result { + authority_key_id(&self.public_key) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AnchorBackendPolicy { + pub backend_id: String, + pub class: String, + pub locator: String, + pub weight: u16, + pub required: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AnchorPolicy { + /// Zero disables required external anchoring while retaining rollback checks against cached heads. + pub threshold: u16, + pub backends: Vec, +} + +impl Default for AnchorPolicy { + fn default() -> Self { + Self { + threshold: 0, + backends: Vec::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AuthorityTransition { + Genesis { + device_id: String, + root_key: AuthorityKey, + anchor_policy: AnchorPolicy, + }, + DeviceAdd { + device_id: String, + permission_ceiling: Vec, + }, + DeviceRevoke { + device_id: String, + }, + KeyAdd { + device_id: String, + key: AuthorityKey, + proof: Vec, + }, + KeyRevoke { + key_id: AuthorityKeyId, + }, + PermissionGrant { + key_id: AuthorityKeyId, + permissions: Vec, + delegable_permissions: Vec, + }, + PermissionRevoke { + key_id: AuthorityKeyId, + permissions: Vec, + delegable_permissions: Vec, + }, + AnchorPolicySet { + policy: AnchorPolicy, + }, + Noop, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileDisclosure { + pub nonce: [u8; 32], + pub payload: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileExtension { + pub profile_id: String, + pub commitment: Digest, + /// Transport-only. The signature and record hash commit to `commitment`, not this field. + pub disclosure: Option, +} + +impl ProfileExtension { + pub fn disclosed( + profile_id: impl Into, + nonce: [u8; 32], + payload: Vec, + ) -> Result { + let profile_id = validate_identifier(profile_id.into())?; + if payload.len() > MAX_PAYLOAD_BYTES { + return Err(SshSigchainError::PayloadTooLarge(payload.len())); + } + let commitment = profile_payload_commitment(&profile_id, &nonce, &payload)?; + Ok(Self { + profile_id, + commitment, + disclosure: Some(ProfileDisclosure { nonce, payload }), + }) + } + + #[must_use] + pub fn withheld(profile_id: String, commitment: Digest) -> Self { + Self { + profile_id, + commitment, + disclosure: None, + } + } +} + +/// JSONL transport envelope. JSON and disclosures are not signed; the exact +/// outer-link bytes returned by [`Self::signing_bytes`] are signed. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SshSigchainRecord { pub chain_id: ChainId, - pub profile: String, - pub sequence: u64, pub previous: Option, - pub payload: Vec, + pub signer_key_id: AuthorityKeyId, + pub signer_public_key: String, + pub authority: AuthorityTransition, + pub extensions: Vec, + pub signature: Vec, +} + +impl SshSigchainRecord { + pub fn unsigned( + chain_id: ChainId, + previous: Option, + signer_public_key: impl AsRef, + authority: AuthorityTransition, + extensions: Vec, + ) -> Result { + let signer_public_key = canonical_ssh_public_key(signer_public_key.as_ref())?; + let record = Self { + chain_id, + previous, + signer_key_id: authority_key_id(&signer_public_key)?, + signer_public_key, + authority, + extensions, + 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) + } + + pub fn signing_bytes(&self) -> Result, SshSigchainError> { + self.validate(false)?; + let mut out = Vec::new(); + out.extend_from_slice(SIGNING_MAGIC); + out.push(SSH_SIGCHAIN_VERSION); + out.extend_from_slice(&self.chain_id.0); + push_optional_digest(&mut out, self.previous); + out.extend_from_slice(&(self.signer_key_id.0).0); + push_u16_bytes(&mut out, self.signer_public_key.as_bytes())?; + encode_transition(&mut out, &self.authority)?; + push_u16(&mut out, self.extensions.len())?; + for extension in &self.extensions { + push_u16_bytes(&mut out, extension.profile_id.as_bytes())?; + out.extend_from_slice(&extension.commitment.0); + } + Ok(out) + } + + pub fn record_hash(&self) -> Result { + self.validate(true)?; + let signed = self.signing_bytes()?; + let mut input = Vec::new(); + input.extend_from_slice(RECORD_HASH_DOMAIN); + input.extend_from_slice(&signed); + Ok(Digest(*blake3::hash(&input).as_bytes())) + } + + fn validate(&self, signature_required: bool) -> Result<(), SshSigchainError> { + let canonical = canonical_ssh_public_key(&self.signer_public_key)?; + if canonical != self.signer_public_key { + return Err(SshSigchainError::NonCanonicalPublicKey); + } + if authority_key_id(&canonical)? != self.signer_key_id { + return Err(SshSigchainError::SignerKeyIdMismatch); + } + 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); + } + validate_transition(&self.authority)?; + if self.extensions.len() > MAX_EXTENSIONS { + return Err(SshSigchainError::TooManyExtensions(self.extensions.len())); + } + let mut previous_profile: Option<&str> = None; + for extension in &self.extensions { + validate_identifier(extension.profile_id.clone())?; + if previous_profile.is_some_and(|value| value >= extension.profile_id.as_str()) { + return Err(SshSigchainError::NonCanonicalExtensions); + } + previous_profile = Some(&extension.profile_id); + if let Some(disclosure) = &extension.disclosure { + if disclosure.payload.len() > MAX_PAYLOAD_BYTES { + return Err(SshSigchainError::PayloadTooLarge(disclosure.payload.len())); + } + if profile_payload_commitment( + &extension.profile_id, + &disclosure.nonce, + &disclosure.payload, + )? != extension.commitment + { + return Err(SshSigchainError::DisclosureCommitmentMismatch( + extension.profile_id.clone(), + )); + } + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorityDeviceState { + pub active: bool, + pub permission_ceiling: BTreeSet, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorityKeyState { + pub public_key: String, + pub device_id: String, + pub active: bool, + pub permissions: BTreeSet, + pub delegable_permissions: BTreeSet, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorityState { + pub devices: BTreeMap, + pub keys: BTreeMap, + pub anchor_policy: AnchorPolicy, + /// Policy which must witness this exact head. A policy change is first witnessed by the old policy. + pub head_anchor_policy: AnchorPolicy, + pub disclosed_profiles: BTreeSet, + pub incomplete_profiles: BTreeSet, +} + +impl AuthorityState { + #[must_use] + pub fn active_key_count(&self) -> usize { + self.keys.values().filter(|key| key.active).count() + } + + #[must_use] + pub fn key_has_permission(&self, key_id: AuthorityKeyId, permission: &Permission) -> bool { + self.keys.get(&key_id).is_some_and(|key| { + key.active + && self + .devices + .get(&key.device_id) + .is_some_and(|device| device.active) + && permission_set_allows(&key.permissions, permission) + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SshSigchainVerification { + pub chain_id: ChainId, + pub state: AuthorityState, + pub records: usize, + pub head: Digest, + /// Hashes in causal order, useful for cached-head and fork checks. + pub history: Vec, +} + +pub trait SshSigchainVerifier { + fn verify(&self, namespace: &str, message: &[u8], public_key: &str, signature: &[u8]) -> bool; +} + +pub fn verify_sshsigchain( + records: &[SshSigchainRecord], + trust: &SshSigchainTrust, + verifier: &V, +) -> Result { + if records.is_empty() { + return Err(SshSigchainError::EmptyChain); + } + if records.len() > MAX_RECORDS { + return Err(SshSigchainError::TooManyRecords(records.len())); + } + let mut state: Option = None; + let mut previous = None; + let mut history = Vec::with_capacity(records.len()); + for (index, record) in records.iter().enumerate() { + record.validate(true)?; + if record.chain_id != trust.chain_id { + return Err(SshSigchainError::WrongChainId { index }); + } + if record.previous != previous { + return Err(SshSigchainError::UnexpectedPrevious { index }); + } + if index == 0 { + if record.signer_public_key != trust.root_public_key { + return Err(SshSigchainError::RootSignerMismatch); + } + if !matches!(record.authority, AuthorityTransition::Genesis { .. }) { + return Err(SshSigchainError::GenesisRequired); + } + } else if matches!(record.authority, AuthorityTransition::Genesis { .. }) { + return Err(SshSigchainError::UnexpectedGenesis); + } + let message = record.signing_bytes()?; + if !verifier.verify( + &trust.namespace, + &message, + &record.signer_public_key, + &record.signature, + ) { + return Err(SshSigchainError::InvalidSignature { index }); + } + if let Some(current) = &state { + authorize_record(current, record, verifier)?; + } + apply_transition(&mut state, record)?; + let current = state.as_mut().expect("genesis creates authority state"); + for extension in &record.extensions { + if extension.disclosure.is_some() { + current + .disclosed_profiles + .insert(extension.profile_id.clone()); + } else { + current + .incomplete_profiles + .insert(extension.profile_id.clone()); + } + } + let hash = record.record_hash()?; + history.push(hash); + previous = Some(hash); + } + Ok(SshSigchainVerification { + chain_id: trust.chain_id, + state: state.expect("non-empty chain has state"), + records: records.len(), + head: previous.expect("non-empty chain has head"), + history, + }) +} + +fn authorize_record( + state: &AuthorityState, + record: &SshSigchainRecord, + verifier: &V, +) -> Result<(), SshSigchainError> { + let signer = state + .keys + .get(&record.signer_key_id) + .ok_or(SshSigchainError::UnknownSigner)?; + if !signer.active + || !state + .devices + .get(&signer.device_id) + .is_some_and(|device| device.active) + || signer.public_key != record.signer_public_key + { + return Err(SshSigchainError::InactiveSigner); + } + for extension in &record.extensions { + require_permission( + signer, + &Permission::ProfileWrite(extension.profile_id.clone()), + )?; + } + match &record.authority { + AuthorityTransition::Genesis { .. } => unreachable!("genesis checked by caller"), + AuthorityTransition::DeviceAdd { .. } => { + require_permission(signer, &Permission::DeviceAdd)?; + } + AuthorityTransition::DeviceRevoke { .. } => { + require_permission(signer, &Permission::DeviceRevoke)?; + } + AuthorityTransition::KeyAdd { + device_id, + key, + proof, + } => { + let permission = if device_id == &signer.device_id { + Permission::KeyAddSelf + } else { + Permission::KeyAddAny + }; + require_permission(signer, &permission)?; + ensure_delegation(signer, &key.permissions)?; + ensure_delegation(signer, &key.delegable_permissions)?; + let key_public = canonical_ssh_public_key(&key.public_key)?; + let proof_message = key_proof_signing_bytes(record.chain_id, device_id, key)?; + if proof.is_empty() + || !verifier.verify( + SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, + &proof_message, + &key_public, + proof, + ) + { + return Err(SshSigchainError::InvalidKeyProof); + } + } + AuthorityTransition::KeyRevoke { key_id } => { + let target = state.keys.get(key_id).ok_or(SshSigchainError::UnknownKey)?; + let permission = if target.device_id == signer.device_id { + Permission::KeyRevokeSelf + } else { + Permission::KeyRevokeAny + }; + require_permission(signer, &permission)?; + } + AuthorityTransition::PermissionGrant { + permissions, + delegable_permissions, + .. + } => { + require_permission(signer, &Permission::PermissionManage)?; + ensure_delegation(signer, permissions)?; + ensure_delegation(signer, delegable_permissions)?; + } + AuthorityTransition::PermissionRevoke { .. } => { + require_permission(signer, &Permission::PermissionManage)?; + } + AuthorityTransition::AnchorPolicySet { .. } => { + require_permission(signer, &Permission::AnchorPolicy)?; + } + AuthorityTransition::Noop => {} + } + Ok(()) +} + +fn apply_transition( + state: &mut Option, + record: &SshSigchainRecord, +) -> Result<(), SshSigchainError> { + if let AuthorityTransition::Genesis { + device_id, + root_key, + anchor_policy, + } = &record.authority + { + if state.is_some() { + return Err(SshSigchainError::UnexpectedGenesis); + } + validate_identifier(device_id.clone())?; + let root_public_key = canonical_ssh_public_key(&root_key.public_key)?; + if root_public_key != record.signer_public_key + || root_key.key_id()? != record.signer_key_id + || !root_key.permissions.contains(&Permission::All) + || !root_key.delegable_permissions.contains(&Permission::All) + { + return Err(SshSigchainError::InvalidGenesisRoot); + } + let ceiling = BTreeSet::from([Permission::All]); + let key = authority_key_state(device_id, root_key)?; + validate_anchor_policy(anchor_policy)?; + *state = Some(AuthorityState { + devices: BTreeMap::from([( + device_id.clone(), + AuthorityDeviceState { + active: true, + permission_ceiling: ceiling, + }, + )]), + keys: BTreeMap::from([(record.signer_key_id, key)]), + anchor_policy: anchor_policy.clone(), + head_anchor_policy: anchor_policy.clone(), + disclosed_profiles: BTreeSet::new(), + incomplete_profiles: BTreeSet::new(), + }); + return Ok(()); + } + let current = state.as_mut().ok_or(SshSigchainError::GenesisRequired)?; + current.head_anchor_policy = current.anchor_policy.clone(); + match &record.authority { + AuthorityTransition::Genesis { .. } => unreachable!(), + AuthorityTransition::DeviceAdd { + device_id, + permission_ceiling, + } => { + validate_identifier(device_id.clone())?; + if current.devices.contains_key(device_id) { + return Err(SshSigchainError::DuplicateDevice); + } + current.devices.insert( + device_id.clone(), + AuthorityDeviceState { + active: true, + permission_ceiling: permission_set(permission_ceiling)?, + }, + ); + } + AuthorityTransition::DeviceRevoke { device_id } => { + let device = current + .devices + .get_mut(device_id) + .ok_or(SshSigchainError::UnknownDevice)?; + if !device.active { + return Err(SshSigchainError::InactiveDevice); + } + device.active = false; + for key in current.keys.values_mut() { + if key.device_id == *device_id { + key.active = false; + } + } + } + AuthorityTransition::KeyAdd { device_id, key, .. } => { + let device = current + .devices + .get(device_id) + .ok_or(SshSigchainError::UnknownDevice)?; + if !device.active { + return Err(SshSigchainError::InactiveDevice); + } + let key_id = key.key_id()?; + if current.keys.contains_key(&key_id) { + return Err(SshSigchainError::DuplicateKey); + } + let key_state = authority_key_state(device_id, key)?; + ensure_within_ceiling(&device.permission_ceiling, &key_state.permissions)?; + ensure_within_ceiling(&device.permission_ceiling, &key_state.delegable_permissions)?; + current.keys.insert(key_id, key_state); + } + AuthorityTransition::KeyRevoke { key_id } => { + let key = current + .keys + .get_mut(key_id) + .ok_or(SshSigchainError::UnknownKey)?; + if !key.active { + return Err(SshSigchainError::InactiveKey); + } + key.active = false; + } + AuthorityTransition::PermissionGrant { + key_id, + permissions, + delegable_permissions, + } => { + let key = current + .keys + .get_mut(key_id) + .ok_or(SshSigchainError::UnknownKey)?; + let ceiling = ¤t + .devices + .get(&key.device_id) + .ok_or(SshSigchainError::UnknownDevice)? + .permission_ceiling; + let permissions = permission_set(permissions)?; + let delegable = permission_set(delegable_permissions)?; + ensure_within_ceiling(ceiling, &permissions)?; + ensure_within_ceiling(ceiling, &delegable)?; + key.permissions.extend(permissions); + key.delegable_permissions.extend(delegable); + } + AuthorityTransition::PermissionRevoke { + key_id, + permissions, + delegable_permissions, + } => { + let key = current + .keys + .get_mut(key_id) + .ok_or(SshSigchainError::UnknownKey)?; + for permission in permissions { + key.permissions.remove(permission); + } + for permission in delegable_permissions { + key.delegable_permissions.remove(permission); + } + } + AuthorityTransition::AnchorPolicySet { policy } => { + validate_anchor_policy(policy)?; + current.anchor_policy = policy.clone(); + // `head_anchor_policy` intentionally remains the policy from before this link. + } + AuthorityTransition::Noop => {} + } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HeadClaim { + pub chain_id: ChainId, + pub head: Digest, + pub signer_key_id: AuthorityKeyId, pub signer_public_key: String, pub signature: Vec, } +impl HeadClaim { + pub fn unsigned( + chain_id: ChainId, + head: Digest, + signer_public_key: impl AsRef, + ) -> Result { + let signer_public_key = canonical_ssh_public_key(signer_public_key.as_ref())?; + Ok(Self { + chain_id, + head, + signer_key_id: authority_key_id(&signer_public_key)?, + signer_public_key, + signature: Vec::new(), + }) + } + + pub fn signing_bytes(&self) -> Result, SshSigchainError> { + let canonical = canonical_ssh_public_key(&self.signer_public_key)?; + if canonical != self.signer_public_key + || authority_key_id(&self.signer_public_key)? != self.signer_key_id + { + return Err(SshSigchainError::SignerKeyIdMismatch); + } + let mut out = Vec::new(); + out.extend_from_slice(ANCHOR_MAGIC); + out.push(SSH_SIGCHAIN_VERSION); + out.extend_from_slice(&self.chain_id.0); + out.extend_from_slice(&self.head.0); + out.extend_from_slice(&(self.signer_key_id.0).0); + push_u16_bytes(&mut out, self.signer_public_key.as_bytes())?; + Ok(out) + } + + pub fn with_signature(mut self, signature: Vec) -> Result { + if signature.is_empty() { + return Err(SshSigchainError::MissingSignature); + } + if signature.len() > MAX_SIGNATURE_BYTES { + return Err(SshSigchainError::SignatureTooLarge(signature.len())); + } + self.signature = signature; + self.signing_bytes()?; + Ok(self) + } + + pub fn claim_hash(&self) -> Result { + let mut input = Vec::new(); + input.extend_from_slice(CLAIM_HASH_DOMAIN); + input.extend_from_slice(&self.signing_bytes()?); + Ok(Digest(*blake3::hash(&input).as_bytes())) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AnchorReceipt { + pub backend_id: String, + pub claim_hash: Digest, + /// Backend-defined proof, such as a transparency inclusion proof or transaction receipt. + pub evidence: Vec, +} + +pub trait AnchorReceiptVerifier { + fn verify_receipt( + &self, + backend: &AnchorBackendPolicy, + claim: &HeadClaim, + receipt: &AnchorReceipt, + ) -> bool; +} + +pub fn verify_head_claim( + verification: &SshSigchainVerification, + claim: &HeadClaim, + verifier: &V, +) -> Result<(), SshSigchainError> { + if claim.chain_id != verification.chain_id || claim.head != verification.head { + return Err(SshSigchainError::ClaimHeadMismatch); + } + let key = verification + .state + .keys + .get(&claim.signer_key_id) + .ok_or(SshSigchainError::UnknownSigner)?; + if key.public_key != canonical_ssh_public_key(&claim.signer_public_key)? + || !verification + .state + .key_has_permission(claim.signer_key_id, &Permission::AnchorAttest) + { + return Err(SshSigchainError::UnauthorizedHeadClaim); + } + if claim.signature.is_empty() + || !verifier.verify( + SSH_SIGCHAIN_ANCHOR_NAMESPACE, + &claim.signing_bytes()?, + &claim.signer_public_key, + &claim.signature, + ) + { + return Err(SshSigchainError::InvalidHeadClaimSignature); + } + Ok(()) +} + +pub fn verify_anchor_receipts( + policy: &AnchorPolicy, + claim: &HeadClaim, + receipts: &[AnchorReceipt], + verifier: &R, +) -> Result<(), SshSigchainError> { + validate_anchor_policy(policy)?; + let claim_hash = claim.claim_hash()?; + let mut weight = 0u32; + for backend in &policy.backends { + let valid = receipts.iter().any(|receipt| { + receipt.backend_id == backend.backend_id + && receipt.claim_hash == claim_hash + && verifier.verify_receipt(backend, claim, receipt) + }); + if backend.required && !valid { + return Err(SshSigchainError::RequiredAnchorMissing( + backend.backend_id.clone(), + )); + } + if valid { + weight += u32::from(backend.weight); + } + } + if weight < u32::from(policy.threshold) { + return Err(SshSigchainError::AnchorThresholdNotMet { + required: policy.threshold, + actual: weight, + }); + } + Ok(()) +} + +#[derive(Clone, Debug)] +pub struct AnchoredHistory<'a> { + pub verification: &'a SshSigchainVerification, + pub claim: &'a HeadClaim, +} + +pub fn select_anchored_head<'a>( + histories: &'a [AnchoredHistory<'a>], + cached_head: Option, +) -> Result<&'a AnchoredHistory<'a>, SshSigchainError> { + let mut candidates: Vec<_> = histories + .iter() + .filter(|history| { + history.claim.head == history.verification.head + && cached_head.is_none_or(|head| history.verification.history.contains(&head)) + }) + .collect(); + if candidates.is_empty() { + return Err(if cached_head.is_some() { + SshSigchainError::RollbackDetected + } else { + SshSigchainError::NoAnchoredHead + }); + } + candidates.sort_by_key(|history| history.verification.history.len()); + let selected = *candidates.last().expect("non-empty candidates"); + for candidate in &candidates[..candidates.len() - 1] { + let prefix = &selected.verification.history[..candidate + .verification + .history + .len() + .min(selected.verification.history.len())]; + if prefix != candidate.verification.history.as_slice() { + return Err(SshSigchainError::ForkDetected); + } + } + Ok(selected) +} + +pub fn key_proof_signing_bytes( + chain_id: ChainId, + device_id: &str, + key: &AuthorityKey, +) -> Result, SshSigchainError> { + validate_identifier(device_id.to_owned())?; + let public_key = canonical_ssh_public_key(&key.public_key)?; + let mut out = Vec::new(); + out.extend_from_slice(KEY_PROOF_MAGIC); + out.push(SSH_SIGCHAIN_VERSION); + out.extend_from_slice(&chain_id.0); + push_u16_bytes(&mut out, device_id.as_bytes())?; + out.extend_from_slice(&(authority_key_id(&public_key)?.0).0); + push_u16_bytes(&mut out, public_key.as_bytes())?; + encode_permissions(&mut out, &key.permissions)?; + encode_permissions(&mut out, &key.delegable_permissions)?; + Ok(out) +} + +pub fn authority_key_id(public_key: &str) -> Result { + let public_key = canonical_ssh_public_key(public_key)?; + let mut input = Vec::new(); + input.extend_from_slice(KEY_ID_DOMAIN); + input.extend_from_slice(public_key.as_bytes()); + Ok(AuthorityKeyId(Digest(*blake3::hash(&input).as_bytes()))) +} + +pub fn profile_payload_commitment( + profile_id: &str, + nonce: &[u8; 32], + payload: &[u8], +) -> Result { + validate_identifier(profile_id.to_owned())?; + if payload.len() > MAX_PAYLOAD_BYTES { + return Err(SshSigchainError::PayloadTooLarge(payload.len())); + } + let mut input = Vec::new(); + input.extend_from_slice(PAYLOAD_COMMITMENT_DOMAIN); + push_u16_bytes(&mut input, profile_id.as_bytes())?; + input.extend_from_slice(nonce); + push_u32_bytes(&mut input, payload)?; + Ok(Digest(*blake3::hash(&input).as_bytes())) +} + pub fn encode_sshsigchain_jsonl(records: &[SshSigchainRecord]) -> Result { let mut output = String::new(); for record in records { @@ -139,606 +988,98 @@ pub fn decode_sshsigchain_jsonl(input: &str) -> Result, S Ok(records) } -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 KeychainSshSigchainPolicy; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KeychainSshSigchainState { - initialized: bool, - admin_public_keys: BTreeMap, - seen_op_ids: BTreeSet, - ops: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KeychainSshSigchainVerification { - pub view: KeychainView, - pub records: usize, - pub head: Digest, -} - -pub fn keychain_sshsigchain_payload(op: &KeychainOp) -> Result, SshSigchainError> { - geth_codec::encode_canonical(&KeychainSshSigchainPayload { - version: KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION, - op: CanonicalKeychainOp::from(op), - }) - .map_err(|error| SshSigchainError::PayloadEncoding(error.to_string())) -} - -pub fn decode_keychain_sshsigchain_payload(bytes: &[u8]) -> Result { - let decoded = geth_codec::decode_canonical::(bytes) - .map_err(|error| SshSigchainError::PayloadDecoding(error.to_string()))?; - if decoded.version != KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION { - return Err(SshSigchainError::UnsupportedPayloadVersion(decoded.version)); - } - let op = KeychainOp::from(decoded.op); - let canonical = keychain_sshsigchain_payload(&op)?; - if canonical != bytes { - return Err(SshSigchainError::NonCanonicalPayload); - } - Ok(op) -} - -pub fn keychain_sshsigchain_unsigned_record( - trust: &SshSigchainTrust, - sequence: u64, - previous: Option, - op: &KeychainOp, - signer_public_key: impl AsRef, -) -> Result { - if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE { - return Err(SshSigchainError::WrongKeychainProfile( - trust.profile.clone(), - )); - } - SshSigchainRecord::unsigned( - trust.chain_id, - KEYCHAIN_SSH_SIGCHAIN_PROFILE, - sequence, - previous, - keychain_sshsigchain_payload(op)?, - signer_public_key, - ) -} - -pub fn verify_keychain_sshsigchain( - records: &[SshSigchainRecord], - trust: &SshSigchainTrust, - verifier: &V, -) -> Result -where - V: SshSigchainVerifier + ?Sized, -{ - if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE { - return Err(SshSigchainError::WrongKeychainProfile( - trust.profile.clone(), - )); - } - let verified = verify_sshsigchain(records, trust, verifier, &KeychainSshSigchainPolicy)?; - Ok(KeychainSshSigchainVerification { - view: reduce_keychain_ops(&verified.state.ops), - records: verified.records, - head: verified.head, - }) -} - -impl SshSigchainPolicy for KeychainSshSigchainPolicy { - type State = KeychainSshSigchainState; - - fn initial_state(&self, trust: &SshSigchainTrust) -> Result { - let root = - canonical_ssh_public_key(&trust.root_public_key).map_err(|error| error.to_string())?; - Ok(KeychainSshSigchainState { - initialized: false, - admin_public_keys: BTreeMap::from([(KeyId::new(admin_key_fingerprint(&root)), root)]), - seen_op_ids: BTreeSet::new(), - 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_sshsigchain_payload(&record.payload) - .map_err(|error| error.to_string())?; - if !state.seen_op_ids.insert(op.id.clone()) { - return Err(format!("duplicate keychain operation ID: {}", op.id)); - } - 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( - "SSHSIGCHAIN 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 SSHSIGCHAIN".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()); - } - if record.sequence == 1 && public_key != record.signer_public_key { - return Err( - "sequence 1 must record the configured root signer as an admin 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()); - } - if record.sequence == 1 { - return Err( - "sequence 1 must record the configured root signer as an admin key" - .to_owned(), - ); - } - state.admin_public_keys.remove(key); - } - _ => { - if !state.initialized { - return Err("keychain must start with KeychainInit".to_owned()); - } - if record.sequence == 1 { - return Err( - "sequence 1 must record the configured root signer as an admin key" - .to_owned(), - ); - } - } - } - state.ops.push(op); - Ok(()) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct KeychainSshSigchainPayload { - version: u16, - op: CanonicalKeychainOp, -} - -/// `KeychainOpKind` uses a human-facing internally tagged JSON enum. Postcard -/// deliberately cannot deserialize that representation, so SSHSIGCHAIN 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)] +#[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum SshSigchainError { - #[error("SSH sigchain chain ID must be 32 bytes encoded as lowercase or uppercase hexadecimal")] + #[error("SSH sigchain chain ID must be 32 bytes encoded as 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 and at most {MAX_NAMESPACE_BYTES} bytes" + "identifier must be non-empty restricted ASCII and at most {MAX_IDENTIFIER_BYTES} bytes" + )] + InvalidIdentifier, + #[error( + "SSH sigchain namespace must be printable non-whitespace ASCII and at most {MAX_NAMESPACE_BYTES} bytes" )] 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")] + #[error("signer key ID does not match the canonical public key")] + SignerKeyIdMismatch, + #[error("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")] + #[error("signature is {0} bytes, over the {MAX_SIGNATURE_BYTES}-byte limit")] SignatureTooLarge(usize), - #[error("SSH sigchain record has no signature")] + #[error("record has no signature")] MissingSignature, - #[error("SSH sigchain cannot be empty")] + #[error("chain cannot be empty")] EmptyChain, + #[error("chain has {0} records, over the {MAX_RECORDS}-record limit")] + TooManyRecords(usize), + #[error("record has {0} extensions, over the {MAX_EXTENSIONS}-extension limit")] + TooManyExtensions(usize), + #[error("profile extensions must be strictly sorted with no duplicate profile IDs")] + NonCanonicalExtensions, + #[error("disclosure for profile {0} does not match its signed commitment")] + DisclosureCommitmentMismatch(String), + #[error("record {index} names a different chain ID")] + WrongChainId { index: usize }, + #[error("record {index} does not link to the immediately previous record")] + UnexpectedPrevious { index: usize }, + #[error("genesis record signer does not equal the explicitly pinned root key")] + RootSignerMismatch, + #[error("the first record must be the authority genesis transition")] + GenesisRequired, + #[error("genesis transition is only valid as the first record")] + UnexpectedGenesis, + #[error("genesis must install the pinned root with All authority and delegation")] + InvalidGenesisRoot, + #[error("record {index} has an invalid OpenSSH signature")] + InvalidSignature { index: usize }, + #[error("record signer is unknown at this chain position")] + UnknownSigner, + #[error("record signer is inactive or does not match its registered key")] + InactiveSigner, + #[error("signer lacks permission {0:?}")] + PermissionDenied(Permission), + #[error("signer may not delegate permission {0:?}")] + DelegationDenied(Permission), + #[error("permission {0:?} exceeds the target device ceiling")] + DeviceCeilingExceeded(Permission), + #[error("permission lists must be strictly sorted with no duplicates")] + NonCanonicalPermissions, + #[error("new key proof-of-possession is invalid")] + InvalidKeyProof, + #[error("device already exists")] + DuplicateDevice, + #[error("device is unknown")] + UnknownDevice, + #[error("device is inactive")] + InactiveDevice, + #[error("key already exists")] + DuplicateKey, + #[error("key is unknown")] + UnknownKey, + #[error("key is inactive")] + InactiveKey, + #[error("anchor policy contains invalid, duplicate, or unsatisfiable backend rules")] + InvalidAnchorPolicy, + #[error("head claim does not name the verified chain head")] + ClaimHeadMismatch, + #[error("head claim signer is not an active anchor attester at that head")] + UnauthorizedHeadClaim, + #[error("head claim signature is invalid")] + InvalidHeadClaimSignature, + #[error("required anchor backend {0} has no valid receipt")] + RequiredAnchorMissing(String), + #[error("anchor threshold not met: required {required}, got {actual}")] + AnchorThresholdNotMet { required: u16, actual: u32 }, + #[error("no valid anchored head was supplied")] + NoAnchoredHead, + #[error("no candidate descends from the locally cached head")] + RollbackDetected, + #[error("anchored claims identify incomparable histories")] + ForkDetected, #[error("SSH sigchain JSONL line {line}: {detail}")] JsonLine { line: usize, detail: String }, #[error("failed to encode SSH sigchain JSONL: {0}")] @@ -749,64 +1090,332 @@ pub enum SshSigchainError { "SSH sigchain JSONL line {line} is {bytes} bytes, over the {MAX_JSONL_LINE_BYTES}-byte limit" )] JsonLineTooLarge { line: usize, bytes: usize }, - #[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 SSHSIGCHAIN payload: {0}")] - PayloadEncoding(String), - #[error("failed to decode keychain SSHSIGCHAIN payload: {0}")] - PayloadDecoding(String), - #[error("unsupported keychain SSHSIGCHAIN payload version {0}")] - UnsupportedPayloadVersion(u16), - #[error("keychain SSHSIGCHAIN payload is not its unique canonical encoding")] - NonCanonicalPayload, - #[error("expected geth keychain SSHSIGCHAIN profile, got {0}")] - WrongKeychainProfile(String), #[error("length does not fit the SSH sigchain wire format")] LengthOverflow, } +fn validate_transition(transition: &AuthorityTransition) -> Result<(), SshSigchainError> { + match transition { + AuthorityTransition::Genesis { + device_id, + root_key, + anchor_policy, + } => { + validate_identifier(device_id.clone())?; + validate_authority_key(root_key)?; + validate_anchor_policy(anchor_policy) + } + AuthorityTransition::DeviceAdd { + device_id, + permission_ceiling, + } => { + validate_identifier(device_id.clone())?; + permission_set(permission_ceiling).map(|_| ()) + } + AuthorityTransition::DeviceRevoke { device_id } => { + validate_identifier(device_id.clone()).map(|_| ()) + } + AuthorityTransition::KeyAdd { + device_id, + key, + proof, + } => { + validate_identifier(device_id.clone())?; + validate_authority_key(key)?; + if proof.len() > MAX_SIGNATURE_BYTES { + return Err(SshSigchainError::SignatureTooLarge(proof.len())); + } + Ok(()) + } + AuthorityTransition::KeyRevoke { .. } | AuthorityTransition::Noop => Ok(()), + AuthorityTransition::PermissionGrant { + permissions, + delegable_permissions, + .. + } + | AuthorityTransition::PermissionRevoke { + permissions, + delegable_permissions, + .. + } => { + permission_set(permissions)?; + permission_set(delegable_permissions)?; + Ok(()) + } + AuthorityTransition::AnchorPolicySet { policy } => validate_anchor_policy(policy), + } +} + +fn validate_authority_key(key: &AuthorityKey) -> Result<(), SshSigchainError> { + if canonical_ssh_public_key(&key.public_key)? != key.public_key { + return Err(SshSigchainError::NonCanonicalPublicKey); + } + permission_set(&key.permissions)?; + permission_set(&key.delegable_permissions)?; + Ok(()) +} + +fn validate_anchor_policy(policy: &AnchorPolicy) -> Result<(), SshSigchainError> { + let mut ids = BTreeSet::new(); + let mut total = 0u32; + let mut previous_id: Option<&str> = None; + for backend in &policy.backends { + validate_identifier(backend.backend_id.clone())?; + validate_identifier(backend.class.clone())?; + if backend.locator.is_empty() + || backend.locator.len() > 2048 + || backend.weight == 0 + || !ids.insert(backend.backend_id.clone()) + || previous_id.is_some_and(|previous| previous >= backend.backend_id.as_str()) + { + return Err(SshSigchainError::InvalidAnchorPolicy); + } + previous_id = Some(&backend.backend_id); + total += u32::from(backend.weight); + } + if u32::from(policy.threshold) > total { + return Err(SshSigchainError::InvalidAnchorPolicy); + } + Ok(()) +} + +fn authority_key_state( + device_id: &str, + key: &AuthorityKey, +) -> Result { + Ok(AuthorityKeyState { + public_key: canonical_ssh_public_key(&key.public_key)?, + device_id: device_id.to_owned(), + active: true, + permissions: permission_set(&key.permissions)?, + delegable_permissions: permission_set(&key.delegable_permissions)?, + }) +} + +fn permission_set(values: &[Permission]) -> Result, SshSigchainError> { + let set: BTreeSet<_> = values.iter().cloned().collect(); + if set.len() != values.len() || !values.windows(2).all(|pair| pair[0] < pair[1]) { + return Err(SshSigchainError::NonCanonicalPermissions); + } + for permission in values { + if let Permission::ProfileWrite(profile) | Permission::ProfileDelegate(profile) = permission + { + validate_identifier(profile.clone())?; + } + } + Ok(set) +} + +fn permission_set_allows(set: &BTreeSet, permission: &Permission) -> bool { + set.contains(&Permission::All) || set.contains(permission) +} + +fn require_permission( + signer: &AuthorityKeyState, + permission: &Permission, +) -> Result<(), SshSigchainError> { + if permission_set_allows(&signer.permissions, permission) { + Ok(()) + } else { + Err(SshSigchainError::PermissionDenied(permission.clone())) + } +} + +fn ensure_delegation( + signer: &AuthorityKeyState, + permissions: &[Permission], +) -> Result<(), SshSigchainError> { + for permission in permissions { + let profile_delegation = match permission { + Permission::ProfileWrite(profile) => signer + .delegable_permissions + .contains(&Permission::ProfileDelegate(profile.clone())), + _ => false, + }; + if !permission_set_allows(&signer.delegable_permissions, permission) && !profile_delegation + { + return Err(SshSigchainError::DelegationDenied(permission.clone())); + } + } + Ok(()) +} + +fn ensure_within_ceiling( + ceiling: &BTreeSet, + permissions: &BTreeSet, +) -> Result<(), SshSigchainError> { + for permission in permissions { + if !permission_set_allows(ceiling, permission) { + return Err(SshSigchainError::DeviceCeilingExceeded(permission.clone())); + } + } + Ok(()) +} + +fn encode_transition( + out: &mut Vec, + transition: &AuthorityTransition, +) -> Result<(), SshSigchainError> { + match transition { + AuthorityTransition::Genesis { + device_id, + root_key, + anchor_policy, + } => { + out.push(0); + push_u16_bytes(out, device_id.as_bytes())?; + encode_authority_key(out, root_key)?; + encode_anchor_policy(out, anchor_policy)?; + } + AuthorityTransition::DeviceAdd { + device_id, + permission_ceiling, + } => { + out.push(1); + push_u16_bytes(out, device_id.as_bytes())?; + encode_permissions(out, permission_ceiling)?; + } + AuthorityTransition::DeviceRevoke { device_id } => { + out.push(2); + push_u16_bytes(out, device_id.as_bytes())?; + } + AuthorityTransition::KeyAdd { + device_id, + key, + proof, + } => { + out.push(3); + push_u16_bytes(out, device_id.as_bytes())?; + encode_authority_key(out, key)?; + push_u32_bytes(out, proof)?; + } + AuthorityTransition::KeyRevoke { key_id } => { + out.push(4); + out.extend_from_slice(&(key_id.0).0); + } + AuthorityTransition::PermissionGrant { + key_id, + permissions, + delegable_permissions, + } => { + out.push(5); + out.extend_from_slice(&(key_id.0).0); + encode_permissions(out, permissions)?; + encode_permissions(out, delegable_permissions)?; + } + AuthorityTransition::PermissionRevoke { + key_id, + permissions, + delegable_permissions, + } => { + out.push(6); + out.extend_from_slice(&(key_id.0).0); + encode_permissions(out, permissions)?; + encode_permissions(out, delegable_permissions)?; + } + AuthorityTransition::AnchorPolicySet { policy } => { + out.push(7); + encode_anchor_policy(out, policy)?; + } + AuthorityTransition::Noop => out.push(8), + } + Ok(()) +} + +fn encode_authority_key(out: &mut Vec, key: &AuthorityKey) -> Result<(), SshSigchainError> { + push_u16_bytes(out, canonical_ssh_public_key(&key.public_key)?.as_bytes())?; + encode_permissions(out, &key.permissions)?; + encode_permissions(out, &key.delegable_permissions) +} + +fn encode_permissions( + out: &mut Vec, + permissions: &[Permission], +) -> Result<(), SshSigchainError> { + permission_set(permissions)?; + push_u16(out, permissions.len())?; + for permission in permissions { + match permission { + Permission::All => out.push(0), + Permission::DeviceAdd => out.push(1), + Permission::DeviceRevoke => out.push(2), + Permission::KeyAddSelf => out.push(3), + Permission::KeyAddAny => out.push(4), + Permission::KeyRevokeSelf => out.push(5), + Permission::KeyRevokeAny => out.push(6), + Permission::PermissionManage => out.push(7), + Permission::AnchorPolicy => out.push(8), + Permission::AnchorAttest => out.push(9), + Permission::ProfileWrite(profile) => { + out.push(10); + push_u16_bytes(out, profile.as_bytes())?; + } + Permission::ProfileDelegate(profile) => { + out.push(11); + push_u16_bytes(out, profile.as_bytes())?; + } + } + } + Ok(()) +} + +fn encode_anchor_policy(out: &mut Vec, policy: &AnchorPolicy) -> Result<(), SshSigchainError> { + validate_anchor_policy(policy)?; + out.extend_from_slice(&policy.threshold.to_be_bytes()); + push_u16(out, policy.backends.len())?; + for backend in &policy.backends { + push_u16_bytes(out, backend.backend_id.as_bytes())?; + push_u16_bytes(out, backend.class.as_bytes())?; + push_u16_bytes(out, backend.locator.as_bytes())?; + out.extend_from_slice(&backend.weight.to_be_bytes()); + out.push(u8::from(backend.required)); + } + Ok(()) +} + +fn push_optional_digest(out: &mut Vec, value: Option) { + match value { + Some(value) => { + out.push(1); + out.extend_from_slice(&value.0); + } + None => out.push(0), + } +} + +fn push_u16(out: &mut Vec, value: usize) -> Result<(), SshSigchainError> { + out.extend_from_slice( + &u16::try_from(value) + .map_err(|_| SshSigchainError::LengthOverflow)? + .to_be_bytes(), + ); + Ok(()) +} + 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()); + push_u16(out, value.len())?; 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( + &u32::try_from(value.len()) + .map_err(|_| SshSigchainError::LengthOverflow)? + .to_be_bytes(), + ); out.extend_from_slice(value); Ok(()) } -fn validate_profile(value: String) -> Result { +fn validate_identifier(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'_')) + || value.len() > MAX_IDENTIFIER_BYTES + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':' | b'/') + }) { - return Err(SshSigchainError::InvalidProfile); + return Err(SshSigchainError::InvalidIdentifier); } Ok(value) } @@ -852,8 +1461,6 @@ fn canonical_ssh_public_key(value: &str) -> Result { #[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"; @@ -883,331 +1490,414 @@ mod tests { } fn trust() -> SshSigchainTrust { - SshSigchainTrust::new( - ChainId([7; 32]), - KEYCHAIN_SSH_SIGCHAIN_PROFILE, - SSH_SIGCHAIN_NAMESPACE, - ROOT_KEY, - ) - .expect("trust") + SshSigchainTrust::new(ChainId([7; 32]), 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 authority_key(public_key: &str, permissions: Vec) -> AuthorityKey { + AuthorityKey { + public_key: public_key.to_owned(), + permissions, + delegable_permissions: vec![], } } + fn genesis(trust: &SshSigchainTrust) -> SshSigchainRecord { + signed_record( + trust, + None, + ROOT_KEY, + AuthorityTransition::Genesis { + device_id: "device:root".to_owned(), + root_key: AuthorityKey { + public_key: ROOT_KEY.to_owned(), + permissions: vec![Permission::All], + delegable_permissions: vec![Permission::All], + }, + anchor_policy: AnchorPolicy::default(), + }, + vec![], + ) + } + fn signed_record( trust: &SshSigchainTrust, - sequence: u64, previous: Option, - op: &KeychainOp, signer: &str, + authority: AuthorityTransition, + extensions: Vec, ) -> SshSigchainRecord { - let record = keychain_sshsigchain_unsigned_record(trust, sequence, previous, op, signer) - .expect("unsigned record"); + let record = + SshSigchainRecord::unsigned(trust.chain_id, previous, signer, authority, extensions) + .expect("unsigned"); 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, - }, - ) + record.with_signature(signature).expect("signed") } #[test] - fn accepts_a_linked_rooted_keychain() { + fn chain_order_uses_only_parent_hashes() { let trust = trust(); - let init = signed_record( + let first = genesis(&trust); + let second = signed_record( &trust, - 0, - None, - &op("op:init", 1, KeychainOpKind::KeychainInit), + Some(first.record_hash().expect("hash")), ROOT_KEY, + AuthorityTransition::Noop, + vec![], ); - 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_sshsigchain(&[init, add, user], &trust, &TestVerifier) + let verified = verify_sshsigchain(&[first.clone(), second], &trust, &TestVerifier) .expect("valid chain"); - assert_eq!(verified.records, 3); + assert_eq!(verified.records, 2); + let fork = signed_record(&trust, None, ROOT_KEY, AuthorityTransition::Noop, vec![]); 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 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_sshsigchain(&[init], &trust, &TestVerifier), - Err(SshSigchainError::RootSignerMismatch) - )); - } - - #[test] - fn 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_sshsigchain(&[init, add], &trust, &TestVerifier), + verify_sshsigchain(&[first, fork], &trust, &TestVerifier), Err(SshSigchainError::UnexpectedPrevious { index: 1 }) - )); + ); } #[test] - fn requires_the_root_to_be_recorded_before_other_identity_changes() { + fn selective_disclosure_does_not_change_signature_or_record_hash() { let trust = trust(); - let init = signed_record( + let extension = ProfileExtension::disclosed("example.profile", [9; 32], b"secret".to_vec()) + .expect("extension"); + let disclosed = signed_record( &trust, - 0, None, - &op("op:init", 1, KeychainOpKind::KeychainInit), ROOT_KEY, - ); - let user = signed_record( - &trust, - 1, - Some(init.record_hash().expect("hash")), - &op( - "op:user", - 2, - KeychainOpKind::UserAdd { - user: "user:alice".into(), - name: "Alice".to_owned(), + AuthorityTransition::Genesis { + device_id: "device:root".to_owned(), + root_key: AuthorityKey { + public_key: ROOT_KEY.to_owned(), + permissions: vec![Permission::All], + delegable_permissions: vec![Permission::All], }, + anchor_policy: AnchorPolicy::default(), + }, + vec![extension.clone()], + ); + let mut withheld = disclosed.clone(); + withheld.extensions[0].disclosure = None; + assert_eq!( + disclosed.signing_bytes().expect("bytes"), + withheld.signing_bytes().expect("bytes") + ); + assert_eq!( + disclosed.record_hash().expect("hash"), + withheld.record_hash().expect("hash") + ); + let state = verify_sshsigchain(&[withheld], &trust, &TestVerifier) + .expect("withheld verifies") + .state; + assert!(state.incomplete_profiles.contains("example.profile")); + } + + #[test] + fn rejects_tampered_disclosure() { + let extension = ProfileExtension::disclosed("example.profile", [1; 32], b"one".to_vec()) + .expect("extension"); + let mut tampered = extension; + tampered.disclosure.as_mut().expect("disclosure").payload = b"two".to_vec(); + let trust = trust(); + assert_eq!( + SshSigchainRecord::unsigned( + trust.chain_id, + None, + ROOT_KEY, + AuthorityTransition::Noop, + vec![tampered] ), + Err(SshSigchainError::DisclosureCommitmentMismatch( + "example.profile".to_owned() + )) + ); + } + + #[test] + fn key_add_requires_possession_and_respects_device_ceiling() { + let trust = trust(); + let first = genesis(&trust); + let add_device = signed_record( + &trust, + Some(first.record_hash().expect("hash")), ROOT_KEY, + AuthorityTransition::DeviceAdd { + device_id: "device:phone".to_owned(), + permission_ceiling: vec![Permission::ProfileWrite("notes".to_owned())], + }, + vec![], + ); + let key = authority_key( + SECOND_KEY, + vec![Permission::ProfileWrite("notes".to_owned())], + ); + let proof_message = + key_proof_signing_bytes(trust.chain_id, "device:phone", &key).expect("proof bytes"); + let proof = test_signature(SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, &proof_message, SECOND_KEY); + let add_key = signed_record( + &trust, + Some(add_device.record_hash().expect("hash")), + ROOT_KEY, + AuthorityTransition::KeyAdd { + device_id: "device:phone".to_owned(), + key: key.clone(), + proof, + }, + vec![], + ); + let verified = verify_sshsigchain( + &[first.clone(), add_device.clone(), add_key], + &trust, + &TestVerifier, + ) + .expect("valid key add"); + assert_eq!(verified.state.active_key_count(), 2); + let bad_key = authority_key(SECOND_KEY, vec![Permission::AnchorPolicy]); + let bad = signed_record( + &trust, + Some(add_device.record_hash().expect("hash")), + ROOT_KEY, + AuthorityTransition::KeyAdd { + device_id: "device:phone".to_owned(), + key: bad_key, + proof: vec![1], + }, + vec![], ); assert!(matches!( - verify_keychain_sshsigchain(&[init, user], &trust, &TestVerifier), - Err(SshSigchainError::Policy(_)) + verify_sshsigchain(&[first, add_device, bad], &trust, &TestVerifier), + Err(SshSigchainError::InvalidKeyProof | SshSigchainError::DeviceCeilingExceeded(_)) )); } #[test] - fn rejects_duplicate_keychain_operation_ids() { + fn device_revocation_disables_all_its_keys() { 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 duplicate = signed_record( - &trust, - 3, - Some(user.record_hash().expect("hash")), - &op( - "op:user", - 4, - KeychainOpKind::UserRename { - user: "user:alice".into(), - name: "Mallory".to_owned(), - }, - ), - ROOT_KEY, - ); - assert!(matches!( - verify_keychain_sshsigchain(&[init, add, user, duplicate], &trust, &TestVerifier), - Err(SshSigchainError::Policy(_)) - )); - } - - #[test] - fn 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 first = genesis(&trust); 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)), - }, - ), + Some(first.record_hash().expect("hash")), ROOT_KEY, + AuthorityTransition::DeviceRevoke { + device_id: "device:root".to_owned(), + }, + vec![], ); - // The attacker backdates this payload, but cannot move it before the - // revocation because its sequence and parent are immutable. - let forged = signed_record( + let after = 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, + AuthorityTransition::Noop, + vec![], ); - assert!(matches!( - verify_keychain_sshsigchain(&[init, add, revoke, forged], &trust, &TestVerifier), - Err(SshSigchainError::Policy(_)) - )); - } - - #[test] - fn payload_rejects_trailing_or_noncanonical_bytes() { - let payload = keychain_sshsigchain_payload(&op("op:init", 1, KeychainOpKind::KeychainInit)) - .expect("payload"); - let mut noncanonical = payload; - noncanonical.push(0); - assert!(matches!( - decode_keychain_sshsigchain_payload(&noncanonical), - Err(SshSigchainError::NonCanonicalPayload) - )); - } - - #[test] - fn jsonl_transport_roundtrips_without_becoming_signed_data() { - let trust = trust(); - let record = signed_record( - &trust, - 0, - None, - &op("op:init", 1, KeychainOpKind::KeychainInit), - ROOT_KEY, - ); - let jsonl = encode_sshsigchain_jsonl(std::slice::from_ref(&record)).expect("encode JSONL"); assert_eq!( - decode_sshsigchain_jsonl(&jsonl).expect("decode JSONL"), - vec![record] + verify_sshsigchain(&[first, revoke, after], &trust, &TestVerifier), + Err(SshSigchainError::InactiveSigner) ); } #[test] - fn jsonl_transport_rejects_unknown_record_fields() { + fn profile_permissions_do_not_escalate_across_profiles() { let trust = trust(); - let record = signed_record( + let first = genesis(&trust); + let add_device = signed_record( &trust, - 0, - None, - &op("op:init", 1, KeychainOpKind::KeychainInit), + Some(first.record_hash().expect("hash")), + ROOT_KEY, + AuthorityTransition::DeviceAdd { + device_id: "device:phone".to_owned(), + permission_ceiling: vec![Permission::ProfileWrite("notes".to_owned())], + }, + vec![], + ); + let key = authority_key( + SECOND_KEY, + vec![Permission::ProfileWrite("notes".to_owned())], + ); + let proof_message = + key_proof_signing_bytes(trust.chain_id, "device:phone", &key).expect("proof bytes"); + let proof = test_signature(SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, &proof_message, SECOND_KEY); + let add_key = signed_record( + &trust, + Some(add_device.record_hash().expect("hash")), + ROOT_KEY, + AuthorityTransition::KeyAdd { + device_id: "device:phone".to_owned(), + key, + proof, + }, + vec![], + ); + let forbidden = signed_record( + &trust, + Some(add_key.record_hash().expect("hash")), + SECOND_KEY, + AuthorityTransition::Noop, + vec![ProfileExtension::disclosed("secrets", [4; 32], vec![1]).expect("extension")], + ); + assert!(matches!( + verify_sshsigchain(&[first, add_device, add_key, forbidden], &trust, &TestVerifier), + Err(SshSigchainError::PermissionDenied(Permission::ProfileWrite(profile))) if profile == "secrets" + )); + } + + #[test] + fn head_claims_require_current_authority_and_policy_receipts() { + struct ReceiptVerifier; + impl AnchorReceiptVerifier for ReceiptVerifier { + fn verify_receipt( + &self, + _: &AnchorBackendPolicy, + _: &HeadClaim, + receipt: &AnchorReceipt, + ) -> bool { + receipt.evidence == b"valid" + } + } + + let trust = trust(); + let verification = + verify_sshsigchain(&[genesis(&trust)], &trust, &TestVerifier).expect("chain"); + let claim = + HeadClaim::unsigned(trust.chain_id, verification.head, ROOT_KEY).expect("claim"); + let signature = test_signature( + SSH_SIGCHAIN_ANCHOR_NAMESPACE, + &claim.signing_bytes().expect("bytes"), ROOT_KEY, ); - let mut value = serde_json::to_value(record).expect("record JSON"); + let claim = claim.with_signature(signature).expect("signed claim"); + verify_head_claim(&verification, &claim, &TestVerifier).expect("authorized claim"); + let policy = AnchorPolicy { + threshold: 1, + backends: vec![AnchorBackendPolicy { + backend_id: "transparency-main".to_owned(), + class: "transparency".to_owned(), + locator: "https://log.example".to_owned(), + weight: 1, + required: true, + }], + }; + let receipt = AnchorReceipt { + backend_id: "transparency-main".to_owned(), + claim_hash: claim.claim_hash().expect("claim hash"), + evidence: b"valid".to_vec(), + }; + verify_anchor_receipts(&policy, &claim, &[receipt], &ReceiptVerifier).expect("receipts"); + assert!(matches!( + verify_anchor_receipts(&policy, &claim, &[], &ReceiptVerifier), + Err(SshSigchainError::RequiredAnchorMissing(_)) + )); + } + + #[test] + fn policy_change_head_is_witnessed_by_previous_policy() { + let trust = trust(); + let first = genesis(&trust); + let new_policy = AnchorPolicy { + threshold: 1, + backends: vec![AnchorBackendPolicy { + backend_id: "nostr-main".to_owned(), + class: "nostr".to_owned(), + locator: "wss://relay.example".to_owned(), + weight: 1, + required: true, + }], + }; + let change = signed_record( + &trust, + Some(first.record_hash().expect("hash")), + ROOT_KEY, + AuthorityTransition::AnchorPolicySet { + policy: new_policy.clone(), + }, + vec![], + ); + let at_change = verify_sshsigchain(&[first.clone(), change.clone()], &trust, &TestVerifier) + .expect("change"); + assert_eq!(at_change.state.anchor_policy, new_policy); + assert_eq!(at_change.state.head_anchor_policy, AnchorPolicy::default()); + let next = signed_record( + &trust, + Some(change.record_hash().expect("hash")), + ROOT_KEY, + AuthorityTransition::Noop, + vec![], + ); + let after = + verify_sshsigchain(&[first, change, next], &trust, &TestVerifier).expect("after"); + assert_eq!(after.state.head_anchor_policy, new_policy); + } + + #[test] + fn anchor_head_selection_rejects_rollback_and_forks() { + let trust = trust(); + let first = genesis(&trust); + let a = signed_record( + &trust, + Some(first.record_hash().expect("hash")), + ROOT_KEY, + AuthorityTransition::Noop, + vec![], + ); + let b = signed_record( + &trust, + Some(first.record_hash().expect("hash")), + ROOT_KEY, + AuthorityTransition::AnchorPolicySet { + policy: AnchorPolicy::default(), + }, + vec![], + ); + let va = verify_sshsigchain(&[first.clone(), a], &trust, &TestVerifier).expect("a"); + let vb = verify_sshsigchain(&[first, b], &trust, &TestVerifier).expect("b"); + let ca = HeadClaim::unsigned(trust.chain_id, va.head, ROOT_KEY).expect("claim a"); + let cb = HeadClaim::unsigned(trust.chain_id, vb.head, ROOT_KEY).expect("claim b"); + assert!(matches!( + select_anchored_head( + &[ + AnchoredHistory { + verification: &va, + claim: &ca + }, + AnchoredHistory { + verification: &vb, + claim: &cb + } + ], + None + ), + Err(SshSigchainError::ForkDetected) + )); + assert!(matches!( + select_anchored_head( + &[AnchoredHistory { + verification: &va, + claim: &ca + }], + Some(Digest([3; 32])) + ), + Err(SshSigchainError::RollbackDetected) + )); + } + + #[test] + fn jsonl_roundtrip_and_unknown_field_rejection() { + let record = genesis(&trust()); + let jsonl = encode_sshsigchain_jsonl(std::slice::from_ref(&record)).expect("encode"); + assert_eq!( + decode_sshsigchain_jsonl(&jsonl).expect("decode"), + vec![record.clone()] + ); + let mut value = serde_json::to_value(record).expect("json"); value .as_object_mut() - .expect("record object") - .insert("unrecognized".to_owned(), serde_json::Value::Bool(true)); + .expect("object") + .insert("sequence".to_owned(), 0.into()); assert!(matches!( decode_sshsigchain_jsonl(&format!("{value}\n")), - Err(SshSigchainError::JsonLine { line: 1, .. }) + Err(SshSigchainError::JsonLine { .. }) )); } - - #[test] - fn signing_bytes_match_the_published_base_vector() { - let record = SshSigchainRecord::unsigned( - ChainId([0; 32]), - "example.test", - 0, - None, - vec![1, 2], - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2", - ) - .expect("unsigned record"); - assert_eq!( - hex::encode(record.signing_bytes().expect("signing bytes")), - concat!( - "5353435301", - "0000000000000000000000000000000000000000000000000000000000000000", - "0000000000000000", - "00000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332" - ) - ); - } } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 9914bc8..57008ea 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -6505,7 +6505,6 @@ pub fn handle_request( let records = read_sshsigchain_jsonl_file(&input)?; let trust = geth_keychain::SshSigchainTrust::new( geth_keychain::SshSigchainChainId::from_hex(&chain_id)?, - geth_keychain::KEYCHAIN_SSH_SIGCHAIN_PROFILE, namespace.unwrap_or_else(|| geth_keychain::SSH_SIGCHAIN_NAMESPACE.to_owned()), std::fs::read_to_string(root_key_path)?, )?; @@ -6515,12 +6514,13 @@ pub fn handle_request( chain_id: trust.chain_id.to_hex(), records: verified.records, head: verified.head.to_hex(), - active_admin_keys: verified.view.admin_keys.len(), - users: verified.view.users.len(), - devices: verified.view.devices.len(), - nodes: verified.view.nodes.len(), + active_keys: verified.state.active_key_count(), + devices: verified.state.devices.values().filter(|device| device.active).count(), + disclosed_profiles: verified.state.disclosed_profiles.len(), + incomplete_profiles: verified.state.incomplete_profiles.len(), + anchor_threshold: verified.state.anchor_policy.threshold, note: - "verified linked SSHSIGCHAIN v1 records against the explicitly pinned root key" + "verified sequence-free SSHSIGCHAIN v1 authority records and disclosed profile commitments against the explicitly pinned root key" .to_owned(), }) } @@ -9545,7 +9545,7 @@ fn verify_keychain_sigchain_entries_with_ssh( fn verify_keychain_sshsigchain_with_ssh( records: &[geth_keychain::SshSigchainRecord], trust: &geth_keychain::SshSigchainTrust, -) -> Result { +) -> Result { geth_ssh_identity::ensure_ssh_keygen_available()?; let verify_dir = tempfile::tempdir()?; @@ -9601,7 +9601,7 @@ fn verify_keychain_sshsigchain_with_ssh( let verifier = SshSigchainOpenSshVerifier { verify_dir: verify_dir.path().to_path_buf(), }; - Ok(geth_keychain::verify_keychain_sshsigchain( + Ok(geth_keychain::verify_sshsigchain( records, trust, &verifier, )?) } @@ -11045,14 +11045,13 @@ mod tests { ); let trust = geth_keychain::SshSigchainTrust::new( geth_keychain::SshSigchainChainId([0x42; 32]), - geth_keychain::KEYCHAIN_SSH_SIGCHAIN_PROFILE, geth_keychain::SSH_SIGCHAIN_NAMESPACE, &root_public_key, ) .expect("trust tuple"); let sign = |record: geth_keychain::SshSigchainRecord| { - let payload = dir.path().join(format!("record-{}", record.sequence)); + let payload = dir.path().join("record"); std::fs::write(&payload, record.signing_bytes().expect("signing bytes")) .expect("write signing payload"); let output = geth_ssh_identity::sign_command( @@ -11072,50 +11071,32 @@ mod tests { record.with_signature(signature).expect("signed record") }; - let init_op = KeychainOp { - id: AuthOpId::new("auth-op:sshsigchain-init"), - created_at: UnixMillis(1), - kind: KeychainOpKind::KeychainInit, - }; let init = sign( - geth_keychain::keychain_sshsigchain_unsigned_record( - &trust, - 0, + geth_keychain::SshSigchainRecord::unsigned( + trust.chain_id, None, - &init_op, &root_public_key, + geth_keychain::AuthorityTransition::Genesis { + device_id: "device:root".to_owned(), + root_key: geth_keychain::AuthorityKey { + public_key: root_public_key.clone(), + permissions: vec![geth_keychain::Permission::All], + delegable_permissions: vec![geth_keychain::Permission::All], + }, + anchor_policy: geth_keychain::AnchorPolicy::default(), + }, + vec![], ) .expect("unsigned init"), ); - let root_add_op = KeychainOp { - id: AuthOpId::new("auth-op:sshsigchain-root"), - created_at: UnixMillis(2), - kind: KeychainOpKind::AdminKeyAdd { - key: KeyId::new(geth_keychain::admin_key_fingerprint(&root_public_key)), - public_key: Some(root_public_key.clone()), - principal: Some("root".to_owned()), - valid_after_ms: None, - valid_before_ms: None, - }, - }; - let root_add = sign( - geth_keychain::keychain_sshsigchain_unsigned_record( - &trust, - 1, - Some(init.record_hash().expect("init hash")), - &root_add_op, - &root_public_key, - ) - .expect("unsigned root add"), - ); - let verified = verify_keychain_sshsigchain_with_ssh(&[init.clone(), root_add], &trust) + let verified = verify_keychain_sshsigchain_with_ssh(&[init.clone()], &trust) .expect("verify real OpenSSH SSHSIG chain"); - assert_eq!(verified.records, 2); - assert_eq!(verified.view.admin_keys.len(), 1); + assert_eq!(verified.records, 1); + assert_eq!(verified.state.active_key_count(), 1); let mut tampered = init; - tampered.payload.push(0); + tampered.chain_id = geth_keychain::SshSigchainChainId([0x43; 32]); assert!(verify_keychain_sshsigchain_with_ssh(&[tampered], &trust).is_err()); }