Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo-minimal.lock
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ dependencies = [
"winnow 0.7.13",
]

[[package]]
name = "asmap"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "996e3818c450a9497e2f1aff7306d1c56b4198a07880222ee0aa85b6c42ac81f"

[[package]]
name = "asn1-rs"
version = "0.7.0"
Expand Down Expand Up @@ -2596,6 +2602,7 @@ version = "1.0.0-rc.0"
dependencies = [
"ahash 0.7.8",
"anyhow",
"asmap",
"async-trait",
"bitcoind-async-client",
"clap 4.5.45",
Expand Down
7 changes: 7 additions & 0 deletions Cargo-recent.lock
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ dependencies = [
"winnow 0.7.15",
]

[[package]]
name = "asmap"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "996e3818c450a9497e2f1aff7306d1c56b4198a07880222ee0aa85b6c42ac81f"

[[package]]
name = "asn1-rs"
version = "0.7.2"
Expand Down Expand Up @@ -2727,6 +2733,7 @@ version = "1.0.0-rc.0"
dependencies = [
"ahash 0.7.8",
"anyhow",
"asmap",
"async-trait",
"bitcoind-async-client",
"clap 4.6.1",
Expand Down
2 changes: 2 additions & 0 deletions payjoin-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
default = ["v2"]
asmap = ["dep:asmap"]
native-certs = ["reqwest/rustls-tls-native-roots"]
_manual-tls = ["reqwest/rustls-tls", "payjoin/_manual-tls", "tokio-rustls"]
v1 = ["payjoin/v1", "hyper", "hyper-util", "http-body-util"]
Expand All @@ -27,6 +28,7 @@ v2 = ["payjoin/v2", "payjoin/io"]
[dependencies]
ahash = "0.7.8"
anyhow = "1.0.99"
asmap = { version = "0.1.0", optional = true }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be good to know what the team think about adding this dep to parse the binary, especially if we are moving to the payjoin crate

is it a good idea or would it be better to have our own parser? #1452 (comment)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did check out the library also saw a contibutor to asmap bitcoin core library give it a star but more eyes on the library would be nice

async-trait = "0.1.89"
bitcoind-async-client = "0.14.0"
clap = { version = "4.5.45", features = ["derive"] }
Expand Down
24 changes: 24 additions & 0 deletions payjoin-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,30 @@ See the
[example.config.toml](https://github.com/payjoin/rust-payjoin/blob/fde867b93ede767c9a50913432a73782a94ef40b/payjoin-cli/example.config.toml)
for inspiration.

`payjoin-cli` also supports optional AS-aware filtering for BIP77 relay
selection:

```toml
[v2]
pj_directories = ["https://payjo.in", "https://backup.example"]
ohttp_relays = ["https://relay-1.example", "https://relay-2.example"]

[v2.asmap]
asmap_file = "./ip_asn.dat"
user_public_ips = ["198.51.100.10"]
user_asns = [64512]
```

Build `payjoin-cli` with `--features asmap` to enable the `[v2.asmap]`
configuration block.

When enabled, directories and relays that resolve into the same ASN as the
configured user identity are excluded, mixed-ASN hostnames are rejected, and
relay ordering becomes deterministic from the receiver key embedded in the
BIP77 URI. This mitigates some AS-level correlation risks, but it does not
eliminate traffic analysis when sender and receiver already share the same
network.

### Asynchronous Operation

Sender and receiver state is saved to a database in the directory from which `payjoin-cli` is run, called `payjoin.sqlite`. Once a send or receive session is started, it may resume using the `resume` argument if prior payjoin sessions have not yet complete.
Expand Down
10 changes: 10 additions & 0 deletions payjoin-cli/example.config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,13 @@ rpcpassword = "password"
# # for the payjoin packets to be encrypted.
# # These can now be fetched and no longer need to be configured.
# ohttp_keys = "./path/to/ohttp_keys"
#
# # Optional AS-aware relay and directory filtering.
# # When enabled, payjoin-cli will reject directories/relays that share an
# # ASN with the configured user identity, and will deterministically order
# # relay selection from the remaining candidates.
# # Requires building payjoin-cli with `--features asmap`.
# [v2.asmap]
# asmap_file = "./ip_asn.dat"
# user_public_ips = ["198.51.100.10", "2001:db8::10"]
# user_asns = [64512]
136 changes: 120 additions & 16 deletions payjoin-cli/src/app/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
#[cfg(all(feature = "v2", feature = "asmap"))]
use std::fmt;
#[cfg(all(feature = "v2", feature = "asmap"))]
use std::net::IpAddr;
use std::path::PathBuf;
#[cfg(all(feature = "v2", feature = "asmap"))]
use std::sync::Arc;

use anyhow::Result;
use config::builder::DefaultState;
Expand Down Expand Up @@ -29,24 +35,102 @@ pub struct V1Config {
pub pj_endpoint: Url,
}

#[cfg(all(feature = "v2", feature = "asmap"))]
#[derive(Clone)]
pub struct LoadedAsmap {
map: Arc<::asmap::Asmap>,
}

#[cfg(all(feature = "v2", feature = "asmap"))]
impl LoadedAsmap {
pub fn lookup(&self, ip: IpAddr) -> u32 { self.map.lookup(ip) }

pub fn as_bytes(&self) -> &[u8] { self.map.as_bytes() }
}

#[cfg(all(feature = "v2", feature = "asmap"))]
impl<'de> Deserialize<'de> for LoadedAsmap {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let path = PathBuf::deserialize(deserializer)?;
let map = ::asmap::Asmap::from_file(&path).map_err(|e| {
serde::de::Error::custom(format!(
"Failed to load v2.asmap.asmap_file {}: {e}",
path.display()
))
})?;
Ok(LoadedAsmap { map: Arc::new(map) })
}
}

#[cfg(all(feature = "v2", feature = "asmap"))]
impl fmt::Debug for LoadedAsmap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LoadedAsmap").field("bytes", &self.as_bytes().len()).finish()
}
}

#[cfg(all(feature = "v2", feature = "asmap"))]
#[derive(Debug, Clone, Deserialize)]
pub struct AsmapConfig {
#[serde(rename = "asmap_file")]
pub asmap: LoadedAsmap,
Comment on lines +77 to +79

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a configured, per-integration asmap file is a client fingerprint two ways: (1) only some integrations load one, so the AS-aware ones stand out (uses-it-or-not), and (2) selection is deterministic from the asmap, so even those that do load one diverge unless they're on the exact same snapshot.

could the lib ship one bundled snapshot per version instead, so every integration on a given version has the same file with nothing to fetch?

it's ~1.5 MB, and asmap-data updates ~monthly (irregular, gaps up to ~2 months), but relay ASNs are stable so across versions keeping everyone consistent

https://github.com/bitcoin-core/asmap-data

wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No answer for this yet , this might require more discussion will get back to you

#[serde(default)]
pub user_public_ips: Vec<IpAddr>,
#[serde(default)]
pub user_asns: Vec<u32>,
Comment on lines +80 to +83

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given a list of trusted directories and relays, these lists should be filtered to exclude servers that share an AS with the user. This is potentially tricky as it users to be able to determine their public IP(s).

requiring the user's own ASN/IP makes AS-aware mode operator-only, a NAT'd wallet can't supply it without an external lookup, which is a privacy leak of its own

#919 lists four AS-overlap risks:

  • sender∩receiver → inherent: relay selection can't fix it (it's the two endpoints themselves). VPN/Tor territory. out of scope.

of the three that selection can address, two don't need your own AS:

  • relay∩directory → exclude relays in the directory's AS. Needs only the directory + relay ASes.
  • relay∩relay → the windowed ordering derived from the receiver key. Needs only the relays' ASes.

both use IPs you resolve via DNS anyway to connect, like Bitcoin Core's asmap, which diversifies its peers by AS (anti-eclipse) and never learns its own ASN; you bucket the servers, not yourself.

  • only user-AS exclusion user∩directory needs your own AS, and thats the one that could be dropped to the network layer (VPN/Tor) , not the selector:
    • a directory's AS (datacenter) vs a user's AS (residential/mobile) almost never coincide.
    • the sender uses the directory that came in the URI, so it can't pick a different one to escape its own AS. it can't be addressed in selection.

so, if we drop this and keep just:

  • directory-AS exclusion relay∩directory
  • windowed ordering relay∩relay

that way both use only server ASes, zero input, so AS-aware selection can be on by default for everyone, which removes the uses-it-or-not fingerprint, default and zero-input, instead of operator-only.

makes sense?

}

#[cfg(all(feature = "v2", feature = "asmap"))]
impl AsmapConfig {
fn validate(&self) -> Result<(), ConfigError> {
if self.user_public_ips.is_empty() && self.user_asns.is_empty() {
return Err(ConfigError::Message(
"v2.asmap requires at least one of user_public_ips or user_asns".into(),
));
}
Ok(())
}
}

#[cfg(feature = "v2")]
#[derive(Debug, Clone, Deserialize)]
pub struct V2Config {
#[serde(deserialize_with = "deserialize_ohttp_keys_from_path")]
pub ohttp_keys: Option<payjoin::OhttpKeys>,
pub ohttp_relays: Vec<Url>,
pub pj_directories: Vec<Url>,
#[cfg(feature = "asmap")]
#[serde(default)]
pub asmap: Option<AsmapConfig>,
}

#[cfg(feature = "v2")]
impl V2Config {
fn validate(&self) -> Result<(), ConfigError> {
if self.pj_directories.is_empty() {
return Err(ConfigError::Message(
"At least one v2 trusted directory is required".to_owned(),
));
}

#[cfg(feature = "asmap")]
if let Some(asmap) = &self.asmap {
asmap.validate()?;
}

Ok(())
}
}

#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "version")]
#[derive(Debug, Clone)]
pub enum VersionConfig {
#[cfg(feature = "v1")]
#[serde(rename = "v1")]
V1(V1Config),
#[cfg(feature = "v2")]
#[serde(rename = "v2")]
V2(V2Config),
}

Expand Down Expand Up @@ -206,7 +290,7 @@ impl Config {
Version::Two => {
#[cfg(feature = "v2")]
{
match built_config.get::<V2Config>("v2") {
match load_v2_config(&built_config) {
Ok(v2) => {
if v2.ohttp_relays.len() < 2 {
tracing::warn!(
Expand Down Expand Up @@ -402,25 +486,45 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result<Builder, ConfigError
}
}

#[cfg(feature = "v2")]
fn load_v2_config(built_config: &config::Config) -> Result<V2Config, ConfigError> {
#[cfg(not(feature = "asmap"))]
if built_config.get_table("v2.asmap").is_ok() {
return Err(ConfigError::Message(
"This build does not include ASMap support. Recompile with --features asmap".to_owned(),
));
}

let v2 = built_config.get::<V2Config>("v2")?;
v2.validate()?;
Ok(v2)
}

#[cfg(feature = "v2")]
fn deserialize_ohttp_keys_from_path<'de, D>(
deserializer: D,
) -> Result<Option<payjoin::OhttpKeys>, D::Error>
where
D: serde::Deserializer<'de>,
{
let path_str: Option<String> = Option::deserialize(deserializer)?;

match path_str {
let path: Option<PathBuf> = Option::deserialize(deserializer)?;
match path {
None => Ok(None),
Some(path) => std::fs::read(path)
.map_err(|e| serde::de::Error::custom(format!("Failed to read ohttp_keys file: {e}")))
.and_then(|bytes| {
payjoin::OhttpKeys::decode(&bytes).map_err(|e| {
serde::de::Error::custom(format!("Failed to decode ohttp keys: {e}"))
})
})
.map(Some),
Some(path) => {
let bytes = std::fs::read(&path).map_err(|e| {
serde::de::Error::custom(format!(
"Failed to read ohttp_keys file {}: {e}",
path.display()
))
})?;
let keys = payjoin::OhttpKeys::decode(&bytes).map_err(|e| {
serde::de::Error::custom(format!(
"Failed to decode ohttp keys from {}: {e}",
path.display()
))
})?;
Ok(Some(keys))
}
}
}

Expand Down
34 changes: 17 additions & 17 deletions payjoin-cli/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,28 +71,28 @@ pub trait App: Send + Sync {
}
}

#[cfg(feature = "_manual-tls")]
#[cfg(feature = "v1")]
fn http_agent(config: &Config) -> Result<reqwest::Client> {
Ok(http_agent_builder(config.root_certificate.as_ref())?.build()?)
}

#[cfg(not(feature = "_manual-tls"))]
fn http_agent(_config: &Config) -> Result<reqwest::Client> {
Ok(reqwest::Client::builder().http1_only().build()?)
Ok(http_client_builder(config)?.build()?)
}

#[cfg(feature = "_manual-tls")]
fn http_agent_builder(
root_cert_path: Option<&std::path::PathBuf>,
) -> Result<reqwest::ClientBuilder> {
let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().http1_only();
pub(crate) fn http_client_builder(config: &Config) -> Result<reqwest::ClientBuilder> {
#[cfg(feature = "_manual-tls")]
{
let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().http1_only();
if let Some(root_cert_path) = config.root_certificate.as_ref() {
let cert_der = std::fs::read(root_cert_path)?;
builder = builder
.add_root_certificate(reqwest::tls::Certificate::from_der(cert_der.as_slice())?);
}
Ok(builder)
}

if let Some(root_cert_path) = root_cert_path {
let cert_der = std::fs::read(root_cert_path)?;
builder =
builder.add_root_certificate(reqwest::tls::Certificate::from_der(cert_der.as_slice())?)
#[cfg(not(feature = "_manual-tls"))]
{
let _ = config;
Ok(reqwest::Client::builder().http1_only())
}
Ok(builder)
}

async fn handle_interrupt(tx: watch::Sender<()>) {
Expand Down
Loading
Loading