diff --git a/payjoin-cli/src/app/config.rs b/payjoin-cli/src/app/config.rs index 6b7f8acb8..b7c3ff271 100644 --- a/payjoin-cli/src/app/config.rs +++ b/payjoin-cli/src/app/config.rs @@ -36,7 +36,14 @@ pub struct V2Config { #[serde(deserialize_with = "deserialize_ohttp_keys_from_path")] pub ohttp_keys: Option, pub ohttp_relays: Vec, - pub pj_directory: Url, + // NOTE: CLI flag --pj-directory still works via backwards compat in resolve_pj_directories. + #[serde(deserialize_with = "deserialize_urls_from_strings")] + pub pj_directories: Vec, + /// Path to ASmap file (Kartograf format: "103.152.34.0/23 AS14618") + /// Generate with: https://github.com/asmap/kartograf + pub asmap_path: Option, + /// User's Autonomous System Number for filtering relays and directories + pub user_asn: Option, } #[allow(clippy::large_enum_variant)] @@ -270,21 +277,27 @@ fn add_v1_defaults(config: Builder, cli: &Cli) -> Result { fn add_v2_defaults(config: Builder, cli: &Cli) -> Result { // Set default values let config = config - .set_default("v2.pj_directory", "https://payjo.in")? + .set_default("v2.pj_directories", vec!["https://payjo.in"])? .set_default("v2.ohttp_keys", None::)?; // Override config values with command line arguments if applicable - let pj_directory = cli.pj_directory.as_ref().map(|s| s.as_str()); + let pj_directories = + resolve_pj_directories(cli.pj_directories.as_deref(), cli.pj_directory.as_ref()); + let ohttp_keys = cli.ohttp_keys.as_ref().map(|p| p.to_string_lossy().into_owned()); let ohttp_relays = cli .ohttp_relays .as_ref() .map(|urls| urls.iter().map(|url| url.as_str()).collect::>()); + let asmap_path = cli.asmap_path.as_ref().map(|p| p.to_string_lossy().into_owned()); + let user_asn = cli.user_asn; config - .set_override_option("v2.pj_directory", pj_directory)? + .set_override_option("v2.pj_directories", pj_directories)? .set_override_option("v2.ohttp_keys", ohttp_keys)? - .set_override_option("v2.ohttp_relays", ohttp_relays) + .set_override_option("v2.ohttp_relays", ohttp_relays)? + .set_override_option("v2.asmap_path", asmap_path)? + .set_override_option("v2.user_asn", user_asn) } /// Handles configuration overrides based on CLI subcommands @@ -311,6 +324,8 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result { @@ -319,8 +334,11 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let strings: Vec = Vec::deserialize(deserializer)?; + strings.into_iter().map(|s| Url::parse(&s).map_err(serde::de::Error::custom)).collect() +} + +#[cfg(feature = "v2")] +fn resolve_pj_directories( + pj_directories: Option<&[Url]>, + pj_directory_fallback: Option<&Url>, +) -> Option> { + if let Some(urls) = pj_directories { + return Some(urls.iter().map(|url| url.to_string()).collect()); + } + pj_directory_fallback.map(|url| vec![url.to_string()]) +} diff --git a/payjoin-cli/src/app/v2/asmap.rs b/payjoin-cli/src/app/v2/asmap.rs new file mode 100644 index 000000000..75760aadb --- /dev/null +++ b/payjoin-cli/src/app/v2/asmap.rs @@ -0,0 +1,402 @@ +use std::fmt; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::path::Path; + +use payjoin::bitcoin::hashes::{sha256, Hash, HashEngine}; + +/// Domain separation tag for tagged hashing (BIP340) to derive ASMAP seeds. +const ASMAP_TAG: &[u8] = b"payjoin/asmap/relay-selection"; + +#[derive(Debug, Clone)] +pub struct AsmapSeed([u8; 32]); + +impl AsmapSeed { + /// A shared seed that allows the sender and receiver to arrive + /// at the same relay order independently, without additional communication. + pub fn from_receiver_pubkey(pubkey_bytes: &[u8]) -> Self { + // BIP340 tagged hash: SHA256(SHA256(tag) || SHA256(tag) || pubkey_bytes) + // Ensures hashes in this context cannot be reinterpreted in another context. + let tag_hash = sha256::Hash::hash(ASMAP_TAG); + let mut engine = sha256::HashEngine::default(); + engine.input(tag_hash.as_byte_array()); + engine.input(tag_hash.as_byte_array()); + engine.input(pubkey_bytes); + AsmapSeed(*sha256::Hash::from_engine(engine).as_byte_array()) + } + + // SHA256(seed || asn_be) + pub fn hash_asn(&self, asn: u32) -> [u8; 32] { + let mut engine = sha256::HashEngine::default(); + engine.input(&self.0); + engine.input(&asn.to_be_bytes()); + *sha256::Hash::from_engine(engine).as_byte_array() + } + + // SHA256(seed || relay_uri_utf8) + pub fn hash_relay(&self, relay_uri: &str) -> [u8; 32] { + let mut engine = sha256::HashEngine::default(); + engine.input(&self.0); + engine.input(relay_uri.as_bytes()); + *sha256::Hash::from_engine(engine).as_byte_array() + } + + #[cfg(test)] + pub fn as_bytes(&self) -> &[u8; 32] { &self.0 } +} + +#[derive(Debug)] +pub enum AsmapError { + Io(std::io::Error), + Parse(String), + InvalidPrefix(String), +} + +impl fmt::Display for AsmapError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AsmapError::Io(e) => write!(f, "Failed to read asmap file: {e}"), + AsmapError::Parse(s) => write!(f, "Failed to parse line: {s}"), + AsmapError::InvalidPrefix(s) => write!(f, "Invalid IP prefix: {s}"), + } + } +} + +impl std::error::Error for AsmapError {} + +impl From for AsmapError { + fn from(e: std::io::Error) -> Self { AsmapError::Io(e) } +} + +#[derive(Debug, Clone)] +pub(crate) struct AsmapEntry { + prefix: IpAddr, + prefix_len: u8, + asn: u32, +} + +#[derive(Debug, Clone, Default)] +pub struct Asmap { + entries: Vec, +} + +impl Asmap { + pub fn from_file>(path: P) -> Result { + let content = std::fs::read_to_string(path)?; + content.parse() + } + + // O(n) linear scan. Bitcoin Core uses a pre-compiled binary trie (.dat, + // bitcoin-core/asmap-data) with O(32) lookup and better UX. users could + // download a pre-audited file instead of running Kartograf. + pub fn lookup(&self, ip: IpAddr) -> Option { + let mut best_match: Option<(u8, u32)> = None; + for entry in &self.entries { + if ip_version_match(&ip, &entry.prefix) + && ip_in_prefix(ip, entry.prefix, entry.prefix_len) + && (best_match.is_none() || entry.prefix_len > best_match.as_ref().unwrap().0) + { + best_match = Some((entry.prefix_len, entry.asn)); + } + } + best_match.map(|(_, asn)| asn) + } +} + +impl std::str::FromStr for Asmap { + type Err = AsmapError; + fn from_str(s: &str) -> Result { + let mut entries = Vec::new(); + for line in s.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + entries.push(parse_line(line)?); + } + Ok(Self { entries }) + } +} + +// should we use a dep like ipnet? +fn ip_version_match(a: &IpAddr, b: &IpAddr) -> bool { + matches!((a, b), (IpAddr::V4(_), IpAddr::V4(_)) | (IpAddr::V6(_), IpAddr::V6(_))) +} + +fn ip_in_prefix(ip: IpAddr, prefix: IpAddr, prefix_len: u8) -> bool { + match (ip, prefix) { + (IpAddr::V4(ip), IpAddr::V4(prefix)) => ipv4_in_prefix(ip, prefix, prefix_len), + (IpAddr::V6(ip), IpAddr::V6(prefix)) => ipv6_in_prefix(ip, prefix, prefix_len), + _ => false, + } +} + +fn ipv4_in_prefix(ip: Ipv4Addr, prefix: Ipv4Addr, prefix_len: u8) -> bool { + if prefix_len == 0 { + return true; + } + let mask = !0u32 << (32 - prefix_len); + let ip_bits = u32::from_be_bytes(ip.octets()); + let prefix_bits = u32::from_be_bytes(prefix.octets()); + (ip_bits & mask) == (prefix_bits & mask) +} + +fn ipv6_in_prefix(ip: Ipv6Addr, prefix: Ipv6Addr, prefix_len: u8) -> bool { + if prefix_len == 0 { + return true; + } + let octets = ip.octets(); + let prefix_octets = prefix.octets(); + let full_bytes = (prefix_len / 8) as usize; + let remaining_bits = prefix_len % 8; + + if full_bytes > 0 && octets[..full_bytes] != prefix_octets[..full_bytes] { + return false; + } + if remaining_bits > 0 && full_bytes < 16 { + let mask = !0u8 << (8 - remaining_bits); + if (octets[full_bytes] & mask) != (prefix_octets[full_bytes] & mask) { + return false; + } + } + true +} + +fn parse_line(line: &str) -> Result { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() != 2 { + return Err(AsmapError::Parse(format!( + "Expected 2 parts, got {}: '{}'", + parts.len(), + line + ))); + } + let prefix_str = parts[0]; + let asn_str = parts[1]; + let asn = asn_str + .strip_prefix("AS") + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| AsmapError::Parse(format!("Invalid ASN: '{}'", asn_str)))?; + + let (prefix, prefix_len) = parse_prefix(prefix_str)?; + Ok(AsmapEntry { prefix, prefix_len, asn }) +} + +fn parse_prefix(s: &str) -> Result<(IpAddr, u8), AsmapError> { + let Some((addr_part, len_part)) = s.split_once('/') else { + return Err(AsmapError::InvalidPrefix(format!("Missing '/' in prefix: {}", s))); + }; + let prefix_len = len_part + .parse() + .map_err(|_| AsmapError::InvalidPrefix(format!("Invalid prefix length: {}", len_part)))?; + + let ip: IpAddr = if addr_part.contains(':') { + let ip: Ipv6Addr = addr_part.parse().map_err(|_| { + AsmapError::InvalidPrefix(format!("Invalid IPv6 address: {}", addr_part)) + })?; + IpAddr::V6(ip) + } else { + let ip: Ipv4Addr = addr_part.parse().map_err(|_| { + AsmapError::InvalidPrefix(format!("Invalid IPv4 address: {}", addr_part)) + })?; + IpAddr::V4(ip) + }; + + let max_len = match ip { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + if prefix_len > max_len { + return Err(AsmapError::InvalidPrefix(format!( + "Prefix length {} exceeds maximum {}", + prefix_len, max_len + ))); + } + + Ok((ip, prefix_len)) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + const SAMPLE_ASMAP: &str = r#" +# +103.152.34.0/23 AS14618 +2406:4440:10::/44 AS142641 +2406:4440:f000::/44 AS38173 +103.152.35.0/24 AS38008 +"#; + + #[test] + fn test_parse_asmap_from_str() { + let asmap: Asmap = SAMPLE_ASMAP.parse().unwrap(); + assert_eq!(asmap.entries.len(), 4); + } + + #[test] + fn test_lookup_ipv4_exact() { + let asmap: Asmap = SAMPLE_ASMAP.parse().unwrap(); + assert_eq!(asmap.lookup(IpAddr::V4(Ipv4Addr::new(103, 152, 34, 5))), Some(14618)); + } + + #[test] + fn test_lookup_ipv4_subnet() { + let asmap: Asmap = SAMPLE_ASMAP.parse().unwrap(); + assert_eq!(asmap.lookup(IpAddr::V4(Ipv4Addr::new(103, 152, 35, 1))), Some(38008)); + } + + #[test] + fn test_lookup_ipv4_in_23_bit_prefix() { + let asmap: Asmap = SAMPLE_ASMAP.parse().unwrap(); + assert_eq!(asmap.lookup(IpAddr::V4(Ipv4Addr::new(103, 152, 34, 255))), Some(14618)); + } + + #[test] + fn test_lookup_ipv6() { + let asmap: Asmap = SAMPLE_ASMAP.parse().unwrap(); + let ip: IpAddr = "2406:4440:10::1".parse().unwrap(); + assert_eq!(asmap.lookup(ip), Some(142641)); + } + + #[test] + fn test_lookup_ipv6_other() { + let asmap: Asmap = SAMPLE_ASMAP.parse().unwrap(); + let ip: IpAddr = "2406:4440:f000::1".parse().unwrap(); + assert_eq!(asmap.lookup(ip), Some(38173)); + } + + #[test] + fn test_lookup_no_match() { + let asmap: Asmap = SAMPLE_ASMAP.parse().unwrap(); + let ip: IpAddr = "1.1.1.1".parse().unwrap(); + assert_eq!(asmap.lookup(ip), None); + } + + #[test] + fn test_parse_empty_lines_and_comments() { + let content = r#" +# +103.152.34.0/23 AS14618 + +# + +2406:4440:10::/44 AS142641 +"#; + let asmap: Asmap = content.parse().unwrap(); + assert_eq!(asmap.entries.len(), 2); + } + + #[test] + fn test_parse_invalid_asn() { + let result = Asmap::from_str("103.152.34.0/23 INVALID"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_invalid_prefix() { + let result = Asmap::from_str("not-an-ip/24 AS14618"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_prefix_too_long() { + let result = Asmap::from_str("103.152.34.0/33 AS14618"); + assert!(result.is_err()); + } + + #[test] + fn test_ipv4_prefix_len_zero() { + let prefix: Ipv4Addr = "0.0.0.0".parse().unwrap(); + let ip: Ipv4Addr = "192.168.1.1".parse().unwrap(); + assert!(ipv4_in_prefix(ip, prefix, 0)); + } + + #[test] + fn test_ipv6_partial_byte_mask_bug() { + // Use prefix with non-zero byte at /44 boundary to expose mask bug + // 2406:4440:f000::/44 means byte 4 = 0xf0, byte 5 = 0x00 + let prefix: Ipv6Addr = "2406:4440:f000::".parse().unwrap(); + // IP with different high nibble in byte 4: 0xff0... should NOT match + let ip_should_not_match: Ipv6Addr = + "2406:4440:ff00:0000:0000:0000:0000:0001".parse().unwrap(); + // IP with matching high nibble: 0xf000... SHOULD match + let ip_should_match: Ipv6Addr = "2406:4440:f001:0000:0000:0000:0000:0001".parse().unwrap(); + + let result_not_match = ipv6_in_prefix(ip_should_not_match, prefix, 44); + let result_match = ipv6_in_prefix(ip_should_match, prefix, 44); + + assert!( + !result_not_match, + "IP (byte4=0x{:02x}) should NOT be in /44 prefix (byte4=0xf0)", + ip_should_not_match.octets()[4] + ); + assert!(result_match, "IP should match /44 prefix"); + } + + #[test] + fn test_ipv6_prefix_len_zero() { + let prefix: Ipv6Addr = "::".parse().unwrap(); + let ip: Ipv6Addr = "2001:db8::1".parse().unwrap(); + assert!(ipv6_in_prefix(ip, prefix, 0)); + } + + #[test] + fn test_longest_prefix_match() { + let content = r#" +10.0.0.0/8 AS1 +10.0.0.0/16 AS2 +10.0.0.0/24 AS3 +"#; + let asmap: Asmap = content.parse().unwrap(); + assert_eq!(asmap.lookup(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), Some(3)); + assert_eq!(asmap.lookup(IpAddr::V4(Ipv4Addr::new(10, 0, 1, 1))), Some(2)); + assert_eq!(asmap.lookup(IpAddr::V4(Ipv4Addr::new(10, 1, 0, 1))), Some(1)); + } + + #[test] + fn test_asmap_seed_deterministic() { + let pubkey: [u8; 33] = [ + 0x02, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + ]; + let seed1 = AsmapSeed::from_receiver_pubkey(&pubkey); + let seed2 = AsmapSeed::from_receiver_pubkey(&pubkey); + assert_eq!(seed1.as_bytes(), seed2.as_bytes()); + } + + #[test] + fn test_asmap_seed_different_pubkeys() { + let pubkey1: [u8; 33] = [ + 0x02, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + ]; + let pubkey2: [u8; 33] = [ + 0x03, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + ]; + let seed1 = AsmapSeed::from_receiver_pubkey(&pubkey1); + let seed2 = AsmapSeed::from_receiver_pubkey(&pubkey2); + assert_ne!(seed1.as_bytes(), seed2.as_bytes()); + } + + #[test] + fn test_asmap_seed_vector() { + // Test vector for AsmapSeed::from_receiver_pubkey with known input + let pubkey: [u8; 33] = [ + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, + ]; + let seed = AsmapSeed::from_receiver_pubkey(&pubkey); + assert_eq!( + seed.as_bytes(), + &[ + 0x3e, 0xaa, 0xe1, 0x39, 0xee, 0xaf, 0x34, 0x11, 0x7c, 0xa2, 0xeb, 0xb1, 0x76, 0x23, + 0xd2, 0x65, 0x64, 0x83, 0x29, 0x4f, 0xfc, 0x04, 0x41, 0xe5, 0x83, 0x45, 0xbf, 0x47, + 0x4e, 0xeb, 0x5e, 0x83, + ] + ); + } +} diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 73f4dcf85..220968f42 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -22,11 +22,12 @@ use tokio::sync::watch; use super::config::Config; use super::wallet::BitcoindWallet; use super::App as AppTrait; -use crate::app::v2::ohttp::{unwrap_ohttp_keys_or_else_fetch, RelayManager}; +use crate::app::v2::ohttp::{unwrap_ohttp_keys_or_else_fetch, RelayManager, RelayRole}; use crate::app::{handle_interrupt, http_agent}; use crate::db::v2::{ReceiverPersister, SenderPersister, SessionId}; use crate::db::Database; +mod asmap; mod ohttp; const W_ID: usize = 12; @@ -41,6 +42,7 @@ pub(crate) struct App { wallet: BitcoindWallet, interrupt: watch::Receiver<()>, relay_manager: Arc>, + asmap: Option, } trait StatusText { @@ -144,7 +146,20 @@ impl AppTrait for App { let (interrupt_tx, interrupt_rx) = watch::channel(()); tokio::spawn(handle_interrupt(interrupt_tx)); let wallet = BitcoindWallet::new(&config.bitcoind).await?; - let app = Self { config, db, wallet, interrupt: interrupt_rx, relay_manager }; + let asmap = match config.v2() { + Ok(v2) => v2.asmap_path.as_ref().and_then(|path| match asmap::Asmap::from_file(path) { + Ok(m) => Some(m), + Err(e) => { + tracing::warn!("Failed to load asmap: {e}"); + None + } + }), + Err(e) => { + tracing::debug!("Not in v2 mode, skipping asmap: {e}"); + None + } + }; + let app = Self { config, db, wallet, interrupt: interrupt_rx, relay_manager, asmap }; app.wallet() .network() .context("Failed to connect to bitcoind. Check config RPC connection.")?; @@ -254,17 +269,29 @@ impl AppTrait for App { async fn receive_payjoin(&self, amount: Amount) -> Result<()> { let address = self.wallet().get_new_address()?; - let ohttp_keys = - unwrap_ohttp_keys_or_else_fetch(&self.config, None, self.relay_manager.clone()) - .await? - .ohttp_keys; + + let receiver_keypair = payjoin::HpkeKeyPair::gen_keypair(); + let receiver_pubkey = receiver_keypair.public_key().to_compressed_bytes(); + + let validated = unwrap_ohttp_keys_or_else_fetch( + &self.config, + None, + self.relay_manager.clone(), + Some(&receiver_pubkey), + RelayRole::Receiver, + self.asmap.as_ref(), + ) + .await?; + let ohttp_keys = validated.ohttp_keys; + let directory = validated.directory_url; + let persister = ReceiverPersister::new(self.db.clone())?; - let session = - ReceiverBuilder::new(address, self.config.v2()?.pj_directory.as_str(), ohttp_keys)? - .with_amount(amount) - .with_max_fee_rate(self.config.max_fee_rate.unwrap_or(FeeRate::BROADCAST_MIN)) - .build() - .save(&persister)?; + let session = ReceiverBuilder::new(address, directory.as_str(), ohttp_keys)? + .with_receiver_keypair(receiver_keypair) + .with_amount(amount) + .with_max_fee_rate(self.config.max_fee_rate.unwrap_or(FeeRate::BROADCAST_MIN)) + .build() + .save(&persister)?; println!("Receive session established"); let pj_uri = session.pj_uri(); @@ -499,8 +526,15 @@ impl App { sender: Sender, persister: &SenderPersister, ) -> Result<()> { + let receiver_pubkey = sender.receiver_pubkey().to_compressed_bytes(); let (req, ctx) = sender.create_v2_post_request( - self.unwrap_relay_or_else_fetch(Some(&sender.endpoint())).await?.as_str(), + self.unwrap_relay_or_else_fetch( + Some(&sender.endpoint()), + Some(&receiver_pubkey), + RelayRole::Sender, + ) + .await? + .as_str(), )?; let response = self.post_request(req).await?; println!("Posted original proposal..."); @@ -513,7 +547,14 @@ impl App { sender: Sender, persister: &SenderPersister, ) -> Result<()> { - let ohttp_relay = self.unwrap_relay_or_else_fetch(Some(&sender.endpoint())).await?; + let receiver_pubkey = sender.receiver_pubkey().to_compressed_bytes(); + let ohttp_relay = self + .unwrap_relay_or_else_fetch( + Some(&sender.endpoint()), + Some(&receiver_pubkey), + RelayRole::Sender, + ) + .await?; let mut session = sender.clone(); // Long poll until we get a response loop { @@ -545,8 +586,15 @@ impl App { session: Receiver, persister: &ReceiverPersister, ) -> Result> { - let ohttp_relay = - self.unwrap_relay_or_else_fetch(Some(&session.pj_uri().extras.endpoint())).await?; + let endpoint = session.pj_uri().extras.endpoint(); + let receiver_pubkey = session.receiver_pubkey().to_compressed_bytes(); + let ohttp_relay = self + .unwrap_relay_or_else_fetch( + Some(&endpoint), + Some(&receiver_pubkey), + RelayRole::Receiver, + ) + .await?; let mut session = session; loop { @@ -747,8 +795,17 @@ impl App { proposal: Receiver, persister: &ReceiverPersister, ) -> Result<()> { + let receiver_pubkey = proposal.receiver_pubkey().to_compressed_bytes(); let (req, ohttp_ctx) = proposal - .create_post_request(self.unwrap_relay_or_else_fetch(None::<&str>).await?.as_str()) + .create_post_request( + self.unwrap_relay_or_else_fetch( + None::<&str>, + Some(&receiver_pubkey), + RelayRole::Receiver, + ) + .await? + .as_str(), + ) .map_err(|e| anyhow!("v2 req extraction failed {}", e))?; let res = self.post_request(req).await?; let payjoin_psbt = proposal.psbt().clone(); @@ -813,6 +870,8 @@ impl App { async fn unwrap_relay_or_else_fetch( &self, directory: Option, + receiver_pubkey: Option<&[u8; 33]>, + role: RelayRole, ) -> Result { let directory = directory.map(|url| url.into_url()).transpose()?; let selected_relay = @@ -820,9 +879,16 @@ impl App { let ohttp_relay = match selected_relay { Some(relay) => relay, None => - unwrap_ohttp_keys_or_else_fetch(&self.config, directory, self.relay_manager.clone()) - .await? - .relay_url, + unwrap_ohttp_keys_or_else_fetch( + &self.config, + directory, + self.relay_manager.clone(), + receiver_pubkey, + role, + self.asmap.as_ref(), + ) + .await? + .relay_url, }; Ok(ohttp_relay) } @@ -833,8 +899,11 @@ impl App { session: Receiver, persister: &ReceiverPersister, ) -> Result<()> { - let (err_req, err_ctx) = session - .create_error_request(self.unwrap_relay_or_else_fetch(None::<&str>).await?.as_str())?; + let (err_req, err_ctx) = session.create_error_request( + self.unwrap_relay_or_else_fetch(None::<&str>, None, RelayRole::Receiver) + .await? + .as_str(), + )?; let err_response = match self.post_request(err_req).await { Ok(response) => response, diff --git a/payjoin-cli/src/app/v2/ohttp.rs b/payjoin-cli/src/app/v2/ohttp.rs index 4ee637ecb..00bbd0854 100644 --- a/payjoin-cli/src/app/v2/ohttp.rs +++ b/payjoin-cli/src/app/v2/ohttp.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; use std::sync::{Arc, Mutex}; use anyhow::{anyhow, Result}; +use super::asmap::{Asmap, AsmapSeed}; use super::Config; #[derive(Debug, Clone)] @@ -25,22 +27,47 @@ impl RelayManager { pub(crate) struct ValidatedOhttpKeys { pub(crate) ohttp_keys: payjoin::OhttpKeys, pub(crate) relay_url: url::Url, + pub(crate) directory_url: url::Url, +} + +pub(crate) enum RelayRole { + Sender, + Receiver, +} + +fn random_directory(directories: &[url::Url]) -> Result { + use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom; + directories + .choose(&mut payjoin::bitcoin::key::rand::thread_rng()) + .cloned() + .ok_or_else(|| anyhow!("No payjoin directories configured")) } pub(crate) async fn unwrap_ohttp_keys_or_else_fetch( config: &Config, directory: Option, relay_manager: Arc>, + receiver_pubkey: Option<&[u8; 33]>, + role: RelayRole, + asmap: Option<&Asmap>, ) -> Result { + let seed = receiver_pubkey.map(|pk| AsmapSeed::from_receiver_pubkey(pk)); + let seed_ref = seed.as_ref(); + // NOTE: `ohttp_keys` in config creates a potential mismatch when ASmap selects + // a different directory than the one the keys were fetched from. There are issues + // discussing removing it from config, which would eliminate the mismatch. if let Some(ohttp_keys) = config.v2()?.ohttp_keys.clone() { println!("Using OHTTP Keys from config"); - let validated = fetch_ohttp_keys(config, directory, relay_manager).await?; - Ok(ValidatedOhttpKeys { ohttp_keys, relay_url: validated.relay_url }) + let validated = + fetch_ohttp_keys(config, directory, relay_manager, seed_ref, role, asmap).await?; + Ok(ValidatedOhttpKeys { + ohttp_keys, + relay_url: validated.relay_url, + directory_url: validated.directory_url, + }) } else { println!("Bootstrapping private network transport over Oblivious HTTP"); - let fetched_keys = fetch_ohttp_keys(config, directory, relay_manager).await?; - - Ok(fetched_keys) + fetch_ohttp_keys(config, directory, relay_manager, seed_ref, role, asmap).await } } @@ -48,10 +75,57 @@ async fn fetch_ohttp_keys( config: &Config, directory: Option, relay_manager: Arc>, + seed: Option<&AsmapSeed>, + role: RelayRole, + asmap: Option<&Asmap>, ) -> Result { use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom; - let payjoin_directory = directory.unwrap_or(config.v2()?.pj_directory.clone()); - let relays = config.v2()?.ohttp_relays.clone(); + let v2_config = config.v2()?; + let relays = v2_config.ohttp_relays.clone(); + let directories = &v2_config.pj_directories; + + let asmap_enabled = asmap.is_some() && v2_config.user_asn.is_some(); + + if asmap.is_some() && v2_config.user_asn.is_none() { + tracing::warn!("--asmap provided but --user-asn missing; AS-aware selection disabled"); + } + + let (payjoin_directory, asmap_context) = if let Some(dir) = directory { + if let (true, Some(asmap)) = (asmap_enabled, asmap) { + tracing::debug!("ASmap loaded, using for relay selection with fixed directory"); + let relay_asns = resolve_relays_asn(&relays, asmap).await; + let directory_asn = url_to_asn(&dir, asmap).await; + ( + dir, + Some(AsmapContext { + user_asn: v2_config.user_asn.unwrap(), + directory_asn, + relay_asns, + }), + ) + } else { + (dir, None) + } + } else if let (true, Some(asmap)) = (asmap_enabled, asmap) { + let user_asn = v2_config.user_asn.unwrap(); + tracing::debug!("ASmap loaded successfully, using AS-aware directory selection"); + let (payjoin_directory, directory_asn) = + match select_directory(directories, user_asn, asmap).await { + Some(result) => result, + None => { + let dir = random_directory(directories) + .expect("At least one directory must be configured"); + let asn = url_to_asn(&dir, asmap).await; + (dir, asn) + } + }; + tracing::debug!("Selected directory: {}", payjoin_directory); + let relay_asns = resolve_relays_asn(&relays, asmap).await; + (payjoin_directory, Some(AsmapContext { user_asn, directory_asn, relay_asns })) + } else { + let payjoin_directory = random_directory(directories)?; + (payjoin_directory, None) + }; loop { let failed_relays = @@ -64,11 +138,41 @@ async fn fetch_ohttp_keys( return Err(anyhow!("No valid relays available")); } - let selected_relay = - match remaining_relays.choose(&mut payjoin::bitcoin::key::rand::thread_rng()) { - Some(relay) => relay.clone(), - None => return Err(anyhow!("Failed to select from remaining relays")), - }; + let selected_relay = if let Some(ctx) = &asmap_context { + if let Some(seed) = seed { + let mut ordered = asmap_order( + &remaining_relays, + Some(ctx.user_asn), + ctx.directory_asn, + &ctx.relay_asns, + seed, + ); + if matches!(role, RelayRole::Sender) { + ordered.reverse(); + } + ordered + .first() + .cloned() + // Fallback to random selection to preserve upstream behavior when + // AS-aware ordering yields no candidates (e.g. all relays share + // the user's or directory's ASN, or none appear in the asmap). + // This ensures connectivity is never blocked by asmap coverage gaps. + .or_else(|| { + remaining_relays + .choose(&mut payjoin::bitcoin::key::rand::thread_rng()) + .cloned() + }) + } else { + remaining_relays.choose(&mut payjoin::bitcoin::key::rand::thread_rng()).cloned() + } + } else { + remaining_relays.choose(&mut payjoin::bitcoin::key::rand::thread_rng()).cloned() + }; + + let selected_relay = match selected_relay { + Some(relay) => relay, + None => return Err(anyhow!("Failed to select from remaining relays")), + }; relay_manager .lock() @@ -100,7 +204,11 @@ async fn fetch_ohttp_keys( match ohttp_keys { Ok(keys) => - return Ok(ValidatedOhttpKeys { ohttp_keys: keys, relay_url: selected_relay }), + return Ok(ValidatedOhttpKeys { + ohttp_keys: keys, + relay_url: selected_relay, + directory_url: payjoin_directory.clone(), + }), Err(payjoin::io::Error::UnexpectedStatusCode(e)) => { return Err(payjoin::io::Error::UnexpectedStatusCode(e).into()); } @@ -114,3 +222,238 @@ async fn fetch_ohttp_keys( } } } + +struct AsmapContext { + user_asn: u32, + directory_asn: Option, + relay_asns: HashMap, +} + +fn asmap_order( + relays: &[url::Url], + user_asn: Option, + directory_asn: Option, + relay_asns: &HashMap, + seed: &AsmapSeed, +) -> Vec { + let filtered: Vec<&url::Url> = relays + .iter() + .filter(|relay| { + let Some(asn) = relay_asns.get(*relay).copied() else { + return false; + }; + user_asn.is_none_or(|u| asn != u) && directory_asn.is_none_or(|d| asn != d) + }) + .collect(); + + let mut buckets: HashMap> = HashMap::new(); + + for relay in &filtered { + if let Some(asn) = relay_asns.get(*relay).copied() { + buckets.entry(asn).or_default().push(relay); + } + } + + for bucket in buckets.values_mut() { + bucket.sort_by_key(|relay| seed.hash_relay(relay.as_str())); + } + + let mut sorted_buckets: Vec<(u32, Vec<&url::Url>)> = buckets.into_iter().collect(); + sorted_buckets.sort_by_key(|(asn, _)| seed.hash_asn(*asn)); + + let max_bucket_size = sorted_buckets.iter().map(|(_, bucket)| bucket.len()).max().unwrap_or(0); + let mut result = Vec::new(); + for round in 0..max_bucket_size { + for (_, bucket) in &sorted_buckets { + if round < bucket.len() { + result.push((*bucket[round]).clone()); + } + } + } + + result +} + +async fn url_to_asn(relay: &url::Url, asmap: &Asmap) -> Option { + let host = relay.host_str()?; + let port = relay.port_or_known_default()?; + let addrs: Vec = + tokio::net::lookup_host(format!("{host}:{port}")).await.ok()?.collect(); + + let ipv4_count = addrs.iter().filter(|a| a.is_ipv4()).count(); + let ipv6_count = addrs.iter().filter(|a| a.is_ipv6()).count(); + if ipv4_count > 1 || ipv6_count > 1 { + tracing::warn!( + "Relay {} has {} A and {} AAAA records (max 1 each allowed), excluding from AS-aware selection", + relay, ipv4_count, ipv6_count + ); + return None; + } + + let preferred: Vec<_> = { + let v6: Vec<_> = addrs.iter().filter(|a| a.is_ipv6()).collect(); + if v6.is_empty() { + addrs.iter().collect() + } else { + v6 + } + }; + + let asns: std::collections::HashSet = + preferred.iter().filter_map(|addr| asmap.lookup(addr.ip())).collect(); + + match asns.len() { + 0 => { + tracing::debug!( + "Relay {} has no ASN mapping, excluding from AS-aware selection", + relay + ); + None + } + 1 => asns.into_iter().next(), + _ => { + tracing::warn!( + "Relay {} resolves to multiple ASes, excluding from AS-aware selection", + relay + ); + None + } + } +} + +async fn resolve_relays_asn(relays: &[url::Url], asmap: &Asmap) -> HashMap { + let mut set = tokio::task::JoinSet::new(); + for relay in relays { + let relay = relay.clone(); + let asmap = asmap.clone(); + set.spawn(async move { (relay.clone(), url_to_asn(&relay, &asmap).await) }); + } + let mut result = HashMap::new(); + while let Some(Ok((relay, Some(asn)))) = set.join_next().await { + result.insert(relay, asn); + } + result +} + +async fn select_directory( + directories: &[url::Url], + user_asn: u32, + asmap: &Asmap, +) -> Option<(url::Url, Option)> { + use payjoin::bitcoin::key::rand::prelude::SliceRandom; + + let mut set = tokio::task::JoinSet::new(); + for dir in directories { + let dir = dir.clone(); + let asmap = asmap.clone(); + set.spawn(async move { (dir.clone(), url_to_asn(&dir, &asmap).await) }); + } + let mut directory_asns: HashMap = HashMap::new(); + while let Some(Ok((dir, Some(asn)))) = set.join_next().await { + directory_asns.insert(dir, asn); + } + + let candidates: Vec<_> = directory_asns + .iter() + .filter(|(_, asn)| **asn != user_asn) + .map(|(dir, asn)| (dir.clone(), Some(*asn))) + .collect(); + + if candidates.is_empty() { + tracing::debug!("All directories share user ASN, selecting randomly"); + None + } else { + candidates.choose(&mut payjoin::bitcoin::key::rand::thread_rng()).cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_seed() -> AsmapSeed { AsmapSeed::from_receiver_pubkey(&[0x02; 33]) } + + fn url(s: &str) -> url::Url { url::Url::parse(s).unwrap() } + + #[test] + fn test_filters_user_asn() { + let relays = vec![url("https://relay1.example.com"), url("https://relay2.example.com")]; + let mut relay_asns = HashMap::new(); + relay_asns.insert(url("https://relay1.example.com"), 1111u32); + relay_asns.insert(url("https://relay2.example.com"), 2222u32); + + let result = asmap_order(&relays, Some(1111), None, &relay_asns, &make_seed()); + assert_eq!(result.len(), 1); + assert_eq!(result[0], url("https://relay2.example.com")); + } + + #[test] + fn test_filters_directory_asn() { + let relays = vec![url("https://relay1.example.com"), url("https://relay2.example.com")]; + let mut relay_asns = HashMap::new(); + relay_asns.insert(url("https://relay1.example.com"), 1111u32); + relay_asns.insert(url("https://relay2.example.com"), 3333u32); + + let result = asmap_order(&relays, Some(9999), Some(1111), &relay_asns, &make_seed()); + assert_eq!(result.len(), 1); + assert_eq!(result[0], url("https://relay2.example.com")); + } + + #[test] + fn test_deterministic_ordering() { + let relays = vec![ + url("https://relay1.example.com"), + url("https://relay2.example.com"), + url("https://relay3.example.com"), + ]; + let mut relay_asns = HashMap::new(); + relay_asns.insert(url("https://relay1.example.com"), 1111u32); + relay_asns.insert(url("https://relay2.example.com"), 2222u32); + relay_asns.insert(url("https://relay3.example.com"), 3333u32); + + let seed = make_seed(); + let result1 = asmap_order(&relays, None, None, &relay_asns, &seed); + let result2 = asmap_order(&relays, None, None, &relay_asns, &seed); + assert_eq!(result1, result2); + } + + #[test] + fn test_sender_receiver_pick_different_ends() { + let relays = vec![ + url("https://relay1.example.com"), + url("https://relay2.example.com"), + url("https://relay3.example.com"), + ]; + let mut relay_asns = HashMap::new(); + relay_asns.insert(url("https://relay1.example.com"), 1111u32); + relay_asns.insert(url("https://relay2.example.com"), 2222u32); + relay_asns.insert(url("https://relay3.example.com"), 3333u32); + + let seed = make_seed(); + let ordered = asmap_order(&relays, None, None, &relay_asns, &seed); + + let receiver_pick = ordered.first().cloned().unwrap(); + let sender_pick = ordered.last().cloned().unwrap(); + + assert_ne!(receiver_pick, sender_pick); + } + + #[test] + fn test_empty_after_filtering_returns_empty() { + let relays = vec![url("https://relay1.example.com")]; + let mut relay_asns = HashMap::new(); + relay_asns.insert(url("https://relay1.example.com"), 1111u32); + + let result = asmap_order(&relays, Some(1111), None, &relay_asns, &make_seed()); + assert!(result.is_empty()); + } + + #[test] + fn test_all_relays_unknown_asn_excluded() { + let relays = vec![url("https://relay1.example.com"), url("https://relay2.example.com")]; + let relay_asns = HashMap::new(); + + let result = asmap_order(&relays, None, None, &relay_asns, &make_seed()); + assert!(result.is_empty(), "Relays without ASN should be excluded from AS-aware selection"); + } +} diff --git a/payjoin-cli/src/cli/mod.rs b/payjoin-cli/src/cli/mod.rs index 1f35979b5..1b7a2becd 100644 --- a/payjoin-cli/src/cli/mod.rs +++ b/payjoin-cli/src/cli/mod.rs @@ -73,9 +73,21 @@ pub struct Cli { pub ohttp_keys: Option, #[cfg(feature = "v2")] - #[arg(long = "pj-directory", help = "The directory to store payjoin requests", value_parser = value_parser!(Url))] + #[arg(long = "pj-directory", help = "Single directory to store payjoin requests", value_parser = value_parser!(Url))] pub pj_directory: Option, + #[cfg(feature = "v2")] + #[arg(long = "pj-directories", help = "Comma-separated list of trusted payjoin directories", value_parser = value_parser!(Url), value_delimiter = ',', action = clap::ArgAction::Append)] + pub pj_directories: Option>, + + #[cfg(feature = "v2")] + #[arg(long = "asmap", help = "Path to ASmap file for AS-aware relay selection (Kartograf format)", value_parser = value_parser!(PathBuf))] + pub asmap_path: Option, + + #[cfg(feature = "v2")] + #[arg(long = "user-asn", help = "Your ASN for filtering relays (e.g. 28573)")] + pub user_asn: Option, + #[cfg(feature = "_manual-tls")] #[arg(long = "root-certificate", help = "Specify a TLS certificate to be added as a root", value_parser = value_parser!(PathBuf))] pub root_certificate: Option, @@ -118,10 +130,15 @@ pub enum Commands { pj_endpoint: Option, #[cfg(feature = "v2")] - /// The directory to store payjoin requests + /// Single directory to store payjoin requests #[arg(long = "pj-directory", value_parser = value_parser!(Url))] pj_directory: Option, + #[cfg(feature = "v2")] + /// Comma-separated list of trusted payjoin directories + #[arg(long = "pj-directories", value_parser = value_parser!(Url), value_delimiter = ',', action = clap::ArgAction::Append)] + pj_directories: Option>, + #[cfg(feature = "v2")] /// The path to the ohttp keys file #[arg(long = "ohttp-keys", value_parser = value_parser!(PathBuf))] diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 307cd6a50..436d5fa84 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -111,6 +111,9 @@ impl SessionContext { pub(crate) fn reply_mailbox_id(&self) -> ShortId { short_id_from_pubkey(self.reply_key.as_ref().unwrap_or(self.receiver_key.public_key())) } + + /// The receiver's public key for this session + pub fn receiver_pubkey(&self) -> &HpkePublicKey { self.receiver_key.public_key() } } fn deserialize_address_assume_checked<'de, D>(deserializer: D) -> Result @@ -285,6 +288,11 @@ impl core::ops::DerefMut for Receiver { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.state } } +impl Receiver { + /// The receiver's public key for this session + pub fn receiver_pubkey(&self) -> &HpkePublicKey { self.session_context.receiver_pubkey() } +} + #[derive(Debug, Clone)] pub struct ReceiverBuilder(SessionContext); @@ -337,6 +345,18 @@ impl ReceiverBuilder { Self(SessionContext { max_fee_rate, ..self.0 }) } + /// Override the generated receiver keypair with a specific keypair. + /// + /// Added for AS-aware relay selection: the receiver pubkey must + /// be known before the session is created to use as a deterministic seed + /// shared with the sender. + /// + /// This is the minimal change to unblock the PoC. Exposing keypair + /// on the public API may not be the right long-term design + pub fn with_receiver_keypair(self, keypair: HpkeKeyPair) -> Self { + Self(SessionContext { receiver_key: keypair, ..self.0 }) + } + pub fn build(self) -> NextStateTransition> { NextStateTransition::success( SessionEvent::Created(self.0.clone()), diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index 7a36553e3..f5bd25553 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -240,6 +240,11 @@ impl core::ops::DerefMut for Sender { impl Sender { /// The endpoint in the Payjoin URI pub fn endpoint(&self) -> String { self.session_context.pj_param.endpoint().to_string() } + + /// The receiver's public key from the Payjoin URI + pub fn receiver_pubkey(&self) -> &crate::hpke::HpkePublicKey { + self.session_context.pj_param.receiver_pubkey() + } } /// Represents the various states of a Payjoin send session during the protocol flow.