use geth_crypto::AgentKey; use geth_types::{AgentId, NodeId, UnixMillis}; use serde::{Deserialize, Serialize}; pub const PEER_CARD_SIGNATURE_NAMESPACE: &str = "geth.peer-card.v1@geth.local"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PeerCard { pub node_id: NodeId, pub agent_id: AgentId, pub endpoints: Vec, pub issued_at: UnixMillis, pub signature: SignatureMetadata, } 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.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)] pub struct EndpointCandidate { pub endpoint_id: String, pub relay_url: Option, pub source: DiscoverySource, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SignatureMetadata { pub namespace: String, pub signer: String, #[serde(default)] pub public_key: String, pub signature: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum DiscoverySource { Manual, Mdns, PeerExchange, Imported, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DiscoveredPeer { pub card: PeerCard, pub discovered_at: UnixMillis, pub source: DiscoverySource, pub trust_state: CandidateTrustState, } impl DiscoveredPeer { pub fn candidate( card: PeerCard, discovered_at: UnixMillis, source: DiscoverySource, ) -> Result { card.validate_candidate()?; Ok(Self { card, discovered_at, source, trust_state: CandidateTrustState::CandidateOnly, }) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum CandidateTrustState { CandidateOnly, } pub trait DiscoveryBackend { fn candidates(&self) -> Result, DiscoveryError>; } #[derive(Debug, thiserror::Error)] pub enum DiscoveryError { #[error("peer card has no endpoint candidates")] MissingEndpoint, #[error("peer card is missing signature metadata")] 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] pub fn discovery_is_untrusted_note() -> &'static str { "discovery returns candidate peers only and never grants trust or authorization" } #[cfg(test)] mod tests { use super::*; fn signed_card() -> PeerCard { 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, }], UnixMillis(1), ) .expect("signed card") } #[test] fn signed_peer_card_is_valid_candidate() { signed_card() .validate_candidate() .expect("valid signed candidate"); } #[test] fn discovered_peer_is_candidate_only() { let peer = DiscoveredPeer::candidate(signed_card(), UnixMillis(2), DiscoverySource::Mdns) .expect("candidate"); assert_eq!(peer.trust_state, CandidateTrustState::CandidateOnly); } #[test] fn unsigned_peer_card_is_not_valid_candidate() { let mut card = signed_card(); card.signature.signature.clear(); assert!(matches!( card.validate_candidate(), 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)) )); } }