Import SSH revocation metadata
This commit is contained in:
parent
b9e7c61e67
commit
4c368253e4
11 changed files with 212 additions and 13 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -148,9 +148,10 @@ Roadmap items should be actionable and checkable:
|
|||
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, OpenSSH KRL specification text, or
|
||||
binary OpenSSH KRL files generated through `ssh-keygen`. Existing KRL import
|
||||
is still roadmap work.
|
||||
binary OpenSSH KRL files generated through `ssh-keygen`. JSONL and OpenSSH KRL
|
||||
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,
|
||||
iroh-docs, iroh-blobs, Automerge sync, broader auth enforcement, KRL import,
|
||||
and Keyhive/BeeKEM-style authorization are future roadmap items unless
|
||||
implemented later.
|
||||
iroh-docs, iroh-blobs, Automerge sync, broader auth enforcement, certificate
|
||||
revocation KRL tests, and Keyhive/BeeKEM-style authorization are future
|
||||
roadmap items unless implemented later.
|
||||
|
|
|
|||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1048,6 +1048,7 @@ dependencies = [
|
|||
"geth-discovery",
|
||||
"geth-iroh",
|
||||
"geth-node",
|
||||
"geth-ssh-identity",
|
||||
"geth-store",
|
||||
"geth-types",
|
||||
"rusqlite",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ The bootstrap implementation provides:
|
|||
- `geth ssh revocation add <kind> <target>`
|
||||
- `geth ssh revocation list`
|
||||
- `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`
|
||||
|
||||
`geth peer export/import/list` is for untrusted peer-card exchange while live
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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.
|
||||
`--ca-public`, matching OpenSSH behavior. It can import geth JSONL revocation
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -298,9 +298,12 @@ Goal: add authorized stream-oriented management workflows over Iroh.
|
|||
- `[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 KRL import and certificate revocations.
|
||||
- `[x]` JSONL exports and OpenSSH KRL specification source files can be
|
||||
imported into revocation metadata.
|
||||
- `[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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue