Import SSH revocation metadata

This commit is contained in:
Eric Wendland 2026-05-18 11:56:42 +02:00
commit 4c368253e4
11 changed files with 212 additions and 13 deletions

View file

@ -400,6 +400,11 @@ pub enum SshRevocationCommand {
#[arg(long)]
ca_public: Option<PathBuf>,
},
Import {
path: PathBuf,
#[arg(long, default_value = "jsonl")]
format: String,
},
}
#[derive(Debug, Args)]
@ -671,6 +676,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
format,
ca_public,
},
SshRevocationCommand::Import { path, format } => {
ControlRequest::SshRevocationImport { path, format }
}
},
},
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"),
@ -1084,6 +1092,22 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("format: {format}");
println!("note: {note}");
}
ControlResponse::SshRevocationImported {
revocations,
format,
count,
note,
} => {
println!("imported {count} ssh revocations");
println!("format: {format}");
for revocation in revocations {
println!(
"{}\t{}\t{}",
revocation.id, revocation.kind, revocation.target
);
}
println!("note: {note}");
}
ControlResponse::DbAdded { db } => {
println!("registered db: {}", db.name);
println!("id: {}", db.id);

View file

@ -149,6 +149,10 @@ pub enum ControlRequest {
format: String,
ca_public: Option<PathBuf>,
},
SshRevocationImport {
path: PathBuf,
format: String,
},
DbAdd {
name: String,
path: PathBuf,
@ -328,6 +332,12 @@ pub enum ControlResponse {
count: usize,
note: String,
},
SshRevocationImported {
revocations: Vec<SshRevocationEntry>,
format: String,
count: usize,
note: String,
},
DbAdded {
db: DbResource,
},
@ -477,6 +487,15 @@ mod tests {
request
);
let request = ControlRequest::SshRevocationImport {
path: PathBuf::from("revocations.jsonl"),
format: "jsonl".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(),

View file

@ -26,8 +26,8 @@ use geth_secrets::{BearerAccess, ResourceMasterSecret};
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,
cert_request_id, certificate_id, openssh_krl_spec, parse_openssh_krl_spec, revocation_id,
ssh_public_key_fingerprint, write_openssh_krl,
};
use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
@ -948,6 +948,53 @@ pub fn handle_request(
note,
})
}
ControlRequest::SshRevocationImport { path, format } => {
let body = std::fs::read_to_string(&path)?;
let created_at = UnixMillis(geth_store::now_ms());
let revocations = match format.as_str() {
"jsonl" => body
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).map_err(NodeError::from))
.collect::<Result<Vec<SshRevocationEntry>, NodeError>>()?,
"openssh-krl-spec" | "krl-spec" => parse_openssh_krl_spec(&body)?
.into_iter()
.enumerate()
.map(|(index, (kind, target))| {
let created_at = UnixMillis(created_at.0 + index as i64);
SshRevocationEntry {
id: revocation_id(&kind, &target, created_at),
kind,
target,
reason: Some(format!("imported from {}", path.display())),
created_at,
published: true,
}
})
.collect(),
"openssh-krl" | "krl" => {
return Err(NodeError::SshIdentity(
geth_ssh_identity::SshIdentityError::BinaryKrlImportUnsupported,
));
}
_ => {
return Err(NodeError::SshIdentity(
geth_ssh_identity::SshIdentityError::InvalidRevocationExportFormat(
format.clone(),
),
));
}
};
for revocation in &revocations {
store.insert_ssh_revocation(&stored_from_ssh_revocation(revocation))?;
}
Ok(ControlResponse::SshRevocationImported {
count: revocations.len(),
revocations,
format,
note: "imported revocation metadata; binary OpenSSH KRL files cannot be enumerated, import JSONL or the KRL spec source instead".to_owned(),
})
}
ControlRequest::DbAdd { name, path } => {
geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?;
if !path.is_file() {

View file

@ -334,6 +334,33 @@ pub fn openssh_krl_spec(entries: &[SshRevocationEntry]) -> Result<String, SshIde
Ok(spec)
}
pub fn parse_openssh_krl_spec(
spec: &str,
) -> Result<Vec<(SshRevocationKind, String)>, SshIdentityError> {
let mut entries = Vec::new();
for line in spec.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((directive, target)) = line.split_once(':') else {
return Err(SshIdentityError::InvalidKrlSpecLine(line.to_owned()));
};
let target = target.trim();
if target.is_empty() || target.bytes().any(|byte| byte == b'\n' || byte == b'\r') {
return Err(SshIdentityError::InvalidRevocationTarget(target.to_owned()));
}
let kind = match directive.trim() {
"key" => SshRevocationKind::PublicKey,
"serial" => SshRevocationKind::Serial,
"id" => SshRevocationKind::KeyId,
other => return Err(SshIdentityError::InvalidKrlSpecLine(other.to_owned())),
};
entries.push((kind, target.to_owned()));
}
Ok(entries)
}
pub fn write_openssh_krl(
entries: &[SshRevocationEntry],
out: &Path,
@ -394,6 +421,10 @@ pub enum SshIdentityError {
InvalidRevocationExportFormat(String),
#[error("invalid ssh revocation target: {0}")]
InvalidRevocationTarget(String),
#[error("invalid OpenSSH KRL specification line: {0}")]
InvalidKrlSpecLine(String),
#[error("binary OpenSSH KRL files cannot be enumerated; import JSONL or the KRL spec source")]
BinaryKrlImportUnsupported,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
@ -485,6 +516,31 @@ mod tests {
assert!(spec.contains("key: ssh-ed25519 AAAA test\n"));
}
#[test]
fn openssh_krl_spec_import_parses_supported_directives() {
let parsed = parse_openssh_krl_spec(
r#"
# comment
serial: 42
id: node:laptop
key: ssh-ed25519 AAAA test
"#,
)
.expect("parse spec");
assert_eq!(
parsed,
vec![
(SshRevocationKind::Serial, "42".to_owned()),
(SshRevocationKind::KeyId, "node:laptop".to_owned()),
(
SshRevocationKind::PublicKey,
"ssh-ed25519 AAAA test".to_owned()
),
]
);
}
#[test]
fn openssh_krl_spec_rejects_multiline_targets() {
let entry = SshRevocationEntry {

View file

@ -22,6 +22,7 @@ geth-control = { path = "../geth-control" }
geth-discovery = { path = "../geth-discovery" }
geth-iroh = { path = "../geth-iroh" }
geth-node = { path = "../geth-node" }
geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" }
rusqlite.workspace = true

View file

@ -1365,10 +1365,53 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
other => panic!("unexpected response: {other:?}"),
}
assert!(
std::fs::read_to_string(krl_spec_path)
std::fs::read_to_string(&krl_spec_path)
.expect("read krl spec")
.contains("key: ssh:blake3:test")
);
let import_home = tempfile::tempdir().expect("import tempdir");
let import_paths = geth_config::GethPaths::from_home(import_home.path());
let import_node = geth_node::init_node(&import_paths).expect("init import node");
let response = geth_node::handle_request(
&import_node,
geth_control::ControlRequest::SshRevocationImport {
path: krl_spec_path,
format: "openssh-krl-spec".to_owned(),
},
)
.expect("import krl spec");
match response {
geth_control::ControlResponse::SshRevocationImported {
count,
revocations,
note,
..
} => {
assert_eq!(count, 1);
assert_eq!(
revocations[0].kind,
geth_ssh_identity::SshRevocationKind::PublicKey
);
assert!(note.contains("binary OpenSSH KRL files cannot be enumerated"));
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(
&import_node,
geth_control::ControlRequest::SshRevocationImport {
path: home.path().join("revocations.jsonl"),
format: "jsonl".to_owned(),
},
)
.expect("import jsonl revocations");
match response {
geth_control::ControlResponse::SshRevocationImported { count, .. } => {
assert_eq!(count, 1);
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test]