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

@ -148,9 +148,10 @@ Roadmap items should be actionable and checkable:
ops and must not allow trust graph mutation capabilities. Payload encryption, ops and must not allow trust graph mutation capabilities. Payload encryption,
key envelopes, and bearer challenge-response are still roadmap work. key envelopes, and bearer challenge-response are still roadmap work.
- SSH revocations can be exported as JSONL, OpenSSH KRL specification text, or - SSH revocations can be exported as JSONL, OpenSSH KRL specification text, or
binary OpenSSH KRL files generated through `ssh-keygen`. Existing KRL import binary OpenSSH KRL files generated through `ssh-keygen`. JSONL and OpenSSH KRL
is still roadmap work. specification imports are supported; binary KRL import is unsupported because
OpenSSH KRL files are not enumerable through OpenSSH tooling.
- Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
iroh-docs, iroh-blobs, Automerge sync, broader auth enforcement, KRL import, iroh-docs, iroh-blobs, Automerge sync, broader auth enforcement, certificate
and Keyhive/BeeKEM-style authorization are future roadmap items unless revocation KRL tests, and Keyhive/BeeKEM-style authorization are future
implemented later. roadmap items unless implemented later.

1
Cargo.lock generated
View file

@ -1048,6 +1048,7 @@ dependencies = [
"geth-discovery", "geth-discovery",
"geth-iroh", "geth-iroh",
"geth-node", "geth-node",
"geth-ssh-identity",
"geth-store", "geth-store",
"geth-types", "geth-types",
"rusqlite", "rusqlite",

View file

@ -113,6 +113,7 @@ The bootstrap implementation provides:
- `geth ssh revocation add <kind> <target>` - `geth ssh revocation add <kind> <target>`
- `geth ssh revocation list` - `geth ssh revocation list`
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl]` - `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl]`
- `geth ssh revocation import <path> [--format jsonl|openssh-krl-spec]`
- local pipe registry commands: `geth pipe listen/connect` - local pipe registry commands: `geth pipe listen/connect`
`geth peer export/import/list` is for untrusted peer-card exchange while live `geth peer export/import/list` is for untrusted peer-card exchange while live

View file

@ -400,6 +400,11 @@ pub enum SshRevocationCommand {
#[arg(long)] #[arg(long)]
ca_public: Option<PathBuf>, ca_public: Option<PathBuf>,
}, },
Import {
path: PathBuf,
#[arg(long, default_value = "jsonl")]
format: String,
},
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
@ -671,6 +676,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
format, format,
ca_public, ca_public,
}, },
SshRevocationCommand::Import { path, format } => {
ControlRequest::SshRevocationImport { path, format }
}
}, },
}, },
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"), 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!("format: {format}");
println!("note: {note}"); 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 } => { ControlResponse::DbAdded { db } => {
println!("registered db: {}", db.name); println!("registered db: {}", db.name);
println!("id: {}", db.id); println!("id: {}", db.id);

View file

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

View file

@ -26,8 +26,8 @@ use geth_secrets::{BearerAccess, ResourceMasterSecret};
use geth_ssh_identity::{ use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind, build_ssh_cert_sign_command, SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind, build_ssh_cert_sign_command,
cert_request_id, certificate_id, openssh_krl_spec, revocation_id, ssh_public_key_fingerprint, cert_request_id, certificate_id, openssh_krl_spec, parse_openssh_krl_spec, revocation_id,
write_openssh_krl, ssh_public_key_fingerprint, write_openssh_krl,
}; };
use geth_store::{ use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
@ -948,6 +948,53 @@ pub fn handle_request(
note, 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 } => { ControlRequest::DbAdd { name, path } => {
geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?; geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?;
if !path.is_file() { if !path.is_file() {

View file

@ -334,6 +334,33 @@ pub fn openssh_krl_spec(entries: &[SshRevocationEntry]) -> Result<String, SshIde
Ok(spec) 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( pub fn write_openssh_krl(
entries: &[SshRevocationEntry], entries: &[SshRevocationEntry],
out: &Path, out: &Path,
@ -394,6 +421,10 @@ pub enum SshIdentityError {
InvalidRevocationExportFormat(String), InvalidRevocationExportFormat(String),
#[error("invalid ssh revocation target: {0}")] #[error("invalid ssh revocation target: {0}")]
InvalidRevocationTarget(String), 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}")] #[error("io error: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
@ -485,6 +516,31 @@ mod tests {
assert!(spec.contains("key: ssh-ed25519 AAAA test\n")); 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] #[test]
fn openssh_krl_spec_rejects_multiline_targets() { fn openssh_krl_spec_rejects_multiline_targets() {
let entry = SshRevocationEntry { let entry = SshRevocationEntry {

View file

@ -22,6 +22,7 @@ geth-control = { path = "../geth-control" }
geth-discovery = { path = "../geth-discovery" } geth-discovery = { path = "../geth-discovery" }
geth-iroh = { path = "../geth-iroh" } geth-iroh = { path = "../geth-iroh" }
geth-node = { path = "../geth-node" } geth-node = { path = "../geth-node" }
geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" } geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }
rusqlite.workspace = true 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:?}"), other => panic!("unexpected response: {other:?}"),
} }
assert!( assert!(
std::fs::read_to_string(krl_spec_path) std::fs::read_to_string(&krl_spec_path)
.expect("read krl spec") .expect("read krl spec")
.contains("key: ssh:blake3:test") .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] #[test]

View file

@ -140,8 +140,11 @@ approval, certificate import, and revocation-list data models. The bootstrap
persists these flows locally and exports revocations as JSONL or OpenSSH KRL 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 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 OpenSSH KRL; serial and key-ID KRL entries require a CA public key via
`--ca-public`, matching OpenSSH behavior. It does not yet import existing KRL `--ca-public`, matching OpenSSH behavior. It can import geth JSONL revocation
files or replicate the lists over Iroh. 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
replicated over Iroh.
## Keychain, Auth, And Secrets ## Keychain, Auth, And Secrets

View file

@ -298,9 +298,12 @@ Goal: add authorized stream-oriented management workflows over Iroh.
- `[x]` Revocation records can produce an OpenSSH binary KRL file. - `[x]` Revocation records can produce an OpenSSH binary KRL file.
- `[x]` Tests cover binary KRL export for public-key revocations when - `[x]` Tests cover binary KRL export for public-key revocations when
`ssh-keygen` is available. `ssh-keygen` is available.
- `[ ]` Existing KRL files can be imported into revocation metadata where - `[x]` JSONL exports and OpenSSH KRL specification source files can be
possible. imported into revocation metadata.
- `[ ]` Tests cover KRL import and certificate revocations. - `[x]` Binary OpenSSH KRL import returns a clear unsupported message because
KRL files are not enumerable through OpenSSH tooling.
- `[x]` Tests cover JSONL and KRL-spec import.
- `[ ]` Tests cover certificate revocations.
## Phase 5: DB And Documents ## Phase 5: DB And Documents