Sync SSH metadata over Iroh

This commit is contained in:
Eric Wendland 2026-05-18 17:24:10 +02:00
commit 9887b47a40
7 changed files with 686 additions and 20 deletions

View file

@ -107,7 +107,8 @@ Roadmap items should be actionable and checkable:
pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card
types, manual signed peer-card export/import/list commands, `geth peer ping`
and `geth peer auth-check` over Iroh, signed peer-card LAN discovery payloads,
authorized `geth cas fetch` over the Iroh control ALPN, untrusted
authorized `geth cas fetch`, `geth ssh cert sync`, and
`geth ssh revocation sync` over the Iroh control ALPN, untrusted
discovery-backend trait, custom relay-map config, and Iroh local-network
discovery toggle exist.
- Canonical signed-operation envelopes exist for keychain/auth signature
@ -157,7 +158,10 @@ Roadmap items should be actionable and checkable:
specification imports are supported; binary KRL import is unsupported because
OpenSSH KRL files are not enumerable through OpenSSH tooling. Tests cover
public-key and certificate binary KRL revocations when `ssh-keygen` is
available.
available. Authorized peers can pull SSH certificate-flow metadata with
`ssh_cert.sync` on `resource:ssh:certs` and revocation metadata with
`ssh_revocation.sync` on `resource:ssh:revocations`; this is pull-only
metadata sync, not yet a CRDT/resource-log replication model.
- cr-sqlite, iroh-docs, iroh-blobs provider/fetch, Automerge sync, broader auth
enforcement, and Keyhive/BeeKEM-style authorization are future roadmap items
unless implemented later.

View file

@ -61,8 +61,9 @@ 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 or a binary OpenSSH KRL generated through
`ssh-keygen -k`. Future Iroh replication will distribute these records between
authorized nodes.
`ssh-keygen -k`. `geth ssh cert sync <node-id>` and
`geth ssh revocation sync <node-id>` pull certificate-flow and revocation
metadata from an authorized peer over Iroh.
## MVP Features
@ -112,10 +113,12 @@ The bootstrap implementation provides:
- `geth ssh cert approve <request-id> --ca-key <path>`
- `geth ssh cert import <request-id> --cert <path>`
- `geth ssh cert list`
- `geth ssh cert sync <node-id>`
- `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]`
- `geth ssh revocation sync <node-id>`
- local pipe registry commands: `geth pipe listen/connect`
`geth peer export/import/list` is for untrusted peer-card exchange. Peer cards
@ -131,6 +134,11 @@ has `cas.fetch` on `resource:cas:local`, and the caller verifies that the bytes
hash to the requested BLAKE3 CAS hash before storing them locally. This is the
bootstrap transfer path; future work will move provider/fetch behavior to
`iroh-blobs`.
`geth ssh cert sync <node-id>` requires `ssh_cert.sync` on `resource:ssh:certs`
at the peer. `geth ssh revocation sync <node-id>` 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.
Importing or pinging a peer card never grants capabilities by itself.
When `[iroh].local_discovery = true`, the daemon also advertises and discovers
signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is

View file

@ -393,6 +393,9 @@ pub enum SshCertCommand {
cert: PathBuf,
},
List,
Sync {
node: String,
},
}
#[derive(Debug, Subcommand)]
@ -417,6 +420,9 @@ pub enum SshRevocationCommand {
#[arg(long, default_value = "jsonl")]
format: String,
},
Sync {
node: String,
},
}
#[derive(Debug, Args)]
@ -681,6 +687,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
cert_path: cert,
},
SshCertCommand::List => ControlRequest::SshCertList,
SshCertCommand::Sync { node } => ControlRequest::SshCertSync { node },
},
SshCommand::Revocation { command } => match command {
SshRevocationCommand::Add {
@ -705,6 +712,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
SshRevocationCommand::Import { path, format } => {
ControlRequest::SshRevocationImport { path, format }
}
SshRevocationCommand::Sync { node } => ControlRequest::SshRevocationSync { node },
},
},
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"),
@ -1140,6 +1148,29 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
}
}
}
ControlResponse::SshCertSynced {
peer_node_id,
peer_agent_id,
endpoint_id,
requests_imported,
certificates_imported,
allowed,
reason,
note,
} => {
if allowed {
println!(
"synced ssh cert metadata from {peer_node_id}: {requests_imported} requests, {certificates_imported} certificates"
);
} else {
println!("ssh cert metadata sync denied by {peer_node_id}");
}
println!("agent: {peer_agent_id}");
println!("endpoint: {endpoint_id}");
println!("allowed: {allowed}");
println!("reason: {reason}");
println!("note: {note}");
}
ControlResponse::SshRevocationAdded { revocation } => {
println!("added ssh revocation: {}", revocation.id);
println!("kind: {}", revocation.kind);
@ -1189,6 +1220,26 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
}
println!("note: {note}");
}
ControlResponse::SshRevocationSynced {
peer_node_id,
peer_agent_id,
endpoint_id,
revocations_imported,
allowed,
reason,
note,
} => {
if allowed {
println!("synced {revocations_imported} ssh revocations from {peer_node_id}");
} else {
println!("ssh revocation sync denied by {peer_node_id}");
}
println!("agent: {peer_agent_id}");
println!("endpoint: {endpoint_id}");
println!("allowed: {allowed}");
println!("reason: {reason}");
println!("note: {note}");
}
ControlResponse::DbAdded { db } => {
println!("registered db: {}", db.name);
println!("id: {}", db.id);

View file

@ -150,6 +150,9 @@ pub enum ControlRequest {
cert_path: PathBuf,
},
SshCertList,
SshCertSync {
node: String,
},
SshRevocationAdd {
kind: String,
target: String,
@ -165,6 +168,9 @@ pub enum ControlRequest {
path: PathBuf,
format: String,
},
SshRevocationSync {
node: String,
},
DbAdd {
name: String,
path: PathBuf,
@ -360,6 +366,16 @@ pub enum ControlResponse {
requests: Vec<SshCertRequest>,
certificates: Vec<SshCertificateRecord>,
},
SshCertSynced {
peer_node_id: String,
peer_agent_id: String,
endpoint_id: String,
requests_imported: usize,
certificates_imported: usize,
allowed: bool,
reason: String,
note: String,
},
SshRevocationAdded {
revocation: SshRevocationEntry,
},
@ -378,6 +394,15 @@ pub enum ControlResponse {
count: usize,
note: String,
},
SshRevocationSynced {
peer_node_id: String,
peer_agent_id: String,
endpoint_id: String,
revocations_imported: usize,
allowed: bool,
reason: String,
note: String,
},
DbAdded {
db: DbResource,
},
@ -486,6 +511,14 @@ pub enum PeerControlRequest {
hash: BlobHash,
nonce: String,
},
SshCertSync {
peer_card: PeerCard,
nonce: String,
},
SshRevocationSync {
peer_card: PeerCard,
nonce: String,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -527,6 +560,31 @@ pub enum PeerControlResponse {
nonce: String,
note: String,
},
SshCertSynced {
node_id: String,
agent_id: String,
endpoint_id: String,
remote_endpoint_id: String,
requests: Vec<SshCertRequest>,
certificates: Vec<SshCertificateRecord>,
allowed: bool,
reason: String,
evaluated_ops: usize,
nonce: String,
note: String,
},
SshRevocationSynced {
node_id: String,
agent_id: String,
endpoint_id: String,
remote_endpoint_id: String,
revocations: Vec<SshRevocationEntry>,
allowed: bool,
reason: String,
evaluated_ops: usize,
nonce: String,
note: String,
},
Error {
message: String,
},
@ -629,6 +687,22 @@ mod tests {
request
);
let request = ControlRequest::SshCertSync {
node: "node:ca".to_owned(),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
let request = ControlRequest::SshRevocationSync {
node: "node:ca".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(),
@ -640,6 +714,35 @@ mod tests {
response
);
let response = ControlResponse::SshCertSynced {
peer_node_id: "node:ca".to_owned(),
peer_agent_id: "agent:ca".to_owned(),
endpoint_id: "endpoint:ca".to_owned(),
requests_imported: 1,
certificates_imported: 0,
allowed: true,
reason: "direct grant".to_owned(),
note: "cert sync".to_owned(),
};
assert_eq!(
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
response
);
let response = ControlResponse::SshRevocationSynced {
peer_node_id: "node:ca".to_owned(),
peer_agent_id: "agent:ca".to_owned(),
endpoint_id: "endpoint:ca".to_owned(),
revocations_imported: 2,
allowed: true,
reason: "direct grant".to_owned(),
note: "revocation sync".to_owned(),
};
assert_eq!(
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
response
);
let request = ControlRequest::DocumentSet {
name: "notes".to_owned(),
state_json: r#"{"title":"notes"}"#.to_owned(),
@ -762,5 +865,42 @@ mod tests {
.expect("decode"),
response
);
let response = PeerControlResponse::SshCertSynced {
node_id: "node:peer".to_owned(),
agent_id: "agent:peer".to_owned(),
endpoint_id: "endpoint:peer".to_owned(),
remote_endpoint_id: "endpoint:caller".to_owned(),
requests: Vec::new(),
certificates: Vec::new(),
allowed: false,
reason: "no grant".to_owned(),
evaluated_ops: 0,
nonce: "nonce".to_owned(),
note: "cert sync".to_owned(),
};
assert_eq!(
decode_peer_response(&encode_peer_response(&response).expect("encode"))
.expect("decode"),
response
);
let response = PeerControlResponse::SshRevocationSynced {
node_id: "node:peer".to_owned(),
agent_id: "agent:peer".to_owned(),
endpoint_id: "endpoint:peer".to_owned(),
remote_endpoint_id: "endpoint:caller".to_owned(),
revocations: Vec::new(),
allowed: false,
reason: "no grant".to_owned(),
evaluated_ops: 0,
nonce: "nonce".to_owned(),
note: "revocation sync".to_owned(),
};
assert_eq!(
decode_peer_response(&encode_peer_response(&response).expect("encode"))
.expect("decode"),
response
);
}
}

View file

@ -244,6 +244,12 @@ pub async fn handle_request_async(
node: peer_node,
hash,
} => cas_fetch_from_peer(node, &peer_node, hash).await,
ControlRequest::SshCertSync { node: peer_node } => {
ssh_cert_sync_from_peer(node, &peer_node).await
}
ControlRequest::SshRevocationSync { node: peer_node } => {
ssh_revocation_sync_from_peer(node, &peer_node).await
}
other => handle_request(node, other),
}
}
@ -517,8 +523,10 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
PeerControlResponse::AuthChecked { .. } => Err(NodeError::IrohPeer(
"peer returned auth-check response to ping request".to_owned(),
)),
PeerControlResponse::CasFetched { .. } => Err(NodeError::IrohPeer(
"peer returned CAS fetch response to ping request".to_owned(),
PeerControlResponse::CasFetched { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to ping request".to_owned(),
)),
}
}
@ -622,8 +630,10 @@ async fn peer_auth_check(
PeerControlResponse::Pong { .. } => Err(NodeError::IrohPeer(
"peer returned pong to auth-check request".to_owned(),
)),
PeerControlResponse::CasFetched { .. } => Err(NodeError::IrohPeer(
"peer returned CAS fetch response to auth-check request".to_owned(),
PeerControlResponse::CasFetched { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to auth-check request".to_owned(),
)),
}
}
@ -755,9 +765,199 @@ async fn cas_fetch_from_peer(
"peer CAS fetch response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
PeerControlResponse::Pong { .. } | PeerControlResponse::AuthChecked { .. } => Err(
NodeError::IrohPeer("peer returned wrong response type to CAS fetch".to_owned()),
),
PeerControlResponse::Pong { .. }
| PeerControlResponse::AuthChecked { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to CAS fetch".to_owned(),
)),
}
}
async fn ssh_cert_sync_from_peer(
node: &LocalNode,
peer_node: &str,
) -> Result<ControlResponse, NodeError> {
let response = request_peer_control(node, peer_node, "ssh-cert-sync", |peer_card, nonce| {
PeerControlRequest::SshCertSync { peer_card, nonce }
})
.await?;
match response {
PeerControlResponse::SshCertSynced {
node_id,
agent_id,
endpoint_id,
requests,
certificates,
allowed,
reason,
note,
..
} => {
if !allowed {
return Ok(ControlResponse::SshCertSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
requests_imported: 0,
certificates_imported: 0,
allowed,
reason,
note,
});
}
let store = Store::open(&node.paths.metadata_db())?;
let requests_imported = requests.len();
let certificates_imported = certificates.len();
for request in &requests {
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(request))?;
}
for certificate in &certificates {
store.insert_ssh_certificate(&stored_from_ssh_certificate(certificate))?;
}
Ok(ControlResponse::SshCertSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
requests_imported,
certificates_imported,
allowed,
reason,
note,
})
}
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to SSH cert sync".to_owned(),
)),
}
}
async fn ssh_revocation_sync_from_peer(
node: &LocalNode,
peer_node: &str,
) -> Result<ControlResponse, NodeError> {
let response = request_peer_control(
node,
peer_node,
"ssh-revocation-sync",
|peer_card, nonce| PeerControlRequest::SshRevocationSync { peer_card, nonce },
)
.await?;
match response {
PeerControlResponse::SshRevocationSynced {
node_id,
agent_id,
endpoint_id,
revocations,
allowed,
reason,
note,
..
} => {
if !allowed {
return Ok(ControlResponse::SshRevocationSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
revocations_imported: 0,
allowed,
reason,
note,
});
}
let store = Store::open(&node.paths.metadata_db())?;
let revocations_imported = revocations.len();
for revocation in &revocations {
store.insert_ssh_revocation(&stored_from_ssh_revocation(revocation))?;
}
Ok(ControlResponse::SshRevocationSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
revocations_imported,
allowed,
reason,
note,
})
}
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to SSH revocation sync".to_owned(),
)),
}
}
async fn request_peer_control(
node: &LocalNode,
peer_node: &str,
operation: &str,
build_request: impl FnOnce(PeerCard, String) -> PeerControlRequest,
) -> Result<PeerControlResponse, NodeError> {
let store = Store::open(&node.paths.metadata_db())?;
let stored = store
.get_peer_card(peer_node)?
.ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?;
let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?;
peer_card.validate_candidate()?;
let candidate = peer_card
.endpoints
.first()
.ok_or(geth_discovery::DiscoveryError::MissingEndpoint)?;
ensure_peer_card_matches_endpoint(&peer_card, &candidate.endpoint_id)?;
let node_addr = iroh_node_addr_from_candidate(candidate)?;
let endpoint = node
.iroh_endpoint
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?
.clone()
.ok_or(NodeError::IrohEndpointUnavailable)?;
let self_card = local_peer_card(node, DiscoverySource::PeerExchange, true).await?;
let nonce = geth_crypto::blake3_hex(
format!(
"{}\0{}\0{}\0{}",
node.node_id,
peer_node,
operation,
geth_store::now_ms()
)
.as_bytes(),
);
let request = build_request(self_card, nonce.clone());
let conn = endpoint
.endpoint()
.connect(node_addr, geth_iroh::ALPN_CONTROL)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
send.write_all(geth_control::encode_peer_request(&request)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
send.finish()
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let bytes = recv
.read_to_end(16 * 1024 * 1024)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let text =
std::str::from_utf8(&bytes).map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let response = geth_control::decode_peer_response(text)?;
match &response {
PeerControlResponse::SshCertSynced {
nonce: response_nonce,
..
}
| PeerControlResponse::SshRevocationSynced {
nonce: response_nonce,
..
} if response_nonce == &nonce => Ok(response),
PeerControlResponse::Error { .. } => Ok(response),
_ => Err(NodeError::IrohPeer(format!(
"peer {operation} response did not match request"
))),
}
}
@ -932,6 +1132,102 @@ async fn handle_iroh_control_connection(
}
}
}
PeerControlRequest::SshCertSync { peer_card, nonce } => {
peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate(
peer_card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::PeerExchange,
)?;
let store = Store::open(&node.paths.metadata_db())?;
store.upsert_peer_card(&StoredPeerCard {
peer_id: peer_card.node_id.to_string(),
card_json: serde_json::to_string(&peer_card)?,
updated_at_ms: discovered.discovered_at.0,
})?;
let resource = "resource:ssh:certs".to_owned();
let capability = "ssh_cert.sync".to_owned();
let explanation = geth_auth::explain_auth_ops(
&load_auth_ops_for_resource(&store, &resource)?,
PrincipalId::new(peer_card.node_id.to_string()),
ResourceId::new(resource),
Capability::new(capability),
);
let (requests, certificates) = if explanation.allowed {
(
store
.list_ssh_cert_requests()?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
store
.list_ssh_certificates()?
.into_iter()
.map(ssh_certificate_from_stored)
.collect(),
)
} else {
(Vec::new(), Vec::new())
};
PeerControlResponse::SshCertSynced {
node_id: node.node_id.clone(),
agent_id: node.agent_id.clone(),
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
remote_endpoint_id,
requests,
certificates,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "SSH certificate metadata sync authenticated endpoint/card binding and required ssh_cert.sync on resource:ssh:certs".to_owned(),
}
}
PeerControlRequest::SshRevocationSync { peer_card, nonce } => {
peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate(
peer_card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::PeerExchange,
)?;
let store = Store::open(&node.paths.metadata_db())?;
store.upsert_peer_card(&StoredPeerCard {
peer_id: peer_card.node_id.to_string(),
card_json: serde_json::to_string(&peer_card)?,
updated_at_ms: discovered.discovered_at.0,
})?;
let resource = "resource:ssh:revocations".to_owned();
let capability = "ssh_revocation.sync".to_owned();
let explanation = geth_auth::explain_auth_ops(
&load_auth_ops_for_resource(&store, &resource)?,
PrincipalId::new(peer_card.node_id.to_string()),
ResourceId::new(resource),
Capability::new(capability),
);
let revocations = if explanation.allowed {
store
.list_ssh_revocations()?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?
} else {
Vec::new()
};
PeerControlResponse::SshRevocationSynced {
node_id: node.node_id.clone(),
agent_id: node.agent_id.clone(),
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
remote_endpoint_id,
revocations,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "SSH revocation sync authenticated endpoint/card binding and required ssh_revocation.sync on resource:ssh:revocations".to_owned(),
}
}
};
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
.await
@ -1068,6 +1364,8 @@ pub fn handle_request(
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshCertSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshRevocationSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
resources: store
.list_resources()?
@ -2546,6 +2844,41 @@ mod tests {
let right_blob = LocalCas::new(right.paths.cas_dir())
.add_bytes(b"remote cas bytes")
.expect("right cas add");
let right_pubkey = right_home.path().join("request.pub");
std::fs::write(
&right_pubkey,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== node\n",
)
.expect("write right public key");
let requested = handle_request(
&right,
ControlRequest::SshCertRequest {
public_key_path: right_pubkey,
cert_kind: "user".to_owned(),
principals: vec!["eric".to_owned()],
requested_validity: Some("+52w".to_owned()),
renewal_of: None,
reason: Some("test sync".to_owned()),
},
)
.expect("right ssh cert request");
let right_request_id = match requested {
ControlResponse::SshCertRequested { request } => request.id,
other => panic!("unexpected SSH cert request response: {other:?}"),
};
let added_revocation = handle_request(
&right,
ControlRequest::SshRevocationAdd {
kind: "key-id".to_owned(),
target: "old-node-key".to_owned(),
reason: Some("test sync".to_owned()),
},
)
.expect("right ssh revocation");
let right_revocation_id = match added_revocation {
ControlResponse::SshRevocationAdded { revocation } => revocation.id,
other => panic!("unexpected SSH revocation response: {other:?}"),
};
let ping = handle_request_async(
&left,
@ -2692,6 +3025,119 @@ mod tests {
other => panic!("unexpected allowed CAS fetch response: {other:?}"),
}
let denied_cert_sync = handle_request_async(
&left,
ControlRequest::SshCertSync {
node: right_card.node_id.to_string(),
},
)
.await
.expect("denied ssh cert sync");
match denied_cert_sync {
ControlResponse::SshCertSynced {
allowed,
requests_imported,
certificates_imported,
reason,
..
} => {
assert!(!allowed);
assert_eq!(requests_imported, 0);
assert_eq!(certificates_imported, 0);
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied SSH cert sync response: {other:?}"),
}
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:ssh:certs".to_owned(),
capability: "ssh_cert.sync".to_owned(),
grant_id: Some("grant:left-ssh-cert-sync".to_owned()),
},
)
.expect("grant left ssh cert sync");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:ssh:revocations".to_owned(),
capability: "ssh_revocation.sync".to_owned(),
grant_id: Some("grant:left-ssh-revocation-sync".to_owned()),
},
)
.expect("grant left ssh revocation sync");
let cert_sync = handle_request_async(
&left,
ControlRequest::SshCertSync {
node: right_card.node_id.to_string(),
},
)
.await
.expect("allowed ssh cert sync");
match cert_sync {
ControlResponse::SshCertSynced {
allowed,
requests_imported,
certificates_imported,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(requests_imported, 1);
assert_eq!(certificates_imported, 0);
assert!(reason.contains("direct grant"));
assert!(note.contains("ssh_cert.sync"));
}
other => panic!("unexpected allowed SSH cert sync response: {other:?}"),
}
let left_requests = Store::open(&left_paths.metadata_db())
.expect("open left after cert sync")
.list_ssh_cert_requests()
.expect("list synced requests");
assert!(
left_requests
.iter()
.any(|request| request.request_id == right_request_id.as_str())
);
let revocation_sync = handle_request_async(
&left,
ControlRequest::SshRevocationSync {
node: right_card.node_id.to_string(),
},
)
.await
.expect("allowed ssh revocation sync");
match revocation_sync {
ControlResponse::SshRevocationSynced {
allowed,
revocations_imported,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(revocations_imported, 1);
assert!(reason.contains("direct grant"));
assert!(note.contains("ssh_revocation.sync"));
}
other => panic!("unexpected allowed SSH revocation sync response: {other:?}"),
}
let left_revocations = Store::open(&left_paths.metadata_db())
.expect("open left after revocation sync")
.list_ssh_revocations()
.expect("list synced revocations");
assert!(
left_revocations
.iter()
.any(|revocation| revocation.revocation_id == right_revocation_id.as_str())
);
left_endpoint.shutdown().await;
right_endpoint.shutdown().await;
}

View file

@ -80,8 +80,11 @@ SSH certificate flows use the same split. Nodes can request new OpenSSH
certificates or renewals through geth metadata. A machine with the CA key or
YubiKey can approve the request and run an explicit `ssh-keygen -s ...` command,
then import the resulting certificate for distribution. Certificate and key
revocations are stored as signed-list-ready records and will be replicated over
Iroh in later phases.
revocations are stored as signed-list-ready records. The bootstrap can pull
certificate-flow metadata over Iroh with `geth ssh cert sync <node-id>` when the
peer grants `ssh_cert.sync` on `resource:ssh:certs`, and revocation metadata with
`geth ssh revocation sync <node-id>` when the peer grants `ssh_revocation.sync`
on `resource:ssh:revocations`.
## Resource Model
@ -164,7 +167,8 @@ OpenSSH KRL; serial and key-ID KRL entries require a CA public key via
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.
full CRDT-replicated resources, but the daemon can already pull cert-flow and
revocation metadata from authorized peers over the protected Iroh control ALPN.
## Keychain, Auth, And Secrets

View file

@ -210,8 +210,12 @@ resource-scoped capability decisions.
revocations.
- `[x]` Revocations can be exported as JSONL and OpenSSH KRL specification
text or as a binary OpenSSH KRL through `ssh-keygen`.
- `[ ]` Future completion requires auth checks for request, approve, import,
publish, and read capabilities.
- `[x]` Authorized peers can pull SSH cert-flow metadata with
`geth ssh cert sync <node-id>`.
- `[x]` Authorized peers can pull SSH revocation metadata with
`geth ssh revocation sync <node-id>`.
- `[ ]` Future completion requires auth checks for local request, approve,
import, publish, and read capabilities.
## Phase 3: CAS, KV, And Pubsub
@ -302,11 +306,20 @@ Goal: add authorized stream-oriented management workflows over Iroh.
admin shell.
- Knowing an EndpointID alone cannot reach sshd.
- `[ ]` SSH certificate and revocation distribution.
- `[~]` SSH certificate and revocation distribution.
Acceptance criteria:
- Issued cert records and revocation records replicate over Iroh.
- Consumers can list current certs/revocations from local state while offline.
- Conflicting or unsigned records are rejected or quarantined.
- `[x]` Cert request and imported certificate records can be pulled from an
imported peer over Iroh.
- `[x]` Revocation records can be pulled from an imported peer over Iroh.
- `[x]` Remote sync validates the caller's signed peer card against the
observed Iroh EndpointID before considering authorization.
- `[x]` Cert metadata sync requires `ssh_cert.sync` on `resource:ssh:certs`.
- `[x]` Revocation metadata sync requires `ssh_revocation.sync` on
`resource:ssh:revocations`.
- `[x]` Consumers can list current certs/revocations from local state while
offline after sync.
- `[ ]` Replace pull-only metadata sync with a resource log or CRDT model.
- `[ ]` Conflicting or unsigned records are rejected or quarantined.
- `[x]` OpenSSH KRL import/export.
Acceptance criteria: