diff --git a/AGENTS.md b/AGENTS.md index 99b893a..025de17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,9 +147,10 @@ Roadmap items should be actionable and checkable: Bearer access metadata can be created/listed/revoked as resource-scoped auth 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 or OpenSSH KRL specification text. - Binary KRL generation/import is 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. - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, - iroh-docs, iroh-blobs, Automerge sync, real auth enforcement, - OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future - roadmap items unless implemented later. + iroh-docs, iroh-blobs, Automerge sync, broader auth enforcement, KRL import, + and Keyhive/BeeKEM-style authorization are future roadmap items unless + implemented later. diff --git a/Cargo.lock b/Cargo.lock index f5b1ac8..b9ca5c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1293,6 +1293,7 @@ dependencies = [ "geth-types", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", ] diff --git a/README.md b/README.md index 7df4db7..ac99492 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ can create a certificate request, another machine can approve it and receive an explicit `ssh-keygen -s ...` command suitable for a CA key or YubiKey-backed CA, and the resulting `-cert.pub` can be imported for distribution. Certificate and key revocation entries are tracked locally and can be exported as JSONL or as an -OpenSSH KRL specification file for later `ssh-keygen -k` use. Future Iroh -replication will distribute these records between authorized nodes. +OpenSSH KRL specification file or a binary OpenSSH KRL generated through +`ssh-keygen -k`. Future Iroh replication will distribute these records between +authorized nodes. ## MVP Features @@ -111,7 +112,7 @@ The bootstrap implementation provides: - `geth ssh cert list` - `geth ssh revocation add ` - `geth ssh revocation list` - - `geth ssh revocation export --out [--format jsonl|openssh-krl-spec]` + - `geth ssh revocation export --out [--format jsonl|openssh-krl-spec|openssh-krl]` - 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 123079c..23ca356 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -397,6 +397,8 @@ pub enum SshRevocationCommand { out: PathBuf, #[arg(long, default_value = "jsonl")] format: String, + #[arg(long)] + ca_public: Option, }, } @@ -660,9 +662,15 @@ fn request_for_command(command: Command) -> Result { reason, }, SshRevocationCommand::List => ControlRequest::SshRevocationList, - SshRevocationCommand::Export { out, format } => { - ControlRequest::SshRevocationExport { out, format } - } + SshRevocationCommand::Export { + out, + format, + ca_public, + } => ControlRequest::SshRevocationExport { + out, + format, + ca_public, + }, }, }, Command::Init | Command::Daemon { .. } => bail!("command is handled directly"), diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index dfc55ae..90b0fe2 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -147,6 +147,7 @@ pub enum ControlRequest { SshRevocationExport { out: PathBuf, format: String, + ca_public: Option, }, DbAdd { name: String, @@ -469,6 +470,7 @@ mod tests { let request = ControlRequest::SshRevocationExport { out: PathBuf::from("revocations.krl-spec"), format: "openssh-krl-spec".to_owned(), + ca_public: None, }; assert_eq!( decode_request(&encode_request(&request).expect("encode")).expect("decode"), diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index c5661d5..07eb1fb 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -27,6 +27,7 @@ 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, }; use geth_store::{ Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, @@ -897,7 +898,11 @@ pub fn handle_request( .map(ssh_revocation_from_stored) .collect::, _>>()?, }), - ControlRequest::SshRevocationExport { out, format } => { + ControlRequest::SshRevocationExport { + out, + format, + ca_public, + } => { let revocations = store .list_ssh_revocations()? .into_iter() @@ -925,8 +930,17 @@ pub fn handle_request( openssh_krl_spec(&revocations)?, "OpenSSH KRL specification; generate a binary KRL with ssh-keygen -k -f [-s ] ".to_owned(), ), + SshRevocationExportFormat::OpenSshKrl => { + write_openssh_krl(&revocations, &out, ca_public.as_deref())?; + ( + String::new(), + "OpenSSH binary KRL generated with ssh-keygen; use ssh-keygen -Q -f to query it".to_owned(), + ) + } }; - std::fs::write(&out, body)?; + if format != SshRevocationExportFormat::OpenSshKrl { + std::fs::write(&out, body)?; + } Ok(ControlResponse::SshRevocationExported { out, format: format.to_string(), diff --git a/crates/geth-ssh-identity/Cargo.toml b/crates/geth-ssh-identity/Cargo.toml index 87ce566..e007000 100644 --- a/crates/geth-ssh-identity/Cargo.toml +++ b/crates/geth-ssh-identity/Cargo.toml @@ -13,3 +13,4 @@ geth-types = { path = "../geth-types" } [dev-dependencies] serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/geth-ssh-identity/src/lib.rs b/crates/geth-ssh-identity/src/lib.rs index 2a32a90..13ff538 100644 --- a/crates/geth-ssh-identity/src/lib.rs +++ b/crates/geth-ssh-identity/src/lib.rs @@ -15,8 +15,8 @@ pub const SSH_CERT_ISSUANCE_NAMESPACE: &str = "geth.ssh-cert-issuance.v1@geth.lo pub const SSH_REVOCATION_LIST_NAMESPACE: &str = "geth.ssh-revocation-list.v1@geth.local"; pub fn ensure_ssh_keygen_available() -> Result<(), SshIdentityError> { - let status = Command::new("ssh-keygen").arg("-?").status(); - match status { + let output = Command::new("ssh-keygen").arg("-?").output(); + match output { Ok(_) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { Err(SshIdentityError::SshKeygenUnavailable) @@ -210,6 +210,7 @@ pub struct SshRevocationEntry { pub enum SshRevocationExportFormat { Jsonl, OpenSshKrlSpec, + OpenSshKrl, } impl SshRevocationExportFormat { @@ -218,6 +219,7 @@ impl SshRevocationExportFormat { match self { Self::Jsonl => "jsonl", Self::OpenSshKrlSpec => "openssh-krl-spec", + Self::OpenSshKrl => "openssh-krl", } } } @@ -235,6 +237,7 @@ impl FromStr for SshRevocationExportFormat { match value { "jsonl" => Ok(Self::Jsonl), "openssh-krl-spec" | "krl-spec" => Ok(Self::OpenSshKrlSpec), + "openssh-krl" | "krl" => Ok(Self::OpenSshKrl), _ => Err(SshIdentityError::InvalidRevocationExportFormat( value.to_owned(), )), @@ -331,6 +334,34 @@ pub fn openssh_krl_spec(entries: &[SshRevocationEntry]) -> Result, +) -> Result<(), SshIdentityError> { + ensure_ssh_keygen_available()?; + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent)?; + } + + let spec = openssh_krl_spec(entries)?; + let spec_path = out.with_extension("geth-krl-spec.tmp"); + std::fs::write(&spec_path, spec)?; + let mut command = Command::new("ssh-keygen"); + command.arg("-k").arg("-f").arg(out); + if let Some(ca_public) = ca_public { + command.arg("-s").arg(ca_public); + } + command.arg(&spec_path); + let output = command.output()?; + let _ = std::fs::remove_file(&spec_path); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + return Err(SshIdentityError::SshKeygenFailed(stderr)); + } + Ok(()) +} + pub fn openssh_krl_spec_line(entry: &SshRevocationEntry) -> Result { let target = entry.target.trim(); if target.is_empty() || target.bytes().any(|byte| byte == b'\n' || byte == b'\r') { @@ -351,6 +382,8 @@ pub fn openssh_krl_spec_line(entry: &SshRevocationEntry) -> Result { + assert_eq!(format, "openssh-krl"); + assert_eq!(count, 1); + assert!(note.contains("binary KRL")); + } + other => panic!("unexpected response: {other:?}"), + } + assert!(krl_path.exists()); + + let query = Command::new("ssh-keygen") + .arg("-Q") + .arg("-f") + .arg(&krl_path) + .arg(&public_key_path) + .output() + .expect("query krl"); + assert!(!query.status.success()); + assert!( + String::from_utf8_lossy(&query.stdout) + .to_ascii_lowercase() + .contains("revoked") + ); +} diff --git a/docs/architecture.md b/docs/architecture.md index 71d7f14..47417b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,8 +138,10 @@ streams yet. `geth-ssh-identity` defines SSH trust namespaces plus certificate request, 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 does not yet generate OpenSSH KRL binaries or replicate -the lists over Iroh. +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. ## Keychain, Auth, And Secrets diff --git a/docs/roadmap.md b/docs/roadmap.md index 6a9a639..dcbd2ae 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -200,7 +200,7 @@ resource-scoped capability decisions. - `[x]` `geth ssh revocation add/list/export` persists and exports revocations. - `[x]` Revocations can be exported as JSONL and OpenSSH KRL specification - text. + text or as a binary OpenSSH KRL through `ssh-keygen`. - `[ ]` Future completion requires auth checks for request, approve, import, publish, and read capabilities. @@ -295,10 +295,12 @@ Goal: add authorized stream-oriented management workflows over Iroh. Acceptance criteria: - `[x]` Revocation records can produce an OpenSSH KRL specification file. - `[x]` Tests cover serial, key ID, and public key revocation spec lines. - - `[ ]` Revocation records can produce an OpenSSH binary KRL file. + - `[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 binary KRL export/import and certificate revocations. + - `[ ]` Tests cover KRL import and certificate revocations. ## Phase 5: DB And Documents