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.
There was a problem hiding this comment.
Agree. Nothing stopping us from shipping that. This is an absolute bare minimum off the shelf strategy to work with easy to access DBs
| 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?
There was a problem hiding this comment.
std::time does not have a utility to achieve the same affect, we'd need to introduce chrono or time.
Yes, this was plainly and obviously LLM written but also checked thereafter and recognized that it's used to fetch the updated DB on a schedule. Kind of like how wikipedia is really good for technical / algorithmic explanation, so is LLM for concrete well understood algorithms and fetch unit tests for well-known time edge cases. This algo is used to fetch the date to get an updated DB at the right time, I think it's acceptable to take this rather than a dependency for that purpose.
| "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"
There was a problem hiding this comment.
Yes definitely, if not the UI will just show a generic error message
| 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?
There was a problem hiding this comment.
Because it's at the mailroom rather than the directory and we're not parsing addresses we're just matching the strings. I can instead have the mailroom do the fetching and just have the directory load the addresses from a path and do loading and normalizing, but I do think this would have worked as-is without giving us a ton of trouble.
| } | ||
| } | ||
|
|
||
| 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?
There was a problem hiding this comment.
No, AccessControl is the Region-specific control, relevant to the top-level mailroom. Blocked addresses indeed are a separate struct / update path to be passed specifically to payjoin-directory as a separate module & lower level of abstraction, whether it's in a separate crate or not doesn't matter here.
There was a problem hiding this comment.
It could be made to encapsulate both geo and blocked addresses but right now they're separate
| ) { | ||
| 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
There was a problem hiding this comment.
It'd help me correct this if you'd define why you think this is excessive, and what level of granularity you'd accept. These are errors on once every 24h checks, and if they fail it'd be good to know precisely why
| } | ||
| } | ||
|
|
||
| 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.
There was a problem hiding this comment.
I agree this config isn't quite right
I agree, and there's a conversation to be had about the acceptable level of sloppiness for something that's 1. on the periphery of our integrations rather than library core 2. a burning time crunch blocking us from proceeding elsewhere. Your comments about
do need to be addressed here asap to ship. The other comment about
is noted. Can you be specific about what's left to be desired? This kind of change seems largely mechanical and peripheral to our main integration goals, which is why I felt a whole week of my time focused on this was not worth it compared to shipping something that works and isn't broken that some folks with less experience and more free cycles can iterate on and refine (as they already are doing) |
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