Add binary OpenSSH KRL export

This commit is contained in:
Eric Wendland 2026-05-18 11:51:12 +02:00
commit b9e7c61e67
11 changed files with 209 additions and 20 deletions

View file

@ -397,6 +397,8 @@ pub enum SshRevocationCommand {
out: PathBuf,
#[arg(long, default_value = "jsonl")]
format: String,
#[arg(long)]
ca_public: Option<PathBuf>,
},
}
@ -660,9 +662,15 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
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"),

View file

@ -147,6 +147,7 @@ pub enum ControlRequest {
SshRevocationExport {
out: PathBuf,
format: String,
ca_public: Option<PathBuf>,
},
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"),

View file

@ -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::<Result<Vec<_>, _>>()?,
}),
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 <krl> [-s <ca.pub>] <spec>".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 <krl> <key-or-cert> 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(),

View file

@ -13,3 +13,4 @@ geth-types = { path = "../geth-types" }
[dev-dependencies]
serde_json.workspace = true
tempfile.workspace = true

View file

@ -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<String, SshIde
Ok(spec)
}
pub fn write_openssh_krl(
entries: &[SshRevocationEntry],
out: &Path,
ca_public: Option<&Path>,
) -> 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<String, SshIdentityError> {
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<String, SshId
pub enum SshIdentityError {
#[error("ssh-keygen failed or is unavailable")]
SshKeygenUnavailable,
#[error("ssh-keygen failed: {0}")]
SshKeygenFailed(String),
#[error("invalid ssh certificate kind: {0}")]
InvalidCertKind(String),
#[error("invalid ssh certificate request status: {0}")]
@ -465,4 +498,53 @@ mod tests {
assert!(openssh_krl_spec_line(&entry).is_err());
}
#[test]
fn openssh_krl_binary_export_revokes_public_key_when_ssh_keygen_available() {
if ensure_ssh_keygen_available().is_err() {
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let key_path = dir.path().join("revoked");
let status = Command::new("ssh-keygen")
.arg("-q")
.arg("-t")
.arg("ed25519")
.arg("-N")
.arg("")
.arg("-f")
.arg(&key_path)
.status()
.expect("run ssh-keygen");
assert!(status.success());
let public_key_path = key_path.with_extension("pub");
let public_key = std::fs::read_to_string(&public_key_path).expect("read public key");
let entry = SshRevocationEntry {
id: "ssh-revocation:key".into(),
kind: SshRevocationKind::PublicKey,
target: public_key,
reason: Some("test".to_owned()),
created_at: UnixMillis(1),
published: true,
};
let krl_path = dir.path().join("revoked.krl");
write_openssh_krl(&[entry], &krl_path, None).expect("write krl");
let output = Command::new("ssh-keygen")
.arg("-Q")
.arg("-f")
.arg(&krl_path)
.arg(&public_key_path)
.output()
.expect("query krl");
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stdout)
.to_ascii_lowercase()
.contains("revoked")
);
}
}

View file

@ -1331,6 +1331,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
geth_control::ControlRequest::SshRevocationExport {
out: export_path.clone(),
format: "jsonl".to_owned(),
ca_public: None,
},
)
.expect("export revocations");
@ -1346,6 +1347,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
geth_control::ControlRequest::SshRevocationExport {
out: krl_spec_path.clone(),
format: "openssh-krl-spec".to_owned(),
ca_public: None,
},
)
.expect("export revocation krl spec");
@ -1368,3 +1370,76 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
.contains("key: ssh:blake3:test")
);
}
#[test]
fn ssh_revocation_export_can_write_binary_openssh_krl() {
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 key_path = home.path().join("revoked");
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 public_key_path = key_path.with_extension("pub");
let public_key = std::fs::read_to_string(&public_key_path).expect("read public key");
geth_node::handle_request(
&node,
geth_control::ControlRequest::SshRevocationAdd {
kind: "public-key".to_owned(),
target: public_key,
reason: Some("test binary krl".to_owned()),
},
)
.expect("add revocation");
let krl_path = home.path().join("revocations.krl");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::SshRevocationExport {
out: krl_path.clone(),
format: "openssh-krl".to_owned(),
ca_public: None,
},
)
.expect("export binary krl");
match response {
geth_control::ControlResponse::SshRevocationExported {
format,
count,
note,
..
} => {
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")
);
}