Add OpenSSH KRL spec export

This commit is contained in:
Eric Wendland 2026-05-17 18:29:47 +02:00
commit b102e07204
9 changed files with 235 additions and 25 deletions

View file

@ -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<ControlRequest> {
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);

View file

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

View file

@ -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::<Result<Vec<_>, _>>()?,
}),
ControlRequest::SshRevocationExport { out } => {
ControlRequest::SshRevocationExport { out, format } => {
let revocations = store
.list_ssh_revocations()?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?;
let format = format
.parse::<SshRevocationExportFormat>()
.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 <krl> [-s <ca.pub>] <spec>".to_owned(),
),
};
std::fs::write(&out, body)?;
Ok(ControlResponse::SshRevocationExported {
out,
format: format.to_string(),
count: revocations.len(),
note,
})
}
ControlRequest::DbAdd { name, path } => {

View file

@ -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<Self, Self::Err> {
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<String, SshIdentityError> {
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<String, SshIdentityError> {
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());
}
}

View file

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