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-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 " ;
2026-05-26 18:58:25 +02:00
pub const DEFAULT_ADMIN_PRINCIPAL : & str = " admin " ;
2026-05-15 15:08:20 +02:00
2026-05-16 14:37:16 +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 ( )
}
}
2026-05-16 14:37:16 +02:00
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 )
2026-05-16 14:37:16 +02:00
}
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 )
2026-05-16 14:37:16 +02:00
}
#[ 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 ,
}
2026-05-26 18:53:20 +02:00
#[ 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 > ,
}
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-26 18:53:20 +02:00
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 ) ,
2026-05-26 18:53:20 +02:00
#[ 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 ,
2026-05-26 18:53:20 +02:00
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 ,
2026-05-26 18:53:20 +02:00
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 } = > {
if let Some ( record ) = devices . get_mut ( device ) {
if ! 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 ) {
if ! 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 ( ) ) ;
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
}
2026-05-26 18:53: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 ,
2026-05-26 18:53:20 +02:00
) -> 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 ( ) ) ,
2026-05-26 18:53:20 +02:00
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 ) ,
2026-05-26 18:53:20 +02:00
) -> 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 ( )
2026-05-26 18:53:20 +02:00
& & signature_uses_claimed_key ( signature )
2026-05-26 18:58:25 +02:00
& & verifier . verify_keychain_signature ( op , signature )
2026-05-26 18:53:20 +02:00
} ) ;
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 )
}
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 ) ;
}
2026-05-16 14:37:16 +02:00
#[ 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 ( ) ,
2026-05-26 18:53:20 +02:00
public_key : Some ( " ssh-ed25519 AAAA test@example " . to_owned ( ) ) ,
principal : Some ( " admin " . to_owned ( ) ) ,
valid_after_ms : None ,
valid_before_ms : None ,
2026-05-16 14:37:16 +02:00
} ,
} ;
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 ( ) ,
2026-05-26 18:53:20 +02:00
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 ( ) ,
2026-05-26 18:53:20 +02:00
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 " ) ) ;
}
2026-05-26 18:53:20 +02:00
#[ 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 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 ] ,
) ;
2026-05-26 18:53:20 +02:00
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
}