Skip to content

Commit b013874

Browse files
authored
Shorten subdirectory IDs (payjoin#386)
The message for the last commit contains a rationale for 64 bit values and hashing.
2 parents 153e6ac + a95a2ba commit b013874

File tree

10 files changed

+156
-83
lines changed

10 files changed

+156
-83
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/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pub mod send;
3030
#[cfg(feature = "v2")]
3131
pub(crate) mod hpke;
3232
#[cfg(feature = "v2")]
33+
pub use crate::hpke::{HpkeKeyPair, HpkePublicKey};
34+
#[cfg(feature = "v2")]
3335
pub(crate) mod ohttp;
3436
#[cfg(feature = "v2")]
3537
pub use crate::ohttp::OhttpKeys;

payjoin/src/receive/v2/mod.rs

+14-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
@@ -182,27 +184,32 @@ impl Receiver {
182184
PjUriBuilder::new(
183185
self.context.address.clone(),
184186
self.pj_url(),
187+
Some(self.context.s.public_key().clone()),
185188
Some(self.context.ohttp_keys.clone()),
186189
Some(self.context.expiry),
187190
)
188191
}
189192

190-
// The contents of the `&pj=` query parameter including the base64url-encoded public key receiver subdirectory.
193+
// The contents of the `&pj=` query parameter.
191194
// This identifies a session at the payjoin directory server.
192195
pub fn pj_url(&self) -> Url {
193-
let pubkey = &self.id();
194-
let pubkey_base64 = BASE64_URL_SAFE_NO_PAD.encode(pubkey);
196+
let id_base64 = BASE64_URL_SAFE_NO_PAD.encode(self.id());
195197
let mut url = self.context.directory.clone();
196198
{
197199
let mut path_segments =
198200
url.path_segments_mut().expect("Payjoin Directory URL cannot be a base");
199-
path_segments.push(&pubkey_base64);
201+
path_segments.push(&id_base64);
200202
}
201203
url
202204
}
203205

204-
/// The per-session public key to use as an identifier
205-
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+
}
206213
}
207214

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

payjoin/src/send/error.rs

+9-41
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ use bitcoin::locktime::absolute::LockTime;
44
use bitcoin::transaction::Version;
55
use bitcoin::{AddressType, Sequence};
66

7+
#[cfg(feature = "v2")]
8+
use crate::uri::error::ParseReceiverPubkeyError;
9+
710
/// Error that may occur when the response from receiver is malformed.
811
///
912
/// This is currently opaque type because we aren't sure which variants will stay.
@@ -194,7 +197,7 @@ pub(crate) enum InternalCreateRequestError {
194197
#[cfg(feature = "v2")]
195198
OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError),
196199
#[cfg(feature = "v2")]
197-
ParseSubdirectory(ParseSubdirectoryError),
200+
ParseReceiverPubkey(ParseReceiverPubkeyError),
198201
#[cfg(feature = "v2")]
199202
MissingOhttpConfig,
200203
#[cfg(feature = "v2")]
@@ -225,7 +228,7 @@ impl fmt::Display for CreateRequestError {
225228
#[cfg(feature = "v2")]
226229
OhttpEncapsulation(e) => write!(f, "v2 error: {}", e),
227230
#[cfg(feature = "v2")]
228-
ParseSubdirectory(e) => write!(f, "cannot parse subdirectory: {}", e),
231+
ParseReceiverPubkey(e) => write!(f, "cannot parse receiver public key: {}", e),
229232
#[cfg(feature = "v2")]
230233
MissingOhttpConfig => write!(f, "no ohttp configuration with which to make a v2 request available"),
231234
#[cfg(feature = "v2")]
@@ -258,7 +261,7 @@ impl std::error::Error for CreateRequestError {
258261
#[cfg(feature = "v2")]
259262
OhttpEncapsulation(error) => Some(error),
260263
#[cfg(feature = "v2")]
261-
ParseSubdirectory(error) => Some(error),
264+
ParseReceiverPubkey(error) => Some(error),
262265
#[cfg(feature = "v2")]
263266
MissingOhttpConfig => None,
264267
#[cfg(feature = "v2")]
@@ -278,44 +281,9 @@ impl From<crate::psbt::AddressTypeError> for CreateRequestError {
278281
}
279282

280283
#[cfg(feature = "v2")]
281-
impl From<ParseSubdirectoryError> for CreateRequestError {
282-
fn from(value: ParseSubdirectoryError) -> Self {
283-
CreateRequestError(InternalCreateRequestError::ParseSubdirectory(value))
284-
}
285-
}
286-
287-
#[cfg(feature = "v2")]
288-
#[derive(Debug)]
289-
pub(crate) enum ParseSubdirectoryError {
290-
MissingSubdirectory,
291-
SubdirectoryNotBase64(bitcoin::base64::DecodeError),
292-
SubdirectoryInvalidPubkey(crate::hpke::HpkeError),
293-
}
294-
295-
#[cfg(feature = "v2")]
296-
impl std::fmt::Display for ParseSubdirectoryError {
297-
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
298-
use ParseSubdirectoryError::*;
299-
300-
match &self {
301-
MissingSubdirectory => write!(f, "subdirectory is missing"),
302-
SubdirectoryNotBase64(e) => write!(f, "subdirectory is not valid base64: {}", e),
303-
SubdirectoryInvalidPubkey(e) =>
304-
write!(f, "subdirectory does not represent a valid pubkey: {}", e),
305-
}
306-
}
307-
}
308-
309-
#[cfg(feature = "v2")]
310-
impl std::error::Error for ParseSubdirectoryError {
311-
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
312-
use ParseSubdirectoryError::*;
313-
314-
match &self {
315-
MissingSubdirectory => None,
316-
SubdirectoryNotBase64(error) => Some(error),
317-
SubdirectoryInvalidPubkey(error) => Some(error),
318-
}
284+
impl From<ParseReceiverPubkeyError> for CreateRequestError {
285+
fn from(value: ParseReceiverPubkeyError) -> Self {
286+
CreateRequestError(InternalCreateRequestError::ParseReceiverPubkey(value))
319287
}
320288
}
321289

payjoin/src/send/mod.rs

+11-17
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};
@@ -322,21 +324,11 @@ impl Sender {
322324
}
323325

324326
#[cfg(feature = "v2")]
325-
fn extract_rs_pubkey(&self) -> Result<HpkePublicKey, error::ParseSubdirectoryError> {
326-
use error::ParseSubdirectoryError;
327-
328-
let subdirectory = self
329-
.endpoint
330-
.path_segments()
331-
.and_then(|mut segments| segments.next())
332-
.ok_or(ParseSubdirectoryError::MissingSubdirectory)?;
333-
334-
let pubkey_bytes = BASE64_URL_SAFE_NO_PAD
335-
.decode(subdirectory)
336-
.map_err(ParseSubdirectoryError::SubdirectoryNotBase64)?;
337-
338-
HpkePublicKey::from_compressed_bytes(&pubkey_bytes)
339-
.map_err(ParseSubdirectoryError::SubdirectoryInvalidPubkey)
327+
fn extract_rs_pubkey(
328+
&self,
329+
) -> Result<HpkePublicKey, crate::uri::error::ParseReceiverPubkeyError> {
330+
use crate::uri::UrlExt;
331+
self.endpoint.receiver_pubkey()
340332
}
341333

342334
pub fn endpoint(&self) -> &Url { &self.endpoint }
@@ -404,8 +396,10 @@ impl V2GetContext {
404396
) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> {
405397
use crate::uri::UrlExt;
406398
let mut url = self.endpoint.clone();
407-
let subdir = BASE64_URL_SAFE_NO_PAD
408-
.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]);
409403
url.set_path(&subdir);
410404
let body = encrypt_message_a(
411405
Vec::new(),

payjoin/src/uri/error.rs

+35
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,41 @@ pub(crate) enum InternalPjParseError {
1111
UnsecureEndpoint,
1212
}
1313

14+
#[cfg(feature = "v2")]
15+
#[derive(Debug)]
16+
pub(crate) enum ParseReceiverPubkeyError {
17+
MissingPubkey,
18+
PubkeyNotBase64(bitcoin::base64::DecodeError),
19+
InvalidPubkey(crate::hpke::HpkeError),
20+
}
21+
22+
#[cfg(feature = "v2")]
23+
impl std::fmt::Display for ParseReceiverPubkeyError {
24+
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
25+
use ParseReceiverPubkeyError::*;
26+
27+
match &self {
28+
MissingPubkey => write!(f, "receiver public key is missing"),
29+
PubkeyNotBase64(e) => write!(f, "receiver public is not valid base64: {}", e),
30+
InvalidPubkey(e) =>
31+
write!(f, "receiver public key does not represent a valid pubkey: {}", e),
32+
}
33+
}
34+
}
35+
36+
#[cfg(feature = "v2")]
37+
impl std::error::Error for ParseReceiverPubkeyError {
38+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
39+
use ParseReceiverPubkeyError::*;
40+
41+
match &self {
42+
MissingPubkey => None,
43+
PubkeyNotBase64(error) => Some(error),
44+
InvalidPubkey(error) => Some(error),
45+
}
46+
}
47+
}
48+
1449
impl From<InternalPjParseError> for PjParseError {
1550
fn from(value: InternalPjParseError) -> Self { PjParseError(value) }
1651
}

0 commit comments

Comments
 (0)