//! Support for the [EIP-1459 DNS Record Structure](https://eips.ethereum.org/EIPS/eip-1459#dns-record-structure) //! //! The nodes in a list are encoded as a merkle tree for distribution via the DNS protocol. Entries //! of the merkle tree are contained in DNS TXT records. The root of the tree is a TXT record with //! the following content: //! //! ```text //! enrtree-root:v1 e= l= seq= sig= //! ``` //! //! where //! //! enr-root and link-root refer to the root hashes of subtrees containing nodes and links to //! subtrees. //! `sequence-number` is the tree’s update sequence number, a decimal integer. //! `signature` is a 65-byte secp256k1 EC signature over the keccak256 hash of the record //! content, excluding the sig= part, encoded as URL-safe base64 (RFC-4648). use crate::error::{ ParseDnsEntryError, ParseDnsEntryError::{FieldNotFound, UnknownEntry}, ParseEntryResult, }; use alloy_primitives::{hex, Bytes}; use data_encoding::{BASE32_NOPAD, BASE64URL_NOPAD}; use enr::{Enr, EnrKey, EnrKeyUnambiguous, EnrPublicKey, Error as EnrError}; use secp256k1::SecretKey; #[cfg(feature = "serde")] use serde_with::{DeserializeFromStr, SerializeDisplay}; use std::{ fmt, hash::{Hash, Hasher}, str::FromStr, }; /// Prefix used for root entries in the ENR tree. const ROOT_V1_PREFIX: &str = "enrtree-root:v1"; /// Prefix used for link entries in the ENR tree. const LINK_PREFIX: &str = "enrtree://"; /// Prefix used for branch entries in the ENR tree. const BRANCH_PREFIX: &str = "enrtree-branch:"; /// Prefix used for ENR entries in the ENR tree. const ENR_PREFIX: &str = "enr:"; /// Represents all variants of DNS entries for Ethereum node lists. #[derive(Debug, Clone)] pub enum DnsEntry { /// Represents a root entry in the DNS tree containing node records. Root(TreeRootEntry), /// Represents a link entry in the DNS tree pointing to another node list. Link(LinkEntry), /// Represents a branch entry in the DNS tree containing hashes of subtree entries. Branch(BranchEntry), /// Represents a leaf entry in the DNS tree containing a node record. Node(NodeEntry), } impl fmt::Display for DnsEntry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Root(entry) => entry.fmt(f), Self::Link(entry) => entry.fmt(f), Self::Branch(entry) => entry.fmt(f), Self::Node(entry) => entry.fmt(f), } } } impl FromStr for DnsEntry { type Err = ParseDnsEntryError; fn from_str(s: &str) -> Result { if let Some(s) = s.strip_prefix(ROOT_V1_PREFIX) { TreeRootEntry::parse_value(s).map(DnsEntry::Root) } else if let Some(s) = s.strip_prefix(BRANCH_PREFIX) { BranchEntry::parse_value(s).map(DnsEntry::Branch) } else if let Some(s) = s.strip_prefix(LINK_PREFIX) { LinkEntry::parse_value(s).map(DnsEntry::Link) } else if let Some(s) = s.strip_prefix(ENR_PREFIX) { NodeEntry::parse_value(s).map(DnsEntry::Node) } else { Err(UnknownEntry(s.to_string())) } } } /// Represents an `enr-root` hash of subtrees containing nodes and links. #[derive(Clone, Eq, PartialEq)] pub struct TreeRootEntry { /// The `enr-root` hash. pub enr_root: String, /// The root hash of the links. pub link_root: String, /// The sequence number associated with the entry. pub sequence_number: u64, /// The signature of the entry. pub signature: Bytes, } // === impl TreeRootEntry === impl TreeRootEntry { /// Parses the entry from text. /// /// Caution: This assumes the prefix is already removed. fn parse_value(mut input: &str) -> ParseEntryResult { let input = &mut input; let enr_root = parse_value(input, "e=", "ENR Root", |s| Ok(s.to_string()))?; let link_root = parse_value(input, "l=", "Link Root", |s| Ok(s.to_string()))?; let sequence_number = parse_value(input, "seq=", "Sequence number", |s| { s.parse::().map_err(|_| { ParseDnsEntryError::Other(format!("Failed to parse sequence number {s}")) }) })?; let signature = parse_value(input, "sig=", "Signature", |s| { BASE64URL_NOPAD.decode(s.as_bytes()).map_err(|err| { ParseDnsEntryError::Base64DecodeError(format!("signature error: {err}")) }) })? .into(); Ok(Self { enr_root, link_root, sequence_number, signature }) } /// Returns the _unsigned_ content pairs of the entry: /// /// ```text /// e= l= seq= sig= /// ``` fn content(&self) -> String { format!( "{} e={} l={} seq={}", ROOT_V1_PREFIX, self.enr_root, self.link_root, self.sequence_number ) } /// Signs the content with the given key pub fn sign(&mut self, key: &K) -> Result<(), EnrError> { let sig = key.sign_v4(self.content().as_bytes()).map_err(|_| EnrError::SigningError)?; self.signature = sig.into(); Ok(()) } /// Verify the signature of the record. #[must_use] pub fn verify(&self, pubkey: &K::PublicKey) -> bool { let mut sig = self.signature.clone(); sig.truncate(64); pubkey.verify_v4(self.content().as_bytes(), &sig) } } impl FromStr for TreeRootEntry { type Err = ParseDnsEntryError; fn from_str(s: &str) -> Result { if let Some(s) = s.strip_prefix(ROOT_V1_PREFIX) { Self::parse_value(s) } else { Err(UnknownEntry(s.to_string())) } } } impl fmt::Debug for TreeRootEntry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("TreeRootEntry") .field("enr_root", &self.enr_root) .field("link_root", &self.link_root) .field("sequence_number", &self.sequence_number) .field("signature", &hex::encode(self.signature.as_ref())) .finish() } } impl fmt::Display for TreeRootEntry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} sig={}", self.content(), BASE64URL_NOPAD.encode(self.signature.as_ref())) } } /// Represents a branch entry in the DNS tree, containing base32 hashes of subtree entries. #[derive(Debug, Clone)] pub struct BranchEntry { /// The list of base32-encoded hashes of subtree entries in the branch. pub children: Vec, } // === impl BranchEntry === impl BranchEntry { /// Parses the entry from text. /// /// Caution: This assumes the prefix is already removed. fn parse_value(input: &str) -> ParseEntryResult { #[inline] fn ensure_valid_hash(hash: &str) -> ParseEntryResult { /// Returns the maximum length in bytes of the no-padding decoded data corresponding to /// `n` bytes of base32-encoded data. /// See also #[inline(always)] const fn base32_no_padding_decoded_len(n: usize) -> usize { n * 5 / 8 } let decoded_len = base32_no_padding_decoded_len(hash.bytes().len()); if !(12..=32).contains(&decoded_len) || hash.chars().any(|c| c.is_whitespace()) { return Err(ParseDnsEntryError::InvalidChildHash(hash.to_string())) } Ok(hash.to_string()) } let children = input.trim().split(',').map(ensure_valid_hash).collect::>>()?; Ok(Self { children }) } } impl FromStr for BranchEntry { type Err = ParseDnsEntryError; fn from_str(s: &str) -> Result { s.strip_prefix(BRANCH_PREFIX) .map_or_else(|| Err(UnknownEntry(s.to_string())), Self::parse_value) } } impl fmt::Display for BranchEntry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}{}", BRANCH_PREFIX, self.children.join(",")) } } /// Represents a link entry in the DNS tree, facilitating federation and web-of-trust functionality. #[derive(Debug, Clone)] #[cfg_attr(feature = "serde", derive(SerializeDisplay, DeserializeFromStr))] pub struct LinkEntry { /// The domain associated with the link entry. pub domain: String, /// The public key corresponding to the Ethereum Node Record (ENR) used for the link entry. pub pubkey: K::PublicKey, } // === impl LinkEntry === impl LinkEntry { /// Parses the entry from text. /// /// Caution: This assumes the prefix is already removed. fn parse_value(input: &str) -> ParseEntryResult { let (pubkey, domain) = input.split_once('@').ok_or_else(|| { ParseDnsEntryError::Other(format!("Missing @ delimiter in Link entry: {input}")) })?; let pubkey = K::decode_public(&BASE32_NOPAD.decode(pubkey.as_bytes()).map_err(|err| { ParseDnsEntryError::Base32DecodeError(format!("pubkey error: {err}")) })?) .map_err(|err| ParseDnsEntryError::RlpDecodeError(err.to_string()))?; Ok(Self { domain: domain.to_string(), pubkey }) } } impl PartialEq for LinkEntry where K: EnrKeyUnambiguous, K::PublicKey: PartialEq, { fn eq(&self, other: &Self) -> bool { self.domain == other.domain && self.pubkey == other.pubkey } } impl Eq for LinkEntry where K: EnrKeyUnambiguous, K::PublicKey: Eq + PartialEq, { } impl Hash for LinkEntry where K: EnrKeyUnambiguous, K::PublicKey: Hash, { fn hash(&self, state: &mut H) { self.domain.hash(state); self.pubkey.hash(state); } } impl FromStr for LinkEntry { type Err = ParseDnsEntryError; fn from_str(s: &str) -> Result { s.strip_prefix(LINK_PREFIX) .map_or_else(|| Err(UnknownEntry(s.to_string())), Self::parse_value) } } impl fmt::Display for LinkEntry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{}{}@{}", LINK_PREFIX, BASE32_NOPAD.encode(self.pubkey.encode().as_ref()), self.domain ) } } /// Represents the actual Ethereum Node Record (ENR) entry in the DNS tree. #[derive(Debug, Clone)] pub struct NodeEntry { /// The Ethereum Node Record (ENR) associated with the node entry. pub enr: Enr, } // === impl NodeEntry === impl NodeEntry { /// Parses the entry from text. /// /// Caution: This assumes the prefix is already removed. fn parse_value(s: &str) -> ParseEntryResult { Ok(Self { enr: s.parse().map_err(ParseDnsEntryError::Other)? }) } } impl FromStr for NodeEntry { type Err = ParseDnsEntryError; fn from_str(s: &str) -> Result { s.strip_prefix(ENR_PREFIX) .map_or_else(|| Err(UnknownEntry(s.to_string())), Self::parse_value) } } impl fmt::Display for NodeEntry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.enr.to_base64().fmt(f) } } /// Parses the value of the key value pair fn parse_value(input: &mut &str, key: &str, err: &'static str, f: F) -> ParseEntryResult where F: Fn(&str) -> ParseEntryResult, { ensure_strip_key(input, key, err)?; let val = input.split_whitespace().next().ok_or(FieldNotFound(err))?; *input = &input[val.len()..]; f(val) } /// Strips the `key` from the `input` /// /// Returns an err if the `input` does not start with the `key` fn ensure_strip_key(input: &mut &str, key: &str, err: &'static str) -> ParseEntryResult<()> { *input = input.trim_start().strip_prefix(key).ok_or(FieldNotFound(err))?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_root_entry() { let s = "enrtree-root:v1 e=QFT4PBCRX4XQCV3VUYJ6BTCEPU l=JGUFMSAGI7KZYB3P7IZW4S5Y3A seq=3 sig=3FmXuVwpa8Y7OstZTx9PIb1mt8FrW7VpDOFv4AaGCsZ2EIHmhraWhe4NxYhQDlw5MjeFXYMbJjsPeKlHzmJREQE"; let root: TreeRootEntry = s.parse().unwrap(); assert_eq!(root.to_string(), s); match s.parse::>().unwrap() { DnsEntry::Root(root) => { assert_eq!(root.to_string(), s); } _ => unreachable!(), } } #[test] fn parse_branch_entry() { let s = "enrtree-branch:CCCCCCCCCCCCCCCCCCCC,BBBBBBBBBBBBBBBBBBBB"; let entry: BranchEntry = s.parse().unwrap(); assert_eq!(entry.to_string(), s); match s.parse::>().unwrap() { DnsEntry::Branch(entry) => { assert_eq!(entry.to_string(), s); } _ => unreachable!(), } } #[test] fn parse_branch_entry_base32() { let s = "enrtree-branch:YNEGZIWHOM7TOOSUATAPTM"; let entry: BranchEntry = s.parse().unwrap(); assert_eq!(entry.to_string(), s); match s.parse::>().unwrap() { DnsEntry::Branch(entry) => { assert_eq!(entry.to_string(), s); } _ => unreachable!(), } } #[test] fn parse_invalid_branch_entry() { let s = "enrtree-branch:1,2"; let res = s.parse::(); assert!(res.is_err()); let s = "enrtree-branch:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; let res = s.parse::(); assert!(res.is_err()); let s = "enrtree-branch:,BBBBBBBBBBBBBBBBBBBB"; let res = s.parse::(); assert!(res.is_err()); let s = "enrtree-branch:CCCCCCCCCCCCCCCCCCCC\n,BBBBBBBBBBBBBBBBBBBB"; let res = s.parse::(); assert!(res.is_err()); } #[test] fn parse_link_entry() { let s = "enrtree://AM5FCQLWIZX2QFPNJAP7VUERCCRNGRHWZG3YYHIUV7BVDQ5FDPRT2@nodes.example.org"; let entry: LinkEntry = s.parse().unwrap(); assert_eq!(entry.to_string(), s); match s.parse::>().unwrap() { DnsEntry::Link(entry) => { assert_eq!(entry.to_string(), s); } _ => unreachable!(), } } #[test] fn parse_enr_entry() { let s = "enr:-HW4QES8QIeXTYlDzbfr1WEzE-XKY4f8gJFJzjJL-9D7TC9lJb4Z3JPRRz1lP4pL_N_QpT6rGQjAU9Apnc-C1iMP36OAgmlkgnY0iXNlY3AyNTZrMaED5IdwfMxdmR8W37HqSFdQLjDkIwBd4Q_MjxgZifgKSdM"; let entry: NodeEntry = s.parse().unwrap(); assert_eq!(entry.to_string(), s); match s.parse::>().unwrap() { DnsEntry::Node(entry) => { assert_eq!(entry.to_string(), s); } _ => unreachable!(), } } }