Skip to content

Add opt-in access control for infrastructure operators#1330

Open
DanGould wants to merge 7 commits intopayjoin:masterfrom
DanGould:access-control
Open

Add opt-in access control for infrastructure operators#1330
DanGould wants to merge 7 commits intopayjoin:masterfrom
DanGould:access-control

Conversation

@DanGould
Copy link
Contributor

@DanGould DanGould commented Feb 15, 2026

This came straight from the trough but I don't think it's actually slop.

I had opus 4.6 draft this (much faster turnover, better integration with GitHub apparently) and codex 5.3 review it and then did a bunch of manual review with the help of codex to get CI passing and understand the design tradeoffs.. nix flaking took forever (1h+), maybe because I just reinstalled nix

Summary

Adds a feature-gated (access-control) opt-in system for infrastructure operators to control access to their payjoin-mailroom instances. All behavior is off by default and requires explicit configuration.

  • GeoIP region filtering — middleware blocks connections from configurable ISO 3166-1 alpha-2 regions. Auto-fetches DB-IP Lite (CC-BY 4.0) if no custom MMDB path is provided.
  • V1 address screening — parses V1 PSBTs and checks output/input addresses against a configurable blocklist file.
  • Address list auto-fetch — background task fetches blocklist from a configurable URL on a refresh interval, with disk cache fallback.
  • V1 enable toggle — config flag to accept all V1 requests otherwise rejected.
  • V1 data minimization — clears plaintext V1 payload from memory immediately after first read.

Test plan

  • 21 tests passing across payjoin-mailroom and payjoin-directory
  • Compiles cleanly with and without access-control feature flag
  • cargo clippy --all-features clean
  • Manual testing with a real GeoIP database and blocklist
  • Review test MMDB fixture (test-data/GeoIP2-Country-Test.mmdb) is appropriate for CI

@coveralls
Copy link
Collaborator

coveralls commented Feb 15, 2026

Pull Request Test Coverage Report for Build 22094646540

Details

  • 331 of 558 (59.32%) changed or added relevant lines in 9 files are covered.
  • 3 unchanged lines in 3 files lost coverage.
  • Overall coverage decreased (-0.9%) to 82.25%

Changes Missing Coverage Covered Lines Changed/Added Lines %
payjoin-directory/src/db/mod.rs 0 1 0.0%
payjoin-directory/src/main.rs 0 1 0.0%
payjoin-mailroom/src/config.rs 4 5 80.0%
payjoin-test-utils/src/lib.rs 16 18 88.89%
payjoin-directory/src/db/files.rs 41 45 91.11%
payjoin-mailroom/src/middleware.rs 17 25 68.0%
payjoin-directory/src/lib.rs 104 135 77.04%
payjoin-mailroom/src/access_control.rs 79 167 47.31%
payjoin-mailroom/src/lib.rs 70 161 43.48%
Files with Coverage Reduction New Missed Lines %
payjoin-directory/src/db/files.rs 1 91.74%
payjoin-directory/src/db/mod.rs 1 0.0%
payjoin-mailroom/src/config.rs 1 44.07%
Totals Coverage Status
Change from base Build 22022393890: -0.9%
Covered Lines: 10542
Relevant Lines: 12817

💛 - Coveralls

@DanGould DanGould force-pushed the access-control branch 7 times, most recently from d67d662 to e00fd05 Compare February 16, 2026 14:31
@DanGould DanGould requested a review from spacebear21 February 16, 2026 15:11
@DanGould DanGould marked this pull request as ready for review February 16, 2026 15:11
@DanGould DanGould marked this pull request as draft February 16, 2026 15:26
@DanGould DanGould removed the request for review from spacebear21 February 16, 2026 15:27
@DanGould DanGould force-pushed the access-control branch 3 times, most recently from 1585aa4 to c95cece Compare February 17, 2026 10:15
Introduce an opt-in `access-control` feature flag that enables IP-based
geographic filtering at the axum middleware layer. When configured with
blocked region codes (ISO 3166-1 alpha-2 [1]), incoming connections from
those regions are rejected before any protocol-level processing.

The system auto-fetches a free DB-IP Lite MMDB database when
`blocked_regions` is configured but no custom `geo_db_path` is provided.
Lookups fail-open on errors to avoid false positives.

A small in-repo MMDB fixture is used in tests to keep GeoIP checks offline
and deterministic while still exercising real MMDB parsing and lookup paths.

[1]: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
Parse V1 PSBT payloads to extract bitcoin addresses from inputs and
outputs, checking each against a locally loaded blocklist file. Requests
containing blocked addresses receive a 403 response before the payload
is stored. Unparseable PSBTs fail-open since they cannot complete a
transaction anyway.

The blocklist is loaded from a configurable file path at startup and
shared via Arc<RwLock<HashSet>> to support later dynamic updates.
Support fetching the blocked address list from a configurable URL with
periodic refresh (default 24h). On startup, attempt an initial fetch;
on failure, fall back to a cached copy on disk. A background task
keeps the shared blocklist updated without restart.
V1 payloads are plaintext PSBTs held in memory. Previously the payload
persisted until the sender timed out or the receiver responded. Now the
payload is take()n on first read — subsequent reads return None while
the oneshot response channel remains alive for the receiver's reply.

This minimizes the window during which plaintext transaction data
resides in process memory.
@DanGould DanGould marked this pull request as ready for review February 17, 2026 14:31
@DanGould DanGould requested a review from spacebear21 February 17, 2026 14:32
Copy link
Collaborator

@spacebear21 spacebear21 left a comment

Choose a reason for hiding this comment

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

This came straight from the trough but I don't think it's actually slop.

It's definitely way sloppier than your usual code quality 🥲 I reviewed the first four commits and found a lot to be desired, posting what I have so far


pub struct AccessControl {
geo_reader: Option<maxminddb::Reader<Vec<u8>>>,
blocked_regions: HashSet<String>,
Copy link
Collaborator

Choose a reason for hiding this comment

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

It seems natural that it should also have a custom blocked_ips HashSet for custom IP ranges.

let geo_reader = match &config.geo_db_path {
Some(path) => Some(maxminddb::Reader::open_readfile(path)?),
None if !config.blocked_regions.is_empty() => {
let cached = storage_dir.join("geoip.mmdb");
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: if other access control things will be stored (e.g. address blocklists), we should group them all in a access control subdir

Comment on lines +109 to +122
fn year_month_from_days_since_epoch(days_since_epoch: i64) -> (i32, u32) {
// Exact conversion from Unix days to Gregorian year/month in UTC.
// Based on Howard Hinnant's civil calendar algorithm.
let z = days_since_epoch + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let month = (mp + if mp < 10 { 3 } else { -9 }) as u32;
let year = (y + if month <= 2 { 1 } else { 0 }) as i32;
(year, month)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This looks like LLM insanity, does std::time not have a utility that could achieve the same effect? Looks like we just need the current year and month?

"payjoin-cli" = "--features v1,v2";
"payjoin-directory" = "";
"ohttp-relay" = "";
"payjoin-mailroom" = "--features acme,telemetry";
Copy link
Collaborator

Choose a reason for hiding this comment

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

We need to enable the access-control feature here if we want it to be accessible in the nix flake and docker images built with nix2container.

.status(StatusCode::FORBIDDEN)
.header(CONTENT_TYPE, "application/json")
.body(full(
r#"{"errorCode": "v1-disabled", "message": "V1 is disabled on this server"}"#,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this use the version-unsupported BIP78 error code? Per the BIP "it is important that error codes that are not well-known and that the message do not appear on the sender's software user interface"

Comment on lines +74 to +95
pub fn load_blocked_addresses(path: &Path) -> anyhow::Result<HashSet<String>> {
let content = std::fs::read_to_string(path)?;
Ok(content
.lines()
.map(|l| normalize_blocked_address(l.trim()))
.filter(|l| !l.is_empty())
.collect())
}

pub fn normalize_blocked_address(addr: &str) -> String {
if is_bech32_address(addr) {
addr.to_ascii_lowercase()
} else {
addr.to_string()
}
}

fn is_bech32_address(addr: &str) -> bool {
let lower = addr.to_ascii_lowercase();
lower.starts_with("bc1") || lower.starts_with("tb1") || lower.starts_with("bcrt1")
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Why not parse these as bitcoin::Address here on read, and let rust-bitcoin handle weird bitcoin things on parsing and equality checks?

}
}

pub fn load_blocked_addresses(path: &Path) -> anyhow::Result<HashSet<String>> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Shouldn't AccessControl be in charge of loading and checking blocked addresses? Are they only separate to accommodate payjoin-directory still living in a separate crate?

Comment on lines +292 to +311
) {
tracing::warn!("Failed to write address cache: {e}");
}
info!("Fetched {} blocked addresses from URL", fetched.len());
*blocked.write().await = fetched;
}
Err(e) => {
tracing::warn!("Failed to read address list response: {e}");
try_load_cache(&cache_path, &blocked).await;
}
},
Ok(resp) => {
tracing::warn!("Failed to fetch address list: HTTP {}", resp.status());
try_load_cache(&cache_path, &blocked).await;
}
Err(e) => {
tracing::warn!("Failed to fetch address list: {e}");
try_load_cache(&cache_path, &blocked).await;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

error handling/tracing here seems excessive

}
}

fn get_v1_disabled(config: &Config) -> bool {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why even have this, it's just flipping the enable_v1 config value. init_directory already takes a config, no need to pass a separate v1_disabled parameter

pub blocked_addresses_path: Option<PathBuf>,
pub blocked_addresses_url: Option<String>,
pub blocked_addresses_refresh_secs: Option<u64>,
pub enable_v1: bool,
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure this should be scoped to AccessControlConfig, it's not strictly access control, an operator might simply not want to store plaintext. Also it's confusing because currently v1 is enabled by default, unless --features=access-control in which case it's disabled by default and needs to be re-enabled explicitly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants