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

@ -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 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 geth analogue of verifying `git-skm` allowed-signers changes from a prior
trusted state. The reusable mechanics live in the `geth-keychain` crate, 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 JSONL sigchain file format suitable for static hosting with HTTP caching/range
requests. requests.

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::{ use geth_types::{
AgentId, AuthOpId, Capability, DeviceId, KeyId, NodeId, ResourceId, UnixMillis, UserId, 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 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 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>; 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> { 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( pub fn keychain_signing_payload_hash(
op: &KeychainOp, op: &KeychainOp,
) -> Result<geth_types::BlobHash, geth_codec::CodecError> { ) -> 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] #[must_use]
pub fn signed_keychain_op(op: KeychainOp, signer: KeyId, signature: Vec<u8>) -> SignedKeychainOp { 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -67,7 +194,18 @@ pub struct KeychainSigchainEntry {
pub signatures: Vec<KeychainOpSignature>, 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeEnrollmentRequest { pub struct NodeEnrollmentRequest {
@ -155,6 +293,12 @@ pub struct NodeEnrollmentRequestSigningPayload {
pub enum KeychainError { pub enum KeychainError {
#[error("invalid node enrollment status: {0}")] #[error("invalid node enrollment status: {0}")]
InvalidEnrollmentStatus(String), 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}")] #[error("sigchain JSONL line {line}: {source}")]
SigchainJsonl { SigchainJsonl {
line: usize, line: usize,
@ -431,6 +575,15 @@ pub fn keychain_op_order(kind: &KeychainOpKind) -> u8 {
pub fn allowed_signers( pub fn allowed_signers(
ops: &[KeychainOp], ops: &[KeychainOp],
signatures: &[KeychainOpSignature], 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> { ) -> Vec<KeychainAllowedSigner> {
let mut entries = BTreeMap::<KeyId, KeychainAllowedSigner>::new(); let mut entries = BTreeMap::<KeyId, KeychainAllowedSigner>::new();
for op in sorted_keychain_ops(ops.to_vec()) { for op in sorted_keychain_ops(ops.to_vec()) {
@ -455,7 +608,8 @@ pub fn allowed_signers(
key.clone(), key.clone(),
KeychainAllowedSigner { KeychainAllowedSigner {
key, key,
principal: principal.unwrap_or_else(|| "admin".to_owned()), principal: principal
.unwrap_or_else(|| profile.default_admin_principal().to_owned()),
public_key, public_key,
valid_after_ms, valid_after_ms,
valid_before_ms, valid_before_ms,
@ -488,7 +642,16 @@ pub fn render_allowed_signers(entries: &[KeychainAllowedSigner]) -> String {
pub fn verify_sigchain( pub fn verify_sigchain(
ops: &[KeychainOp], ops: &[KeychainOp],
signatures: &[KeychainOpSignature], 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 { ) -> KeychainSigchainReport {
let ops = sorted_keychain_ops(ops.to_vec()); let ops = sorted_keychain_ops(ops.to_vec());
let mut accepted = Vec::<KeychainOp>::new(); let mut accepted = Vec::<KeychainOp>::new();
@ -508,8 +671,9 @@ pub fn verify_sigchain(
|| op_signatures.iter().any(|signature| { || op_signatures.iter().any(|signature| {
let signer_is_authorized = trusted_admins.contains(&signature.signer); let signer_is_authorized = trusted_admins.contains(&signature.signer);
signer_is_authorized signer_is_authorized
&& signature.namespace == profile.keychain_signature_namespace()
&& signature_uses_claimed_key(signature) && signature_uses_claimed_key(signature)
&& verifier(op, signature) && verifier.verify_keychain_signature(op, signature)
}); });
if valid { if valid {
accepted.push(op.clone()); accepted.push(op.clone());
@ -599,6 +763,50 @@ pub fn flatten_sigchain_entries(
(ops, signatures) (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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -645,6 +853,37 @@ mod tests {
assert_eq!(signed.payload(), &op); 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 { fn op(sequence: i64, kind: KeychainOpKind) -> KeychainOp {
KeychainOp { KeychainOp {
id: format!("op:{sequence}").into(), id: format!("op:{sequence}").into(),
@ -905,14 +1144,102 @@ mod tests {
created_at: UnixMillis(4), created_at: UnixMillis(4),
}, },
]; ];
let report = verify_sigchain(&ops, &signatures, &|_, signature| { let report = verify_sigchain(
signature.signature == vec![1] &ops,
}); &signatures,
&|_: &KeychainOp, signature: &KeychainOpSignature| signature.signature == vec![1],
);
assert_eq!(report.accepted_ops, 4); assert_eq!(report.accepted_ops, 4);
assert_eq!(report.rejected_ops, 0); assert_eq!(report.rejected_ops, 0);
assert_eq!(report.active_admin_keys, 1); 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] #[test]
fn reducer_excludes_revoked_identity_subtrees() { fn reducer_excludes_revoked_identity_subtrees() {
let ops = vec![ let ops = vec![

View file

@ -10505,20 +10505,29 @@ fn verify_keychain_sigchain_with_ssh(
store: &Store, store: &Store,
node: &LocalNode, node: &LocalNode,
) -> Result<geth_keychain::KeychainSigchainReport, NodeError> { ) -> Result<geth_keychain::KeychainSigchainReport, NodeError> {
let ops = load_keychain_ops(store)?; struct SshKeychainVerifier<'a> {
let signatures = load_keychain_signatures(store)?; node: &'a LocalNode,
Ok(geth_keychain::verify_sigchain( }
&ops,
&signatures, impl geth_keychain::KeychainSignatureVerifier for SshKeychainVerifier<'_> {
&|op, signature| { fn verify_keychain_signature(
&self,
op: &KeychainOp,
signature: &KeychainOpSignature,
) -> bool {
verify_keychain_signature_with_ssh( verify_keychain_signature_with_ssh(
node, self.node,
op, op,
&stored_keychain_signature_from_signature(signature), &stored_keychain_signature_from_signature(signature),
) )
.unwrap_or(false) .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( fn store_and_sign_keychain_ops(

View file

@ -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 admin-revoke` operations; `AdminKeyAdd` carries the public key material needed
to reconstruct an OpenSSH `allowed_signers` view. `geth keychain verify` replays to reconstruct an OpenSSH `allowed_signers` view. `geth keychain verify` replays
the log against the previously accepted admin-key view, mirroring the `git-skm` 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 pattern of verifying key-registry changes from a prior trusted state. The
keychain sync <node>` pulls keychain operations and signatures from an imported transport-neutral replay rules, application-specific signature namespaces,
peer over Iroh and imports only operations with a valid OpenSSH signature from a allowed-signers projection, and JSONL sigchain helpers live in `geth-keychain`
currently trusted admin key over the canonical payload. See so other applications can reuse the same identity-log model without depending
`docs/sigchain-keychain.md` for the detailed sigchain design. This is currently on the daemon, SQLite, Iroh, or local control. `geth keychain sync <node>` pulls
a pull-based signed operation log, not a CRDT or Keyhive-style convergent keychain operations and signatures from an imported peer over Iroh and imports
authority. 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 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 state. `geth node enroll request` creates a canonical, agent-key-signed request

View file

@ -332,6 +332,11 @@ resource-scoped capability decisions.
- `[x]` `geth-keychain` exposes transport-neutral allowed-signers projection, - `[x]` `geth-keychain` exposes transport-neutral allowed-signers projection,
replay verification with an injected verifier, and appendable JSONL replay verification with an injected verifier, and appendable JSONL
sigchain encode/decode helpers for static hosting or alternate transports. 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, - `[x]` `docs/sigchain-keychain.md` documents the sigchain data model,
verification algorithm, commands, and current security limits. verification algorithm, commands, and current security limits.
- `[x]` Missing `ssh-keygen` or unavailable hardware keys produce clear - `[x]` Missing `ssh-keygen` or unavailable hardware keys produce clear

View file

@ -26,6 +26,19 @@ The durable log is `KeychainOp[]` plus `KeychainOpSignature[]`. Each operation
has a deterministic canonical signing payload under the has a deterministic canonical signing payload under the
`geth.keychain.v1@geth.local` namespace. `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("<app>", "<domain>")`. 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: Important operation kinds:
- `KeychainInit` - `KeychainInit`
@ -105,12 +118,31 @@ provide:
- ordered or unordered `KeychainOp[]` - ordered or unordered `KeychainOp[]`
- `KeychainOpSignature[]` - `KeychainOpSignature[]`
- a signature verification callback - a `KeychainProfile`
- a `KeychainSignatureVerifier`
The callback is responsible for the cryptographic backend, such as The verifier is responsible for the cryptographic backend, such as
`ssh-keygen -Y verify`, WebCrypto, an HSM, or a test verifier. The crate owns `ssh-keygen -Y verify`, WebCrypto, an HSM, a service-side verifier, or a test
the replay order, bootstrap rule, previous-view authorization rule, verifier. The crate owns the replay order, bootstrap rule, profile namespace
`allowed_signers` projection, and reduced keychain view. 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 ## Static Sigchain File