geth/crates/geth-keychain/src/lib.rs

1415 lines
46 KiB
Rust
Raw Normal View History

2026-05-26 18:58:25 +02:00
//! 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.
2026-07-18 20:22:58 +02:00
mod sshsigchain;
pub use sshsigchain::{
ChainId as SshSigchainChainId, Digest as SshSigchainDigest, KEYCHAIN_V2_PAYLOAD_VERSION,
KEYCHAIN_V2_PROFILE, KeychainV2Policy, KeychainV2State, KeychainV2Verification,
SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy,
SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier,
decode_keychain_v2_payload, keychain_v2_payload, keychain_v2_unsigned_record,
verify_keychain_v2_sigchain, verify_sshsigchain,
};
2026-05-21 18:01:38 +02:00
use geth_types::{
AgentId, AuthOpId, Capability, DeviceId, KeyId, NodeId, ResourceId, UnixMillis, UserId,
};
2026-05-15 15:08:20 +02:00
use serde::{Deserialize, Serialize};
2026-05-16 14:39:18 +02:00
use std::collections::{BTreeMap, BTreeSet};
2026-05-15 15:08:20 +02:00
pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local";
2026-05-21 18:01:38 +02:00
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 KEYCHAIN_CHECKPOINT_NAMESPACE: &str = "geth.sigchain-checkpoint.v1@eric.wendland.dev";
pub const DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL: &str = "https://example.com/.well-known/sshsigchain/";
2026-05-26 18:58:25 +02:00
pub const DEFAULT_ADMIN_PRINCIPAL: &str = "admin";
pub const KEYCHAIN_CHECKPOINT_VERSION: u16 = 1;
2026-05-15 15:08:20 +02:00
pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>;
2026-05-26 18:58:25 +02:00
#[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> {
2026-05-26 18:58:25 +02:00
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> {
2026-05-26 18:58:25 +02:00
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 {
2026-05-26 18:58:25 +02:00
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,
)
2026-05-15 15:08:20 +02:00
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainOp {
pub id: geth_types::AuthOpId,
pub created_at: UnixMillis,
pub kind: KeychainOpKind,
}
2026-05-19 16:04:20 +02:00
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainOpSignature {
pub op_id: AuthOpId,
pub signer: KeyId,
2026-05-19 18:58:07 +02:00
pub signer_public_key: String,
2026-05-19 16:04:20 +02:00
pub namespace: String,
pub signature: Vec<u8>,
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<i64>,
pub valid_before_ms: Option<i64>,
}
#[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<AuthOpId>,
pub note: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainSigchainEntry {
pub op: KeychainOp,
pub signatures: Vec<KeychainOpSignature>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainCheckpoint {
pub version: u16,
pub profile: KeychainProfile,
pub base_url: String,
pub head: Option<AuthOpId>,
pub ops: usize,
pub signatures: usize,
pub sigchain_bytes: u64,
pub sigchain_hash: String,
pub allowed_signers_hash: String,
pub reduced_view_hash: String,
pub generated_at: UnixMillis,
}
2026-05-26 18:58:25 +02:00
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)
}
}
2026-05-21 18:01:38 +02:00
#[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<NodeEnrollmentCapability>,
pub endpoint_id: Option<String>,
pub reason: Option<String>,
pub status: NodeEnrollmentStatus,
pub created_at: UnixMillis,
pub provenance: Option<NodeEnrollmentProvenance>,
}
#[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<Self, Self::Err> {
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<NodeEnrollmentCapability>,
pub endpoint_id: Option<String>,
pub reason: Option<String>,
pub created_at: UnixMillis,
}
#[derive(Debug, thiserror::Error)]
pub enum KeychainError {
#[error("invalid node enrollment status: {0}")]
InvalidEnrollmentStatus(String),
2026-05-26 18:58:25 +02:00
#[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),
#[error("sigchain JSONL line {line}: {source}")]
SigchainJsonl {
line: usize,
source: serde_json::Error,
},
2026-05-21 18:01:38 +02:00
}
#[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,
}
}
2026-05-15 15:08:20 +02:00
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum KeychainOpKind {
KeychainInit,
AdminKeyAdd {
key: KeyId,
public_key: Option<String>,
principal: Option<String>,
valid_after_ms: Option<i64>,
valid_before_ms: Option<i64>,
2026-05-15 15:08:20 +02:00
},
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<KeyId>,
2026-05-16 14:39:18 +02:00
pub users: BTreeMap<UserId, UserRecord>,
pub devices: BTreeMap<DeviceId, DeviceRecord>,
pub nodes: BTreeMap<NodeId, NodeRecord>,
pub agents: BTreeMap<AgentId, NodeId>,
pub endpoints: BTreeMap<String, NodeId>,
}
#[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<KeyId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeRecord {
pub id: NodeId,
pub device: DeviceId,
pub name: String,
pub endpoints: Vec<String>,
2026-05-15 15:08:20 +02:00
}
pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView {
2026-05-16 14:39:18 +02:00
let mut initialized = false;
let mut admin_keys = BTreeSet::new();
let mut users = BTreeMap::<UserId, UserRecord>::new();
let mut revoked_users = BTreeSet::new();
let mut devices = BTreeMap::<DeviceId, DeviceRecord>::new();
let mut revoked_devices = BTreeSet::new();
let mut nodes = BTreeMap::<NodeId, NodeRecord>::new();
let mut revoked_nodes = BTreeSet::new();
let mut agent_bindings = BTreeMap::<AgentId, NodeId>::new();
2026-05-15 15:08:20 +02:00
for op in ops {
match &op.kind {
2026-05-16 14:39:18 +02:00
KeychainOpKind::KeychainInit => initialized = true,
KeychainOpKind::AdminKeyAdd { key, .. } => {
2026-05-16 14:39:18 +02:00
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);
}
2026-05-15 15:08:20 +02:00
}
2026-05-16 14:39:18 +02:00
KeychainOpKind::UserRevoke { user } => {
revoked_users.insert(user.clone());
2026-05-15 15:08:20 +02:00
}
2026-05-16 14:39:18 +02:00
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(),
});
2026-05-15 15:08:20 +02:00
}
2026-05-16 14:39:18 +02:00
KeychainOpKind::DeviceRevoke { device } => {
revoked_devices.insert(device.clone());
2026-05-15 15:08:20 +02:00
}
2026-05-16 14:39:18 +02:00
KeychainOpKind::DeviceKeyAdd { device, key } => {
2026-07-05 17:24:03 +02:00
if let Some(record) = devices.get_mut(device)
&& !record.keys.contains(key)
{
record.keys.push(key.clone());
record.keys.sort();
2026-05-16 14:39:18 +02:00
}
}
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 } => {
2026-07-05 17:24:03 +02:00
if let Some(record) = nodes.get_mut(node)
&& !record.endpoints.contains(endpoint)
{
record.endpoints.push(endpoint.clone());
record.endpoints.sort();
2026-05-16 14:39:18 +02:00
}
}
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());
2026-05-15 15:08:20 +02:00
}
}
2026-05-16 14:39:18 +02:00
KeychainView {
initialized,
admin_keys: admin_keys.into_iter().collect(),
users,
devices,
nodes,
agents: agent_bindings,
endpoints,
}
2026-05-15 15:08:20 +02:00
}
pub fn sorted_keychain_ops(mut ops: Vec<KeychainOp>) -> Vec<KeychainOp> {
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],
2026-05-26 18:58:25 +02:00
) -> 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()) {
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,
2026-05-26 18:58:25 +02:00
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],
2026-05-26 18:58:25 +02:00
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();
let mut trusted_admins = BTreeSet::<KeyId>::new();
let mut rejected_ops = 0;
for op in &ops {
let op_signatures = signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.collect::<Vec<_>>();
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
2026-05-26 18:58:25 +02:00
&& signature.namespace == profile.keychain_signature_namespace()
&& signature_uses_claimed_key(signature)
2026-05-26 18:58:25 +02:00
&& 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()))
}
#[must_use]
pub fn sigchain_entries(
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
) -> Vec<KeychainSigchainEntry> {
sorted_keychain_ops(ops.to_vec())
.into_iter()
.map(|op| KeychainSigchainEntry {
signatures: signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.cloned()
.collect(),
op,
})
.collect()
}
pub fn encode_sigchain_jsonl(
entries: &[KeychainSigchainEntry],
) -> Result<String, serde_json::Error> {
let mut text = String::new();
for entry in entries {
text.push_str(&serde_json::to_string(entry)?);
text.push('\n');
}
Ok(text)
}
pub fn decode_sigchain_jsonl(text: &str) -> Result<Vec<KeychainSigchainEntry>, KeychainError> {
text.lines()
.enumerate()
.filter(|(_, line)| !line.trim().is_empty())
.map(|(index, line)| {
serde_json::from_str::<KeychainSigchainEntry>(line).map_err(|source| {
KeychainError::SigchainJsonl {
line: index + 1,
source,
}
})
})
.collect()
}
#[must_use]
pub fn flatten_sigchain_entries(
entries: &[KeychainSigchainEntry],
) -> (Vec<KeychainOp>, Vec<KeychainOpSignature>) {
let ops = entries.iter().map(|entry| entry.op.clone()).collect();
let signatures = entries
.iter()
.flat_map(|entry| entry.signatures.clone())
.collect();
(ops, signatures)
}
pub fn keychain_checkpoint(
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
sigchain_jsonl: &str,
allowed_signers: &str,
base_url: impl Into<String>,
generated_at: UnixMillis,
) -> Result<KeychainCheckpoint, KeychainError> {
let sorted_ops = sorted_keychain_ops(ops.to_vec());
let view = reduce_keychain_ops(&sorted_ops);
Ok(KeychainCheckpoint {
version: KEYCHAIN_CHECKPOINT_VERSION,
profile: KeychainProfile::geth(),
base_url: normalize_base_url(base_url.into()),
head: sorted_ops.last().map(|op| op.id.clone()),
ops: sorted_ops.len(),
signatures: signatures.len(),
sigchain_bytes: sigchain_jsonl.len() as u64,
sigchain_hash: blake3_tagged_hash(sigchain_jsonl.as_bytes()),
allowed_signers_hash: blake3_tagged_hash(allowed_signers.as_bytes()),
reduced_view_hash: geth_codec::hash_canonical(&view)?.to_string(),
generated_at,
})
}
#[must_use]
pub fn blake3_tagged_hash(bytes: &[u8]) -> String {
format!("blake3:{}", blake3::hash(bytes))
}
#[must_use]
pub fn normalize_base_url(mut value: String) -> String {
if !value.ends_with('/') {
value.push('/');
}
value
}
2026-05-26 18:58:25 +02:00
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()))
}
}
2026-05-15 15:08:20 +02:00
#[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);
}
2026-05-16 14:39:18 +02:00
2026-05-26 18:58:25 +02:00
#[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());
}
2026-05-16 14:39:18 +02:00
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,
2026-05-16 14:39:18 +02:00
},
),
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,
2026-05-16 14:39:18 +02:00
},
),
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 allowed_signers_and_sigchain_jsonl_are_portable() {
let ops = vec![
op(1, KeychainOpKind::KeychainInit),
op(
2,
KeychainOpKind::AdminKeyAdd {
key: admin_key_fingerprint("ssh-ed25519 AAAA 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,
},
),
];
let signatures = vec![KeychainOpSignature {
op_id: ops[1].id.clone(),
signer: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(),
signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(),
namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
signature: vec![1],
created_at: UnixMillis(2),
}];
let allowed = allowed_signers(&ops, &signatures);
assert_eq!(allowed.len(), 1);
assert!(render_allowed_signers(&allowed).contains("admin-a ssh-ed25519"));
let entries = sigchain_entries(&ops, &signatures);
let jsonl = encode_sigchain_jsonl(&entries).expect("encode jsonl");
assert_eq!(jsonl.lines().count(), 2);
let decoded = decode_sigchain_jsonl(&jsonl).expect("decode jsonl");
assert_eq!(decoded, entries);
let (decoded_ops, decoded_signatures) = flatten_sigchain_entries(&decoded);
assert_eq!(decoded_ops, sorted_keychain_ops(ops));
assert_eq!(decoded_signatures, signatures);
}
#[test]
fn checkpoint_records_static_publication_hashes() {
let ops = vec![
op(1, KeychainOpKind::KeychainInit),
op(
2,
KeychainOpKind::AdminKeyAdd {
key: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(),
public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()),
principal: Some("admin".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
),
];
let signatures = Vec::new();
let entries = sigchain_entries(&ops, &signatures);
let jsonl = encode_sigchain_jsonl(&entries).expect("jsonl");
let allowed = render_allowed_signers(&allowed_signers(&ops, &signatures));
let checkpoint = keychain_checkpoint(
&ops,
&signatures,
&jsonl,
&allowed,
"https://example.com/.well-known/sshsigchain",
UnixMillis(10),
)
.expect("checkpoint");
assert_eq!(checkpoint.version, KEYCHAIN_CHECKPOINT_VERSION);
assert_eq!(
checkpoint.base_url,
"https://example.com/.well-known/sshsigchain/"
);
assert_eq!(checkpoint.ops, 2);
assert_eq!(checkpoint.sigchain_bytes, jsonl.len() as u64);
assert_eq!(
checkpoint.sigchain_hash,
blake3_tagged_hash(jsonl.as_bytes())
);
assert_eq!(
checkpoint.allowed_signers_hash,
blake3_tagged_hash(allowed.as_bytes())
);
assert!(!checkpoint.reduced_view_hash.is_empty());
}
#[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),
},
];
2026-05-26 18:58:25 +02:00
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);
}
2026-05-26 18:58:25 +02:00
#[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");
}
2026-05-16 14:39:18 +02:00
#[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());
}
2026-05-15 15:08:20 +02:00
}