use base64::Engine; 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"; pub const PEER_CARD_LAN_DISCOVERY_SERVICE: &str = "geth-peer-card"; pub const PEER_CARD_TXT_VERSION_KEY: &str = "geth"; pub const PEER_CARD_TXT_VERSION: &str = "peer-card-v1"; pub const PEER_CARD_TXT_CHUNKS_KEY: &str = "card-chunks"; pub const PEER_CARD_TXT_CHUNK_PREFIX: &str = "card-"; const PEER_CARD_TXT_CHUNK_BYTES: usize = 200; const PEER_CARD_TXT_MAX_CHUNKS: usize = 32; #[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, #[serde(default)] pub direct_addresses: Vec, 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>; } pub fn peer_card_txt_attributes( card: &PeerCard, ) -> Result)>, DiscoveryError> { card.validate_candidate()?; let json = serde_json::to_vec(card)?; let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(json); let chunks = encoded .as_bytes() .chunks(PEER_CARD_TXT_CHUNK_BYTES) .map(|chunk| { std::str::from_utf8(chunk) .expect("base64 is utf-8") .to_owned() }) .collect::>(); if chunks.is_empty() || chunks.len() > PEER_CARD_TXT_MAX_CHUNKS { return Err(DiscoveryError::PeerCardTxtTooLarge { chunks: chunks.len(), max_chunks: PEER_CARD_TXT_MAX_CHUNKS, }); } let mut attributes = vec![ ( PEER_CARD_TXT_VERSION_KEY.to_owned(), Some(PEER_CARD_TXT_VERSION.to_owned()), ), ( PEER_CARD_TXT_CHUNKS_KEY.to_owned(), Some(chunks.len().to_string()), ), ]; for (index, chunk) in chunks.into_iter().enumerate() { attributes.push(( format!("{PEER_CARD_TXT_CHUNK_PREFIX}{index:02}"), Some(chunk), )); } Ok(attributes) } pub fn peer_card_from_txt_attributes<'a>( attributes: impl IntoIterator)>, ) -> Result { let attributes = attributes.into_iter().collect::>(); let version = txt_value(&attributes, PEER_CARD_TXT_VERSION_KEY) .ok_or_else(|| DiscoveryError::MissingTxtAttribute(PEER_CARD_TXT_VERSION_KEY.to_owned()))?; if version != PEER_CARD_TXT_VERSION { return Err(DiscoveryError::UnsupportedTxtVersion(version.to_owned())); } let chunk_count = txt_value(&attributes, PEER_CARD_TXT_CHUNKS_KEY) .ok_or_else(|| DiscoveryError::MissingTxtAttribute(PEER_CARD_TXT_CHUNKS_KEY.to_owned()))? .parse::() .map_err(|_| DiscoveryError::InvalidTxtAttribute(PEER_CARD_TXT_CHUNKS_KEY.to_owned()))?; if chunk_count == 0 || chunk_count > PEER_CARD_TXT_MAX_CHUNKS { return Err(DiscoveryError::PeerCardTxtTooLarge { chunks: chunk_count, max_chunks: PEER_CARD_TXT_MAX_CHUNKS, }); } let mut encoded = String::new(); for index in 0..chunk_count { let key = format!("{PEER_CARD_TXT_CHUNK_PREFIX}{index:02}"); let chunk = txt_value(&attributes, &key) .ok_or_else(|| DiscoveryError::MissingTxtAttribute(key.clone()))?; encoded.push_str(chunk); } let json = base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded)?; let card: PeerCard = serde_json::from_slice(&json)?; card.validate_candidate()?; Ok(card) } fn txt_value<'a>(attributes: &[(&'a str, Option<&'a str>)], key: &str) -> Option<&'a str> { attributes .iter() .find_map(|(candidate, value)| (*candidate == key).then_some(*value).flatten()) } #[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), #[error("peer card TXT payload is too large: {chunks} chunks, max {max_chunks}")] PeerCardTxtTooLarge { chunks: usize, max_chunks: usize }, #[error("missing peer card TXT attribute: {0}")] MissingTxtAttribute(String), #[error("invalid peer card TXT attribute: {0}")] InvalidTxtAttribute(String), #[error("unsupported peer card TXT version: {0}")] UnsupportedTxtVersion(String), #[error("peer card TXT base64 error: {0}")] Base64(#[from] base64::DecodeError), #[error("peer card JSON error: {0}")] Json(#[from] serde_json::Error), } #[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, direct_addresses: vec!["127.0.0.1:12345".to_owned()], 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)) )); } #[test] fn peer_card_txt_attributes_roundtrip_signed_payload() { let card = signed_card(); let attributes = peer_card_txt_attributes(&card).expect("txt attributes"); assert_eq!( attributes .iter() .find(|(key, _)| key == PEER_CARD_TXT_VERSION_KEY) .and_then(|(_, value)| value.as_deref()), Some(PEER_CARD_TXT_VERSION) ); assert!(attributes.iter().any(|(key, _)| key == "card-00")); assert!( attributes .iter() .all(|(key, value)| key.len() + value.as_deref().unwrap_or("").len() <= 254) ); let decoded = peer_card_from_txt_attributes( attributes .iter() .map(|(key, value)| (key.as_str(), value.as_deref())), ) .expect("decode txt attributes"); assert_eq!(decoded, card); } #[test] fn peer_card_txt_attributes_reject_missing_chunk() { let card = signed_card(); let attributes = peer_card_txt_attributes(&card).expect("txt attributes"); let without_first_chunk = attributes .iter() .filter(|(key, _)| key != "card-00") .map(|(key, value)| (key.as_str(), value.as_deref())); assert!(matches!( peer_card_from_txt_attributes(without_first_chunk), Err(DiscoveryError::MissingTxtAttribute(key)) if key == "card-00" )); } }