From 91c65e367d146583da81249ad8a221502e9dffbe Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Tue, 19 May 2026 15:56:47 +0200 Subject: [PATCH] Add OpenSSH certificate signing path --- AGENTS.md | 5 +- README.md | 11 ++-- crates/geth-cli/src/lib.rs | 8 +++ crates/geth-control/src/lib.rs | 1 + crates/geth-node/src/lib.rs | 39 ++++++++++++- crates/geth-ssh-identity/src/lib.rs | 2 + crates/geth/tests/bootstrap.rs | 87 +++++++++++++++++++++++++++++ docs/architecture.md | 16 ++++-- docs/roadmap.md | 4 ++ 9 files changed, 160 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6f469c9..c08a767 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,10 @@ Roadmap items should be actionable and checkable: specification imports are supported; binary KRL import is unsupported because OpenSSH KRL files are not enumerable through OpenSSH tooling. Tests cover public-key and certificate binary KRL revocations when `ssh-keygen` is - available. Local SSH certificate and revocation commands accept optional + available. `geth ssh cert approve` emits the OpenSSH signing command by + default; `approve --sign` runs `ssh-keygen`, imports the resulting + certificate, and marks the request signed when local signing succeeds. Local + SSH certificate and revocation commands accept optional `--subject` principals and enforce `ssh_cert.*` capabilities on `resource:ssh:certs` plus `ssh_revocation.*` capabilities on `resource:ssh:revocations` for non-owner subjects. Authorized peers can pull diff --git a/README.md b/README.md index 02b0a6d..f54a9cb 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,11 @@ over authorized Iroh streams, but the geth transport remains Iroh. 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 or as an -OpenSSH KRL specification file or a binary OpenSSH KRL generated through -`ssh-keygen -k`. `geth ssh cert sync ` and +or pass `--sign` to run `ssh-keygen` immediately and import the resulting +`-cert.pub` for distribution. Certificate and key revocation entries are tracked +locally and can be exported as JSONL or as an OpenSSH KRL specification file or +a binary OpenSSH KRL generated through `ssh-keygen -k`. `geth ssh cert sync +` and `geth ssh revocation sync ` pull certificate-flow and revocation metadata from an authorized peer over Iroh. @@ -114,7 +115,7 @@ The bootstrap implementation provides: - SSH certificate flow metadata: - `geth ssh cert request --public-key --principal [--subject ]` - `geth ssh cert requests [--subject ]` - - `geth ssh cert approve --ca-key [--subject ]` + - `geth ssh cert approve --ca-key [--sign] [--subject ]` - `geth ssh cert import --cert [--subject ]` - `geth ssh cert list [--subject ]` - `geth ssh cert sync ` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index eebb4e4..fa3de1e 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -421,6 +421,8 @@ pub enum SshCertCommand { #[arg(long)] out: Option, #[arg(long)] + sign: bool, + #[arg(long)] subject: Option, }, Import { @@ -736,6 +738,7 @@ fn request_for_command(command: Command) -> Result { valid_for, serial, out, + sign, subject, } => ControlRequest::SshCertApprove { request_id, @@ -743,6 +746,7 @@ fn request_for_command(command: Command) -> Result { valid_for, serial, out, + sign, subject, }, SshCertCommand::Import { @@ -1201,6 +1205,10 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } println!("signing_command:"); println!("{}", shell_quote_command(&approval.signing_command)); + println!("signed: {}", approval.signed); + if let Some(certificate_id) = approval.certificate_id { + println!("certificate_id: {certificate_id}"); + } println!("note: {}", approval.note); } ControlResponse::SshCertImported { certificate } => { diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index faf8434..e32dab1 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -151,6 +151,7 @@ pub enum ControlRequest { valid_for: Option, serial: Option, out: Option, + sign: bool, subject: Option, }, SshCertImport { diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index ca1da09..b40ef4d 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -3117,6 +3117,7 @@ pub fn handle_request( valid_for, serial, out, + sign, subject, } => { ensure_subject_authorized( @@ -3152,6 +3153,38 @@ pub fn handle_request( &valid_for, serial, )?; + let expected_certificate_path = expected_openssh_cert_path(&public_key_path); + let mut signed = false; + let mut certificate_id_value = None; + let mut note = "request approved; run the signing command on the CA/YubiKey machine, then import the resulting -cert.pub file".to_owned(); + if sign { + geth_ssh_identity::ensure_ssh_keygen_available()?; + let output = std::process::Command::new(&signing_command[0]) + .args(&signing_command[1..]) + .output()?; + if !output.status.success() { + return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed( + String::from_utf8_lossy(&output.stderr).trim().to_owned(), + ) + .into()); + } + let certificate = std::fs::read_to_string(&expected_certificate_path)?; + let record = SshCertificateRecord { + id: certificate_id(&certificate), + request_id: request.id.clone(), + certificate_fingerprint: ssh_public_key_fingerprint(&certificate), + certificate, + imported_at: UnixMillis(geth_store::now_ms()), + }; + store.insert_ssh_certificate(&stored_from_ssh_certificate(&record))?; + store.update_ssh_cert_request_status( + request.id.as_str(), + SshCertRequestStatus::Signed.as_str(), + )?; + signed = true; + certificate_id_value = Some(record.id); + note = "request approved, signed with ssh-keygen, and imported into local certificate metadata".to_owned(); + } let approval = SshCertApproval { request_id: request.id, approved_by_node: NodeId::new(node.node_id.clone()), @@ -3159,9 +3192,11 @@ pub fn handle_request( key_id: request_id, valid_for, serial, - output_path: Some(expected_openssh_cert_path(&public_key_path)), + output_path: Some(expected_certificate_path), signing_command, - note: "request approved; run the signing command on the CA/YubiKey machine, then import the resulting -cert.pub file".to_owned(), + signed, + certificate_id: certificate_id_value, + note, }; Ok(ControlResponse::SshCertApproved { approval }) } diff --git a/crates/geth-ssh-identity/src/lib.rs b/crates/geth-ssh-identity/src/lib.rs index ac7a6e5..7843191 100644 --- a/crates/geth-ssh-identity/src/lib.rs +++ b/crates/geth-ssh-identity/src/lib.rs @@ -142,6 +142,8 @@ pub struct SshCertApproval { pub serial: Option, pub output_path: Option, pub signing_command: Vec, + pub signed: bool, + pub certificate_id: Option, pub note: String, } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index bd12394..6a6f33b 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -1392,6 +1392,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { valid_for: Some("+4w".to_owned()), serial: Some(42), out: None, + sign: false, subject: None, }, ) @@ -1591,6 +1592,92 @@ fn ssh_cert_and_revocation_commands_check_subject_capabilities() { .expect("authorized revocation add"); } +#[test] +fn ssh_cert_approve_can_sign_and_import_with_openssh_key() { + 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 ca_key_path = home.path().join("ca_ed25519"); + let user_key_path = home.path().join("user_ed25519"); + for key_path in [&ca_key_path, &user_key_path] { + let status = Command::new("ssh-keygen") + .arg("-q") + .arg("-t") + .arg("ed25519") + .arg("-N") + .arg("") + .arg("-f") + .arg(key_path) + .status() + .expect("generate ssh key"); + assert!(status.success()); + } + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::SshCertRequest { + public_key_path: user_key_path.with_extension("pub"), + cert_kind: "user".to_owned(), + principals: vec!["eric".to_owned()], + requested_validity: Some("+1w".to_owned()), + renewal_of: None, + reason: Some("sign now".to_owned()), + subject: None, + }, + ) + .expect("request cert"); + let request_id = match response { + geth_control::ControlResponse::SshCertRequested { request } => request.id.to_string(), + other => panic!("unexpected response: {other:?}"), + }; + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::SshCertApprove { + request_id: request_id.clone(), + ca_key_path, + valid_for: Some("+1w".to_owned()), + serial: Some(7), + out: None, + sign: true, + subject: None, + }, + ) + .expect("approve and sign cert"); + match response { + geth_control::ControlResponse::SshCertApproved { approval } => { + assert!(approval.signed); + assert!(approval.certificate_id.is_some()); + assert!(approval.note.contains("signed with ssh-keygen")); + } + other => panic!("unexpected response: {other:?}"), + } + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::SshCertList { subject: None }, + ) + .expect("list certs"); + match response { + geth_control::ControlResponse::SshCertList { + requests, + certificates, + } => { + assert_eq!(certificates.len(), 1); + assert_eq!(certificates[0].request_id.to_string(), request_id); + assert_eq!( + requests[0].status, + geth_ssh_identity::SshCertRequestStatus::Signed + ); + } + other => panic!("unexpected response: {other:?}"), + } +} + #[test] fn ssh_revocation_export_can_write_binary_openssh_krl() { if Command::new("ssh-keygen").arg("-?").output().is_err() { diff --git a/docs/architecture.md b/docs/architecture.md index a771ca0..a60ebf3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,8 +83,11 @@ shell remain future work, and will not make SSH a geth transport backend. SSH certificate flows use the same split. Nodes can request new OpenSSH certificates or renewals through geth metadata. A machine with the CA key or YubiKey can approve the request and run an explicit `ssh-keygen -s ...` command, -then import the resulting certificate for distribution. Certificate and key -revocations are stored as signed-list-ready records. The bootstrap can pull +or pass `--sign` to execute that command immediately and import the resulting +certificate into local metadata for distribution. This relies on the local +OpenSSH ecosystem, so hardware-backed keys remain mediated by `ssh-keygen` and +the host's agent/security-key flow. Certificate and key revocations are stored +as signed-list-ready records. The bootstrap can pull certificate-flow metadata over Iroh with `geth ssh cert sync ` when the peer grants `ssh_cert.sync` on `resource:ssh:certs`, and revocation metadata with `geth ssh revocation sync ` when the peer grants `ssh_revocation.sync` @@ -204,9 +207,12 @@ forward bytes or connect to sshd/admin shell. `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 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 can import geth JSONL revocation +specification text. Certificate approval normally emits the exact +`ssh-keygen -s ...` command, and `approve --sign` can run that command, import +the resulting OpenSSH certificate, and mark the request signed. 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 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 62f1227..b903395 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -218,6 +218,10 @@ resource-scoped capability decisions. metadata. - `[x]` Approval emits an explicit `ssh-keygen -s ...` command for CA/YubiKey use. + - `[x]` `geth ssh cert approve --sign` can run `ssh-keygen`, import the + resulting OpenSSH certificate, and mark the request signed. + - `[x]` Tests cover signing with a generated local OpenSSH CA key when + `ssh-keygen` is available. - `[x]` `geth ssh revocation add/list/export` persists and exports revocations. - `[x]` Revocations can be exported as JSONL and OpenSSH KRL specification