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

@ -147,9 +147,10 @@ Roadmap items should be actionable and checkable:
Bearer access metadata can be created/listed/revoked as resource-scoped auth
ops and must not allow trust graph mutation capabilities. Payload encryption,
key envelopes, and bearer challenge-response are still roadmap work.
- SSH revocations can be exported as JSONL or OpenSSH KRL specification text.
Binary KRL generation/import is still roadmap work.
- SSH revocations can be exported as JSONL, OpenSSH KRL specification text, or
binary OpenSSH KRL files generated through `ssh-keygen`. Existing KRL import
is still roadmap work.
- Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
iroh-docs, iroh-blobs, Automerge sync, real auth enforcement,
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future
roadmap items unless implemented later.
iroh-docs, iroh-blobs, Automerge sync, broader auth enforcement, KRL import,
and Keyhive/BeeKEM-style authorization are future roadmap items unless
implemented later.

1
Cargo.lock generated
View file

@ -1293,6 +1293,7 @@ dependencies = [
"geth-types",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
]

View file

@ -60,8 +60,9 @@ 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 for later `ssh-keygen -k` use. Future Iroh
replication will distribute these records between authorized nodes.
OpenSSH KRL specification file or a binary OpenSSH KRL generated through
`ssh-keygen -k`. Future Iroh replication will distribute these records between
authorized nodes.
## MVP Features
@ -111,7 +112,7 @@ The bootstrap implementation provides:
- `geth ssh cert list`
- `geth ssh revocation add <kind> <target>`
- `geth ssh revocation list`
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec]`
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl]`
- local pipe registry commands: `geth pipe listen/connect`
`geth peer export/import/list` is for untrusted peer-card exchange while live

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

View file

@ -138,8 +138,10 @@ streams yet.
`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 does not yet generate OpenSSH KRL binaries or replicate
the lists over Iroh.
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 does not yet import existing KRL
files or replicate the lists over Iroh.
## Keychain, Auth, And Secrets

View file

@ -200,7 +200,7 @@ resource-scoped capability decisions.
- `[x]` `geth ssh revocation add/list/export` persists and exports
revocations.
- `[x]` Revocations can be exported as JSONL and OpenSSH KRL specification
text.
text or as a binary OpenSSH KRL through `ssh-keygen`.
- `[ ]` Future completion requires auth checks for request, approve, import,
publish, and read capabilities.
@ -295,10 +295,12 @@ Goal: add authorized stream-oriented management workflows over Iroh.
Acceptance criteria:
- `[x]` Revocation records can produce an OpenSSH KRL specification file.
- `[x]` Tests cover serial, key ID, and public key revocation spec lines.
- `[ ]` 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
`ssh-keygen` is available.
- `[ ]` Existing KRL files can be imported into revocation metadata where
possible.
- `[ ]` Tests cover binary KRL export/import and certificate revocations.
- `[ ]` Tests cover KRL import and certificate revocations.
## Phase 5: DB And Documents