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

@ -134,6 +134,8 @@ 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.
- 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

View file

@ -59,9 +59,9 @@ SSH certificate request and renewal flows are managed as geth metadata. A node
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 for
distribution. Future Iroh replication will distribute these records between
authorized nodes.
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.
## MVP Features
@ -100,7 +100,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>`
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec]`
Other command groups exist as explicit stubs: `pipe` and `ssh`.

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

View file

@ -116,8 +116,9 @@ roadmap stubs.
`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. It does not yet
generate OpenSSH KRL binaries or replicate the lists over Iroh.
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.
## Keychain, Auth, And Secrets

View file

@ -189,11 +189,16 @@ resource-scoped capability decisions.
- `[~]` SSH certificate and revocation lifecycle.
Acceptance criteria:
- `geth ssh cert request/requests/approve/import/list` persist local metadata.
- Approval emits an explicit `ssh-keygen -s ...` command for CA/YubiKey use.
- `geth ssh revocation add/list/export` persists and exports revocations.
- Future completion requires auth checks for request, approve, import, publish,
and read capabilities.
- `[x]` `geth ssh cert request/requests/approve/import/list` persist local
metadata.
- `[x]` Approval emits an explicit `ssh-keygen -s ...` command for
CA/YubiKey use.
- `[x]` `geth ssh revocation add/list/export` persists and exports
revocations.
- `[x]` Revocations can be exported as JSONL and OpenSSH KRL specification
text.
- `[ ]` Future completion requires auth checks for request, approve, import,
publish, and read capabilities.
## Phase 3: CAS, KV, And Pubsub
@ -277,11 +282,14 @@ Goal: add authorized stream-oriented management workflows over Iroh.
- Consumers can list current certs/revocations from local state while offline.
- Conflicting or unsigned records are rejected or quarantined.
- `[ ]` OpenSSH KRL import/export.
- `[~]` OpenSSH KRL import/export.
Acceptance criteria:
- Revocation records can produce an OpenSSH KRL file.
- Existing KRL files can be imported into revocation metadata where possible.
- Tests cover serial, key ID, public key, and certificate revocations.
- `[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.
- `[ ]` Existing KRL files can be imported into revocation metadata where
possible.
- `[ ]` Tests cover binary KRL export/import and certificate revocations.
## Phase 5: DB And Documents