Skip to content

Commit 58e7ccf

Browse files
DanGouldspacebear21
authored andcommitted
Add address blocklist screening for V1 PSBTs
Screen V1 payjoin payloads against a configurable address blocklist. Addresses are parsed into script pubkeys at load time for canonical comparison, avoiding encoding round-trips and bech32 case issues. Supports loading from a local file, a remote URL with periodic background refresh, and local cache fallback.
1 parent c291110 commit 58e7ccf

4 files changed

Lines changed: 339 additions & 4 deletions

File tree

payjoin-directory/src/lib.rs

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,60 @@ fn init_tls_acceptor(cert_key: (Vec<u8>, Vec<u8>)) -> Result<tokio_rustls::TlsAc
6565
Ok(TlsAcceptor::from(std::sync::Arc::new(server_config)))
6666
}
6767

68+
/// Opaque blocklist of Bitcoin addresses stored as script pubkeys.
69+
///
70+
/// Addresses are converted to `ScriptBuf` at parse time so that
71+
/// screening only requires a `HashSet::contains` on raw scripts,
72+
/// avoiding address-encoding round-trips and bech32 case issues.
73+
#[derive(Clone)]
74+
pub struct BlockedAddresses(
75+
Arc<tokio::sync::RwLock<std::collections::HashSet<bitcoin::ScriptBuf>>>,
76+
);
77+
78+
impl BlockedAddresses {
79+
pub fn empty() -> Self {
80+
Self(Arc::new(tokio::sync::RwLock::new(std::collections::HashSet::new())))
81+
}
82+
83+
pub fn from_address_lines(text: &str) -> Self {
84+
Self(Arc::new(tokio::sync::RwLock::new(parse_address_lines(text))))
85+
}
86+
87+
/// Replace the contents with scripts parsed from newline-delimited
88+
/// address text. Returns the number of entries after update.
89+
pub async fn update_from_lines(&self, text: &str) -> usize {
90+
let scripts = parse_address_lines(text);
91+
let count = scripts.len();
92+
*self.0.write().await = scripts;
93+
count
94+
}
95+
}
96+
97+
fn parse_address_lines(text: &str) -> std::collections::HashSet<bitcoin::ScriptBuf> {
98+
text.lines()
99+
.filter_map(|l| {
100+
let trimmed = l.trim();
101+
if trimmed.is_empty() {
102+
return None;
103+
}
104+
match trimmed.parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>() {
105+
Ok(addr) => Some(addr.assume_checked().script_pubkey()),
106+
Err(e) => {
107+
tracing::warn!("Skipping unparsable blocked address {trimmed:?}: {e}");
108+
None
109+
}
110+
}
111+
})
112+
.collect()
113+
}
114+
68115
#[derive(Clone)]
69116
pub struct Service<D: Db> {
70117
db: D,
71118
ohttp: ohttp::Server,
72119
sentinel_tag: SentinelTag,
73120
enable_v1: bool,
121+
blocked_addresses: Option<BlockedAddresses>,
74122
}
75123

76124
impl<D: Db, B> tower::Service<Request<B>> for Service<D>
@@ -95,7 +143,12 @@ where
95143

96144
impl<D: Db> Service<D> {
97145
pub fn new(db: D, ohttp: ohttp::Server, sentinel_tag: SentinelTag, enable_v1: bool) -> Self {
98-
Self { db, ohttp, sentinel_tag, enable_v1 }
146+
Self { db, ohttp, sentinel_tag, enable_v1, blocked_addresses: None }
147+
}
148+
149+
pub fn with_blocked_addresses(mut self, addrs: BlockedAddresses) -> Self {
150+
self.blocked_addresses = Some(addrs);
151+
self
99152
}
100153

101154
#[cfg(feature = "_manual-tls")]
@@ -419,6 +472,24 @@ impl<D: Db> Service<D> {
419472
Err(_) => return Ok(bad_request_body_res),
420473
};
421474

475+
if let Some(blocked) = &self.blocked_addresses {
476+
let scripts = blocked.0.read().await;
477+
if !scripts.is_empty() {
478+
match screen_v1_addresses(&body_str, &scripts) {
479+
ScreenResult::Blocked => {
480+
return Ok(Response::builder()
481+
.status(StatusCode::FORBIDDEN)
482+
.body(empty())?);
483+
}
484+
ScreenResult::Clean => {}
485+
ScreenResult::ParseError(e) => {
486+
warn!("Could not screen V1 payload: {e}");
487+
// fail-open: unparsable PSBTs can't complete transactions
488+
}
489+
}
490+
}
491+
}
492+
422493
let v2_compat_body = format!("{body_str}\n{query}");
423494
let id = ShortId::from_str(id)?;
424495
handle_peek(
@@ -629,6 +700,57 @@ fn full<T: Into<Bytes>>(chunk: T) -> BoxBody<Bytes, hyper::Error> {
629700
Full::new(chunk.into()).map_err(|never| match never {}).boxed()
630701
}
631702

703+
enum ScreenResult {
704+
Blocked,
705+
Clean,
706+
ParseError(String),
707+
}
708+
709+
fn screen_v1_addresses(
710+
body: &str,
711+
blocked: &std::collections::HashSet<bitcoin::ScriptBuf>,
712+
) -> ScreenResult {
713+
use bitcoin::base64::prelude::{Engine, BASE64_STANDARD};
714+
use bitcoin::psbt::Psbt;
715+
716+
let psbt_bytes = match BASE64_STANDARD.decode(body) {
717+
Ok(b) => b,
718+
Err(e) => return ScreenResult::ParseError(format!("base64 decode: {e}")),
719+
};
720+
721+
let psbt = match Psbt::deserialize(&psbt_bytes) {
722+
Ok(p) => p,
723+
Err(e) => return ScreenResult::ParseError(format!("PSBT deserialize: {e}")),
724+
};
725+
726+
// Check output scripts
727+
for txout in &psbt.unsigned_tx.output {
728+
if blocked.contains(&txout.script_pubkey) {
729+
return ScreenResult::Blocked;
730+
}
731+
}
732+
733+
// Check input scripts from witness_utxo and non_witness_utxo
734+
for (i, input) in psbt.inputs.iter().enumerate() {
735+
if let Some(ref utxo) = input.witness_utxo {
736+
if blocked.contains(&utxo.script_pubkey) {
737+
return ScreenResult::Blocked;
738+
}
739+
}
740+
if let Some(ref tx) = input.non_witness_utxo {
741+
if let Some(prev_out) = psbt.unsigned_tx.input.get(i) {
742+
if let Some(txout) = tx.output.get(prev_out.previous_output.vout as usize) {
743+
if blocked.contains(&txout.script_pubkey) {
744+
return ScreenResult::Blocked;
745+
}
746+
}
747+
}
748+
}
749+
}
750+
751+
ScreenResult::Clean
752+
}
753+
632754
#[cfg(test)]
633755
mod tests {
634756
use std::time::Duration;
@@ -712,3 +834,88 @@ mod tests {
712834
assert_eq!(body, V1_UNAVAILABLE_RES_JSON);
713835
}
714836
}
837+
838+
#[cfg(test)]
839+
mod screen_tests {
840+
use super::*;
841+
842+
fn addr_to_script(address: &str) -> bitcoin::ScriptBuf {
843+
let addr: bitcoin::Address<bitcoin::address::NetworkUnchecked> =
844+
address.parse().expect("valid address");
845+
addr.assume_checked().script_pubkey()
846+
}
847+
848+
fn make_test_psbt_base64(output_address: &str) -> String {
849+
use bitcoin::base64::prelude::{Engine, BASE64_STANDARD};
850+
use bitcoin::psbt::Psbt;
851+
use bitcoin::{Amount, Transaction, TxIn, TxOut};
852+
853+
let script_pubkey = addr_to_script(output_address);
854+
855+
let tx = Transaction {
856+
version: bitcoin::transaction::Version::TWO,
857+
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
858+
input: vec![TxIn::default()],
859+
output: vec![TxOut { value: Amount::from_sat(50_000), script_pubkey }],
860+
};
861+
862+
let psbt = Psbt::from_unsigned_tx(tx).expect("valid psbt");
863+
let serialized = psbt.serialize();
864+
BASE64_STANDARD.encode(&serialized)
865+
}
866+
867+
#[test]
868+
fn screen_blocks_blocked_output_address() {
869+
let blocked_addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa";
870+
let blocked = std::collections::HashSet::from([addr_to_script(blocked_addr)]);
871+
872+
let psbt_b64 = make_test_psbt_base64(blocked_addr);
873+
assert!(matches!(screen_v1_addresses(&psbt_b64, &blocked), ScreenResult::Blocked));
874+
}
875+
876+
#[test]
877+
fn screen_allows_clean_psbt() {
878+
let clean_addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa";
879+
let blocked = std::collections::HashSet::new(); // empty
880+
let psbt_b64 = make_test_psbt_base64(clean_addr);
881+
assert!(matches!(screen_v1_addresses(&psbt_b64, &blocked), ScreenResult::Clean));
882+
}
883+
884+
#[test]
885+
fn screen_allows_non_blocked_address() {
886+
let addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa";
887+
let blocked =
888+
std::collections::HashSet::from([addr_to_script("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy")]);
889+
890+
let psbt_b64 = make_test_psbt_base64(addr);
891+
assert!(matches!(screen_v1_addresses(&psbt_b64, &blocked), ScreenResult::Clean));
892+
}
893+
894+
#[test]
895+
fn screen_parse_error_on_invalid_base64() {
896+
let blocked =
897+
std::collections::HashSet::from([addr_to_script("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")]);
898+
assert!(matches!(
899+
screen_v1_addresses("not-valid-base64!!!", &blocked),
900+
ScreenResult::ParseError(_)
901+
));
902+
}
903+
904+
#[test]
905+
fn screen_parse_error_on_invalid_psbt() {
906+
use bitcoin::base64::prelude::{Engine, BASE64_STANDARD};
907+
let blocked =
908+
std::collections::HashSet::from([addr_to_script("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")]);
909+
let bad_psbt = BASE64_STANDARD.encode(b"not a psbt");
910+
assert!(matches!(screen_v1_addresses(&bad_psbt, &blocked), ScreenResult::ParseError(_)));
911+
}
912+
913+
#[test]
914+
fn screen_blocks_bech32_address() {
915+
let addr = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh";
916+
let blocked = std::collections::HashSet::from([addr_to_script(addr)]);
917+
918+
let psbt_b64 = make_test_psbt_base64(addr);
919+
assert!(matches!(screen_v1_addresses(&psbt_b64, &blocked), ScreenResult::Blocked));
920+
}
921+
}

payjoin-mailroom/src/access_control.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,36 @@ impl GeoIp {
7171
}
7272
}
7373

74+
pub fn load_blocked_address_text(path: &Path) -> anyhow::Result<String> {
75+
Ok(std::fs::read_to_string(path)?)
76+
}
77+
78+
pub fn spawn_address_list_updater(
79+
url: String,
80+
refresh: std::time::Duration,
81+
cache_path: std::path::PathBuf,
82+
blocked: payjoin_directory::BlockedAddresses,
83+
) {
84+
tokio::spawn(async move {
85+
loop {
86+
match reqwest::get(&url).await.and_then(|r| r.error_for_status()) {
87+
Ok(resp) => match resp.text().await {
88+
Ok(body) => {
89+
if let Err(e) = std::fs::write(&cache_path, &body) {
90+
tracing::warn!("Failed to write address cache: {e}");
91+
}
92+
let count = blocked.update_from_lines(&body).await;
93+
tracing::info!("Updated blocked address list ({count} entries)");
94+
}
95+
Err(e) => tracing::warn!("Failed to read address list response: {e}"),
96+
},
97+
Err(e) => tracing::warn!("Failed to fetch address list: {e}"),
98+
}
99+
tokio::time::sleep(refresh).await;
100+
}
101+
});
102+
}
103+
74104
async fn fetch_geoip_db(dest: &Path) -> anyhow::Result<()> {
75105
use std::io::Read;
76106

payjoin-mailroom/src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ pub struct AcmeConfig {
4444
pub struct AccessControlConfig {
4545
pub geo_db_path: Option<PathBuf>,
4646
pub blocked_regions: Vec<String>,
47+
pub blocked_addresses_path: Option<PathBuf>,
48+
pub blocked_addresses_url: Option<String>,
49+
pub blocked_addresses_refresh_secs: Option<u64>,
4750
}
4851

4952
#[cfg(feature = "acme")]

0 commit comments

Comments
 (0)