diff --git a/AGENTS.md b/AGENTS.md index db4c4cb..14d5005 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,8 +105,9 @@ Roadmap items should be actionable and checkable: daemon-owned Iroh endpoint startup, built-in relay-mode config, SSH certificate metadata, revocation metadata, user service definitions, and a pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card - types, untrusted discovery-backend trait, custom relay-map config, and Iroh - local-network discovery toggle exist. + types, manual signed peer-card export/import/list commands, untrusted + discovery-backend trait, custom relay-map config, and Iroh local-network + discovery toggle exist. - Canonical signed-operation envelopes exist for keychain/auth signature payloads. The keychain reducer builds an active identity view for admin keys, users, devices, nodes, agents, and endpoint bindings. diff --git a/Cargo.lock b/Cargo.lock index 429b2fb..f5b1ac8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1045,6 +1045,8 @@ dependencies = [ "geth-cli", "geth-config", "geth-control", + "geth-discovery", + "geth-iroh", "geth-node", "geth-store", "geth-types", @@ -1122,6 +1124,7 @@ dependencies = [ "geth-auth", "geth-cas", "geth-db", + "geth-discovery", "geth-document", "geth-keychain", "geth-kv", @@ -1167,7 +1170,9 @@ dependencies = [ name = "geth-discovery" version = "0.1.0" dependencies = [ + "geth-crypto", "geth-types", + "hex", "serde", "thiserror 2.0.18", ] @@ -1225,6 +1230,7 @@ dependencies = [ "geth-control", "geth-crypto", "geth-db", + "geth-discovery", "geth-document", "geth-iroh", "geth-keychain", diff --git a/README.md b/README.md index 95f4677..264f2af 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,9 @@ The bootstrap implementation provides: - `geth daemon service install|uninstall|start|stop|status|print` - `geth status` - `geth node id` +- `geth peer export [--out ]` +- `geth peer import ` +- `geth peer list` - `geth resource list` - `geth resource create ` - `geth keychain init [--admin-key ]` @@ -109,7 +112,11 @@ The bootstrap implementation provides: - `geth ssh revocation export --out [--format jsonl|openssh-krl-spec]` - local pipe registry commands: `geth pipe listen/connect` -Other command groups exist as explicit stubs: `ssh`. +`geth peer export/import/list` is for untrusted peer-card exchange while live +LAN discovery and authenticated Iroh dialing are still being built. Importing a +peer card never grants capabilities by itself. + +Other command groups exist as explicit stubs: `ssh proxy`. ## Resource Modules diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 84248ed..35d3016 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -28,6 +28,10 @@ pub enum Command { #[command(subcommand)] command: NodeCommand, }, + Peer { + #[command(subcommand)] + command: PeerCommand, + }, Resource { #[command(subcommand)] command: ResourceCommand, @@ -123,6 +127,18 @@ pub enum NodeCommand { Status, } +#[derive(Debug, Subcommand)] +pub enum PeerCommand { + Export { + #[arg(long)] + out: Option, + }, + Import { + path: PathBuf, + }, + List, +} + #[derive(Debug, Subcommand)] pub enum ResourceCommand { List, @@ -431,6 +447,11 @@ fn request_for_command(command: Command) -> Result { Command::Node { command: NodeCommand::Status, } => ControlRequest::Status, + Command::Peer { command } => match command { + PeerCommand::Export { out } => ControlRequest::PeerCardExport { out }, + PeerCommand::Import { path } => ControlRequest::PeerCardImport { path }, + PeerCommand::List => ControlRequest::PeerCardList, + }, Command::Resource { command: ResourceCommand::List, } => ControlRequest::ResourceList, @@ -721,6 +742,38 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { .unwrap_or("not started in bootstrap") ); } + ControlResponse::PeerCardExported { card, out, note } => { + println!("peer card: {}", card.node_id); + println!("agent: {}", card.agent_id); + println!("endpoints: {}", card.endpoints.len()); + if let Some(path) = out { + println!("wrote: {}", path.display()); + } else { + println!("{}", serde_json::to_string_pretty(&card)?); + } + println!("note: {note}"); + } + ControlResponse::PeerCardImported { peer, note } => { + println!("imported peer: {}", peer.card.node_id); + println!("agent: {}", peer.card.agent_id); + println!("trust: candidate-only"); + println!("note: {note}"); + } + ControlResponse::PeerCardList { peers, note } => { + if peers.is_empty() { + println!("no peer candidates"); + } else { + for peer in peers { + println!( + "{}\t{}\t{} endpoints\tcandidate-only", + peer.card.node_id, + peer.card.agent_id, + peer.card.endpoints.len() + ); + } + } + println!("note: {note}"); + } ControlResponse::ResourceList { resources } => { if resources.is_empty() { println!("no resources"); diff --git a/crates/geth-control/Cargo.toml b/crates/geth-control/Cargo.toml index 0f3547e..a20411a 100644 --- a/crates/geth-control/Cargo.toml +++ b/crates/geth-control/Cargo.toml @@ -12,6 +12,7 @@ thiserror.workspace = true geth-auth = { path = "../geth-auth" } geth-cas = { path = "../geth-cas" } geth-db = { path = "../geth-db" } +geth-discovery = { path = "../geth-discovery" } geth-document = { path = "../geth-document" } geth-keychain = { path = "../geth-keychain" } geth-kv = { path = "../geth-kv" } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index ea1c79d..52c0ac8 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -1,6 +1,7 @@ use geth_auth::{AuthExplanation, AuthOp}; use geth_cas::{FileConflict, FileRoot, FileRootScan}; use geth_db::{CrSqliteChangeBatch, DbResource}; +use geth_discovery::{DiscoveredPeer, PeerCard}; use geth_document::{DocumentResource, DocumentState}; use geth_keychain::KeychainOp; use geth_kv::{KvEntry, KvResource}; @@ -20,6 +21,13 @@ use std::path::PathBuf; pub enum ControlRequest { Status, NodeId, + PeerCardExport { + out: Option, + }, + PeerCardImport { + path: PathBuf, + }, + PeerCardList, ResourceList, ResourceCreate { kind: String, @@ -201,6 +209,19 @@ pub enum ControlRequest { pub enum ControlResponse { Status(StatusResponse), NodeId(NodeIdResponse), + PeerCardExported { + card: PeerCard, + out: Option, + note: String, + }, + PeerCardImported { + peer: DiscoveredPeer, + note: String, + }, + PeerCardList { + peers: Vec, + note: String, + }, ResourceList { resources: Vec, }, @@ -508,5 +529,11 @@ mod tests { decode_request(&encode_request(&request).expect("encode")).expect("decode"), request ); + + let request = ControlRequest::PeerCardExport { out: None }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); } } diff --git a/crates/geth-discovery/Cargo.toml b/crates/geth-discovery/Cargo.toml index bb8c3d7..baad906 100644 --- a/crates/geth-discovery/Cargo.toml +++ b/crates/geth-discovery/Cargo.toml @@ -8,4 +8,6 @@ license.workspace = true [dependencies] serde.workspace = true thiserror.workspace = true +hex.workspace = true +geth-crypto = { path = "../geth-crypto" } geth-types = { path = "../geth-types" } diff --git a/crates/geth-discovery/src/lib.rs b/crates/geth-discovery/src/lib.rs index 68296e1..5f520ec 100644 --- a/crates/geth-discovery/src/lib.rs +++ b/crates/geth-discovery/src/lib.rs @@ -1,3 +1,4 @@ +use geth_crypto::AgentKey; use geth_types::{AgentId, NodeId, UnixMillis}; use serde::{Deserialize, Serialize}; @@ -13,20 +14,93 @@ pub struct PeerCard { } impl PeerCard { + pub fn signed( + node_id: NodeId, + agent_key: &AgentKey, + endpoints: Vec, + issued_at: UnixMillis, + ) -> Result { + let agent_id = agent_key.agent_id(); + let payload = PeerCardSigningPayload { + node_id: node_id.clone(), + agent_id: agent_id.clone(), + endpoints: endpoints.clone(), + issued_at, + }; + let signature = agent_key.sign_canonical(PEER_CARD_SIGNATURE_NAMESPACE, &payload)?; + Ok(Self { + node_id, + agent_id: agent_id.clone(), + endpoints, + issued_at, + signature: SignatureMetadata { + namespace: PEER_CARD_SIGNATURE_NAMESPACE.to_owned(), + signer: agent_id.to_string(), + public_key: agent_key.public_key_hex(), + signature: hex::encode(signature), + }, + }) + } + pub fn validate_candidate(&self) -> Result<(), DiscoveryError> { if self.endpoints.is_empty() { return Err(DiscoveryError::MissingEndpoint); } + self.verify_signature() + } + + pub fn verify_signature(&self) -> Result<(), DiscoveryError> { if self.signature.namespace != PEER_CARD_SIGNATURE_NAMESPACE { return Err(DiscoveryError::InvalidSignatureNamespace( self.signature.namespace.clone(), )); } - if self.signature.signer.is_empty() || self.signature.signature.is_empty() { + if self.signature.signer.is_empty() + || self.signature.public_key.is_empty() + || self.signature.signature.is_empty() + { return Err(DiscoveryError::UnsignedPeerCard); } + if self.signature.signer != self.agent_id.as_str() { + return Err(DiscoveryError::SignerMismatch { + signer: self.signature.signer.clone(), + agent: self.agent_id.to_string(), + }); + } + let public_key = hex::decode(&self.signature.public_key)?; + let fingerprint = geth_crypto::key_fingerprint(&public_key); + if fingerprint != self.signature.signer { + return Err(DiscoveryError::SignerPublicKeyMismatch { + signer: self.signature.signer.clone(), + fingerprint, + }); + } + let signature = hex::decode(&self.signature.signature)?; + geth_crypto::verify_canonical( + &public_key, + PEER_CARD_SIGNATURE_NAMESPACE, + &self.signing_payload(), + &signature, + )?; Ok(()) } + + fn signing_payload(&self) -> PeerCardSigningPayload { + PeerCardSigningPayload { + node_id: self.node_id.clone(), + agent_id: self.agent_id.clone(), + endpoints: self.endpoints.clone(), + issued_at: self.issued_at, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct PeerCardSigningPayload { + node_id: NodeId, + agent_id: AgentId, + endpoints: Vec, + issued_at: UnixMillis, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -40,6 +114,8 @@ pub struct EndpointCandidate { pub struct SignatureMetadata { pub namespace: String, pub signer: String, + #[serde(default)] + pub public_key: String, pub signature: String, } @@ -94,6 +170,14 @@ pub enum DiscoveryError { UnsignedPeerCard, #[error("invalid peer card signature namespace: {0}")] InvalidSignatureNamespace(String), + #[error("peer card signer {signer} does not match agent {agent}")] + SignerMismatch { signer: String, agent: String }, + #[error("peer card signer {signer} does not match public key fingerprint {fingerprint}")] + SignerPublicKeyMismatch { signer: String, fingerprint: String }, + #[error("invalid peer card hex: {0}")] + Hex(#[from] hex::FromHexError), + #[error("peer card signature error: {0}")] + Crypto(#[from] geth_crypto::CryptoError), } #[must_use] @@ -106,21 +190,19 @@ mod tests { use super::*; fn signed_card() -> PeerCard { - PeerCard { - node_id: "node:laptop".into(), - agent_id: "agent:abc".into(), - endpoints: vec![EndpointCandidate { + let key = AgentKey::generate(); + let agent_id = key.agent_id(); + PeerCard::signed( + format!("node:{agent_id}").into(), + &key, + vec![EndpointCandidate { endpoint_id: "endpoint:iroh".to_owned(), relay_url: None, source: DiscoverySource::Manual, }], - issued_at: UnixMillis(1), - signature: SignatureMetadata { - namespace: PEER_CARD_SIGNATURE_NAMESPACE.to_owned(), - signer: "agent:abc".to_owned(), - signature: "sig:test".to_owned(), - }, - } + UnixMillis(1), + ) + .expect("signed card") } #[test] @@ -146,4 +228,14 @@ mod tests { Err(DiscoveryError::UnsignedPeerCard) )); } + + #[test] + fn tampered_peer_card_signature_is_rejected() { + let mut card = signed_card(); + card.endpoints[0].endpoint_id = "endpoint:tampered".to_owned(); + assert!(matches!( + card.validate_candidate(), + Err(DiscoveryError::Crypto(geth_crypto::CryptoError::Verify)) + )); + } } diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index e178f6a..58df197 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -16,6 +16,7 @@ geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } geth-crypto = { path = "../geth-crypto" } geth-db = { path = "../geth-db" } +geth-discovery = { path = "../geth-discovery" } geth-document = { path = "../geth-document" } geth-iroh = { path = "../geth-iroh" } geth-keychain = { path = "../geth-keychain" } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index fe9eee3..576718a 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -12,6 +12,9 @@ use geth_control::{ }; use geth_crypto::AgentKey; use geth_db::DbResource; +use geth_discovery::{ + DiscoveredPeer, DiscoverySource, EndpointCandidate, PeerCard, discovery_is_untrusted_note, +}; use geth_document::{DocumentResource, DocumentState}; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; use geth_keychain::{KeychainOp, KeychainOpKind}; @@ -27,7 +30,7 @@ use geth_ssh_identity::{ }; use geth_store::{ Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, - StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredResource, + StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredPeerCard, StoredResource, StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, }; use geth_types::{ @@ -102,6 +105,10 @@ pub enum NodeError { SshCertFlow(#[from] geth_ssh_identity::SshCertFlowError), #[error("ssh identity error: {0}")] SshIdentity(#[from] geth_ssh_identity::SshIdentityError), + #[error("discovery error: {0}")] + Discovery(#[from] geth_discovery::DiscoveryError), + #[error("cannot export peer card before the daemon has an Iroh EndpointID")] + IrohEndpointUnavailable, } #[derive(Clone, Debug)] @@ -241,6 +248,62 @@ pub fn handle_request( node_id: node.node_id.clone(), endpoint_id: node.iroh_status.endpoint_id.clone(), })), + ControlRequest::PeerCardExport { out } => { + let endpoint_id = node + .iroh_status + .endpoint_id + .clone() + .ok_or(NodeError::IrohEndpointUnavailable)?; + let key = AgentKey::load(&node.paths.agent_key())?; + let card = PeerCard::signed( + NodeId::new(node.node_id.clone()), + &key, + vec![EndpointCandidate { + endpoint_id, + relay_url: None, + source: DiscoverySource::Manual, + }], + UnixMillis(geth_store::now_ms()), + )?; + if let Some(path) = &out { + std::fs::write(path, serde_json::to_string_pretty(&card)?)?; + } + Ok(ControlResponse::PeerCardExported { + card, + out, + note: discovery_is_untrusted_note().to_owned(), + }) + } + ControlRequest::PeerCardImport { path } => { + let card_json = std::fs::read_to_string(&path)?; + let card: PeerCard = serde_json::from_str(&card_json)?; + card.validate_candidate()?; + let discovered = DiscoveredPeer::candidate( + card.clone(), + UnixMillis(geth_store::now_ms()), + DiscoverySource::Imported, + )?; + store.upsert_peer_card(&StoredPeerCard { + peer_id: card.node_id.to_string(), + card_json: serde_json::to_string(&card)?, + updated_at_ms: discovered.discovered_at.0, + })?; + Ok(ControlResponse::PeerCardImported { + peer: discovered, + note: discovery_is_untrusted_note().to_owned(), + }) + } + ControlRequest::PeerCardList => { + let peers = store + .list_peer_cards()? + .into_iter() + .map(discovered_peer_from_stored) + .collect::, _>>()?; + Ok(ControlResponse::PeerCardList { + peers, + note: discovery_is_untrusted_note().to_owned(), + }) + } ControlRequest::ResourceList => Ok(ControlResponse::ResourceList { resources: store .list_resources()? @@ -1214,6 +1277,15 @@ fn file_root_from_stored(stored: &StoredFileRoot) -> FileRoot { } } +fn discovered_peer_from_stored(stored: StoredPeerCard) -> Result { + let card: PeerCard = serde_json::from_str(&stored.card_json)?; + Ok(DiscoveredPeer::candidate( + card, + UnixMillis(stored.updated_at_ms), + DiscoverySource::Imported, + )?) +} + fn file_conflict_from_stored(stored: StoredFileConflict) -> Result { Ok(FileConflict { id: stored.conflict_id, diff --git a/crates/geth/Cargo.toml b/crates/geth/Cargo.toml index 9450eaa..41fc599 100644 --- a/crates/geth/Cargo.toml +++ b/crates/geth/Cargo.toml @@ -19,6 +19,8 @@ geth-cli = { path = "../geth-cli" } geth-cas = { path = "../geth-cas" } geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } +geth-discovery = { path = "../geth-discovery" } +geth-iroh = { path = "../geth-iroh" } geth-node = { path = "../geth-node" } geth-store = { path = "../geth-store" } geth-types = { path = "../geth-types" } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 0f74778..0a55a38 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -103,6 +103,72 @@ fn geth_status_against_running_daemon() { assert!(stdout.contains("iroh discovery: local-network disabled")); } +#[test] +fn peer_card_export_import_and_list_are_candidate_only() { + let source_home = tempfile::tempdir().expect("source tempdir"); + let source_paths = geth_config::GethPaths::from_home(source_home.path()); + let mut source = geth_node::init_node(&source_paths).expect("init source"); + source.iroh_status = geth_iroh::EndpointStatus { + enabled: true, + endpoint_id: Some("endpoint:test-source".to_owned()), + relay_mode: "disabled".to_owned(), + local_discovery: false, + note: "test endpoint".to_owned(), + }; + let peer_card_path = source_home.path().join("peer-card.json"); + + let exported = geth_node::handle_request( + &source, + geth_control::ControlRequest::PeerCardExport { + out: Some(peer_card_path.clone()), + }, + ) + .expect("export peer card"); + let exported_card = match exported { + geth_control::ControlResponse::PeerCardExported { card, out, note } => { + assert_eq!(out, Some(peer_card_path.clone())); + assert!(note.contains("never grants trust")); + card.validate_candidate().expect("valid exported card"); + card + } + other => panic!("unexpected response: {other:?}"), + }; + assert!(peer_card_path.exists()); + + let target_home = tempfile::tempdir().expect("target tempdir"); + let target_paths = geth_config::GethPaths::from_home(target_home.path()); + let target = geth_node::init_node(&target_paths).expect("init target"); + let imported = geth_node::handle_request( + &target, + geth_control::ControlRequest::PeerCardImport { + path: peer_card_path, + }, + ) + .expect("import peer card"); + match imported { + geth_control::ControlResponse::PeerCardImported { peer, note } => { + assert_eq!(peer.card, exported_card); + assert_eq!( + peer.trust_state, + geth_discovery::CandidateTrustState::CandidateOnly + ); + assert!(note.contains("never grants trust")); + } + other => panic!("unexpected response: {other:?}"), + } + + let listed = geth_node::handle_request(&target, geth_control::ControlRequest::PeerCardList) + .expect("list peers"); + match listed { + geth_control::ControlResponse::PeerCardList { peers, note } => { + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].card, exported_card); + assert!(note.contains("never grants trust")); + } + other => panic!("unexpected response: {other:?}"), + } +} + #[test] fn initialized_node_can_roundtrip_cas_blob() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/architecture.md b/docs/architecture.md index cc54b65..5d2a642 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,14 +42,15 @@ and candidate-discovery mechanisms only. They do not grant trust, mutate authorization state, or make EndpointID knowledge sufficient for access. The current daemon can enable Iroh's local-network discovery service through `[iroh].local_discovery = true`, which is the default. This publishes and -discovers Iroh node addressing. Signed geth peer-card payloads over LAN -discovery remain separate future work. +discovers Iroh node addressing. `geth peer export/import/list` supports manual +exchange of signed peer cards as untrusted candidates. Automatic signed +peer-card advertisement over LAN discovery remains separate future work. Peer cards are the discovery payload. A peer card carries node ID, agent ID, -endpoint candidates, timestamp, and signature metadata. The current scaffold -stores peer cards as untrusted metadata in `peer_cards`; signature verification -and trust reduction are future work. `auth explain` reports when a subject is -only a discovered peer candidate and denies access. +endpoint candidates, timestamp, signing public key, and an Ed25519 signature +over a canonical payload. Imported peer cards are stored as untrusted metadata +in `peer_cards`; trust reduction is future work. `auth explain` reports when a +subject is only a discovered peer candidate and denies access. The daemon starts this endpoint during `geth daemon run` and keeps it alive for the daemon lifetime. When endpoint startup succeeds, the Iroh EndpointID is diff --git a/docs/roadmap.md b/docs/roadmap.md index 1926dc3..70d3e7e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -96,10 +96,12 @@ geth-to-geth connections without granting trust from discovery alone. - `[ ]` Signed peer-card LAN discovery payloads. Acceptance criteria: - - The daemon can advertise and discover signed geth peer cards over LAN + - `[x]` Manual `geth peer export/import/list` can exchange signed peer cards + and store them as untrusted candidates. + - `[ ]` The daemon can advertise and discover signed geth peer cards over LAN discovery. - - LAN-discovered peer cards are stored only as untrusted peer candidates. - - Discovered EndpointIDs do not grant module access without keychain/auth + - `[x]` Imported peer cards are stored only as untrusted peer candidates. + - `[x]` Discovered EndpointIDs do not grant module access without keychain/auth validation. - `[x]` Protocol/router scaffold. @@ -112,6 +114,8 @@ geth-to-geth connections without granting trust from discovery alone. Acceptance criteria: - A peer card contains node ID, agent ID, endpoint candidates, timestamp, and signature metadata. + - Peer-card signatures cover deterministic canonical payloads and reject + tampered endpoint candidates. - Peer cards are stored in `peer_cards`. - Invalid or unsigned peer cards do not update trust state.