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 validate_candidate(&self) -> Result<(), DiscoveryError> { if self.endpoints.is_empty() { return Err(DiscoveryError::MissingEndpoint); } 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() { return Err(DiscoveryError::UnsignedPeerCard); } Ok(()) } } #[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, 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), } #[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 { PeerCard { node_id: "node:laptop".into(), agent_id: "agent:abc".into(), endpoints: 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(), }, } } #[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) )); } }