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 pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card
types, manual signed peer-card export/import/list commands, `geth peer ping` 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, 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-backend trait, custom relay-map config, and Iroh local-network
discovery toggle exist. discovery toggle exist.
- Canonical signed-operation envelopes exist for keychain/auth signature - 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 specification imports are supported; binary KRL import is unsupported because
OpenSSH KRL files are not enumerable through OpenSSH tooling. Tests cover OpenSSH KRL files are not enumerable through OpenSSH tooling. Tests cover
public-key and certificate binary KRL revocations when `ssh-keygen` is 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 - cr-sqlite, iroh-docs, iroh-blobs provider/fetch, Automerge sync, broader auth
enforcement, and Keyhive/BeeKEM-style authorization are future roadmap items enforcement, and Keyhive/BeeKEM-style authorization are future roadmap items
unless implemented later. 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 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 or a binary OpenSSH KRL generated through OpenSSH KRL specification file or a binary OpenSSH KRL generated through
`ssh-keygen -k`. Future Iroh replication will distribute these records between `ssh-keygen -k`. `geth ssh cert sync <node-id>` and
authorized nodes. `geth ssh revocation sync <node-id>` pull certificate-flow and revocation
metadata from an authorized peer over Iroh.
## MVP Features ## MVP Features
@ -112,10 +113,12 @@ The bootstrap implementation provides:
- `geth ssh cert approve <request-id> --ca-key <path>` - `geth ssh cert approve <request-id> --ca-key <path>`
- `geth ssh cert import <request-id> --cert <path>` - `geth ssh cert import <request-id> --cert <path>`
- `geth ssh cert list` - `geth ssh cert list`
- `geth ssh cert sync <node-id>`
- `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|openssh-krl]` - `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 import <path> [--format jsonl|openssh-krl-spec]`
- `geth ssh revocation sync <node-id>`
- 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. Peer cards `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 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 bootstrap transfer path; future work will move provider/fetch behavior to
`iroh-blobs`. `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. Importing or pinging a peer card never grants capabilities by itself.
When `[iroh].local_discovery = true`, the daemon also advertises and discovers 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 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, cert: PathBuf,
}, },
List, List,
Sync {
node: String,
},
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
@ -417,6 +420,9 @@ pub enum SshRevocationCommand {
#[arg(long, default_value = "jsonl")] #[arg(long, default_value = "jsonl")]
format: String, format: String,
}, },
Sync {
node: String,
},
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
@ -681,6 +687,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
cert_path: cert, cert_path: cert,
}, },
SshCertCommand::List => ControlRequest::SshCertList, SshCertCommand::List => ControlRequest::SshCertList,
SshCertCommand::Sync { node } => ControlRequest::SshCertSync { node },
}, },
SshCommand::Revocation { command } => match command { SshCommand::Revocation { command } => match command {
SshRevocationCommand::Add { SshRevocationCommand::Add {
@ -705,6 +712,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
SshRevocationCommand::Import { path, format } => { SshRevocationCommand::Import { path, format } => {
ControlRequest::SshRevocationImport { path, format } ControlRequest::SshRevocationImport { path, format }
} }
SshRevocationCommand::Sync { node } => ControlRequest::SshRevocationSync { node },
}, },
}, },
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"), 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 } => { ControlResponse::SshRevocationAdded { revocation } => {
println!("added ssh revocation: {}", revocation.id); println!("added ssh revocation: {}", revocation.id);
println!("kind: {}", revocation.kind); println!("kind: {}", revocation.kind);
@ -1189,6 +1220,26 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
} }
println!("note: {note}"); 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 } => { ControlResponse::DbAdded { db } => {
println!("registered db: {}", db.name); println!("registered db: {}", db.name);
println!("id: {}", db.id); println!("id: {}", db.id);

View file

@ -150,6 +150,9 @@ pub enum ControlRequest {
cert_path: PathBuf, cert_path: PathBuf,
}, },
SshCertList, SshCertList,
SshCertSync {
node: String,
},
SshRevocationAdd { SshRevocationAdd {
kind: String, kind: String,
target: String, target: String,
@ -165,6 +168,9 @@ pub enum ControlRequest {
path: PathBuf, path: PathBuf,
format: String, format: String,
}, },
SshRevocationSync {
node: String,
},
DbAdd { DbAdd {
name: String, name: String,
path: PathBuf, path: PathBuf,
@ -360,6 +366,16 @@ pub enum ControlResponse {
requests: Vec<SshCertRequest>, requests: Vec<SshCertRequest>,
certificates: Vec<SshCertificateRecord>, 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 { SshRevocationAdded {
revocation: SshRevocationEntry, revocation: SshRevocationEntry,
}, },
@ -378,6 +394,15 @@ pub enum ControlResponse {
count: usize, count: usize,
note: String, note: String,
}, },
SshRevocationSynced {
peer_node_id: String,
peer_agent_id: String,
endpoint_id: String,
revocations_imported: usize,
allowed: bool,
reason: String,
note: String,
},
DbAdded { DbAdded {
db: DbResource, db: DbResource,
}, },
@ -486,6 +511,14 @@ pub enum PeerControlRequest {
hash: BlobHash, hash: BlobHash,
nonce: String, nonce: String,
}, },
SshCertSync {
peer_card: PeerCard,
nonce: String,
},
SshRevocationSync {
peer_card: PeerCard,
nonce: String,
},
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -527,6 +560,31 @@ pub enum PeerControlResponse {
nonce: String, nonce: String,
note: 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 { Error {
message: String, message: String,
}, },
@ -629,6 +687,22 @@ mod tests {
request 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 { let response = ControlResponse::SshRevocationExported {
out: PathBuf::from("revocations.krl-spec"), out: PathBuf::from("revocations.krl-spec"),
format: "openssh-krl-spec".to_owned(), format: "openssh-krl-spec".to_owned(),
@ -640,6 +714,35 @@ mod tests {
response 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 { let request = ControlRequest::DocumentSet {
name: "notes".to_owned(), name: "notes".to_owned(),
state_json: r#"{"title":"notes"}"#.to_owned(), state_json: r#"{"title":"notes"}"#.to_owned(),
@ -762,5 +865,42 @@ mod tests {
.expect("decode"), .expect("decode"),
response 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, node: peer_node,
hash, hash,
} => cas_fetch_from_peer(node, &peer_node, hash).await, } => 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), 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( PeerControlResponse::AuthChecked { .. } => Err(NodeError::IrohPeer(
"peer returned auth-check response to ping request".to_owned(), "peer returned auth-check response to ping request".to_owned(),
)), )),
PeerControlResponse::CasFetched { .. } => Err(NodeError::IrohPeer( PeerControlResponse::CasFetched { .. }
"peer returned CAS fetch response to ping request".to_owned(), | 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( PeerControlResponse::Pong { .. } => Err(NodeError::IrohPeer(
"peer returned pong to auth-check request".to_owned(), "peer returned pong to auth-check request".to_owned(),
)), )),
PeerControlResponse::CasFetched { .. } => Err(NodeError::IrohPeer( PeerControlResponse::CasFetched { .. }
"peer returned CAS fetch response to auth-check request".to_owned(), | 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(), "peer CAS fetch response did not match request".to_owned(),
)), )),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)), PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
PeerControlResponse::Pong { .. } | PeerControlResponse::AuthChecked { .. } => Err( PeerControlResponse::Pong { .. }
NodeError::IrohPeer("peer returned wrong response type to CAS fetch".to_owned()), | 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()) send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
.await .await
@ -1068,6 +1364,8 @@ pub fn handle_request(
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshCertSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshRevocationSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList { ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
resources: store resources: store
.list_resources()? .list_resources()?
@ -2546,6 +2844,41 @@ mod tests {
let right_blob = LocalCas::new(right.paths.cas_dir()) let right_blob = LocalCas::new(right.paths.cas_dir())
.add_bytes(b"remote cas bytes") .add_bytes(b"remote cas bytes")
.expect("right cas add"); .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( let ping = handle_request_async(
&left, &left,
@ -2692,6 +3025,119 @@ mod tests {
other => panic!("unexpected allowed CAS fetch response: {other:?}"), 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; left_endpoint.shutdown().await;
right_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 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, YubiKey can approve the request and run an explicit `ssh-keygen -s ...` command,
then import the resulting certificate for distribution. Certificate and key then import the resulting certificate for distribution. Certificate and key
revocations are stored as signed-list-ready records and will be replicated over revocations are stored as signed-list-ready records. The bootstrap can pull
Iroh in later phases. 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 ## 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 exports and OpenSSH KRL specification source files. Binary OpenSSH KRL files are
not enumerable through OpenSSH tooling, so geth treats binary import as 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 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 ## Keychain, Auth, And Secrets

View file

@ -210,8 +210,12 @@ resource-scoped capability decisions.
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 or as a binary OpenSSH KRL through `ssh-keygen`. text or as a binary OpenSSH KRL through `ssh-keygen`.
- `[ ]` Future completion requires auth checks for request, approve, import, - `[x]` Authorized peers can pull SSH cert-flow metadata with
publish, and read capabilities. `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 ## Phase 3: CAS, KV, And Pubsub
@ -302,11 +306,20 @@ Goal: add authorized stream-oriented management workflows over Iroh.
admin shell. admin shell.
- Knowing an EndpointID alone cannot reach sshd. - Knowing an EndpointID alone cannot reach sshd.
- `[ ]` SSH certificate and revocation distribution. - `[~]` SSH certificate and revocation distribution.
Acceptance criteria: Acceptance criteria:
- Issued cert records and revocation records replicate over Iroh. - `[x]` Cert request and imported certificate records can be pulled from an
- Consumers can list current certs/revocations from local state while offline. imported peer over Iroh.
- Conflicting or unsigned records are rejected or quarantined. - `[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. - `[x]` OpenSSH KRL import/export.
Acceptance criteria: Acceptance criteria: