Add opt-in access control for infrastructure operators#1330
Add opt-in access control for infrastructure operators#1330DanGould wants to merge 7 commits intopayjoin:masterfrom
Conversation
Pull Request Test Coverage Report for Build 22094646540Details
💛 - Coveralls |
d67d662 to
e00fd05
Compare
1585aa4 to
c95cece
Compare
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.
c95cece to
c1326da
Compare
spacebear21
left a comment
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
nit: if other access control things will be stored (e.g. address blocklists), we should group them all in a access control subdir
| 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) | ||
| } |
There was a problem hiding this comment.
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"; |
There was a problem hiding this comment.
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"}"#, |
There was a problem hiding this comment.
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"
| 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") | ||
| } | ||
|
|
There was a problem hiding this comment.
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>> { |
There was a problem hiding this comment.
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?
| ) { | ||
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
error handling/tracing here seems excessive
| } | ||
| } | ||
|
|
||
| fn get_v1_disabled(config: &Config) -> bool { |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
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.Test plan
payjoin-mailroomandpayjoin-directoryaccess-controlfeature flagcargo clippy --all-featurescleantest-data/GeoIP2-Country-Test.mmdb) is appropriate for CI