Some checks failed
CI / fmt, clippy, docs (push) Failing after 5s
CI / test (ubuntu-latest) (push) Failing after 5s
CI / iroh integration smoke tests (push) Failing after 5s
CodeQL / Analyze Rust (push) Failing after 4s
Security / RustSec cargo-audit (push) Failing after 5s
CI / test (macos-latest) (push) Has been cancelled
CI / test (windows-latest) (push) Has been cancelled
1208 lines
40 KiB
Rust
1208 lines
40 KiB
Rust
//! 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.
|
|
//!
|
|
//! The current local operation-log verifier remains local to geth's daemon
|
|
//! state. Portable SSHSIGCHAIN records are implemented separately in
|
|
//! [`sshsigchain`]: they use an explicit trust tuple, fixed signing bytes, and
|
|
//! a causal hash chain rather than a timestamp-sorted JSONL bundle.
|
|
|
|
mod sshsigchain;
|
|
|
|
pub use sshsigchain::{
|
|
AnchorAttesterPolicy, AnchorBackendPolicy, AnchorPolicy, AnchorReceipt, AnchorReceiptVerifier,
|
|
AnchoredHistory, AuthorityDeviceState, AuthorityKey, AuthorityKeyId, AuthorityKeyState,
|
|
AuthorityState, AuthorityTransition, ChainId as SshSigchainChainId,
|
|
Digest as SshSigchainDigest, HeadClaim, MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES,
|
|
MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure, ProfileExtension,
|
|
SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, SSH_SIGCHAIN_NAMESPACE,
|
|
SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainRecord,
|
|
SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier, VerifiedAnchoredHistory,
|
|
authority_key_id, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, key_proof_signing_bytes,
|
|
profile_payload_commitment, select_anchored_head, verify_anchor_receipts,
|
|
verify_anchored_history, verify_head_claim, verify_sshsigchain,
|
|
};
|
|
|
|
use geth_types::{
|
|
AgentId, AuthOpId, Capability, DeviceId, KeyId, NodeId, ResourceId, UnixMillis, UserId,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
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 AUTHORIZED_KEYS_NAMESPACE: &str = "geth.authorized-keys.v1@eric.wendland.dev";
|
|
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> {
|
|
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> {
|
|
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 {
|
|
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)]
|
|
pub struct KeychainOp {
|
|
pub id: geth_types::AuthOpId,
|
|
pub created_at: UnixMillis,
|
|
pub kind: KeychainOpKind,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct KeychainOpSignature {
|
|
pub op_id: AuthOpId,
|
|
pub signer: KeyId,
|
|
pub signer_public_key: String,
|
|
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,
|
|
}
|
|
|
|
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 {
|
|
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),
|
|
#[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),
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
}
|
|
|
|
#[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>,
|
|
},
|
|
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>,
|
|
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>,
|
|
}
|
|
|
|
pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView {
|
|
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();
|
|
|
|
for op in ops {
|
|
match &op.kind {
|
|
KeychainOpKind::KeychainInit => initialized = true,
|
|
KeychainOpKind::AdminKeyAdd { key, .. } => {
|
|
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);
|
|
}
|
|
}
|
|
KeychainOpKind::UserRevoke { user } => {
|
|
revoked_users.insert(user.clone());
|
|
}
|
|
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(),
|
|
});
|
|
}
|
|
KeychainOpKind::DeviceRevoke { device } => {
|
|
revoked_devices.insert(device.clone());
|
|
}
|
|
KeychainOpKind::DeviceKeyAdd { device, key } => {
|
|
if let Some(record) = devices.get_mut(device)
|
|
&& !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)
|
|
&& !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());
|
|
}
|
|
}
|
|
|
|
KeychainView {
|
|
initialized,
|
|
admin_keys: admin_keys.into_iter().collect(),
|
|
users,
|
|
devices,
|
|
nodes,
|
|
agents: agent_bindings,
|
|
endpoints,
|
|
}
|
|
}
|
|
|
|
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],
|
|
) -> 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,
|
|
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],
|
|
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
|
|
&& signature.namespace == profile.keychain_signature_namespace()
|
|
&& signature_uses_claimed_key(signature)
|
|
&& 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()))
|
|
}
|
|
|
|
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::*;
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[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(),
|
|
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,
|
|
},
|
|
),
|
|
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,
|
|
},
|
|
),
|
|
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 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),
|
|
},
|
|
];
|
|
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![
|
|
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());
|
|
}
|
|
}
|