Add OpenSSH certificate signing path

This commit is contained in:
Eric Wendland 2026-05-19 15:56:47 +02:00
commit 91c65e367d
9 changed files with 160 additions and 13 deletions

View file

@ -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

View file

@ -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 <node-id>` 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
<node-id>` and
`geth ssh revocation sync <node-id>` 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 <path> --principal <name> [--subject <principal>]`
- `geth ssh cert requests [--subject <principal>]`
- `geth ssh cert approve <request-id> --ca-key <path> [--subject <principal>]`
- `geth ssh cert approve <request-id> --ca-key <path> [--sign] [--subject <principal>]`
- `geth ssh cert import <request-id> --cert <path> [--subject <principal>]`
- `geth ssh cert list [--subject <principal>]`
- `geth ssh cert sync <node-id>`

View file

@ -421,6 +421,8 @@ pub enum SshCertCommand {
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
sign: bool,
#[arg(long)]
subject: Option<String>,
},
Import {
@ -736,6 +738,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
valid_for,
serial,
out,
sign,
subject,
} => ControlRequest::SshCertApprove {
request_id,
@ -743,6 +746,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
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 } => {

View file

@ -151,6 +151,7 @@ pub enum ControlRequest {
valid_for: Option<String>,
serial: Option<u64>,
out: Option<PathBuf>,
sign: bool,
subject: Option<String>,
},
SshCertImport {

View file

@ -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 })
}

View file

@ -142,6 +142,8 @@ pub struct SshCertApproval {
pub serial: Option<u64>,
pub output_path: Option<String>,
pub signing_command: Vec<String>,
pub signed: bool,
pub certificate_id: Option<SshCertId>,
pub note: String,
}

View file

@ -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() {

View file

@ -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 <node-id>` when the
peer grants `ssh_cert.sync` on `resource:ssh:certs`, and revocation metadata with
`geth ssh revocation sync <node-id>` 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

View file

@ -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