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 Bearer access metadata can be created/listed/revoked as resource-scoped auth
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 or OpenSSH KRL specification text. - SSH revocations can be exported as JSONL, OpenSSH KRL specification text, or
Binary KRL generation/import is still roadmap work. 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, - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
iroh-docs, iroh-blobs, Automerge sync, real auth enforcement, iroh-docs, iroh-blobs, Automerge sync, broader auth enforcement, KRL import,
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future and Keyhive/BeeKEM-style authorization are future roadmap items unless
roadmap items unless implemented later. implemented later.

1
Cargo.lock generated
View file

@ -1293,6 +1293,7 @@ dependencies = [
"geth-types", "geth-types",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"thiserror 2.0.18", "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, 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 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 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 OpenSSH KRL specification file or a binary OpenSSH KRL generated through
replication will distribute these records between authorized nodes. `ssh-keygen -k`. Future Iroh replication will distribute these records between
authorized nodes.
## MVP Features ## MVP Features
@ -111,7 +112,7 @@ The bootstrap implementation provides:
- `geth ssh cert list` - `geth ssh cert list`
- `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]` - `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl]`
- 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

@ -397,6 +397,8 @@ pub enum SshRevocationCommand {
out: PathBuf, out: PathBuf,
#[arg(long, default_value = "jsonl")] #[arg(long, default_value = "jsonl")]
format: String, format: String,
#[arg(long)]
ca_public: Option<PathBuf>,
}, },
} }
@ -660,9 +662,15 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
reason, reason,
}, },
SshRevocationCommand::List => ControlRequest::SshRevocationList, SshRevocationCommand::List => ControlRequest::SshRevocationList,
SshRevocationCommand::Export { out, format } => { SshRevocationCommand::Export {
ControlRequest::SshRevocationExport { out, format } out,
} format,
ca_public,
} => ControlRequest::SshRevocationExport {
out,
format,
ca_public,
},
}, },
}, },
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"), Command::Init | Command::Daemon { .. } => bail!("command is handled directly"),

View file

@ -147,6 +147,7 @@ pub enum ControlRequest {
SshRevocationExport { SshRevocationExport {
out: PathBuf, out: PathBuf,
format: String, format: String,
ca_public: Option<PathBuf>,
}, },
DbAdd { DbAdd {
name: String, name: String,
@ -469,6 +470,7 @@ mod tests {
let request = ControlRequest::SshRevocationExport { let request = ControlRequest::SshRevocationExport {
out: PathBuf::from("revocations.krl-spec"), out: PathBuf::from("revocations.krl-spec"),
format: "openssh-krl-spec".to_owned(), format: "openssh-krl-spec".to_owned(),
ca_public: None,
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),

View file

@ -27,6 +27,7 @@ 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, revocation_id, ssh_public_key_fingerprint,
write_openssh_krl,
}; };
use geth_store::{ use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
@ -897,7 +898,11 @@ pub fn handle_request(
.map(ssh_revocation_from_stored) .map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?, .collect::<Result<Vec<_>, _>>()?,
}), }),
ControlRequest::SshRevocationExport { out, format } => { ControlRequest::SshRevocationExport {
out,
format,
ca_public,
} => {
let revocations = store let revocations = store
.list_ssh_revocations()? .list_ssh_revocations()?
.into_iter() .into_iter()
@ -925,8 +930,17 @@ pub fn handle_request(
openssh_krl_spec(&revocations)?, openssh_krl_spec(&revocations)?,
"OpenSSH KRL specification; generate a binary KRL with ssh-keygen -k -f <krl> [-s <ca.pub>] <spec>".to_owned(), "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(),
)
}
}; };
if format != SshRevocationExportFormat::OpenSshKrl {
std::fs::write(&out, body)?; std::fs::write(&out, body)?;
}
Ok(ControlResponse::SshRevocationExported { Ok(ControlResponse::SshRevocationExported {
out, out,
format: format.to_string(), format: format.to_string(),

View file

@ -13,3 +13,4 @@ geth-types = { path = "../geth-types" }
[dev-dependencies] [dev-dependencies]
serde_json.workspace = true 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 const SSH_REVOCATION_LIST_NAMESPACE: &str = "geth.ssh-revocation-list.v1@geth.local";
pub fn ensure_ssh_keygen_available() -> Result<(), SshIdentityError> { pub fn ensure_ssh_keygen_available() -> Result<(), SshIdentityError> {
let status = Command::new("ssh-keygen").arg("-?").status(); let output = Command::new("ssh-keygen").arg("-?").output();
match status { match output {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => { Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Err(SshIdentityError::SshKeygenUnavailable) Err(SshIdentityError::SshKeygenUnavailable)
@ -210,6 +210,7 @@ pub struct SshRevocationEntry {
pub enum SshRevocationExportFormat { pub enum SshRevocationExportFormat {
Jsonl, Jsonl,
OpenSshKrlSpec, OpenSshKrlSpec,
OpenSshKrl,
} }
impl SshRevocationExportFormat { impl SshRevocationExportFormat {
@ -218,6 +219,7 @@ impl SshRevocationExportFormat {
match self { match self {
Self::Jsonl => "jsonl", Self::Jsonl => "jsonl",
Self::OpenSshKrlSpec => "openssh-krl-spec", Self::OpenSshKrlSpec => "openssh-krl-spec",
Self::OpenSshKrl => "openssh-krl",
} }
} }
} }
@ -235,6 +237,7 @@ impl FromStr for SshRevocationExportFormat {
match value { match value {
"jsonl" => Ok(Self::Jsonl), "jsonl" => Ok(Self::Jsonl),
"openssh-krl-spec" | "krl-spec" => Ok(Self::OpenSshKrlSpec), "openssh-krl-spec" | "krl-spec" => Ok(Self::OpenSshKrlSpec),
"openssh-krl" | "krl" => Ok(Self::OpenSshKrl),
_ => Err(SshIdentityError::InvalidRevocationExportFormat( _ => Err(SshIdentityError::InvalidRevocationExportFormat(
value.to_owned(), value.to_owned(),
)), )),
@ -331,6 +334,34 @@ pub fn openssh_krl_spec(entries: &[SshRevocationEntry]) -> Result<String, SshIde
Ok(spec) 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> { pub fn openssh_krl_spec_line(entry: &SshRevocationEntry) -> Result<String, SshIdentityError> {
let target = entry.target.trim(); let target = entry.target.trim();
if target.is_empty() || target.bytes().any(|byte| byte == b'\n' || byte == b'\r') { 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 { pub enum SshIdentityError {
#[error("ssh-keygen failed or is unavailable")] #[error("ssh-keygen failed or is unavailable")]
SshKeygenUnavailable, SshKeygenUnavailable,
#[error("ssh-keygen failed: {0}")]
SshKeygenFailed(String),
#[error("invalid ssh certificate kind: {0}")] #[error("invalid ssh certificate kind: {0}")]
InvalidCertKind(String), InvalidCertKind(String),
#[error("invalid ssh certificate request status: {0}")] #[error("invalid ssh certificate request status: {0}")]
@ -465,4 +498,53 @@ mod tests {
assert!(openssh_krl_spec_line(&entry).is_err()); 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 { geth_control::ControlRequest::SshRevocationExport {
out: export_path.clone(), out: export_path.clone(),
format: "jsonl".to_owned(), format: "jsonl".to_owned(),
ca_public: None,
}, },
) )
.expect("export revocations"); .expect("export revocations");
@ -1346,6 +1347,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
geth_control::ControlRequest::SshRevocationExport { geth_control::ControlRequest::SshRevocationExport {
out: krl_spec_path.clone(), out: krl_spec_path.clone(),
format: "openssh-krl-spec".to_owned(), format: "openssh-krl-spec".to_owned(),
ca_public: None,
}, },
) )
.expect("export revocation krl spec"); .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") .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, `geth-ssh-identity` defines SSH trust namespaces plus certificate request,
approval, certificate import, and revocation-list data models. The bootstrap 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 does not yet generate OpenSSH KRL binaries or replicate specification text. It can also invoke `ssh-keygen -k` to produce a binary
the lists over Iroh. 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 ## Keychain, Auth, And Secrets

View file

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