diff --git a/AGENTS.md b/AGENTS.md index 025de17..37547d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,9 +148,10 @@ Roadmap items should be actionable and checkable: ops and must not allow trust graph mutation capabilities. Payload encryption, key envelopes, and bearer challenge-response are still roadmap work. - SSH revocations can be exported as JSONL, OpenSSH KRL specification text, or - binary OpenSSH KRL files generated through `ssh-keygen`. Existing KRL import - is still roadmap work. + binary OpenSSH KRL files generated through `ssh-keygen`. JSONL and OpenSSH KRL + specification imports are supported; binary KRL import is unsupported because + OpenSSH KRL files are not enumerable through OpenSSH tooling. - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, - iroh-docs, iroh-blobs, Automerge sync, broader auth enforcement, KRL import, - and Keyhive/BeeKEM-style authorization are future roadmap items unless - implemented later. + iroh-docs, iroh-blobs, Automerge sync, broader auth enforcement, certificate + revocation KRL tests, and Keyhive/BeeKEM-style authorization are future + roadmap items unless implemented later. diff --git a/Cargo.lock b/Cargo.lock index b9ca5c0..6d0944e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,6 +1048,7 @@ dependencies = [ "geth-discovery", "geth-iroh", "geth-node", + "geth-ssh-identity", "geth-store", "geth-types", "rusqlite", diff --git a/README.md b/README.md index ac99492..a41dd34 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ The bootstrap implementation provides: - `geth ssh revocation add ` - `geth ssh revocation list` - `geth ssh revocation export --out [--format jsonl|openssh-krl-spec|openssh-krl]` + - `geth ssh revocation import [--format jsonl|openssh-krl-spec]` - local pipe registry commands: `geth pipe listen/connect` `geth peer export/import/list` is for untrusted peer-card exchange while live diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 23ca356..667d3c4 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -400,6 +400,11 @@ pub enum SshRevocationCommand { #[arg(long)] ca_public: Option, }, + Import { + path: PathBuf, + #[arg(long, default_value = "jsonl")] + format: String, + }, } #[derive(Debug, Args)] @@ -671,6 +676,9 @@ fn request_for_command(command: Command) -> Result { format, ca_public, }, + SshRevocationCommand::Import { path, format } => { + ControlRequest::SshRevocationImport { path, format } + } }, }, Command::Init | Command::Daemon { .. } => bail!("command is handled directly"), @@ -1084,6 +1092,22 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("format: {format}"); println!("note: {note}"); } + ControlResponse::SshRevocationImported { + revocations, + format, + count, + note, + } => { + println!("imported {count} ssh revocations"); + println!("format: {format}"); + for revocation in revocations { + println!( + "{}\t{}\t{}", + revocation.id, revocation.kind, revocation.target + ); + } + println!("note: {note}"); + } ControlResponse::DbAdded { db } => { println!("registered db: {}", db.name); println!("id: {}", db.id); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 90b0fe2..71ed244 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -149,6 +149,10 @@ pub enum ControlRequest { format: String, ca_public: Option, }, + SshRevocationImport { + path: PathBuf, + format: String, + }, DbAdd { name: String, path: PathBuf, @@ -328,6 +332,12 @@ pub enum ControlResponse { count: usize, note: String, }, + SshRevocationImported { + revocations: Vec, + format: String, + count: usize, + note: String, + }, DbAdded { db: DbResource, }, @@ -477,6 +487,15 @@ mod tests { request ); + let request = ControlRequest::SshRevocationImport { + path: PathBuf::from("revocations.jsonl"), + format: "jsonl".to_owned(), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + let response = ControlResponse::SshRevocationExported { out: PathBuf::from("revocations.krl-spec"), format: "openssh-krl-spec".to_owned(), diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 07eb1fb..88c7778 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -26,8 +26,8 @@ use geth_secrets::{BearerAccess, ResourceMasterSecret}; use geth_ssh_identity::{ SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind, build_ssh_cert_sign_command, - cert_request_id, certificate_id, openssh_krl_spec, revocation_id, ssh_public_key_fingerprint, - write_openssh_krl, + cert_request_id, certificate_id, openssh_krl_spec, parse_openssh_krl_spec, revocation_id, + ssh_public_key_fingerprint, write_openssh_krl, }; use geth_store::{ Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, @@ -948,6 +948,53 @@ pub fn handle_request( note, }) } + ControlRequest::SshRevocationImport { path, format } => { + let body = std::fs::read_to_string(&path)?; + let created_at = UnixMillis(geth_store::now_ms()); + let revocations = match format.as_str() { + "jsonl" => body + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).map_err(NodeError::from)) + .collect::, NodeError>>()?, + "openssh-krl-spec" | "krl-spec" => parse_openssh_krl_spec(&body)? + .into_iter() + .enumerate() + .map(|(index, (kind, target))| { + let created_at = UnixMillis(created_at.0 + index as i64); + SshRevocationEntry { + id: revocation_id(&kind, &target, created_at), + kind, + target, + reason: Some(format!("imported from {}", path.display())), + created_at, + published: true, + } + }) + .collect(), + "openssh-krl" | "krl" => { + return Err(NodeError::SshIdentity( + geth_ssh_identity::SshIdentityError::BinaryKrlImportUnsupported, + )); + } + _ => { + return Err(NodeError::SshIdentity( + geth_ssh_identity::SshIdentityError::InvalidRevocationExportFormat( + format.clone(), + ), + )); + } + }; + for revocation in &revocations { + store.insert_ssh_revocation(&stored_from_ssh_revocation(revocation))?; + } + Ok(ControlResponse::SshRevocationImported { + count: revocations.len(), + revocations, + format, + note: "imported revocation metadata; binary OpenSSH KRL files cannot be enumerated, import JSONL or the KRL spec source instead".to_owned(), + }) + } ControlRequest::DbAdd { name, path } => { geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?; if !path.is_file() { diff --git a/crates/geth-ssh-identity/src/lib.rs b/crates/geth-ssh-identity/src/lib.rs index 13ff538..b46dec8 100644 --- a/crates/geth-ssh-identity/src/lib.rs +++ b/crates/geth-ssh-identity/src/lib.rs @@ -334,6 +334,33 @@ pub fn openssh_krl_spec(entries: &[SshRevocationEntry]) -> Result Result, SshIdentityError> { + let mut entries = Vec::new(); + for line in spec.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((directive, target)) = line.split_once(':') else { + return Err(SshIdentityError::InvalidKrlSpecLine(line.to_owned())); + }; + let target = target.trim(); + if target.is_empty() || target.bytes().any(|byte| byte == b'\n' || byte == b'\r') { + return Err(SshIdentityError::InvalidRevocationTarget(target.to_owned())); + } + let kind = match directive.trim() { + "key" => SshRevocationKind::PublicKey, + "serial" => SshRevocationKind::Serial, + "id" => SshRevocationKind::KeyId, + other => return Err(SshIdentityError::InvalidKrlSpecLine(other.to_owned())), + }; + entries.push((kind, target.to_owned())); + } + Ok(entries) +} + pub fn write_openssh_krl( entries: &[SshRevocationEntry], out: &Path, @@ -394,6 +421,10 @@ pub enum SshIdentityError { InvalidRevocationExportFormat(String), #[error("invalid ssh revocation target: {0}")] InvalidRevocationTarget(String), + #[error("invalid OpenSSH KRL specification line: {0}")] + InvalidKrlSpecLine(String), + #[error("binary OpenSSH KRL files cannot be enumerated; import JSONL or the KRL spec source")] + BinaryKrlImportUnsupported, #[error("io error: {0}")] Io(#[from] std::io::Error), } @@ -485,6 +516,31 @@ mod tests { assert!(spec.contains("key: ssh-ed25519 AAAA test\n")); } + #[test] + fn openssh_krl_spec_import_parses_supported_directives() { + let parsed = parse_openssh_krl_spec( + r#" + # comment + serial: 42 + id: node:laptop + key: ssh-ed25519 AAAA test + "#, + ) + .expect("parse spec"); + + assert_eq!( + parsed, + vec![ + (SshRevocationKind::Serial, "42".to_owned()), + (SshRevocationKind::KeyId, "node:laptop".to_owned()), + ( + SshRevocationKind::PublicKey, + "ssh-ed25519 AAAA test".to_owned() + ), + ] + ); + } + #[test] fn openssh_krl_spec_rejects_multiline_targets() { let entry = SshRevocationEntry { diff --git a/crates/geth/Cargo.toml b/crates/geth/Cargo.toml index 41fc599..019a651 100644 --- a/crates/geth/Cargo.toml +++ b/crates/geth/Cargo.toml @@ -22,6 +22,7 @@ geth-control = { path = "../geth-control" } geth-discovery = { path = "../geth-discovery" } geth-iroh = { path = "../geth-iroh" } geth-node = { path = "../geth-node" } +geth-ssh-identity = { path = "../geth-ssh-identity" } geth-store = { path = "../geth-store" } geth-types = { path = "../geth-types" } rusqlite.workspace = true diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 9423cc3..7a55eab 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -1365,10 +1365,53 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { other => panic!("unexpected response: {other:?}"), } assert!( - std::fs::read_to_string(krl_spec_path) + std::fs::read_to_string(&krl_spec_path) .expect("read krl spec") .contains("key: ssh:blake3:test") ); + + let import_home = tempfile::tempdir().expect("import tempdir"); + let import_paths = geth_config::GethPaths::from_home(import_home.path()); + let import_node = geth_node::init_node(&import_paths).expect("init import node"); + let response = geth_node::handle_request( + &import_node, + geth_control::ControlRequest::SshRevocationImport { + path: krl_spec_path, + format: "openssh-krl-spec".to_owned(), + }, + ) + .expect("import krl spec"); + match response { + geth_control::ControlResponse::SshRevocationImported { + count, + revocations, + note, + .. + } => { + assert_eq!(count, 1); + assert_eq!( + revocations[0].kind, + geth_ssh_identity::SshRevocationKind::PublicKey + ); + assert!(note.contains("binary OpenSSH KRL files cannot be enumerated")); + } + other => panic!("unexpected response: {other:?}"), + } + + let response = geth_node::handle_request( + &import_node, + geth_control::ControlRequest::SshRevocationImport { + path: home.path().join("revocations.jsonl"), + format: "jsonl".to_owned(), + }, + ) + .expect("import jsonl revocations"); + match response { + geth_control::ControlResponse::SshRevocationImported { count, .. } => { + assert_eq!(count, 1); + } + other => panic!("unexpected response: {other:?}"), + } } #[test] diff --git a/docs/architecture.md b/docs/architecture.md index 47417b4..4fa8367 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -140,8 +140,11 @@ approval, certificate import, and revocation-list data models. The bootstrap persists these flows locally and exports revocations as JSONL or OpenSSH KRL specification text. It can also invoke `ssh-keygen -k` to produce a binary OpenSSH KRL; serial and key-ID KRL entries require a CA public key via -`--ca-public`, matching OpenSSH behavior. It does not yet import existing KRL -files or replicate the lists over Iroh. +`--ca-public`, matching OpenSSH behavior. It can import geth JSONL revocation +exports and OpenSSH KRL specification source files. Binary OpenSSH KRL files are +not enumerable through OpenSSH tooling, so geth treats binary import as +unsupported and asks for JSONL or the spec source. Revocation lists are not yet +replicated over Iroh. ## Keychain, Auth, And Secrets diff --git a/docs/roadmap.md b/docs/roadmap.md index dcbd2ae..f9b414c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -298,9 +298,12 @@ Goal: add authorized stream-oriented management workflows over Iroh. - `[x]` Revocation records can produce an OpenSSH binary KRL file. - `[x]` Tests cover binary KRL export for public-key revocations when `ssh-keygen` is available. - - `[ ]` Existing KRL files can be imported into revocation metadata where - possible. - - `[ ]` Tests cover KRL import and certificate revocations. + - `[x]` JSONL exports and OpenSSH KRL specification source files can be + imported into revocation metadata. + - `[x]` Binary OpenSSH KRL import returns a clear unsupported message because + KRL files are not enumerable through OpenSSH tooling. + - `[x]` Tests cover JSONL and KRL-spec import. + - `[ ]` Tests cover certificate revocations. ## Phase 5: DB And Documents