Skip to content

Commit a95a2ba

Browse files
committed
Shorten subdirectory IDs to 64 pseudorandom bits
Instead of the compressed public key, subdirectory IDs are now a truncated SHA256 of the compressed public key. These are should only be treated unique identifiers, not hashes: the use of SHA256 is only an implementation detail, and should not be specified by BIP77, nor verified/enforced by clients. This is because 64 bit hashes are insufficiently binding: finding a pair of colliding keys is almost trivial and finding a 2nd preimage for a given ID is tractable. For this reason no tagging is used to derive the IDs: public keys are ephemeral and have sufficient entropy to be unguessable. Random IDs could also have been used, but hashing seems simpler and reduces the receiver's statefulness requirements. ID collisions are only a liveness concern, and do not affect safety. With BIP77, HPKE will fail due to the wrong key (and/or domain separation if future protocols also use short IDs). With BIP 78 fallback the PSBT will not contain the intended receiver's outputs. The intended receiver can still poll the same subdirectory and respond, eventually, but only one sender will succeed. 64 bits are sufficient to make the probability of experiencing a random collisions negligible. As of writing, the UTXO set has ~2^28 elements. This is a very loose upper bound for the number of concurrent (non-spam) sessions, for which the probability of a random collision with will be <1%. The actual number of sessions will of course be (orders of magnitudes) lower given they are short lived. With ~2^21 sessions (loose bound on number of transactions that can be confirmed in 24H) the probability is less than 1e-6. These figures are for the existence of a collision in the set, the probability for an individual session to experience a random collision is << 1e-10 in either case. Since no rate limiting or access control mechanism exists for the directory yet, it's notable that this change changes the nature of a hypothetical DoS attack. With long IDs the adversary could only cause operational errors in theory (e.g. by filling the directory's storage). Note that by polling a large number of IDs an adversary can succeed in randomly *intercepting* v2 clients' sessions, and POST garbage data to the session causing HPKE to fail. For v1 sessions this can leak PSBT proposals, since those are not encrypted, which can leak input ownership information to the adversary. As implemented this change is not a regression but an actually hardens against DoS/malice in practice, because although in theory subdirectory IDs contained more entropy, the underlying redis keys prior to this change only contained 41 bits of entropy (8 characters of base64 encoded data, with 0x02 or 0x03 for the first encoded byte). Both the random collision and abuse scenarios can be mitigated by restricting the number of concurrent sessions in the directory to more reasonable values (less than 2^20). This is not done in this change.
1 parent 3db8512 commit a95a2ba

File tree

4 files changed

+63
-23
lines changed

4 files changed

+63
-23
lines changed

payjoin-directory/src/db.rs

+27-9
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ use tracing::debug;
77
const DEFAULT_COLUMN: &str = "";
88
const PJ_V1_COLUMN: &str = "pjv1";
99

10+
// TODO move to payjoin crate as pub?
11+
// TODO impl From<HpkePublicKey> for ShortId
12+
// TODO impl Display for ShortId (Base64)
13+
// TODO impl TryFrom<&str> for ShortId (Base64)
14+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15+
pub(crate) struct ShortId(pub [u8; 8]);
16+
17+
impl ShortId {
18+
pub fn column_key(&self, column: &str) -> Vec<u8> {
19+
self.0.iter().chain(column.as_bytes()).copied().collect()
20+
}
21+
}
22+
1023
#[derive(Debug, Clone)]
1124
pub(crate) struct DbPool {
1225
client: Client,
@@ -19,23 +32,28 @@ impl DbPool {
1932
Ok(Self { client, timeout })
2033
}
2134

22-
pub async fn push_default(&self, pubkey_id: &str, data: Vec<u8>) -> RedisResult<()> {
35+
pub async fn push_default(&self, pubkey_id: &ShortId, data: Vec<u8>) -> RedisResult<()> {
2336
self.push(pubkey_id, DEFAULT_COLUMN, data).await
2437
}
2538

26-
pub async fn peek_default(&self, pubkey_id: &str) -> Option<RedisResult<Vec<u8>>> {
39+
pub async fn peek_default(&self, pubkey_id: &ShortId) -> Option<RedisResult<Vec<u8>>> {
2740
self.peek_with_timeout(pubkey_id, DEFAULT_COLUMN).await
2841
}
2942

30-
pub async fn push_v1(&self, pubkey_id: &str, data: Vec<u8>) -> RedisResult<()> {
43+
pub async fn push_v1(&self, pubkey_id: &ShortId, data: Vec<u8>) -> RedisResult<()> {
3144
self.push(pubkey_id, PJ_V1_COLUMN, data).await
3245
}
3346

34-
pub async fn peek_v1(&self, pubkey_id: &str) -> Option<RedisResult<Vec<u8>>> {
47+
pub async fn peek_v1(&self, pubkey_id: &ShortId) -> Option<RedisResult<Vec<u8>>> {
3548
self.peek_with_timeout(pubkey_id, PJ_V1_COLUMN).await
3649
}
3750

38-
async fn push(&self, pubkey_id: &str, channel_type: &str, data: Vec<u8>) -> RedisResult<()> {
51+
async fn push(
52+
&self,
53+
pubkey_id: &ShortId,
54+
channel_type: &str,
55+
data: Vec<u8>,
56+
) -> RedisResult<()> {
3957
let mut conn = self.client.get_async_connection().await?;
4058
let key = channel_name(pubkey_id, channel_type);
4159
() = conn.set(&key, data.clone()).await?;
@@ -45,13 +63,13 @@ impl DbPool {
4563

4664
async fn peek_with_timeout(
4765
&self,
48-
pubkey_id: &str,
66+
pubkey_id: &ShortId,
4967
channel_type: &str,
5068
) -> Option<RedisResult<Vec<u8>>> {
5169
tokio::time::timeout(self.timeout, self.peek(pubkey_id, channel_type)).await.ok()
5270
}
5371

54-
async fn peek(&self, pubkey_id: &str, channel_type: &str) -> RedisResult<Vec<u8>> {
72+
async fn peek(&self, pubkey_id: &ShortId, channel_type: &str) -> RedisResult<Vec<u8>> {
5573
let mut conn = self.client.get_async_connection().await?;
5674
let key = channel_name(pubkey_id, channel_type);
5775

@@ -99,6 +117,6 @@ impl DbPool {
99117
}
100118
}
101119

102-
fn channel_name(pubkey_id: &str, channel_type: &str) -> String {
103-
format!("{}:{}", pubkey_id, channel_type)
120+
fn channel_name(pubkey_id: &ShortId, channel_type: &str) -> Vec<u8> {
121+
pubkey_id.column_key(channel_type)
104122
}

payjoin-directory/src/lib.rs

+17-5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use std::sync::Arc;
33
use std::time::Duration;
44

55
use anyhow::Result;
6+
use bitcoin::base64::prelude::BASE64_URL_SAFE_NO_PAD;
7+
use bitcoin::base64::Engine;
68
use http_body_util::combinators::BoxBody;
79
use http_body_util::{BodyExt, Empty, Full};
810
use hyper::body::{Body, Bytes, Incoming};
@@ -15,6 +17,8 @@ use tokio::net::TcpListener;
1517
use tokio::sync::Mutex;
1618
use tracing::{debug, error, info, trace};
1719

20+
use crate::db::ShortId;
21+
1822
pub const DEFAULT_DIR_PORT: u16 = 8080;
1923
pub const DEFAULT_DB_HOST: &str = "localhost:6379";
2024
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
@@ -295,7 +299,7 @@ async fn post_fallback_v1(
295299
};
296300

297301
let v2_compat_body = format!("{}\n{}", body_str, query);
298-
let id = shorten_string(id);
302+
let id = decode_short_id(id)?;
299303
pool.push_default(&id, v2_compat_body.into())
300304
.await
301305
.map_err(|e| HandlerError::BadRequest(e.into()))?;
@@ -316,7 +320,7 @@ async fn put_payjoin_v1(
316320
trace!("Put_payjoin_v1");
317321
let ok_response = Response::builder().status(StatusCode::OK).body(empty())?;
318322

319-
let id = shorten_string(id);
323+
let id = decode_short_id(id)?;
320324
let req =
321325
body.collect().await.map_err(|e| HandlerError::InternalServerError(e.into()))?.to_bytes();
322326
if req.len() > MAX_BUFFER_SIZE {
@@ -337,7 +341,7 @@ async fn post_subdir(
337341
let none_response = Response::builder().status(StatusCode::OK).body(empty())?;
338342
trace!("post_subdir");
339343

340-
let id = shorten_string(id);
344+
let id = decode_short_id(id)?;
341345
let req =
342346
body.collect().await.map_err(|e| HandlerError::InternalServerError(e.into()))?.to_bytes();
343347
if req.len() > MAX_BUFFER_SIZE {
@@ -355,7 +359,7 @@ async fn get_subdir(
355359
pool: DbPool,
356360
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, HandlerError> {
357361
trace!("get_subdir");
358-
let id = shorten_string(id);
362+
let id = decode_short_id(id)?;
359363
match pool.peek_default(&id).await {
360364
Some(result) => match result {
361365
Ok(buffered_req) => Ok(Response::new(full(buffered_req))),
@@ -385,7 +389,15 @@ async fn get_ohttp_keys(
385389
Ok(res)
386390
}
387391

388-
fn shorten_string(input: &str) -> String { input.chars().take(8).collect() }
392+
fn decode_short_id(input: &str) -> Result<ShortId, HandlerError> {
393+
let decoded =
394+
BASE64_URL_SAFE_NO_PAD.decode(input).map_err(|e| HandlerError::BadRequest(e.into()))?;
395+
396+
decoded[..8]
397+
.try_into()
398+
.map_err(|_| HandlerError::BadRequest(anyhow::anyhow!("Invalid subdirectory ID")))
399+
.map(ShortId)
400+
}
389401

390402
fn empty() -> BoxBody<Bytes, hyper::Error> {
391403
Empty::<Bytes>::new().map_err(|never| match never {}).boxed()

payjoin/src/receive/v2/mod.rs

+13-7
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::time::{Duration, SystemTime};
33

44
use bitcoin::base64::prelude::BASE64_URL_SAFE_NO_PAD;
55
use bitcoin::base64::Engine;
6+
use bitcoin::hashes::{sha256, Hash};
67
use bitcoin::psbt::Psbt;
78
use bitcoin::{Address, FeeRate, OutPoint, Script, TxOut};
89
use serde::de::Deserializer;
@@ -48,7 +49,8 @@ where
4849
}
4950

5051
fn subdir_path_from_pubkey(pubkey: &HpkePublicKey) -> String {
51-
BASE64_URL_SAFE_NO_PAD.encode(pubkey.to_compressed_bytes())
52+
let hash = sha256::Hash::hash(&pubkey.to_compressed_bytes());
53+
BASE64_URL_SAFE_NO_PAD.encode(&hash.as_byte_array()[..8])
5254
}
5355

5456
/// A payjoin V2 receiver, allowing for polled requests to the
@@ -188,22 +190,26 @@ impl Receiver {
188190
)
189191
}
190192

191-
// The contents of the `&pj=` query parameter including the base64url-encoded public key receiver subdirectory.
193+
// The contents of the `&pj=` query parameter.
192194
// This identifies a session at the payjoin directory server.
193195
pub fn pj_url(&self) -> Url {
194-
let pubkey = &self.id();
195-
let pubkey_base64 = BASE64_URL_SAFE_NO_PAD.encode(pubkey);
196+
let id_base64 = BASE64_URL_SAFE_NO_PAD.encode(self.id());
196197
let mut url = self.context.directory.clone();
197198
{
198199
let mut path_segments =
199200
url.path_segments_mut().expect("Payjoin Directory URL cannot be a base");
200-
path_segments.push(&pubkey_base64);
201+
path_segments.push(&id_base64);
201202
}
202203
url
203204
}
204205

205-
/// The per-session public key to use as an identifier
206-
pub fn id(&self) -> [u8; 33] { self.context.s.public_key().to_compressed_bytes() }
206+
/// The per-session identifier
207+
pub fn id(&self) -> [u8; 8] {
208+
let hash = sha256::Hash::hash(&self.context.s.public_key().to_compressed_bytes());
209+
hash.as_byte_array()[..8]
210+
.try_into()
211+
.expect("truncating SHA256 to 8 bytes should always succeed")
212+
}
207213
}
208214

209215
/// The sender's original PSBT and optional parameters

payjoin/src/send/mod.rs

+6-2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ use std::str::FromStr;
2525

2626
#[cfg(feature = "v2")]
2727
use bitcoin::base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
28+
#[cfg(feature = "v2")]
29+
use bitcoin::hashes::{sha256, Hash};
2830
use bitcoin::psbt::Psbt;
2931
use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight};
3032
pub use error::{CreateRequestError, ResponseError, ValidationError};
@@ -394,8 +396,10 @@ impl V2GetContext {
394396
) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> {
395397
use crate::uri::UrlExt;
396398
let mut url = self.endpoint.clone();
397-
let subdir = BASE64_URL_SAFE_NO_PAD
398-
.encode(self.hpke_ctx.reply_pair.public_key().to_compressed_bytes());
399+
400+
// TODO unify with receiver's fn subdir_path_from_pubkey
401+
let hash = sha256::Hash::hash(&self.hpke_ctx.reply_pair.public_key().to_compressed_bytes());
402+
let subdir = BASE64_URL_SAFE_NO_PAD.encode(&hash.as_byte_array()[..8]);
399403
url.set_path(&subdir);
400404
let body = encrypt_message_a(
401405
Vec::new(),

0 commit comments

Comments
 (0)