//! Reusable signed keychain and sigchain model. //! //! This crate owns geth's identity-plane data model: admin keys, users, //! devices, nodes, agents, endpoint bindings, and the signed operation log used //! to update them. It intentionally has no dependency on the daemon, SQLite, //! Iroh, local sockets, or any particular publication mechanism. //! //! The current local operation-log verifier remains local to geth's daemon //! state. Portable SSHSIGCHAIN records are implemented separately in //! [`sshsigchain`]: they use an explicit trust tuple, fixed signing bytes, and //! a causal hash chain rather than a timestamp-sorted JSONL bundle. mod sshsigchain; pub use sshsigchain::{ AnchorAttesterPolicy, 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, VerifiedAnchoredHistory, authority_key_id, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, key_proof_signing_bytes, profile_payload_commitment, select_anchored_head, verify_anchor_receipts, verify_anchored_history, verify_head_claim, verify_sshsigchain, }; use geth_types::{ AgentId, AuthOpId, Capability, DeviceId, KeyId, NodeId, ResourceId, UnixMillis, UserId, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local"; pub const NODE_ENROLLMENT_REQUEST_NAMESPACE: &str = "geth.node-enrollment-request.v1@geth.local"; pub const AUTHORIZED_KEYS_NAMESPACE: &str = "geth.authorized-keys.v1@eric.wendland.dev"; pub const DEFAULT_ADMIN_PRINCIPAL: &str = "admin"; pub type SignedKeychainOp = geth_codec::SignedEnvelope; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct KeychainProfile { keychain_signature_namespace: String, node_enrollment_request_namespace: String, default_admin_principal: String, } impl KeychainProfile { /// Create a profile from explicit signature namespaces. /// /// Use this when an application wants stable, audited namespaces rather /// than the generated `..v1@` form. pub fn new( keychain_signature_namespace: impl Into, node_enrollment_request_namespace: impl Into, default_admin_principal: impl Into, ) -> Result { let keychain_signature_namespace = keychain_signature_namespace.into(); let node_enrollment_request_namespace = node_enrollment_request_namespace.into(); let default_admin_principal = default_admin_principal.into(); validate_namespace(&keychain_signature_namespace)?; validate_namespace(&node_enrollment_request_namespace)?; validate_principal(&default_admin_principal)?; Ok(Self { keychain_signature_namespace, node_enrollment_request_namespace, default_admin_principal, }) } /// Build application-specific namespaces under a DNS-style domain. /// /// For example, `for_application("acme", "example.com")` creates: /// /// - `acme.keychain.v1@example.com` /// - `acme.node-enrollment-request.v1@example.com` pub fn for_application( application: impl AsRef, domain: impl AsRef, ) -> Result { let application = validate_namespace_component(application.as_ref(), "application")?; let domain = validate_namespace_component(domain.as_ref(), "domain")?; Self::new( format!("{application}.keychain.v1@{domain}"), format!("{application}.node-enrollment-request.v1@{domain}"), DEFAULT_ADMIN_PRINCIPAL, ) } #[must_use] pub fn geth() -> Self { Self { keychain_signature_namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), node_enrollment_request_namespace: NODE_ENROLLMENT_REQUEST_NAMESPACE.to_owned(), default_admin_principal: DEFAULT_ADMIN_PRINCIPAL.to_owned(), } } #[must_use] pub fn keychain_signature_namespace(&self) -> &str { &self.keychain_signature_namespace } #[must_use] pub fn node_enrollment_request_namespace(&self) -> &str { &self.node_enrollment_request_namespace } #[must_use] pub fn default_admin_principal(&self) -> &str { &self.default_admin_principal } } impl Default for KeychainProfile { fn default() -> Self { Self::geth() } } pub fn keychain_signing_payload(op: &KeychainOp) -> Result, geth_codec::CodecError> { keychain_signing_payload_with_profile(op, &KeychainProfile::geth()) } pub fn keychain_signing_payload_with_profile( op: &KeychainOp, profile: &KeychainProfile, ) -> Result, geth_codec::CodecError> { geth_codec::signing_payload(profile.keychain_signature_namespace(), op) } pub fn keychain_signing_payload_hash( op: &KeychainOp, ) -> Result { keychain_signing_payload_hash_with_profile(op, &KeychainProfile::geth()) } pub fn keychain_signing_payload_hash_with_profile( op: &KeychainOp, profile: &KeychainProfile, ) -> Result { geth_codec::signing_payload_hash(profile.keychain_signature_namespace(), op) } #[must_use] pub fn signed_keychain_op(op: KeychainOp, signer: KeyId, signature: Vec) -> SignedKeychainOp { signed_keychain_op_with_profile(op, signer, signature, &KeychainProfile::geth()) } #[must_use] pub fn signed_keychain_op_with_profile( op: KeychainOp, signer: KeyId, signature: Vec, profile: &KeychainProfile, ) -> SignedKeychainOp { geth_codec::SignedEnvelope::new( profile.keychain_signature_namespace(), op, signer, signature, ) } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct KeychainOp { pub id: geth_types::AuthOpId, pub created_at: UnixMillis, pub kind: KeychainOpKind, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct KeychainOpSignature { pub op_id: AuthOpId, pub signer: KeyId, pub signer_public_key: String, pub namespace: String, pub signature: Vec, pub created_at: UnixMillis, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct KeychainAllowedSigner { pub key: KeyId, pub principal: String, pub public_key: String, pub valid_after_ms: Option, pub valid_before_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct KeychainSigchainReport { pub ops: usize, pub signatures: usize, pub accepted_ops: usize, pub rejected_ops: usize, pub active_admin_keys: usize, pub accepted_head: Option, pub note: String, } pub trait KeychainSignatureVerifier { fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool; } impl KeychainSignatureVerifier for F where F: for<'op, 'signature> Fn(&'op KeychainOp, &'signature KeychainOpSignature) -> bool, { fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool { self(op, signature) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NodeEnrollmentRequest { pub id: AuthOpId, pub requester_node: NodeId, pub requester_agent: AgentId, pub requester_agent_public_key: String, pub requested_node_name: String, pub requested_capabilities: Vec, pub endpoint_id: Option, pub reason: Option, pub status: NodeEnrollmentStatus, pub created_at: UnixMillis, pub provenance: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NodeEnrollmentCapability { pub resource: ResourceId, pub capability: Capability, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NodeEnrollmentProvenance { pub namespace: String, pub signer_node: NodeId, pub signer_agent: AgentId, pub signer_public_key: String, pub signature_hex: String, pub signed_at: UnixMillis, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum NodeEnrollmentStatus { Pending, Approved, Rejected, } impl NodeEnrollmentStatus { #[must_use] pub fn as_str(&self) -> &'static str { match self { Self::Pending => "pending", Self::Approved => "approved", Self::Rejected => "rejected", } } } impl std::fmt::Display for NodeEnrollmentStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } impl std::str::FromStr for NodeEnrollmentStatus { type Err = KeychainError; fn from_str(value: &str) -> Result { match value { "pending" => Ok(Self::Pending), "approved" => Ok(Self::Approved), "rejected" => Ok(Self::Rejected), _ => Err(KeychainError::InvalidEnrollmentStatus(value.to_owned())), } } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NodeEnrollmentRequestSigningPayload { pub id: AuthOpId, pub requester_node: NodeId, pub requester_agent: AgentId, pub requester_agent_public_key: String, pub requested_node_name: String, pub requested_capabilities: Vec, pub endpoint_id: Option, pub reason: Option, pub created_at: UnixMillis, } #[derive(Debug, thiserror::Error)] pub enum KeychainError { #[error("invalid node enrollment status: {0}")] InvalidEnrollmentStatus(String), #[error("invalid keychain namespace: {0}")] InvalidNamespace(String), #[error("invalid keychain namespace {field}: {value}")] InvalidNamespaceComponent { field: String, value: String }, #[error("invalid keychain principal: {0}")] InvalidPrincipal(String), #[error("codec error: {0}")] Codec(#[from] geth_codec::CodecError), } #[must_use] pub fn node_enrollment_request_signing_payload( request: &NodeEnrollmentRequest, ) -> NodeEnrollmentRequestSigningPayload { NodeEnrollmentRequestSigningPayload { id: request.id.clone(), requester_node: request.requester_node.clone(), requester_agent: request.requester_agent.clone(), requester_agent_public_key: request.requester_agent_public_key.clone(), requested_node_name: request.requested_node_name.clone(), requested_capabilities: request.requested_capabilities.clone(), endpoint_id: request.endpoint_id.clone(), reason: request.reason.clone(), created_at: request.created_at, } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum KeychainOpKind { 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, }, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct KeychainView { pub initialized: bool, pub admin_keys: Vec, pub users: BTreeMap, pub devices: BTreeMap, pub nodes: BTreeMap, pub agents: BTreeMap, pub endpoints: BTreeMap, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct UserRecord { pub id: UserId, pub name: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DeviceRecord { pub id: DeviceId, pub user: UserId, pub keys: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NodeRecord { pub id: NodeId, pub device: DeviceId, pub name: String, pub endpoints: Vec, } pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView { let mut initialized = false; let mut admin_keys = BTreeSet::new(); let mut users = BTreeMap::::new(); let mut revoked_users = BTreeSet::new(); let mut devices = BTreeMap::::new(); let mut revoked_devices = BTreeSet::new(); let mut nodes = BTreeMap::::new(); let mut revoked_nodes = BTreeSet::new(); let mut agent_bindings = BTreeMap::::new(); for op in ops { match &op.kind { KeychainOpKind::KeychainInit => initialized = true, KeychainOpKind::AdminKeyAdd { key, .. } => { admin_keys.insert(key.clone()); } KeychainOpKind::AdminKeyRevoke { key } => { admin_keys.remove(key); } KeychainOpKind::UserAdd { user, name } => { revoked_users.remove(user); users.entry(user.clone()).or_insert_with(|| UserRecord { id: user.clone(), name: name.clone(), }); } KeychainOpKind::UserRename { user, name } => { if let Some(record) = users.get_mut(user) { record.name.clone_from(name); } } KeychainOpKind::UserRevoke { user } => { revoked_users.insert(user.clone()); } KeychainOpKind::DeviceAdd { device, user } => { revoked_devices.remove(device); devices .entry(device.clone()) .or_insert_with(|| DeviceRecord { id: device.clone(), user: user.clone(), keys: Vec::new(), }); } KeychainOpKind::DeviceRevoke { device } => { revoked_devices.insert(device.clone()); } KeychainOpKind::DeviceKeyAdd { device, key } => { if let Some(record) = devices.get_mut(device) && !record.keys.contains(key) { record.keys.push(key.clone()); record.keys.sort(); } } KeychainOpKind::DeviceKeyRevoke { device, key } => { if let Some(record) = devices.get_mut(device) { record.keys.retain(|item| item != key); } } KeychainOpKind::NodeAdd { node, device, name } => { revoked_nodes.remove(node); nodes.entry(node.clone()).or_insert_with(|| NodeRecord { id: node.clone(), device: device.clone(), name: name.clone(), endpoints: Vec::new(), }); } KeychainOpKind::NodeRename { node, name } => { if let Some(record) = nodes.get_mut(node) { record.name.clone_from(name); } } KeychainOpKind::NodeRevoke { node } => { revoked_nodes.insert(node.clone()); } KeychainOpKind::NodeEndpointAdd { node, endpoint } => { if let Some(record) = nodes.get_mut(node) && !record.endpoints.contains(endpoint) { record.endpoints.push(endpoint.clone()); record.endpoints.sort(); } } KeychainOpKind::NodeEndpointRevoke { node, endpoint } => { if let Some(record) = nodes.get_mut(node) { record.endpoints.retain(|item| item != endpoint); } } KeychainOpKind::AgentBind { agent, node } => { agent_bindings.insert(agent.clone(), node.clone()); } } } users.retain(|user, _| !revoked_users.contains(user)); devices.retain(|device, record| { !revoked_devices.contains(device) && users.contains_key(&record.user) }); nodes.retain(|node, record| { !revoked_nodes.contains(node) && devices.contains_key(&record.device) }); agent_bindings.retain(|_, node| nodes.contains_key(node)); let mut endpoints = BTreeMap::new(); for (node, record) in &nodes { for endpoint in &record.endpoints { endpoints.insert(endpoint.clone(), node.clone()); } } KeychainView { initialized, admin_keys: admin_keys.into_iter().collect(), users, devices, nodes, agents: agent_bindings, endpoints, } } pub fn sorted_keychain_ops(mut ops: Vec) -> Vec { ops.sort_by(|left, right| { ( left.created_at.0, keychain_op_order(&left.kind), left.id.to_string(), ) .cmp(&( right.created_at.0, keychain_op_order(&right.kind), right.id.to_string(), )) }); ops } #[must_use] pub fn keychain_op_order(kind: &KeychainOpKind) -> u8 { match kind { KeychainOpKind::KeychainInit => 0, KeychainOpKind::AdminKeyAdd { .. } => 1, KeychainOpKind::AdminKeyRevoke { .. } => 2, _ => 10, } } #[must_use] pub fn allowed_signers( ops: &[KeychainOp], signatures: &[KeychainOpSignature], ) -> Vec { allowed_signers_with_profile(ops, signatures, &KeychainProfile::geth()) } #[must_use] pub fn allowed_signers_with_profile( ops: &[KeychainOp], signatures: &[KeychainOpSignature], profile: &KeychainProfile, ) -> Vec { let mut entries = BTreeMap::::new(); for op in sorted_keychain_ops(ops.to_vec()) { match op.kind { KeychainOpKind::AdminKeyAdd { key, public_key, principal, valid_after_ms, valid_before_ms, } => { let public_key = public_key .or_else(|| { signatures .iter() .find(|signature| signature.signer == key) .map(|signature| signature.signer_public_key.trim().to_owned()) }) .unwrap_or_default(); if !public_key.is_empty() { entries.insert( key.clone(), KeychainAllowedSigner { key, principal: principal .unwrap_or_else(|| profile.default_admin_principal().to_owned()), public_key, valid_after_ms, valid_before_ms, }, ); } } KeychainOpKind::AdminKeyRevoke { key } => { entries.remove(&key); } _ => {} } } entries.into_values().collect() } #[must_use] pub fn render_allowed_signers(entries: &[KeychainAllowedSigner]) -> String { let mut text = String::new(); for entry in entries { text.push_str(&format!( "{} {}\n", entry.principal, entry.public_key.trim() )); } text } pub fn verify_sigchain( ops: &[KeychainOp], signatures: &[KeychainOpSignature], verifier: &(impl KeychainSignatureVerifier + ?Sized), ) -> KeychainSigchainReport { verify_sigchain_with_profile(ops, signatures, &KeychainProfile::geth(), verifier) } pub fn verify_sigchain_with_profile( ops: &[KeychainOp], signatures: &[KeychainOpSignature], profile: &KeychainProfile, verifier: &(impl KeychainSignatureVerifier + ?Sized), ) -> KeychainSigchainReport { let ops = sorted_keychain_ops(ops.to_vec()); let mut accepted = Vec::::new(); let mut trusted_admins = BTreeSet::::new(); let mut rejected_ops = 0; for op in &ops { let op_signatures = signatures .iter() .filter(|signature| signature.op_id == op.id) .collect::>(); let bootstrap_init = accepted.is_empty() && matches!(op.kind, KeychainOpKind::KeychainInit); let bootstrap_admin = accepted.len() == 1 && matches!(accepted[0].kind, KeychainOpKind::KeychainInit) && matches!(&op.kind, KeychainOpKind::AdminKeyAdd { .. }); let valid = bootstrap_init || bootstrap_admin || op_signatures.iter().any(|signature| { let signer_is_authorized = trusted_admins.contains(&signature.signer); signer_is_authorized && signature.namespace == profile.keychain_signature_namespace() && signature_uses_claimed_key(signature) && verifier.verify_keychain_signature(op, signature) }); if valid { accepted.push(op.clone()); trusted_admins = reduce_keychain_ops(&accepted) .admin_keys .into_iter() .collect(); } else { rejected_ops += 1; } } let view = reduce_keychain_ops(&accepted); KeychainSigchainReport { ops: ops.len(), signatures: signatures.len(), accepted_ops: accepted.len(), rejected_ops, active_admin_keys: view.admin_keys.len(), accepted_head: accepted.last().map(|op| op.id.clone()), note: "verified by replaying keychain operations against the previously accepted admin-key view, similar to git-skm's parent allowed_signers verification".to_owned(), } } #[must_use] pub fn signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool { KeyId::new(admin_key_fingerprint(&signature.signer_public_key)) == signature.signer } #[must_use] pub fn admin_key_fingerprint(public_key: &str) -> String { format!("ssh:blake3:{}", blake3::hash(public_key.trim().as_bytes())) } fn validate_namespace(value: &str) -> Result<(), KeychainError> { let has_single_domain_separator = value.matches('@').count() == 1; let valid = has_single_domain_separator && !value.trim().is_empty() && value == value.trim() && value.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'@' | b':' | b'/' | b'+') }); if valid { Ok(()) } else { Err(KeychainError::InvalidNamespace(value.to_owned())) } } fn validate_namespace_component<'a>(value: &'a str, field: &str) -> Result<&'a str, KeychainError> { let valid = !value.trim().is_empty() && value == value.trim() && !value.contains('@') && value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')); if valid { Ok(value) } else { Err(KeychainError::InvalidNamespaceComponent { field: field.to_owned(), value: value.to_owned(), }) } } fn validate_principal(value: &str) -> Result<(), KeychainError> { let valid = !value.trim().is_empty() && value == value.trim() && !value.bytes().any(|byte| byte.is_ascii_whitespace()); if valid { Ok(()) } else { Err(KeychainError::InvalidPrincipal(value.to_owned())) } } #[cfg(test)] mod tests { use super::*; #[test] fn keychain_structs_roundtrip() { let op = KeychainOp { id: "op:1".into(), created_at: UnixMillis(1), kind: KeychainOpKind::UserAdd { user: "user:eric".into(), name: "Eric".to_owned(), }, }; let json = serde_json::to_string(&op).expect("json"); let decoded: KeychainOp = serde_json::from_str(&json).expect("decode"); assert_eq!(decoded, op); } #[test] fn keychain_signing_payload_is_canonical_and_namespaced() { let op = KeychainOp { id: "op:1".into(), created_at: UnixMillis(1), kind: KeychainOpKind::AdminKeyAdd { key: "key:admin".into(), public_key: Some("ssh-ed25519 AAAA test@example".to_owned()), principal: Some("admin".to_owned()), valid_after_ms: None, valid_before_ms: None, }, }; assert_eq!( keychain_signing_payload(&op).expect("payload"), keychain_signing_payload(&op).expect("payload again") ); assert_ne!( keychain_signing_payload_hash(&op).expect("hash"), geth_codec::hash_canonical(&op).expect("raw op hash") ); let signed = signed_keychain_op(op.clone(), "key:admin".into(), vec![1, 2, 3]); assert_eq!(signed.namespace(), KEYCHAIN_SIGNATURE_NAMESPACE); assert_eq!(signed.payload(), &op); } #[test] fn profile_namespaces_support_other_applications() { let profile = KeychainProfile::for_application("acme-notes", "example.com") .expect("application profile"); assert_eq!( profile.keychain_signature_namespace(), "acme-notes.keychain.v1@example.com" ); assert_eq!( profile.node_enrollment_request_namespace(), "acme-notes.node-enrollment-request.v1@example.com" ); let op = KeychainOp { id: "op:1".into(), created_at: UnixMillis(1), kind: KeychainOpKind::KeychainInit, }; assert_ne!( keychain_signing_payload(&op).expect("geth payload"), keychain_signing_payload_with_profile(&op, &profile).expect("app payload") ); let signed = signed_keychain_op_with_profile(op.clone(), "key:admin".into(), vec![1], &profile); assert_eq!(signed.namespace(), "acme-notes.keychain.v1@example.com"); assert!(KeychainProfile::for_application("bad app", "example.com").is_err()); assert!(KeychainProfile::new("missing-domain", "also-missing-domain", "admin").is_err()); } fn op(sequence: i64, kind: KeychainOpKind) -> KeychainOp { KeychainOp { id: format!("op:{sequence}").into(), created_at: UnixMillis(sequence), kind, } } #[test] fn reducer_tracks_active_keychain_view() { let ops = vec![ op(1, KeychainOpKind::KeychainInit), op( 2, KeychainOpKind::AdminKeyAdd { key: "key:admin-a".into(), public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()), principal: Some("admin-a".to_owned()), valid_after_ms: None, valid_before_ms: None, }, ), op( 3, KeychainOpKind::AdminKeyAdd { key: "key:admin-b".into(), public_key: Some("ssh-ed25519 AAAA admin-b".to_owned()), principal: Some("admin-b".to_owned()), valid_after_ms: None, valid_before_ms: None, }, ), op( 4, KeychainOpKind::AdminKeyRevoke { key: "key:admin-b".into(), }, ), op( 5, KeychainOpKind::UserAdd { user: "user:eric".into(), name: "Eric".to_owned(), }, ), op( 6, KeychainOpKind::UserRename { user: "user:eric".into(), name: "Eric Updated".to_owned(), }, ), op( 7, KeychainOpKind::DeviceAdd { device: "device:laptop".into(), user: "user:eric".into(), }, ), op( 8, KeychainOpKind::DeviceKeyAdd { device: "device:laptop".into(), key: "key:device-a".into(), }, ), op( 9, KeychainOpKind::DeviceKeyAdd { device: "device:laptop".into(), key: "key:device-b".into(), }, ), op( 10, KeychainOpKind::DeviceKeyRevoke { device: "device:laptop".into(), key: "key:device-b".into(), }, ), op( 11, KeychainOpKind::NodeAdd { node: "node:laptop".into(), device: "device:laptop".into(), name: "laptop".to_owned(), }, ), op( 12, KeychainOpKind::NodeRename { node: "node:laptop".into(), name: "work-laptop".to_owned(), }, ), op( 13, KeychainOpKind::NodeEndpointAdd { node: "node:laptop".into(), endpoint: "endpoint:old".to_owned(), }, ), op( 14, KeychainOpKind::NodeEndpointAdd { node: "node:laptop".into(), endpoint: "endpoint:new".to_owned(), }, ), op( 15, KeychainOpKind::NodeEndpointRevoke { node: "node:laptop".into(), endpoint: "endpoint:old".to_owned(), }, ), op( 16, KeychainOpKind::AgentBind { agent: "agent:daemon".into(), node: "node:laptop".into(), }, ), ]; let view = reduce_keychain_ops(&ops); assert!(view.initialized); assert_eq!(view.admin_keys, vec![KeyId::from("key:admin-a")]); assert_eq!( view.users.get(&UserId::from("user:eric")), Some(&UserRecord { id: "user:eric".into(), name: "Eric Updated".to_owned(), }) ); assert_eq!( view.devices.get(&DeviceId::from("device:laptop")), Some(&DeviceRecord { id: "device:laptop".into(), user: "user:eric".into(), keys: vec!["key:device-a".into()], }) ); assert_eq!( view.nodes.get(&NodeId::from("node:laptop")), Some(&NodeRecord { id: "node:laptop".into(), device: "device:laptop".into(), name: "work-laptop".to_owned(), endpoints: vec!["endpoint:new".to_owned()], }) ); assert_eq!( view.agents.get(&AgentId::from("agent:daemon")), Some(&NodeId::from("node:laptop")) ); assert_eq!( view.endpoints.get("endpoint:new"), Some(&NodeId::from("node:laptop")) ); assert!(!view.endpoints.contains_key("endpoint:old")); } #[test] fn sigchain_verification_replays_against_prior_admin_view() { let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(); let admin_b: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-b").into(); let ops = vec![ op(1, KeychainOpKind::KeychainInit), op( 2, KeychainOpKind::AdminKeyAdd { key: admin_a.clone(), public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()), principal: Some("admin-a".to_owned()), valid_after_ms: None, valid_before_ms: None, }, ), op( 3, KeychainOpKind::AdminKeyAdd { key: admin_b.clone(), public_key: Some("ssh-ed25519 AAAA admin-b".to_owned()), principal: Some("admin-b".to_owned()), valid_after_ms: None, valid_before_ms: None, }, ), op( 4, KeychainOpKind::AdminKeyRevoke { key: admin_a.clone(), }, ), ]; let signatures = vec![ KeychainOpSignature { op_id: ops[1].id.clone(), signer: admin_a.clone(), signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(), namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), signature: vec![1], created_at: UnixMillis(2), }, KeychainOpSignature { op_id: ops[2].id.clone(), signer: admin_a.clone(), signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(), namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), signature: vec![1], created_at: UnixMillis(3), }, KeychainOpSignature { op_id: ops[3].id.clone(), signer: admin_b, signer_public_key: "ssh-ed25519 AAAA admin-b".to_owned(), namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), signature: vec![1], created_at: UnixMillis(4), }, ]; let report = verify_sigchain( &ops, &signatures, &|_: &KeychainOp, signature: &KeychainOpSignature| signature.signature == vec![1], ); assert_eq!(report.accepted_ops, 4); assert_eq!(report.rejected_ops, 0); assert_eq!(report.active_admin_keys, 1); } #[test] fn sigchain_verification_uses_profile_namespace_and_verifier_trait() { struct AcceptNonEmptySignatures; impl KeychainSignatureVerifier for AcceptNonEmptySignatures { fn verify_keychain_signature( &self, _: &KeychainOp, signature: &KeychainOpSignature, ) -> bool { !signature.signature.is_empty() } } let profile = KeychainProfile::for_application("acme", "example.com").expect("profile"); let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(); let ops = vec![ op(1, KeychainOpKind::KeychainInit), op( 2, KeychainOpKind::AdminKeyAdd { key: admin_a.clone(), public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()), principal: None, valid_after_ms: None, valid_before_ms: None, }, ), op( 3, KeychainOpKind::UserAdd { user: "user:external".into(), name: "External App User".to_owned(), }, ), ]; let mut signature = KeychainOpSignature { op_id: ops[2].id.clone(), signer: admin_a, signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(), namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), signature: vec![1], created_at: UnixMillis(3), }; let rejected = verify_sigchain_with_profile( &ops, &[signature.clone()], &profile, &AcceptNonEmptySignatures, ); assert_eq!(rejected.accepted_ops, 2); assert_eq!(rejected.rejected_ops, 1); signature.namespace = profile.keychain_signature_namespace().to_owned(); let accepted = verify_sigchain_with_profile(&ops, &[signature], &profile, &AcceptNonEmptySignatures); assert_eq!(accepted.accepted_ops, 3); assert_eq!(accepted.rejected_ops, 0); } #[test] fn allowed_signers_uses_profile_default_principal() { let profile = KeychainProfile::new( "acme.keychain.v1@example.com", "acme.node-enrollment-request.v1@example.com", "owner", ) .expect("profile"); let ops = vec![ op(1, KeychainOpKind::KeychainInit), op( 2, KeychainOpKind::AdminKeyAdd { key: admin_key_fingerprint("ssh-ed25519 AAAA owner").into(), public_key: Some("ssh-ed25519 AAAA owner".to_owned()), principal: None, valid_after_ms: None, valid_before_ms: None, }, ), ]; let allowed = allowed_signers_with_profile(&ops, &[], &profile); assert_eq!(allowed[0].principal, "owner"); } #[test] fn reducer_excludes_revoked_identity_subtrees() { let ops = vec![ op( 1, KeychainOpKind::UserAdd { user: "user:eric".into(), name: "Eric".to_owned(), }, ), op( 2, KeychainOpKind::DeviceAdd { device: "device:laptop".into(), user: "user:eric".into(), }, ), op( 3, KeychainOpKind::NodeAdd { node: "node:laptop".into(), device: "device:laptop".into(), name: "laptop".to_owned(), }, ), op( 4, KeychainOpKind::NodeEndpointAdd { node: "node:laptop".into(), endpoint: "endpoint:live".to_owned(), }, ), op( 5, KeychainOpKind::AgentBind { agent: "agent:daemon".into(), node: "node:laptop".into(), }, ), op( 6, KeychainOpKind::UserRevoke { user: "user:eric".into(), }, ), ]; let view = reduce_keychain_ops(&ops); assert!(view.users.is_empty()); assert!(view.devices.is_empty()); assert!(view.nodes.is_empty()); assert!(view.agents.is_empty()); assert!(view.endpoints.is_empty()); } }