Harden reusable keychain API

This commit is contained in:
Eric Wendland 2026-05-26 18:58:25 +02:00
commit b6ffcde54c
6 changed files with 410 additions and 33 deletions

View file

@ -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<KeychainOp, KeyId>;
#[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 `<application>.<purpose>.v1@<domain>` form.
pub fn new(
keychain_signature_namespace: impl Into<String>,
node_enrollment_request_namespace: impl Into<String>,
default_admin_principal: impl Into<String>,
) -> Result<Self, KeychainError> {
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<str>,
domain: impl AsRef<str>,
) -> Result<Self, KeychainError> {
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<Vec<u8>, 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<Vec<u8>, geth_codec::CodecError> {
geth_codec::signing_payload(profile.keychain_signature_namespace(), op)
}
pub fn keychain_signing_payload_hash(
op: &KeychainOp,
) -> Result<geth_types::BlobHash, geth_codec::CodecError> {
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_types::BlobHash, geth_codec::CodecError> {
geth_codec::signing_payload_hash(profile.keychain_signature_namespace(), op)
}
#[must_use]
pub fn signed_keychain_op(op: KeychainOp, signer: KeyId, signature: Vec<u8>) -> 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<u8>,
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<KeychainOpSignature>,
}
pub type KeychainSignatureVerifier<'a> = dyn Fn(&KeychainOp, &KeychainOpSignature) -> bool + 'a;
pub trait KeychainSignatureVerifier {
fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool;
}
impl<F> 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<KeychainAllowedSigner> {
allowed_signers_with_profile(ops, signatures, &KeychainProfile::geth())
}
#[must_use]
pub fn allowed_signers_with_profile(
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
profile: &KeychainProfile,
) -> Vec<KeychainAllowedSigner> {
let mut entries = BTreeMap::<KeyId, KeychainAllowedSigner>::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::<KeychainOp>::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![