Harden reusable keychain API
This commit is contained in:
parent
4013c868aa
commit
b6ffcde54c
6 changed files with 410 additions and 33 deletions
|
|
@ -456,7 +456,8 @@ The keychain follows a sigchain model documented in
|
|||
signed by an admin key from the previously accepted reduced view. This is the
|
||||
geth analogue of verifying `git-skm` allowed-signers changes from a prior
|
||||
trusted state. The reusable mechanics live in the `geth-keychain` crate,
|
||||
including allowed-signers projection, replay verification, and an appendable
|
||||
including application-specific signature profiles, allowed-signers projection,
|
||||
replay verification through a caller-provided verifier trait, and an appendable
|
||||
JSONL sigchain file format suitable for static hosting with HTTP caching/range
|
||||
requests.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,20 @@
|
|||
//! Reusable signed keychain and sigchain model.
|
||||
//!
|
||||
//! This crate owns geth's identity-plane data model: admin keys, users,
|
||||
//! devices, nodes, agents, endpoint bindings, and the signed operation log used
|
||||
//! to update them. It intentionally has no dependency on the daemon, SQLite,
|
||||
//! Iroh, local sockets, or any particular publication mechanism.
|
||||
//!
|
||||
//! Applications can publish `KeychainSigchainEntry` values in an append-only
|
||||
//! JSONL file, object store, database row stream, document CRDT, or another
|
||||
//! transport. Consumers decode entries, flatten them into operations and
|
||||
//! signatures, then call `verify_sigchain_with_profile` with an application
|
||||
//! profile and a `KeychainSignatureVerifier` implementation.
|
||||
//!
|
||||
//! The default `KeychainProfile` is geth-specific. Other applications should
|
||||
//! create their own profile with `KeychainProfile::for_application` or
|
||||
//! `KeychainProfile::new` so signed payload namespaces do not overlap.
|
||||
|
||||
use geth_types::{
|
||||
AgentId, AuthOpId, Capability, DeviceId, KeyId, NodeId, ResourceId, UnixMillis, UserId,
|
||||
};
|
||||
|
|
@ -6,22 +23,132 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||
|
||||
pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local";
|
||||
pub const NODE_ENROLLMENT_REQUEST_NAMESPACE: &str = "geth.node-enrollment-request.v1@geth.local";
|
||||
pub const DEFAULT_ADMIN_PRINCIPAL: &str = "admin";
|
||||
|
||||
pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeychainProfile {
|
||||
keychain_signature_namespace: String,
|
||||
node_enrollment_request_namespace: String,
|
||||
default_admin_principal: String,
|
||||
}
|
||||
|
||||
impl KeychainProfile {
|
||||
/// Create a profile from explicit signature namespaces.
|
||||
///
|
||||
/// Use this when an application wants stable, audited namespaces rather
|
||||
/// than the generated `<application>.<purpose>.v1@<domain>` form.
|
||||
pub fn new(
|
||||
keychain_signature_namespace: impl Into<String>,
|
||||
node_enrollment_request_namespace: impl Into<String>,
|
||||
default_admin_principal: impl Into<String>,
|
||||
) -> Result<Self, KeychainError> {
|
||||
let keychain_signature_namespace = keychain_signature_namespace.into();
|
||||
let node_enrollment_request_namespace = node_enrollment_request_namespace.into();
|
||||
let default_admin_principal = default_admin_principal.into();
|
||||
validate_namespace(&keychain_signature_namespace)?;
|
||||
validate_namespace(&node_enrollment_request_namespace)?;
|
||||
validate_principal(&default_admin_principal)?;
|
||||
Ok(Self {
|
||||
keychain_signature_namespace,
|
||||
node_enrollment_request_namespace,
|
||||
default_admin_principal,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build application-specific namespaces under a DNS-style domain.
|
||||
///
|
||||
/// For example, `for_application("acme", "example.com")` creates:
|
||||
///
|
||||
/// - `acme.keychain.v1@example.com`
|
||||
/// - `acme.node-enrollment-request.v1@example.com`
|
||||
pub fn for_application(
|
||||
application: impl AsRef<str>,
|
||||
domain: impl AsRef<str>,
|
||||
) -> Result<Self, KeychainError> {
|
||||
let application = validate_namespace_component(application.as_ref(), "application")?;
|
||||
let domain = validate_namespace_component(domain.as_ref(), "domain")?;
|
||||
Self::new(
|
||||
format!("{application}.keychain.v1@{domain}"),
|
||||
format!("{application}.node-enrollment-request.v1@{domain}"),
|
||||
DEFAULT_ADMIN_PRINCIPAL,
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn geth() -> Self {
|
||||
Self {
|
||||
keychain_signature_namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
|
||||
node_enrollment_request_namespace: NODE_ENROLLMENT_REQUEST_NAMESPACE.to_owned(),
|
||||
default_admin_principal: DEFAULT_ADMIN_PRINCIPAL.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn keychain_signature_namespace(&self) -> &str {
|
||||
&self.keychain_signature_namespace
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn node_enrollment_request_namespace(&self) -> &str {
|
||||
&self.node_enrollment_request_namespace
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn default_admin_principal(&self) -> &str {
|
||||
&self.default_admin_principal
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KeychainProfile {
|
||||
fn default() -> Self {
|
||||
Self::geth()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keychain_signing_payload(op: &KeychainOp) -> Result<Vec<u8>, geth_codec::CodecError> {
|
||||
geth_codec::signing_payload(KEYCHAIN_SIGNATURE_NAMESPACE, op)
|
||||
keychain_signing_payload_with_profile(op, &KeychainProfile::geth())
|
||||
}
|
||||
|
||||
pub fn keychain_signing_payload_with_profile(
|
||||
op: &KeychainOp,
|
||||
profile: &KeychainProfile,
|
||||
) -> Result<Vec<u8>, geth_codec::CodecError> {
|
||||
geth_codec::signing_payload(profile.keychain_signature_namespace(), op)
|
||||
}
|
||||
|
||||
pub fn keychain_signing_payload_hash(
|
||||
op: &KeychainOp,
|
||||
) -> Result<geth_types::BlobHash, geth_codec::CodecError> {
|
||||
geth_codec::signing_payload_hash(KEYCHAIN_SIGNATURE_NAMESPACE, op)
|
||||
keychain_signing_payload_hash_with_profile(op, &KeychainProfile::geth())
|
||||
}
|
||||
|
||||
pub fn keychain_signing_payload_hash_with_profile(
|
||||
op: &KeychainOp,
|
||||
profile: &KeychainProfile,
|
||||
) -> Result<geth_types::BlobHash, geth_codec::CodecError> {
|
||||
geth_codec::signing_payload_hash(profile.keychain_signature_namespace(), op)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn signed_keychain_op(op: KeychainOp, signer: KeyId, signature: Vec<u8>) -> SignedKeychainOp {
|
||||
geth_codec::SignedEnvelope::new(KEYCHAIN_SIGNATURE_NAMESPACE, op, signer, signature)
|
||||
signed_keychain_op_with_profile(op, signer, signature, &KeychainProfile::geth())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn signed_keychain_op_with_profile(
|
||||
op: KeychainOp,
|
||||
signer: KeyId,
|
||||
signature: Vec<u8>,
|
||||
profile: &KeychainProfile,
|
||||
) -> SignedKeychainOp {
|
||||
geth_codec::SignedEnvelope::new(
|
||||
profile.keychain_signature_namespace(),
|
||||
op,
|
||||
signer,
|
||||
signature,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -67,7 +194,18 @@ pub struct KeychainSigchainEntry {
|
|||
pub signatures: Vec<KeychainOpSignature>,
|
||||
}
|
||||
|
||||
pub type KeychainSignatureVerifier<'a> = dyn Fn(&KeychainOp, &KeychainOpSignature) -> bool + 'a;
|
||||
pub trait KeychainSignatureVerifier {
|
||||
fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool;
|
||||
}
|
||||
|
||||
impl<F> KeychainSignatureVerifier for F
|
||||
where
|
||||
F: for<'op, 'signature> Fn(&'op KeychainOp, &'signature KeychainOpSignature) -> bool,
|
||||
{
|
||||
fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool {
|
||||
self(op, signature)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NodeEnrollmentRequest {
|
||||
|
|
@ -155,6 +293,12 @@ pub struct NodeEnrollmentRequestSigningPayload {
|
|||
pub enum KeychainError {
|
||||
#[error("invalid node enrollment status: {0}")]
|
||||
InvalidEnrollmentStatus(String),
|
||||
#[error("invalid keychain namespace: {0}")]
|
||||
InvalidNamespace(String),
|
||||
#[error("invalid keychain namespace {field}: {value}")]
|
||||
InvalidNamespaceComponent { field: String, value: String },
|
||||
#[error("invalid keychain principal: {0}")]
|
||||
InvalidPrincipal(String),
|
||||
#[error("sigchain JSONL line {line}: {source}")]
|
||||
SigchainJsonl {
|
||||
line: usize,
|
||||
|
|
@ -431,6 +575,15 @@ pub fn keychain_op_order(kind: &KeychainOpKind) -> u8 {
|
|||
pub fn allowed_signers(
|
||||
ops: &[KeychainOp],
|
||||
signatures: &[KeychainOpSignature],
|
||||
) -> Vec<KeychainAllowedSigner> {
|
||||
allowed_signers_with_profile(ops, signatures, &KeychainProfile::geth())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn allowed_signers_with_profile(
|
||||
ops: &[KeychainOp],
|
||||
signatures: &[KeychainOpSignature],
|
||||
profile: &KeychainProfile,
|
||||
) -> Vec<KeychainAllowedSigner> {
|
||||
let mut entries = BTreeMap::<KeyId, KeychainAllowedSigner>::new();
|
||||
for op in sorted_keychain_ops(ops.to_vec()) {
|
||||
|
|
@ -455,7 +608,8 @@ pub fn allowed_signers(
|
|||
key.clone(),
|
||||
KeychainAllowedSigner {
|
||||
key,
|
||||
principal: principal.unwrap_or_else(|| "admin".to_owned()),
|
||||
principal: principal
|
||||
.unwrap_or_else(|| profile.default_admin_principal().to_owned()),
|
||||
public_key,
|
||||
valid_after_ms,
|
||||
valid_before_ms,
|
||||
|
|
@ -488,7 +642,16 @@ pub fn render_allowed_signers(entries: &[KeychainAllowedSigner]) -> String {
|
|||
pub fn verify_sigchain(
|
||||
ops: &[KeychainOp],
|
||||
signatures: &[KeychainOpSignature],
|
||||
verifier: &KeychainSignatureVerifier<'_>,
|
||||
verifier: &(impl KeychainSignatureVerifier + ?Sized),
|
||||
) -> KeychainSigchainReport {
|
||||
verify_sigchain_with_profile(ops, signatures, &KeychainProfile::geth(), verifier)
|
||||
}
|
||||
|
||||
pub fn verify_sigchain_with_profile(
|
||||
ops: &[KeychainOp],
|
||||
signatures: &[KeychainOpSignature],
|
||||
profile: &KeychainProfile,
|
||||
verifier: &(impl KeychainSignatureVerifier + ?Sized),
|
||||
) -> KeychainSigchainReport {
|
||||
let ops = sorted_keychain_ops(ops.to_vec());
|
||||
let mut accepted = Vec::<KeychainOp>::new();
|
||||
|
|
@ -508,8 +671,9 @@ pub fn verify_sigchain(
|
|||
|| op_signatures.iter().any(|signature| {
|
||||
let signer_is_authorized = trusted_admins.contains(&signature.signer);
|
||||
signer_is_authorized
|
||||
&& signature.namespace == profile.keychain_signature_namespace()
|
||||
&& signature_uses_claimed_key(signature)
|
||||
&& verifier(op, signature)
|
||||
&& verifier.verify_keychain_signature(op, signature)
|
||||
});
|
||||
if valid {
|
||||
accepted.push(op.clone());
|
||||
|
|
@ -599,6 +763,50 @@ pub fn flatten_sigchain_entries(
|
|||
(ops, signatures)
|
||||
}
|
||||
|
||||
fn validate_namespace(value: &str) -> Result<(), KeychainError> {
|
||||
let has_single_domain_separator = value.matches('@').count() == 1;
|
||||
let valid = has_single_domain_separator
|
||||
&& !value.trim().is_empty()
|
||||
&& value == value.trim()
|
||||
&& value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric()
|
||||
|| matches!(byte, b'.' | b'-' | b'_' | b'@' | b':' | b'/' | b'+')
|
||||
});
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(KeychainError::InvalidNamespace(value.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_namespace_component<'a>(value: &'a str, field: &str) -> Result<&'a str, KeychainError> {
|
||||
let valid = !value.trim().is_empty()
|
||||
&& value == value.trim()
|
||||
&& !value.contains('@')
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'));
|
||||
if valid {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(KeychainError::InvalidNamespaceComponent {
|
||||
field: field.to_owned(),
|
||||
value: value.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_principal(value: &str) -> Result<(), KeychainError> {
|
||||
let valid = !value.trim().is_empty()
|
||||
&& value == value.trim()
|
||||
&& !value.bytes().any(|byte| byte.is_ascii_whitespace());
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(KeychainError::InvalidPrincipal(value.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -645,6 +853,37 @@ mod tests {
|
|||
assert_eq!(signed.payload(), &op);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_namespaces_support_other_applications() {
|
||||
let profile = KeychainProfile::for_application("acme-notes", "example.com")
|
||||
.expect("application profile");
|
||||
assert_eq!(
|
||||
profile.keychain_signature_namespace(),
|
||||
"acme-notes.keychain.v1@example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
profile.node_enrollment_request_namespace(),
|
||||
"acme-notes.node-enrollment-request.v1@example.com"
|
||||
);
|
||||
|
||||
let op = KeychainOp {
|
||||
id: "op:1".into(),
|
||||
created_at: UnixMillis(1),
|
||||
kind: KeychainOpKind::KeychainInit,
|
||||
};
|
||||
assert_ne!(
|
||||
keychain_signing_payload(&op).expect("geth payload"),
|
||||
keychain_signing_payload_with_profile(&op, &profile).expect("app payload")
|
||||
);
|
||||
|
||||
let signed =
|
||||
signed_keychain_op_with_profile(op.clone(), "key:admin".into(), vec![1], &profile);
|
||||
assert_eq!(signed.namespace(), "acme-notes.keychain.v1@example.com");
|
||||
|
||||
assert!(KeychainProfile::for_application("bad app", "example.com").is_err());
|
||||
assert!(KeychainProfile::new("missing-domain", "also-missing-domain", "admin").is_err());
|
||||
}
|
||||
|
||||
fn op(sequence: i64, kind: KeychainOpKind) -> KeychainOp {
|
||||
KeychainOp {
|
||||
id: format!("op:{sequence}").into(),
|
||||
|
|
@ -905,14 +1144,102 @@ mod tests {
|
|||
created_at: UnixMillis(4),
|
||||
},
|
||||
];
|
||||
let report = verify_sigchain(&ops, &signatures, &|_, signature| {
|
||||
signature.signature == vec![1]
|
||||
});
|
||||
let report = verify_sigchain(
|
||||
&ops,
|
||||
&signatures,
|
||||
&|_: &KeychainOp, signature: &KeychainOpSignature| signature.signature == vec![1],
|
||||
);
|
||||
assert_eq!(report.accepted_ops, 4);
|
||||
assert_eq!(report.rejected_ops, 0);
|
||||
assert_eq!(report.active_admin_keys, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigchain_verification_uses_profile_namespace_and_verifier_trait() {
|
||||
struct AcceptNonEmptySignatures;
|
||||
|
||||
impl KeychainSignatureVerifier for AcceptNonEmptySignatures {
|
||||
fn verify_keychain_signature(
|
||||
&self,
|
||||
_: &KeychainOp,
|
||||
signature: &KeychainOpSignature,
|
||||
) -> bool {
|
||||
!signature.signature.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
let profile = KeychainProfile::for_application("acme", "example.com").expect("profile");
|
||||
let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into();
|
||||
let ops = vec![
|
||||
op(1, KeychainOpKind::KeychainInit),
|
||||
op(
|
||||
2,
|
||||
KeychainOpKind::AdminKeyAdd {
|
||||
key: admin_a.clone(),
|
||||
public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()),
|
||||
principal: None,
|
||||
valid_after_ms: None,
|
||||
valid_before_ms: None,
|
||||
},
|
||||
),
|
||||
op(
|
||||
3,
|
||||
KeychainOpKind::UserAdd {
|
||||
user: "user:external".into(),
|
||||
name: "External App User".to_owned(),
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut signature = KeychainOpSignature {
|
||||
op_id: ops[2].id.clone(),
|
||||
signer: admin_a,
|
||||
signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(),
|
||||
namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
|
||||
signature: vec![1],
|
||||
created_at: UnixMillis(3),
|
||||
};
|
||||
|
||||
let rejected = verify_sigchain_with_profile(
|
||||
&ops,
|
||||
&[signature.clone()],
|
||||
&profile,
|
||||
&AcceptNonEmptySignatures,
|
||||
);
|
||||
assert_eq!(rejected.accepted_ops, 2);
|
||||
assert_eq!(rejected.rejected_ops, 1);
|
||||
|
||||
signature.namespace = profile.keychain_signature_namespace().to_owned();
|
||||
let accepted =
|
||||
verify_sigchain_with_profile(&ops, &[signature], &profile, &AcceptNonEmptySignatures);
|
||||
assert_eq!(accepted.accepted_ops, 3);
|
||||
assert_eq!(accepted.rejected_ops, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_signers_uses_profile_default_principal() {
|
||||
let profile = KeychainProfile::new(
|
||||
"acme.keychain.v1@example.com",
|
||||
"acme.node-enrollment-request.v1@example.com",
|
||||
"owner",
|
||||
)
|
||||
.expect("profile");
|
||||
let ops = vec![
|
||||
op(1, KeychainOpKind::KeychainInit),
|
||||
op(
|
||||
2,
|
||||
KeychainOpKind::AdminKeyAdd {
|
||||
key: admin_key_fingerprint("ssh-ed25519 AAAA owner").into(),
|
||||
public_key: Some("ssh-ed25519 AAAA owner".to_owned()),
|
||||
principal: None,
|
||||
valid_after_ms: None,
|
||||
valid_before_ms: None,
|
||||
},
|
||||
),
|
||||
];
|
||||
let allowed = allowed_signers_with_profile(&ops, &[], &profile);
|
||||
assert_eq!(allowed[0].principal, "owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reducer_excludes_revoked_identity_subtrees() {
|
||||
let ops = vec![
|
||||
|
|
|
|||
|
|
@ -10505,20 +10505,29 @@ fn verify_keychain_sigchain_with_ssh(
|
|||
store: &Store,
|
||||
node: &LocalNode,
|
||||
) -> Result<geth_keychain::KeychainSigchainReport, NodeError> {
|
||||
let ops = load_keychain_ops(store)?;
|
||||
let signatures = load_keychain_signatures(store)?;
|
||||
Ok(geth_keychain::verify_sigchain(
|
||||
&ops,
|
||||
&signatures,
|
||||
&|op, signature| {
|
||||
struct SshKeychainVerifier<'a> {
|
||||
node: &'a LocalNode,
|
||||
}
|
||||
|
||||
impl geth_keychain::KeychainSignatureVerifier for SshKeychainVerifier<'_> {
|
||||
fn verify_keychain_signature(
|
||||
&self,
|
||||
op: &KeychainOp,
|
||||
signature: &KeychainOpSignature,
|
||||
) -> bool {
|
||||
verify_keychain_signature_with_ssh(
|
||||
node,
|
||||
self.node,
|
||||
op,
|
||||
&stored_keychain_signature_from_signature(signature),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let ops = load_keychain_ops(store)?;
|
||||
let signatures = load_keychain_signatures(store)?;
|
||||
let verifier = SshKeychainVerifier { node };
|
||||
Ok(geth_keychain::verify_sigchain(&ops, &signatures, &verifier))
|
||||
}
|
||||
|
||||
fn store_and_sign_keychain_ops(
|
||||
|
|
|
|||
|
|
@ -352,13 +352,16 @@ are updated with signed `geth keychain admin-add` and `geth keychain
|
|||
admin-revoke` operations; `AdminKeyAdd` carries the public key material needed
|
||||
to reconstruct an OpenSSH `allowed_signers` view. `geth keychain verify` replays
|
||||
the log against the previously accepted admin-key view, mirroring the `git-skm`
|
||||
pattern of verifying key-registry changes from a prior trusted state. `geth
|
||||
keychain sync <node>` pulls keychain operations and signatures from an imported
|
||||
peer over Iroh and imports only operations with a valid OpenSSH signature from a
|
||||
currently trusted admin key over the canonical payload. See
|
||||
`docs/sigchain-keychain.md` for the detailed sigchain design. This is currently
|
||||
a pull-based signed operation log, not a CRDT or Keyhive-style convergent
|
||||
authority.
|
||||
pattern of verifying key-registry changes from a prior trusted state. The
|
||||
transport-neutral replay rules, application-specific signature namespaces,
|
||||
allowed-signers projection, and JSONL sigchain helpers live in `geth-keychain`
|
||||
so other applications can reuse the same identity-log model without depending
|
||||
on the daemon, SQLite, Iroh, or local control. `geth keychain sync <node>` pulls
|
||||
keychain operations and signatures from an imported peer over Iroh and imports
|
||||
only operations with a valid OpenSSH signature from a currently trusted admin
|
||||
key over the canonical payload. See `docs/sigchain-keychain.md` for the
|
||||
detailed sigchain design. This is currently a pull-based signed operation log,
|
||||
not a CRDT or Keyhive-style convergent authority.
|
||||
|
||||
New devices can use the node enrollment flow instead of hand-editing keychain
|
||||
state. `geth node enroll request` creates a canonical, agent-key-signed request
|
||||
|
|
|
|||
|
|
@ -332,6 +332,11 @@ resource-scoped capability decisions.
|
|||
- `[x]` `geth-keychain` exposes transport-neutral allowed-signers projection,
|
||||
replay verification with an injected verifier, and appendable JSONL
|
||||
sigchain encode/decode helpers for static hosting or alternate transports.
|
||||
- `[x]` `geth-keychain` exposes `KeychainProfile` so non-geth applications
|
||||
can use distinct signature namespaces and default principals.
|
||||
- `[x]` `geth-keychain` exposes a `KeychainSignatureVerifier` trait so
|
||||
callers can plug in OpenSSH, HSM, WebCrypto, service-side, or test
|
||||
verification backends without daemon coupling.
|
||||
- `[x]` `docs/sigchain-keychain.md` documents the sigchain data model,
|
||||
verification algorithm, commands, and current security limits.
|
||||
- `[x]` Missing `ssh-keygen` or unavailable hardware keys produce clear
|
||||
|
|
|
|||
|
|
@ -26,6 +26,19 @@ The durable log is `KeychainOp[]` plus `KeychainOpSignature[]`. Each operation
|
|||
has a deterministic canonical signing payload under the
|
||||
`geth.keychain.v1@geth.local` namespace.
|
||||
|
||||
The namespace is part of a `KeychainProfile`. geth uses:
|
||||
|
||||
- `geth.keychain.v1@geth.local`
|
||||
- `geth.node-enrollment-request.v1@geth.local`
|
||||
- default admin principal `admin`
|
||||
|
||||
Other applications should create their own profile with explicit namespaces or
|
||||
`KeychainProfile::for_application("<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:
|
||||
|
||||
- `KeychainInit`
|
||||
|
|
@ -105,12 +118,31 @@ provide:
|
|||
|
||||
- ordered or unordered `KeychainOp[]`
|
||||
- `KeychainOpSignature[]`
|
||||
- a signature verification callback
|
||||
- a `KeychainProfile`
|
||||
- a `KeychainSignatureVerifier`
|
||||
|
||||
The callback is responsible for the cryptographic backend, such as
|
||||
`ssh-keygen -Y verify`, WebCrypto, an HSM, or a test verifier. The crate owns
|
||||
the replay order, bootstrap rule, previous-view authorization rule,
|
||||
`allowed_signers` projection, and reduced keychain view.
|
||||
The verifier is responsible for the cryptographic backend, such as
|
||||
`ssh-keygen -Y verify`, WebCrypto, an HSM, a service-side verifier, or a test
|
||||
verifier. The crate owns the replay order, bootstrap rule, profile namespace
|
||||
check, previous-view authorization rule, `allowed_signers` projection, and
|
||||
reduced keychain view.
|
||||
|
||||
Minimal reusable Rust shape:
|
||||
|
||||
```rust
|
||||
let profile = geth_keychain::KeychainProfile::for_application(
|
||||
"acme-notes",
|
||||
"example.com",
|
||||
)?;
|
||||
let entries = geth_keychain::decode_sigchain_jsonl(sigchain_text)?;
|
||||
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
|
||||
let report = geth_keychain::verify_sigchain_with_profile(
|
||||
&ops,
|
||||
&signatures,
|
||||
&profile,
|
||||
&my_verifier,
|
||||
);
|
||||
```
|
||||
|
||||
## Static Sigchain File
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue