diff --git a/AGENTS.md b/AGENTS.md index e7c5857..fc3d88b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,6 +134,8 @@ 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. - 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 diff --git a/README.md b/README.md index e6a527c..0a7c9ae 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,9 @@ SSH certificate request and renewal flows are managed as geth metadata. A node 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 for -distribution. Future Iroh replication will distribute these records between -authorized nodes. +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. ## MVP Features @@ -100,7 +100,7 @@ The bootstrap implementation provides: - `geth ssh cert list` - `geth ssh revocation add ` - `geth ssh revocation list` - - `geth ssh revocation export --out ` + - `geth ssh revocation export --out [--format jsonl|openssh-krl-spec]` Other command groups exist as explicit stubs: `pipe` and `ssh`. diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 42b2099..aaefd2d 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -321,6 +321,8 @@ pub enum SshRevocationCommand { Export { #[arg(long)] out: PathBuf, + #[arg(long, default_value = "jsonl")] + format: String, }, } @@ -522,7 +524,9 @@ fn request_for_command(command: Command) -> Result { reason, }, SshRevocationCommand::List => ControlRequest::SshRevocationList, - SshRevocationCommand::Export { out } => ControlRequest::SshRevocationExport { out }, + SshRevocationCommand::Export { out, format } => { + ControlRequest::SshRevocationExport { out, format } + } }, }, Command::Init | Command::Daemon { .. } => bail!("command is handled directly"), @@ -838,8 +842,15 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } } } - ControlResponse::SshRevocationExported { out, count } => { + ControlResponse::SshRevocationExported { + out, + format, + count, + note, + } => { println!("exported {count} ssh revocations to {}", out.display()); + println!("format: {format}"); + println!("note: {note}"); } ControlResponse::DbAdded { db } => { println!("registered db: {}", db.name); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index b2050c7..9ddd88e 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -111,6 +111,7 @@ pub enum ControlRequest { SshRevocationList, SshRevocationExport { out: PathBuf, + format: String, }, DbAdd { name: String, @@ -237,7 +238,9 @@ pub enum ControlResponse { }, SshRevocationExported { out: PathBuf, + format: String, count: usize, + note: String, }, DbAdded { db: DbResource, @@ -361,5 +364,25 @@ mod tests { decode_response(&encode_response(&response).expect("encode")).expect("decode"), response ); + + let request = ControlRequest::SshRevocationExport { + out: PathBuf::from("revocations.krl-spec"), + format: "openssh-krl-spec".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(), + count: 2, + note: "OpenSSH KRL specification".to_owned(), + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); } } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index a869900..158d766 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -18,8 +18,8 @@ use geth_resource::ResourceDescriptor; use geth_secrets::{BearerAccess, ResourceMasterSecret}; use geth_ssh_identity::{ SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, - SshRevocationEntry, SshRevocationKind, build_ssh_cert_sign_command, cert_request_id, - certificate_id, revocation_id, ssh_public_key_fingerprint, + SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind, build_ssh_cert_sign_command, + cert_request_id, certificate_id, openssh_krl_spec, revocation_id, ssh_public_key_fingerprint, }; use geth_store::{ Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredKeychainOp, StoredKvEntry, @@ -92,6 +92,8 @@ pub enum NodeError { MissingSshCertPrincipal, #[error("ssh certificate flow error: {0}")] SshCertFlow(#[from] geth_ssh_identity::SshCertFlowError), + #[error("ssh identity error: {0}")] + SshIdentity(#[from] geth_ssh_identity::SshIdentityError), } #[derive(Clone, Debug)] @@ -668,24 +670,41 @@ pub fn handle_request( .map(ssh_revocation_from_stored) .collect::, _>>()?, }), - ControlRequest::SshRevocationExport { out } => { + ControlRequest::SshRevocationExport { out, format } => { let revocations = store .list_ssh_revocations()? .into_iter() .map(ssh_revocation_from_stored) .collect::, _>>()?; + let format = format + .parse::() + .map_err(NodeError::SshIdentity)?; if let Some(parent) = out.parent() { std::fs::create_dir_all(parent)?; } - let mut body = String::new(); - for revocation in &revocations { - body.push_str(&serde_json::to_string(revocation)?); - body.push('\n'); - } + let (body, note) = match format { + SshRevocationExportFormat::Jsonl => { + let mut body = String::new(); + for revocation in &revocations { + body.push_str(&serde_json::to_string(revocation)?); + body.push('\n'); + } + ( + body, + "JSONL geth revocation metadata; not an OpenSSH KRL binary".to_owned(), + ) + } + SshRevocationExportFormat::OpenSshKrlSpec => ( + openssh_krl_spec(&revocations)?, + "OpenSSH KRL specification; generate a binary KRL with ssh-keygen -k -f [-s ] ".to_owned(), + ), + }; std::fs::write(&out, body)?; Ok(ControlResponse::SshRevocationExported { out, + format: format.to_string(), count: revocations.len(), + note, }) } ControlRequest::DbAdd { name, path } => { diff --git a/crates/geth-ssh-identity/src/lib.rs b/crates/geth-ssh-identity/src/lib.rs index 8d0ac36..2a32a90 100644 --- a/crates/geth-ssh-identity/src/lib.rs +++ b/crates/geth-ssh-identity/src/lib.rs @@ -1,5 +1,6 @@ use std::path::Path; use std::process::Command; +use std::str::FromStr; use geth_types::{NodeId, SshCertId, SshCertRequestId, SshRevocationId, UnixMillis}; use serde::{Deserialize, Serialize}; @@ -204,6 +205,43 @@ pub struct SshRevocationEntry { pub published: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SshRevocationExportFormat { + Jsonl, + OpenSshKrlSpec, +} + +impl SshRevocationExportFormat { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Jsonl => "jsonl", + Self::OpenSshKrlSpec => "openssh-krl-spec", + } + } +} + +impl std::fmt::Display for SshRevocationExportFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for SshRevocationExportFormat { + type Err = SshIdentityError; + + fn from_str(value: &str) -> Result { + match value { + "jsonl" => Ok(Self::Jsonl), + "openssh-krl-spec" | "krl-spec" => Ok(Self::OpenSshKrlSpec), + _ => Err(SshIdentityError::InvalidRevocationExportFormat( + value.to_owned(), + )), + } + } +} + #[derive(Debug, thiserror::Error)] pub enum SshCertFlowError { #[error("certificate request has no principals")] @@ -284,6 +322,31 @@ pub fn build_ssh_cert_sign_command( Ok(command) } +pub fn openssh_krl_spec(entries: &[SshRevocationEntry]) -> Result { + let mut spec = String::new(); + for entry in entries { + spec.push_str(&openssh_krl_spec_line(entry)?); + spec.push('\n'); + } + Ok(spec) +} + +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') { + return Err(SshIdentityError::InvalidRevocationTarget( + entry.target.clone(), + )); + } + + let directive = match entry.kind { + SshRevocationKind::PublicKey | SshRevocationKind::Certificate => "key", + SshRevocationKind::Serial => "serial", + SshRevocationKind::KeyId => "id", + }; + Ok(format!("{directive}: {target}")) +} + #[derive(Debug, thiserror::Error)] pub enum SshIdentityError { #[error("ssh-keygen failed or is unavailable")] @@ -294,6 +357,10 @@ pub enum SshIdentityError { InvalidRequestStatus(String), #[error("invalid ssh revocation kind: {0}")] InvalidRevocationKind(String), + #[error("invalid ssh revocation export format: {0}")] + InvalidRevocationExportFormat(String), + #[error("invalid ssh revocation target: {0}")] + InvalidRevocationTarget(String), #[error("io error: {0}")] Io(#[from] std::io::Error), } @@ -348,4 +415,54 @@ mod tests { assert!(command.contains(&"-h".to_owned())); assert!(command.contains(&"server.local".to_owned())); } + + #[test] + fn openssh_krl_spec_maps_revocation_kinds() { + let entries = vec![ + SshRevocationEntry { + id: "ssh-revocation:1".into(), + kind: SshRevocationKind::Serial, + target: "42".to_owned(), + reason: None, + created_at: UnixMillis(1), + published: false, + }, + SshRevocationEntry { + id: "ssh-revocation:2".into(), + kind: SshRevocationKind::KeyId, + target: "node:laptop".to_owned(), + reason: None, + created_at: UnixMillis(2), + published: false, + }, + SshRevocationEntry { + id: "ssh-revocation:3".into(), + kind: SshRevocationKind::PublicKey, + target: "ssh-ed25519 AAAA test".to_owned(), + reason: None, + created_at: UnixMillis(3), + published: false, + }, + ]; + + let spec = openssh_krl_spec(&entries).expect("krl spec"); + + assert!(spec.contains("serial: 42\n")); + assert!(spec.contains("id: node:laptop\n")); + assert!(spec.contains("key: ssh-ed25519 AAAA test\n")); + } + + #[test] + fn openssh_krl_spec_rejects_multiline_targets() { + let entry = SshRevocationEntry { + id: "ssh-revocation:1".into(), + kind: SshRevocationKind::PublicKey, + target: "ssh-ed25519 AAAA\nextra".to_owned(), + reason: None, + created_at: UnixMillis(1), + published: false, + }; + + assert!(openssh_krl_spec_line(&entry).is_err()); + } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index c034b86..7cc46e3 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -863,6 +863,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { &node, geth_control::ControlRequest::SshRevocationExport { out: export_path.clone(), + format: "jsonl".to_owned(), }, ) .expect("export revocations"); @@ -871,4 +872,32 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { .expect("read revocations") .contains("lost key") ); + + let krl_spec_path = home.path().join("revocations.krl-spec"); + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::SshRevocationExport { + out: krl_spec_path.clone(), + format: "openssh-krl-spec".to_owned(), + }, + ) + .expect("export revocation krl spec"); + match response { + geth_control::ControlResponse::SshRevocationExported { + format, + count, + note, + .. + } => { + assert_eq!(format, "openssh-krl-spec"); + assert_eq!(count, 1); + assert!(note.contains("ssh-keygen -k")); + } + other => panic!("unexpected response: {other:?}"), + } + assert!( + std::fs::read_to_string(krl_spec_path) + .expect("read krl spec") + .contains("key: ssh:blake3:test") + ); } diff --git a/docs/architecture.md b/docs/architecture.md index 8696c3d..f60019d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,8 +116,9 @@ roadmap stubs. `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. It does not yet -generate OpenSSH KRL binaries or replicate the lists over Iroh. +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. ## Keychain, Auth, And Secrets diff --git a/docs/roadmap.md b/docs/roadmap.md index 95888f0..44f7f3e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -189,11 +189,16 @@ resource-scoped capability decisions. - `[~]` SSH certificate and revocation lifecycle. Acceptance criteria: - - `geth ssh cert request/requests/approve/import/list` persist local metadata. - - Approval emits an explicit `ssh-keygen -s ...` command for CA/YubiKey use. - - `geth ssh revocation add/list/export` persists and exports revocations. - - Future completion requires auth checks for request, approve, import, publish, - and read capabilities. + - `[x]` `geth ssh cert request/requests/approve/import/list` persist local + metadata. + - `[x]` Approval emits an explicit `ssh-keygen -s ...` command for + CA/YubiKey use. + - `[x]` `geth ssh revocation add/list/export` persists and exports + revocations. + - `[x]` Revocations can be exported as JSONL and OpenSSH KRL specification + text. + - `[ ]` Future completion requires auth checks for request, approve, import, + publish, and read capabilities. ## Phase 3: CAS, KV, And Pubsub @@ -277,11 +282,14 @@ Goal: add authorized stream-oriented management workflows over Iroh. - Consumers can list current certs/revocations from local state while offline. - Conflicting or unsigned records are rejected or quarantined. -- `[ ]` OpenSSH KRL import/export. +- `[~]` OpenSSH KRL import/export. Acceptance criteria: - - Revocation records can produce an OpenSSH KRL file. - - Existing KRL files can be imported into revocation metadata where possible. - - Tests cover serial, key ID, public key, and certificate revocations. + - `[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. + - `[ ]` Existing KRL files can be imported into revocation metadata where + possible. + - `[ ]` Tests cover binary KRL export/import and certificate revocations. ## Phase 5: DB And Documents