From b6ffcde54c54472a5bf189fbaebaae9702da990d Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Tue, 26 May 2026 18:58:25 +0200 Subject: [PATCH] Harden reusable keychain API --- README.md | 3 +- crates/geth-keychain/src/lib.rs | 347 +++++++++++++++++++++++++++++++- crates/geth-node/src/lib.rs | 27 ++- docs/architecture.md | 17 +- docs/roadmap.md | 5 + docs/sigchain-keychain.md | 42 +++- 6 files changed, 409 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index a4d98dc..d97a601 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,8 @@ The keychain follows a sigchain model documented in signed by an admin key from the previously accepted reduced view. This is the geth analogue of verifying `git-skm` allowed-signers changes from a prior trusted state. The reusable mechanics live in the `geth-keychain` crate, -including allowed-signers projection, replay verification, and an appendable +including application-specific signature profiles, allowed-signers projection, +replay verification through a caller-provided verifier trait, and an appendable JSONL sigchain file format suitable for static hosting with HTTP caching/range requests. diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index a511cb3..2fd91c4 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -1,3 +1,20 @@ +//! 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. +//! +//! Applications can publish `KeychainSigchainEntry` values in an append-only +//! JSONL file, object store, database row stream, document CRDT, or another +//! transport. Consumers decode entries, flatten them into operations and +//! signatures, then call `verify_sigchain_with_profile` with an application +//! profile and a `KeychainSignatureVerifier` implementation. +//! +//! The default `KeychainProfile` is geth-specific. Other applications should +//! create their own profile with `KeychainProfile::for_application` or +//! `KeychainProfile::new` so signed payload namespaces do not overlap. + use geth_types::{ AgentId, AuthOpId, Capability, DeviceId, KeyId, NodeId, ResourceId, UnixMillis, UserId, }; @@ -6,22 +23,132 @@ 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 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> { - geth_codec::signing_payload(KEYCHAIN_SIGNATURE_NAMESPACE, op) + 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 { - geth_codec::signing_payload_hash(KEYCHAIN_SIGNATURE_NAMESPACE, op) + 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 { - geth_codec::SignedEnvelope::new(KEYCHAIN_SIGNATURE_NAMESPACE, op, signer, signature) + 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)] @@ -67,7 +194,18 @@ pub struct KeychainSigchainEntry { pub signatures: Vec, } -pub type KeychainSignatureVerifier<'a> = dyn Fn(&KeychainOp, &KeychainOpSignature) -> bool + 'a; +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 { @@ -155,6 +293,12 @@ pub struct NodeEnrollmentRequestSigningPayload { 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("sigchain JSONL line {line}: {source}")] SigchainJsonl { line: usize, @@ -431,6 +575,15 @@ pub fn keychain_op_order(kind: &KeychainOpKind) -> u8 { 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()) { @@ -455,7 +608,8 @@ pub fn allowed_signers( key.clone(), KeychainAllowedSigner { key, - principal: principal.unwrap_or_else(|| "admin".to_owned()), + principal: principal + .unwrap_or_else(|| profile.default_admin_principal().to_owned()), public_key, valid_after_ms, valid_before_ms, @@ -488,7 +642,16 @@ pub fn render_allowed_signers(entries: &[KeychainAllowedSigner]) -> String { pub fn verify_sigchain( ops: &[KeychainOp], signatures: &[KeychainOpSignature], - verifier: &KeychainSignatureVerifier<'_>, + 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(); @@ -508,8 +671,9 @@ pub fn verify_sigchain( || 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(op, signature) + && verifier.verify_keychain_signature(op, signature) }); if valid { accepted.push(op.clone()); @@ -599,6 +763,50 @@ pub fn flatten_sigchain_entries( (ops, signatures) } +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::*; @@ -645,6 +853,37 @@ mod tests { 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(), @@ -905,14 +1144,102 @@ mod tests { created_at: UnixMillis(4), }, ]; - let report = verify_sigchain(&ops, &signatures, &|_, signature| { - signature.signature == vec![1] - }); + 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![ diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 035b293..fbf2492 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -10505,20 +10505,29 @@ fn verify_keychain_sigchain_with_ssh( store: &Store, node: &LocalNode, ) -> Result { - let ops = load_keychain_ops(store)?; - let signatures = load_keychain_signatures(store)?; - Ok(geth_keychain::verify_sigchain( - &ops, - &signatures, - &|op, signature| { + struct SshKeychainVerifier<'a> { + node: &'a LocalNode, + } + + impl geth_keychain::KeychainSignatureVerifier for SshKeychainVerifier<'_> { + fn verify_keychain_signature( + &self, + op: &KeychainOp, + signature: &KeychainOpSignature, + ) -> bool { verify_keychain_signature_with_ssh( - node, + self.node, op, &stored_keychain_signature_from_signature(signature), ) .unwrap_or(false) - }, - )) + } + } + + let ops = load_keychain_ops(store)?; + let signatures = load_keychain_signatures(store)?; + let verifier = SshKeychainVerifier { node }; + Ok(geth_keychain::verify_sigchain(&ops, &signatures, &verifier)) } fn store_and_sign_keychain_ops( diff --git a/docs/architecture.md b/docs/architecture.md index b869bae..3948dee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -352,13 +352,16 @@ are updated with signed `geth keychain admin-add` and `geth keychain admin-revoke` operations; `AdminKeyAdd` carries the public key material needed to reconstruct an OpenSSH `allowed_signers` view. `geth keychain verify` replays the log against the previously accepted admin-key view, mirroring the `git-skm` -pattern of verifying key-registry changes from a prior trusted state. `geth -keychain sync ` pulls keychain operations and signatures from an imported -peer over Iroh and imports only operations with a valid OpenSSH signature from a -currently trusted admin key over the canonical payload. See -`docs/sigchain-keychain.md` for the detailed sigchain design. This is currently -a pull-based signed operation log, not a CRDT or Keyhive-style convergent -authority. +pattern of verifying key-registry changes from a prior trusted state. The +transport-neutral replay rules, application-specific signature namespaces, +allowed-signers projection, and JSONL sigchain helpers live in `geth-keychain` +so other applications can reuse the same identity-log model without depending +on the daemon, SQLite, Iroh, or local control. `geth keychain sync ` pulls +keychain operations and signatures from an imported peer over Iroh and imports +only operations with a valid OpenSSH signature from a currently trusted admin +key over the canonical payload. See `docs/sigchain-keychain.md` for the +detailed sigchain design. This is currently a pull-based signed operation log, +not a CRDT or Keyhive-style convergent authority. New devices can use the node enrollment flow instead of hand-editing keychain state. `geth node enroll request` creates a canonical, agent-key-signed request diff --git a/docs/roadmap.md b/docs/roadmap.md index 63217d1..fe4626a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -332,6 +332,11 @@ resource-scoped capability decisions. - `[x]` `geth-keychain` exposes transport-neutral allowed-signers projection, replay verification with an injected verifier, and appendable JSONL sigchain encode/decode helpers for static hosting or alternate transports. + - `[x]` `geth-keychain` exposes `KeychainProfile` so non-geth applications + can use distinct signature namespaces and default principals. + - `[x]` `geth-keychain` exposes a `KeychainSignatureVerifier` trait so + callers can plug in OpenSSH, HSM, WebCrypto, service-side, or test + verification backends without daemon coupling. - `[x]` `docs/sigchain-keychain.md` documents the sigchain data model, verification algorithm, commands, and current security limits. - `[x]` Missing `ssh-keygen` or unavailable hardware keys produce clear diff --git a/docs/sigchain-keychain.md b/docs/sigchain-keychain.md index 933f2f8..bec757c 100644 --- a/docs/sigchain-keychain.md +++ b/docs/sigchain-keychain.md @@ -26,6 +26,19 @@ The durable log is `KeychainOp[]` plus `KeychainOpSignature[]`. Each operation has a deterministic canonical signing payload under the `geth.keychain.v1@geth.local` namespace. +The namespace is part of a `KeychainProfile`. geth uses: + +- `geth.keychain.v1@geth.local` +- `geth.node-enrollment-request.v1@geth.local` +- default admin principal `admin` + +Other applications should create their own profile with explicit namespaces or +`KeychainProfile::for_application("", "")`. For example, +`for_application("acme-notes", "example.com")` produces +`acme-notes.keychain.v1@example.com`. This prevents signatures from one +application's keychain from being replayed into another application's +keychain. + Important operation kinds: - `KeychainInit` @@ -105,12 +118,31 @@ provide: - ordered or unordered `KeychainOp[]` - `KeychainOpSignature[]` -- a signature verification callback +- a `KeychainProfile` +- a `KeychainSignatureVerifier` -The callback is responsible for the cryptographic backend, such as -`ssh-keygen -Y verify`, WebCrypto, an HSM, or a test verifier. The crate owns -the replay order, bootstrap rule, previous-view authorization rule, -`allowed_signers` projection, and reduced keychain view. +The verifier is responsible for the cryptographic backend, such as +`ssh-keygen -Y verify`, WebCrypto, an HSM, a service-side verifier, or a test +verifier. The crate owns the replay order, bootstrap rule, profile namespace +check, previous-view authorization rule, `allowed_signers` projection, and +reduced keychain view. + +Minimal reusable Rust shape: + +```rust +let profile = geth_keychain::KeychainProfile::for_application( + "acme-notes", + "example.com", +)?; +let entries = geth_keychain::decode_sigchain_jsonl(sigchain_text)?; +let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries); +let report = geth_keychain::verify_sigchain_with_profile( + &ops, + &signatures, + &profile, + &my_verifier, +); +``` ## Static Sigchain File