Extract reusable keychain sigchain model

This commit is contained in:
Eric Wendland 2026-05-26 18:53:20 +02:00
commit 4013c868aa
11 changed files with 938 additions and 20 deletions

View file

@ -635,6 +635,27 @@ pub enum KeychainCommand {
signing_key: Option<PathBuf>,
},
Status,
AdminAdd {
#[arg(long)]
admin_key: PathBuf,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
principal: Option<String>,
#[arg(long)]
valid_after_ms: Option<i64>,
#[arg(long)]
valid_before_ms: Option<i64>,
},
AdminRevoke {
key: String,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
},
AllowedSigners,
Verify,
Sync {
node: String,
},
@ -1432,6 +1453,40 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Keychain {
command: KeychainCommand::Status,
} => ControlRequest::KeychainStatus,
Command::Keychain {
command:
KeychainCommand::AdminAdd {
admin_key,
signing_key,
principal,
valid_after_ms,
valid_before_ms,
},
} => ControlRequest::KeychainAdminAdd {
admin_key_path: admin_key,
signing_key_path: signing_key,
principal,
valid_after_ms,
valid_before_ms,
},
Command::Keychain {
command:
KeychainCommand::AdminRevoke {
key,
signing_key,
admin_key,
},
} => ControlRequest::KeychainAdminRevoke {
key,
signing_key_path: signing_key,
admin_key_path: admin_key,
},
Command::Keychain {
command: KeychainCommand::AllowedSigners,
} => ControlRequest::KeychainAllowedSigners,
Command::Keychain {
command: KeychainCommand::Verify,
} => ControlRequest::KeychainVerify,
Command::Keychain {
command: KeychainCommand::Sync { node },
} => ControlRequest::KeychainSync { node },
@ -2458,6 +2513,47 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
);
}
}
ControlResponse::KeychainAdminUpdated {
op,
signatures,
note,
} => {
println!("recorded keychain op: {}", op.id);
for signature in signatures {
println!(
"signed keychain op: {} by {} ({})",
signature.op_id, signature.signer, signature.namespace
);
}
println!("note: {note}");
}
ControlResponse::KeychainAllowedSigners {
allowed_signers,
note,
..
} => {
print!("{allowed_signers}");
if allowed_signers.is_empty() {
println!("no active admin public keys available");
}
eprintln!("note: {note}");
}
ControlResponse::KeychainVerified { report } => {
println!("ops: {}", report.ops);
println!("signatures: {}", report.signatures);
println!("accepted_ops: {}", report.accepted_ops);
println!("rejected_ops: {}", report.rejected_ops);
println!("active_admin_keys: {}", report.active_admin_keys);
println!(
"accepted_head: {}",
report
.accepted_head
.as_ref()
.map(|head| head.as_str())
.unwrap_or("none")
);
println!("note: {}", report.note);
}
ControlResponse::KeychainSynced {
peer_node_id,
peer_agent_id,

View file

@ -3,7 +3,10 @@ use geth_cas::{FileConflict, FileRoot, FileRootScan};
use geth_db::{CrSqliteChangeBatch, DbResource};
use geth_discovery::{DiscoveredPeer, PeerCard};
use geth_document::{DocumentResource, DocumentState};
use geth_keychain::{KeychainOp, KeychainOpSignature, NodeEnrollmentRequest, NodeRecord};
use geth_keychain::{
KeychainAllowedSigner, KeychainOp, KeychainOpSignature, KeychainSigchainReport,
NodeEnrollmentRequest, NodeRecord,
};
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_overlay::{
OverlayInterfacePlan, OverlayJoinPlan, OverlayNetworkStatus, OverlayPacket, OverlayPlan,
@ -226,6 +229,20 @@ pub enum ControlRequest {
signing_key_path: Option<PathBuf>,
},
KeychainStatus,
KeychainAdminAdd {
admin_key_path: PathBuf,
signing_key_path: PathBuf,
principal: Option<String>,
valid_after_ms: Option<i64>,
valid_before_ms: Option<i64>,
},
KeychainAdminRevoke {
key: String,
signing_key_path: PathBuf,
admin_key_path: Option<PathBuf>,
},
KeychainAllowedSigners,
KeychainVerify,
KeychainSync {
node: String,
},
@ -662,6 +679,19 @@ pub enum ControlResponse {
ops: Vec<KeychainOp>,
signatures: Vec<KeychainOpSignature>,
},
KeychainAdminUpdated {
op: KeychainOp,
signatures: Vec<KeychainOpSignature>,
note: String,
},
KeychainAllowedSigners {
entries: Vec<KeychainAllowedSigner>,
allowed_signers: String,
note: String,
},
KeychainVerified {
report: KeychainSigchainReport,
},
KeychainSynced {
peer_node_id: String,
peer_agent_id: String,

View file

@ -6,10 +6,9 @@ rust-version.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
geth-codec = { path = "../geth-codec" }
geth-types = { path = "../geth-types" }
[dev-dependencies]
serde_json.workspace = true

View file

@ -41,6 +41,34 @@ pub struct KeychainOpSignature {
pub created_at: UnixMillis,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainAllowedSigner {
pub key: KeyId,
pub principal: String,
pub public_key: String,
pub valid_after_ms: Option<i64>,
pub valid_before_ms: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainSigchainReport {
pub ops: usize,
pub signatures: usize,
pub accepted_ops: usize,
pub rejected_ops: usize,
pub active_admin_keys: usize,
pub accepted_head: Option<AuthOpId>,
pub note: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainSigchainEntry {
pub op: KeychainOp,
pub signatures: Vec<KeychainOpSignature>,
}
pub type KeychainSignatureVerifier<'a> = dyn Fn(&KeychainOp, &KeychainOpSignature) -> bool + 'a;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeEnrollmentRequest {
pub id: AuthOpId,
@ -127,6 +155,11 @@ pub struct NodeEnrollmentRequestSigningPayload {
pub enum KeychainError {
#[error("invalid node enrollment status: {0}")]
InvalidEnrollmentStatus(String),
#[error("sigchain JSONL line {line}: {source}")]
SigchainJsonl {
line: usize,
source: serde_json::Error,
},
}
#[must_use]
@ -152,6 +185,10 @@ 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,
@ -254,7 +291,7 @@ pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView {
for op in ops {
match &op.kind {
KeychainOpKind::KeychainInit => initialized = true,
KeychainOpKind::AdminKeyAdd { key } => {
KeychainOpKind::AdminKeyAdd { key, .. } => {
admin_keys.insert(key.clone());
}
KeychainOpKind::AdminKeyRevoke { key } => {
@ -364,6 +401,204 @@ pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView {
}
}
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> {
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(|| "admin".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: &KeychainSignatureVerifier<'_>,
) -> 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_uses_claimed_key(signature)
&& verifier(op, signature)
});
if valid {
accepted.push(op.clone());
trusted_admins = reduce_keychain_ops(&accepted)
.admin_keys
.into_iter()
.collect();
} else {
rejected_ops += 1;
}
}
let view = reduce_keychain_ops(&accepted);
KeychainSigchainReport {
ops: ops.len(),
signatures: signatures.len(),
accepted_ops: accepted.len(),
rejected_ops,
active_admin_keys: view.admin_keys.len(),
accepted_head: accepted.last().map(|op| op.id.clone()),
note: "verified by replaying keychain operations against the previously accepted admin-key view, similar to git-skm's parent allowed_signers verification".to_owned(),
}
}
#[must_use]
pub fn signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool {
KeyId::new(admin_key_fingerprint(&signature.signer_public_key)) == signature.signer
}
#[must_use]
pub fn admin_key_fingerprint(public_key: &str) -> String {
format!("ssh:blake3:{}", blake3::hash(public_key.trim().as_bytes()))
}
#[must_use]
pub fn sigchain_entries(
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
) -> Vec<KeychainSigchainEntry> {
sorted_keychain_ops(ops.to_vec())
.into_iter()
.map(|op| KeychainSigchainEntry {
signatures: signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.cloned()
.collect(),
op,
})
.collect()
}
pub fn encode_sigchain_jsonl(
entries: &[KeychainSigchainEntry],
) -> Result<String, serde_json::Error> {
let mut text = String::new();
for entry in entries {
text.push_str(&serde_json::to_string(entry)?);
text.push('\n');
}
Ok(text)
}
pub fn decode_sigchain_jsonl(text: &str) -> Result<Vec<KeychainSigchainEntry>, KeychainError> {
text.lines()
.enumerate()
.filter(|(_, line)| !line.trim().is_empty())
.map(|(index, line)| {
serde_json::from_str::<KeychainSigchainEntry>(line).map_err(|source| {
KeychainError::SigchainJsonl {
line: index + 1,
source,
}
})
})
.collect()
}
#[must_use]
pub fn flatten_sigchain_entries(
entries: &[KeychainSigchainEntry],
) -> (Vec<KeychainOp>, Vec<KeychainOpSignature>) {
let ops = entries.iter().map(|entry| entry.op.clone()).collect();
let signatures = entries
.iter()
.flat_map(|entry| entry.signatures.clone())
.collect();
(ops, signatures)
}
#[cfg(test)]
mod tests {
use super::*;
@ -390,6 +625,10 @@ mod tests {
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!(
@ -422,12 +661,20 @@ mod tests {
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(
@ -562,6 +809,110 @@ mod tests {
assert!(!view.endpoints.contains_key("endpoint:old"));
}
#[test]
fn allowed_signers_and_sigchain_jsonl_are_portable() {
let ops = vec![
op(1, KeychainOpKind::KeychainInit),
op(
2,
KeychainOpKind::AdminKeyAdd {
key: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(),
public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()),
principal: Some("admin-a".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
),
];
let signatures = vec![KeychainOpSignature {
op_id: ops[1].id.clone(),
signer: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(),
signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(),
namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
signature: vec![1],
created_at: UnixMillis(2),
}];
let allowed = allowed_signers(&ops, &signatures);
assert_eq!(allowed.len(), 1);
assert!(render_allowed_signers(&allowed).contains("admin-a ssh-ed25519"));
let entries = sigchain_entries(&ops, &signatures);
let jsonl = encode_sigchain_jsonl(&entries).expect("encode jsonl");
assert_eq!(jsonl.lines().count(), 2);
let decoded = decode_sigchain_jsonl(&jsonl).expect("decode jsonl");
assert_eq!(decoded, entries);
let (decoded_ops, decoded_signatures) = flatten_sigchain_entries(&decoded);
assert_eq!(decoded_ops, sorted_keychain_ops(ops));
assert_eq!(decoded_signatures, signatures);
}
#[test]
fn 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, &|_, signature| {
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 reducer_excludes_revoked_identity_subtrees() {
let ops = vec![

View file

@ -403,7 +403,13 @@ fn initialize_owner_keychain(
ops.push(KeychainOp {
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), now),
created_at: now,
kind: KeychainOpKind::AdminKeyAdd { key: admin_key },
kind: KeychainOpKind::AdminKeyAdd {
key: admin_key,
public_key: Some(public_key.trim().to_owned()),
principal: Some("admin".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
});
}
@ -3389,7 +3395,7 @@ async fn keychain_sync_from_peer_since(
.iter()
.filter(|signature| {
if !trusted_admins.contains(&signature.signer)
|| !keychain_signature_uses_claimed_key(signature)
|| !geth_keychain::signature_uses_claimed_key(signature)
{
return false;
}
@ -7578,7 +7584,13 @@ pub fn handle_request(
let op = KeychainOp {
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), created_at),
created_at,
kind: KeychainOpKind::AdminKeyAdd { key: admin_key },
kind: KeychainOpKind::AdminKeyAdd {
key: admin_key,
public_key: Some(public_key.trim().to_owned()),
principal: Some("admin".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
};
store_keychain_op(&store, &op)?;
ops.push(op);
@ -7608,6 +7620,81 @@ pub fn handle_request(
nodes: view.nodes.len(),
}))
}
ControlRequest::KeychainAdminAdd {
admin_key_path,
signing_key_path,
principal,
valid_after_ms,
valid_before_ms,
} => {
let public_key = std::fs::read_to_string(&admin_key_path)?;
let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key));
let created_at = UnixMillis(geth_store::now_ms());
let op = KeychainOp {
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), created_at),
created_at,
kind: KeychainOpKind::AdminKeyAdd {
key: admin_key,
public_key: Some(public_key.trim().to_owned()),
principal: principal.or_else(|| Some("admin".to_owned())),
valid_after_ms,
valid_before_ms,
},
};
let signatures = store_and_sign_keychain_ops(
&store,
node,
std::slice::from_ref(&op),
Some(&signing_key_path),
None,
)?;
Ok(ControlResponse::KeychainAdminUpdated {
op,
signatures,
note: "recorded signed admin key addition in the keychain sigchain".to_owned(),
})
}
ControlRequest::KeychainAdminRevoke {
key,
signing_key_path,
admin_key_path,
} => {
let created_at = UnixMillis(geth_store::now_ms());
let op = KeychainOp {
id: generated_keychain_op_id("admin-key-revoke", &key, created_at),
created_at,
kind: KeychainOpKind::AdminKeyRevoke {
key: KeyId::new(key),
},
};
let signatures = store_and_sign_keychain_ops(
&store,
node,
std::slice::from_ref(&op),
Some(&signing_key_path),
admin_key_path.as_deref(),
)?;
Ok(ControlResponse::KeychainAdminUpdated {
op,
signatures,
note: "recorded signed admin key revocation in the keychain sigchain".to_owned(),
})
}
ControlRequest::KeychainAllowedSigners => {
let entries = geth_keychain::allowed_signers(
&load_keychain_ops(&store)?,
&load_keychain_signatures(&store)?,
);
let allowed_signers = geth_keychain::render_allowed_signers(&entries);
Ok(ControlResponse::KeychainAllowedSigners {
entries,
allowed_signers,
note: "derived from active AdminKeyAdd/AdminKeyRevoke operations; compatible with ssh-keygen -Y allowed_signers format".to_owned(),
})
}
ControlRequest::KeychainVerify => Ok(ControlResponse::KeychainVerified {
report: verify_keychain_sigchain_with_ssh(&store, node)?,
}),
ControlRequest::NodeList => {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
Ok(ControlResponse::NodeList {
@ -10414,6 +10501,26 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
Ok(())
}
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| {
verify_keychain_signature_with_ssh(
node,
op,
&stored_keychain_signature_from_signature(signature),
)
.unwrap_or(false)
},
))
}
fn store_and_sign_keychain_ops(
store: &Store,
node: &LocalNode,
@ -10519,10 +10626,6 @@ fn stored_keychain_signature_from_signature(
}
}
fn keychain_signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool {
KeyId::new(ssh_public_key_fingerprint(&signature.signer_public_key)) == signature.signer
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct KeychainSignatureStatus {
total: usize,
@ -10614,11 +10717,12 @@ fn verify_keychain_signature_with_ssh(
}
fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
store
let ops = store
.list_keychain_ops()?
.into_iter()
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
.collect()
.map(|stored| serde_json::from_str::<KeychainOp>(&stored.op_json).map_err(NodeError::from))
.collect::<Result<Vec<_>, NodeError>>()?;
Ok(geth_keychain::sorted_keychain_ops(ops))
}
fn load_keychain_signatures(store: &Store) -> Result<Vec<KeychainOpSignature>, NodeError> {

View file

@ -2575,6 +2575,85 @@ fn keychain_init_can_record_openssh_signatures() {
}
}
#[test]
fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
if Command::new("ssh-keygen").arg("-?").output().is_err() {
return;
}
let home = tempfile::tempdir().expect("tempdir");
let paths = geth_config::GethPaths::from_home(home.path());
let node = geth_node::init_node(&paths).expect("init node");
let admin_key_path = home.path().join("admin_ed25519");
generate_ssh_key(&admin_key_path);
geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainInit {
admin_key_path: Some(admin_key_path.with_extension("pub")),
signing_key_path: Some(admin_key_path.clone()),
},
)
.expect("init signed keychain");
let second_admin_key_path = home.path().join("second_admin_ed25519");
generate_ssh_key(&second_admin_key_path);
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainAdminAdd {
admin_key_path: second_admin_key_path.with_extension("pub"),
signing_key_path: admin_key_path.clone(),
principal: Some("second-admin".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
)
.expect("admin add");
match response {
geth_control::ControlResponse::KeychainAdminUpdated { op, signatures, .. } => {
assert_eq!(signatures.len(), 1);
match op.kind {
geth_keychain::KeychainOpKind::AdminKeyAdd {
public_key,
principal,
..
} => {
assert!(public_key.expect("public key").starts_with("ssh-ed25519 "));
assert_eq!(principal.as_deref(), Some("second-admin"));
}
other => panic!("unexpected op kind: {other:?}"),
}
}
other => panic!("unexpected response: {other:?}"),
}
let response =
geth_node::handle_request(&node, geth_control::ControlRequest::KeychainAllowedSigners)
.expect("allowed signers");
match response {
geth_control::ControlResponse::KeychainAllowedSigners {
entries,
allowed_signers,
..
} => {
assert_eq!(entries.len(), 2);
assert!(allowed_signers.contains("second-admin ssh-ed25519 "));
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainVerify)
.expect("verify sigchain");
match response {
geth_control::ControlResponse::KeychainVerified { report } => {
assert_eq!(report.rejected_ops, 0);
assert_eq!(report.active_admin_keys, 2);
assert!(report.note.contains("git-skm"));
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test]
fn init_owned_node_records_signed_owner_device_and_node() {
if Command::new("ssh-keygen").arg("-?").output().is_err() {