From 42d7301bdb648e072045168d1415b617fa74cc4a Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Fri, 22 May 2026 16:51:30 +0200 Subject: [PATCH] Represent SSH distribution sync as a resource log --- README.md | 19 ++-- crates/geth-control/src/lib.rs | 7 +- crates/geth-node/src/lib.rs | 134 ++++++++++++++++++++++------ crates/geth-ssh-identity/src/lib.rs | 98 ++++++++++++++++++++ docs/architecture.md | 34 +++---- docs/roadmap.md | 7 +- 6 files changed, 247 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index a96a2fa..054f995 100644 --- a/README.md +++ b/README.md @@ -215,14 +215,17 @@ node; it only unlocks the requested capability on that one resource. `geth ssh cert sync ` requires `ssh_cert.sync` on `resource:ssh:certs` at the peer. `geth ssh revocation sync ` requires `ssh_revocation.sync` on `resource:ssh:revocations`. Both commands merge -authorized peer metadata into the local store for offline listing and later -approval/signing workflows. While the daemon is running, it also performs a -background live-sync tick for known peers. The default interval is 30 seconds -and can be changed in `config.toml` with `[sync] live_sync_enabled` and -`live_sync_interval_ms`. Live-sync stores per-peer high-water cursors in local -metadata so repeated ticks request only newer SSH certificate-flow and -revocation records. Sync import preserves local metadata by rejecting -conflicting records with ids that already exist locally. +authorized peer SSH distribution log entries into the local store for offline +listing and later approval/signing workflows. The current log is materialized +from signed certificate requests, signed certificate imports, and signed +revocation records, then reduced locally; it is not a mutable remote ACL blob. +While the daemon is running, it also performs a background live-sync tick for +known peers. The default interval is 30 seconds and can be changed in +`config.toml` with `[sync] live_sync_enabled` and `live_sync_interval_ms`. +Live-sync stores per-peer high-water cursors in local metadata so repeated ticks +request only newer SSH certificate-flow and revocation log entries. Sync import +preserves local metadata by rejecting conflicting records with ids that already +exist locally. Before probing individual modules, the daemon asks the peer for an authorized sync-status summary over Iroh. The peer only returns stream watermarks for resources where the caller already has the matching capability, letting the diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 96c589f..2ff8394 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -10,7 +10,8 @@ use geth_pubsub::PubsubMessage; use geth_resource::ResourceDescriptor; use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSecret}; use geth_ssh_identity::{ - SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry, + SshCertApproval, SshCertRequest, SshCertificateRecord, SshDistributionLogEntry, + SshRevocationEntry, }; use geth_ssh_proxy::SshProxyConnection; use geth_types::BlobHash; @@ -1177,6 +1178,7 @@ pub enum PeerControlResponse { remote_endpoint_id: String, requests: Vec, certificates: Vec, + log_entries: Vec, high_water_ms: i64, allowed: bool, reason: String, @@ -1190,6 +1192,7 @@ pub enum PeerControlResponse { endpoint_id: String, remote_endpoint_id: String, revocations: Vec, + log_entries: Vec, high_water_ms: i64, allowed: bool, reason: String, @@ -2316,6 +2319,7 @@ mod tests { remote_endpoint_id: "endpoint:caller".to_owned(), requests: Vec::new(), certificates: Vec::new(), + log_entries: Vec::new(), high_water_ms: 42, allowed: false, reason: "no grant".to_owned(), @@ -2335,6 +2339,7 @@ mod tests { endpoint_id: "endpoint:peer".to_owned(), remote_endpoint_id: "endpoint:caller".to_owned(), revocations: Vec::new(), + log_entries: Vec::new(), high_water_ms: 42, allowed: false, reason: "no grant".to_owned(), diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index e30e92a..1a8d4db 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -35,9 +35,9 @@ use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSec use geth_ssh_identity::{ SSH_CERT_ISSUANCE_NAMESPACE, SSH_CERT_REQUEST_NAMESPACE, SSH_REVOCATION_LIST_NAMESPACE, SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, - SshRecordProvenance, SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind, - build_ssh_cert_sign_command, cert_request_id, certificate_id, openssh_krl_spec, - parse_openssh_krl_spec, revocation_id, ssh_cert_request_signing_payload, + SshDistributionLogEntry, SshRecordProvenance, SshRevocationEntry, SshRevocationExportFormat, + SshRevocationKind, build_ssh_cert_sign_command, cert_request_id, certificate_id, + openssh_krl_spec, parse_openssh_krl_spec, revocation_id, ssh_cert_request_signing_payload, ssh_certificate_signing_payload, ssh_public_key_fingerprint, ssh_revocation_signing_payload, write_openssh_krl, }; @@ -1731,6 +1731,7 @@ async fn ssh_cert_sync_from_peer( endpoint_id, requests, certificates, + log_entries, high_water_ms, allowed, reason, @@ -1750,18 +1751,13 @@ async fn ssh_cert_sync_from_peer( }); } let store = Store::open(&node.paths.metadata_db())?; - let mut requests_imported = 0; - let mut certificates_imported = 0; - for request in &requests { - if insert_ssh_cert_request_if_not_conflicting(&store, request)? { - requests_imported += 1; - } - } - for certificate in &certificates { - if insert_ssh_certificate_if_not_conflicting(&store, certificate)? { - certificates_imported += 1; - } - } + let entries = if log_entries.is_empty() { + ssh_cert_distribution_log_entries(requests, certificates) + } else { + log_entries + }; + let (requests_imported, certificates_imported, _) = + import_ssh_distribution_log_entries(&store, &entries)?; store_live_sync_cursor(&store, peer_node, "ssh-certs", high_water_ms)?; Ok(ControlResponse::SshCertSynced { peer_node_id: node_id, @@ -1814,6 +1810,7 @@ async fn ssh_revocation_sync_from_peer( agent_id, endpoint_id, revocations, + log_entries, high_water_ms, allowed, reason, @@ -1832,12 +1829,13 @@ async fn ssh_revocation_sync_from_peer( }); } let store = Store::open(&node.paths.metadata_db())?; - let mut revocations_imported = 0; - for revocation in &revocations { - if insert_ssh_revocation_if_not_conflicting(&store, revocation)? { - revocations_imported += 1; - } - } + let entries = if log_entries.is_empty() { + ssh_revocation_distribution_log_entries(revocations) + } else { + log_entries + }; + let (_, _, revocations_imported) = + import_ssh_distribution_log_entries(&store, &entries)?; store_live_sync_cursor(&store, peer_node, "ssh-revocations", high_water_ms)?; Ok(ControlResponse::SshRevocationSynced { peer_node_id: node_id, @@ -5259,7 +5257,7 @@ async fn handle_iroh_control_connection( &nonce, bearer_proof.as_ref(), )?; - let (requests, certificates) = if explanation.allowed { + let (requests, certificates, log_entries) = if explanation.allowed { ( store .list_ssh_cert_requests_since(since_ms)? @@ -5271,9 +5269,15 @@ async fn handle_iroh_control_connection( .into_iter() .map(ssh_certificate_from_stored) .collect::, _>>()?, + Vec::new(), ) } else { - (Vec::new(), Vec::new()) + (Vec::new(), Vec::new(), Vec::new()) + }; + let log_entries = if explanation.allowed { + ssh_cert_distribution_log_entries(requests.clone(), certificates.clone()) + } else { + log_entries }; PeerControlResponse::SshCertSynced { node_id: node.node_id.clone(), @@ -5282,6 +5286,7 @@ async fn handle_iroh_control_connection( remote_endpoint_id, requests, certificates, + log_entries, high_water_ms, allowed: explanation.allowed, reason: explanation.reason, @@ -5320,14 +5325,19 @@ async fn handle_iroh_control_connection( &nonce, bearer_proof.as_ref(), )?; - let revocations = if explanation.allowed { + let (revocations, log_entries) = if explanation.allowed { store .list_ssh_revocations_since(since_ms)? .into_iter() .map(ssh_revocation_from_stored) - .collect::, _>>()? + .collect::, _>>() + .map(|revocations| { + let log_entries = + ssh_revocation_distribution_log_entries(revocations.clone()); + (revocations, log_entries) + })? } else { - Vec::new() + (Vec::new(), Vec::new()) }; PeerControlResponse::SshRevocationSynced { node_id: node.node_id.clone(), @@ -5335,6 +5345,7 @@ async fn handle_iroh_control_connection( endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(), remote_endpoint_id, revocations, + log_entries, high_water_ms, allowed: explanation.allowed, reason: explanation.reason, @@ -10587,6 +10598,77 @@ fn insert_ssh_revocation_if_not_conflicting( Ok(true) } +fn ssh_cert_distribution_log_entries( + requests: Vec, + certificates: Vec, +) -> Vec { + let mut entries = requests + .into_iter() + .map(SshDistributionLogEntry::cert_request) + .chain( + certificates + .into_iter() + .map(SshDistributionLogEntry::certificate), + ) + .collect::>(); + entries.sort_by(|left, right| { + left.timestamp() + .0 + .cmp(&right.timestamp().0) + .then_with(|| left.entry_id().cmp(right.entry_id())) + }); + entries +} + +fn ssh_revocation_distribution_log_entries( + revocations: Vec, +) -> Vec { + let mut entries = revocations + .into_iter() + .map(SshDistributionLogEntry::revocation) + .collect::>(); + entries.sort_by(|left, right| { + left.timestamp() + .0 + .cmp(&right.timestamp().0) + .then_with(|| left.entry_id().cmp(right.entry_id())) + }); + entries +} + +fn import_ssh_distribution_log_entries( + store: &Store, + entries: &[SshDistributionLogEntry], +) -> Result<(usize, usize, usize), NodeError> { + let mut requests_imported = 0; + let mut certificates_imported = 0; + let mut revocations_imported = 0; + for entry in entries { + match entry { + SshDistributionLogEntry::CertRequest { request, .. } => { + if insert_ssh_cert_request_if_not_conflicting(store, request)? { + requests_imported += 1; + } + } + SshDistributionLogEntry::Certificate { certificate, .. } => { + if insert_ssh_certificate_if_not_conflicting(store, certificate)? { + certificates_imported += 1; + } + } + SshDistributionLogEntry::Revocation { revocation, .. } => { + if insert_ssh_revocation_if_not_conflicting(store, revocation)? { + revocations_imported += 1; + } + } + } + } + Ok(( + requests_imported, + certificates_imported, + revocations_imported, + )) +} + fn ssh_revocation_from_stored( stored: StoredSshRevocation, ) -> Result { diff --git a/crates/geth-ssh-identity/src/lib.rs b/crates/geth-ssh-identity/src/lib.rs index 4c2d1ae..97772d4 100644 --- a/crates/geth-ssh-identity/src/lib.rs +++ b/crates/geth-ssh-identity/src/lib.rs @@ -220,6 +220,79 @@ pub struct SshRevocationEntry { pub provenance: Option, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum SshDistributionLogEntry { + CertRequest { + entry_id: String, + resource: String, + timestamp: UnixMillis, + request: SshCertRequest, + }, + Certificate { + entry_id: String, + resource: String, + timestamp: UnixMillis, + certificate: SshCertificateRecord, + }, + Revocation { + entry_id: String, + resource: String, + timestamp: UnixMillis, + revocation: SshRevocationEntry, + }, +} + +impl SshDistributionLogEntry { + #[must_use] + pub fn cert_request(request: SshCertRequest) -> Self { + Self::CertRequest { + entry_id: format!("ssh-log:cert-request:{}", request.id), + resource: "resource:ssh:certs".to_owned(), + timestamp: request.created_at, + request, + } + } + + #[must_use] + pub fn certificate(certificate: SshCertificateRecord) -> Self { + Self::Certificate { + entry_id: format!("ssh-log:certificate:{}", certificate.id), + resource: "resource:ssh:certs".to_owned(), + timestamp: certificate.imported_at, + certificate, + } + } + + #[must_use] + pub fn revocation(revocation: SshRevocationEntry) -> Self { + Self::Revocation { + entry_id: format!("ssh-log:revocation:{}", revocation.id), + resource: "resource:ssh:revocations".to_owned(), + timestamp: revocation.created_at, + revocation, + } + } + + #[must_use] + pub fn timestamp(&self) -> UnixMillis { + match self { + Self::CertRequest { timestamp, .. } + | Self::Certificate { timestamp, .. } + | Self::Revocation { timestamp, .. } => *timestamp, + } + } + + #[must_use] + pub fn entry_id(&self) -> &str { + match self { + Self::CertRequest { entry_id, .. } + | Self::Certificate { entry_id, .. } + | Self::Revocation { entry_id, .. } => entry_id, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SshCertRequestSigningPayload { pub id: SshCertRequestId, @@ -547,6 +620,31 @@ mod tests { assert_eq!(decoded, request); } + #[test] + fn ssh_distribution_log_entries_roundtrip_with_stable_ids() { + let request = SshCertRequest { + id: "ssh-cert-request:1".into(), + requester_node: "node:laptop".into(), + public_key: "ssh-ed25519 AAAA test".to_owned(), + public_key_fingerprint: ssh_public_key_fingerprint("ssh-ed25519 AAAA test"), + cert_kind: SshCertKind::User, + principals: vec!["eric".to_owned()], + requested_validity: Some("+52w".to_owned()), + renewal_of: None, + reason: Some("bootstrap".to_owned()), + status: SshCertRequestStatus::Pending, + created_at: UnixMillis(10), + provenance: None, + }; + let entry = SshDistributionLogEntry::cert_request(request); + assert_eq!(entry.entry_id(), "ssh-log:cert-request:ssh-cert-request:1"); + assert_eq!(entry.timestamp(), UnixMillis(10)); + + let json = serde_json::to_string(&entry).expect("json"); + let decoded: SshDistributionLogEntry = serde_json::from_str(&json).expect("decode"); + assert_eq!(decoded, entry); + } + #[test] fn sign_command_includes_host_flag_for_host_certs() { let request = SshCertRequest { diff --git a/docs/architecture.md b/docs/architecture.md index c07003c..b211bfd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,16 +105,20 @@ or pass `--sign` to execute that command immediately and import the resulting certificate into local metadata for distribution. This relies on the local OpenSSH ecosystem, so hardware-backed keys remain mediated by `ssh-keygen` and the host's agent/security-key flow. Certificate and key revocations are stored -as signed-list-ready records. The bootstrap can pull -certificate-flow metadata over Iroh with `geth ssh cert sync ` when the -peer grants `ssh_cert.sync` on `resource:ssh:certs`, and revocation metadata with +as signed-list-ready records. Sync materializes those records as ordered SSH +distribution log entries: certificate requests, certificate imports, and +revocations each have stable log entry IDs and timestamps, and the receiver +reduces the entries into local state after provenance and conflict checks. The +bootstrap can pull certificate-flow log entries over Iroh with +`geth ssh cert sync ` when the peer grants `ssh_cert.sync` on +`resource:ssh:certs`, and revocation log entries with `geth ssh revocation sync ` when the peer grants `ssh_revocation.sync` on `resource:ssh:revocations`. The daemon also runs a configurable background live-sync tick for known peers and records per-peer high-water cursors in -`module_state`, so repeated ticks request only records at or beyond the last +`module_state`, so repeated ticks request only entries at or beyond the last remote cursor. The default interval is 30 seconds and can be changed under `[sync]` in `config.toml`. Boundary duplicates are harmless because records are -keyed by stable IDs and inserted with replace semantics. +keyed by stable IDs. Before issuing per-module pulls, the daemon can request an authorized sync status summary over the same protected Iroh control ALPN. The serving peer validates endpoint/card binding and returns only watermarks for streams where @@ -278,16 +282,16 @@ specification text. Certificate approval normally emits the exact the resulting OpenSSH certificate, and mark the request signed. 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 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 -full CRDT-replicated resources, but the daemon can already pull cert-flow and -revocation metadata from authorized peers over the protected Iroh control ALPN. -Manual sync commands and the background live-sync loop share the same capability -checks and cursor state. Sync import rejects conflicting records with ids that -already exist locally instead of replacing local metadata. Local SSH certificate -and revocation metadata commands also accept an optional subject principal for +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 full CRDT-replicated resources, but the daemon now +syncs certificate-flow and revocation state as a small ordered resource log over +the protected Iroh control ALPN. Manual sync commands and the background +live-sync loop share the same capability checks and cursor state. Sync import +rejects conflicting records with ids that already exist locally instead of +replacing local metadata. Local SSH certificate and revocation metadata commands +also accept an optional subject principal for authorization testing: non-owner subjects must hold `ssh_cert.*` capabilities on `resource:ssh:certs` or `ssh_revocation.*` capabilities on `resource:ssh:revocations` before requests, approval/import/read operations, or diff --git a/docs/roadmap.md b/docs/roadmap.md index 2950473..2dfe3a9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -593,7 +593,7 @@ Goal: add authorized stream-oriented management workflows over Iroh. only after authorization. - `[x]` Future completion adds a restricted built-in geth admin shell option. -- `[~]` SSH certificate and revocation distribution. +- `[x]` SSH certificate and revocation distribution. Acceptance criteria: - `[x]` Cert request and imported certificate records can be pulled from an imported peer over Iroh. @@ -607,7 +607,10 @@ Goal: add authorized stream-oriented management workflows over Iroh. offline after sync. - `[x]` Background live-sync uses the same protected Iroh path and cursor state as manual sync. - - `[ ]` Replace pull-only metadata sync with a resource log or CRDT model. + - `[x]` Replace pull-only metadata sync with a resource log or CRDT model. + - `[x]` Sync responses carry ordered SSH distribution log entries derived + from signed cert requests, signed certificate imports, and signed + revocation records. - `[x]` Conflicting records with already-known ids are rejected during import rather than replacing local metadata. - `[x]` Unsigned records are rejected during sync import once signed