Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shorten subdirectory IDs #386

Merged
merged 3 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions payjoin-directory/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ use tracing::debug;
const DEFAULT_COLUMN: &str = "";
const PJ_V1_COLUMN: &str = "pjv1";

// TODO move to payjoin crate as pub?
// TODO impl From<HpkePublicKey> for ShortId
// TODO impl Display for ShortId (Base64)
// TODO impl TryFrom<&str> for ShortId (Base64)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ShortId(pub [u8; 8]);

impl ShortId {
pub fn column_key(&self, column: &str) -> Vec<u8> {
self.0.iter().chain(column.as_bytes()).copied().collect()
}
}

#[derive(Debug, Clone)]
pub(crate) struct DbPool {
client: Client,
Expand All @@ -19,23 +32,28 @@ impl DbPool {
Ok(Self { client, timeout })
}

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

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

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

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

async fn push(&self, pubkey_id: &str, channel_type: &str, data: Vec<u8>) -> RedisResult<()> {
async fn push(
&self,
pubkey_id: &ShortId,
channel_type: &str,
data: Vec<u8>,
) -> RedisResult<()> {
let mut conn = self.client.get_async_connection().await?;
let key = channel_name(pubkey_id, channel_type);
() = conn.set(&key, data.clone()).await?;
Expand All @@ -45,13 +63,13 @@ impl DbPool {

async fn peek_with_timeout(
&self,
pubkey_id: &str,
pubkey_id: &ShortId,
channel_type: &str,
) -> Option<RedisResult<Vec<u8>>> {
tokio::time::timeout(self.timeout, self.peek(pubkey_id, channel_type)).await.ok()
}

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

Expand Down Expand Up @@ -99,6 +117,6 @@ impl DbPool {
}
}

fn channel_name(pubkey_id: &str, channel_type: &str) -> String {
format!("{}:{}", pubkey_id, channel_type)
fn channel_name(pubkey_id: &ShortId, channel_type: &str) -> Vec<u8> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper is no longer that helpful IMO and could be replaced by direct calls to column_key() in later commits.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed... do you think the TODO comments for ShortId are missing anything or could be simplified?

pubkey_id.column_key(channel_type)
}
22 changes: 17 additions & 5 deletions payjoin-directory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use std::sync::Arc;
use std::time::Duration;

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

use crate::db::ShortId;

pub const DEFAULT_DIR_PORT: u16 = 8080;
pub const DEFAULT_DB_HOST: &str = "localhost:6379";
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
Expand Down Expand Up @@ -295,7 +299,7 @@ async fn post_fallback_v1(
};

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

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

let id = shorten_string(id);
let id = decode_short_id(id)?;
let req =
body.collect().await.map_err(|e| HandlerError::InternalServerError(e.into()))?.to_bytes();
if req.len() > MAX_BUFFER_SIZE {
Expand All @@ -355,7 +359,7 @@ async fn get_subdir(
pool: DbPool,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, HandlerError> {
trace!("get_subdir");
let id = shorten_string(id);
let id = decode_short_id(id)?;
match pool.peek_default(&id).await {
Some(result) => match result {
Ok(buffered_req) => Ok(Response::new(full(buffered_req))),
Expand Down Expand Up @@ -385,7 +389,15 @@ async fn get_ohttp_keys(
Ok(res)
}

fn shorten_string(input: &str) -> String { input.chars().take(8).collect() }
fn decode_short_id(input: &str) -> Result<ShortId, HandlerError> {
let decoded =
BASE64_URL_SAFE_NO_PAD.decode(input).map_err(|e| HandlerError::BadRequest(e.into()))?;

decoded[..8]
.try_into()
.map_err(|_| HandlerError::BadRequest(anyhow::anyhow!("Invalid subdirectory ID")))
.map(ShortId)
}

fn empty() -> BoxBody<Bytes, hyper::Error> {
Empty::<Bytes>::new().map_err(|never| match never {}).boxed()
Expand Down
2 changes: 2 additions & 0 deletions payjoin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub mod send;
#[cfg(feature = "v2")]
pub(crate) mod hpke;
#[cfg(feature = "v2")]
pub use crate::hpke::{HpkeKeyPair, HpkePublicKey};
#[cfg(feature = "v2")]
pub(crate) mod ohttp;
#[cfg(feature = "v2")]
pub use crate::ohttp::OhttpKeys;
Expand Down
21 changes: 14 additions & 7 deletions payjoin/src/receive/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::time::{Duration, SystemTime};

use bitcoin::base64::prelude::BASE64_URL_SAFE_NO_PAD;
use bitcoin::base64::Engine;
use bitcoin::hashes::{sha256, Hash};
use bitcoin::psbt::Psbt;
use bitcoin::{Address, FeeRate, OutPoint, Script, TxOut};
use serde::de::Deserializer;
Expand Down Expand Up @@ -48,7 +49,8 @@ where
}

fn subdir_path_from_pubkey(pubkey: &HpkePublicKey) -> String {
BASE64_URL_SAFE_NO_PAD.encode(pubkey.to_compressed_bytes())
let hash = sha256::Hash::hash(&pubkey.to_compressed_bytes());
BASE64_URL_SAFE_NO_PAD.encode(&hash.as_byte_array()[..8])
}

/// A payjoin V2 receiver, allowing for polled requests to the
Expand Down Expand Up @@ -182,27 +184,32 @@ impl Receiver {
PjUriBuilder::new(
self.context.address.clone(),
self.pj_url(),
Some(self.context.s.public_key().clone()),
Some(self.context.ohttp_keys.clone()),
Some(self.context.expiry),
)
}

// The contents of the `&pj=` query parameter including the base64url-encoded public key receiver subdirectory.
// The contents of the `&pj=` query parameter.
// This identifies a session at the payjoin directory server.
pub fn pj_url(&self) -> Url {
let pubkey = &self.id();
let pubkey_base64 = BASE64_URL_SAFE_NO_PAD.encode(pubkey);
let id_base64 = BASE64_URL_SAFE_NO_PAD.encode(self.id());
let mut url = self.context.directory.clone();
{
let mut path_segments =
url.path_segments_mut().expect("Payjoin Directory URL cannot be a base");
path_segments.push(&pubkey_base64);
path_segments.push(&id_base64);
}
url
}

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

/// The sender's original PSBT and optional parameters
Expand Down
50 changes: 9 additions & 41 deletions payjoin/src/send/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ use bitcoin::locktime::absolute::LockTime;
use bitcoin::transaction::Version;
use bitcoin::{AddressType, Sequence};

#[cfg(feature = "v2")]
use crate::uri::error::ParseReceiverPubkeyError;

/// Error that may occur when the response from receiver is malformed.
///
/// This is currently opaque type because we aren't sure which variants will stay.
Expand Down Expand Up @@ -194,7 +197,7 @@ pub(crate) enum InternalCreateRequestError {
#[cfg(feature = "v2")]
OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError),
#[cfg(feature = "v2")]
ParseSubdirectory(ParseSubdirectoryError),
ParseReceiverPubkey(ParseReceiverPubkeyError),
#[cfg(feature = "v2")]
MissingOhttpConfig,
#[cfg(feature = "v2")]
Expand Down Expand Up @@ -225,7 +228,7 @@ impl fmt::Display for CreateRequestError {
#[cfg(feature = "v2")]
OhttpEncapsulation(e) => write!(f, "v2 error: {}", e),
#[cfg(feature = "v2")]
ParseSubdirectory(e) => write!(f, "cannot parse subdirectory: {}", e),
ParseReceiverPubkey(e) => write!(f, "cannot parse receiver public key: {}", e),
#[cfg(feature = "v2")]
MissingOhttpConfig => write!(f, "no ohttp configuration with which to make a v2 request available"),
#[cfg(feature = "v2")]
Expand Down Expand Up @@ -258,7 +261,7 @@ impl std::error::Error for CreateRequestError {
#[cfg(feature = "v2")]
OhttpEncapsulation(error) => Some(error),
#[cfg(feature = "v2")]
ParseSubdirectory(error) => Some(error),
ParseReceiverPubkey(error) => Some(error),
#[cfg(feature = "v2")]
MissingOhttpConfig => None,
#[cfg(feature = "v2")]
Expand All @@ -278,44 +281,9 @@ impl From<crate::psbt::AddressTypeError> for CreateRequestError {
}

#[cfg(feature = "v2")]
impl From<ParseSubdirectoryError> for CreateRequestError {
fn from(value: ParseSubdirectoryError) -> Self {
CreateRequestError(InternalCreateRequestError::ParseSubdirectory(value))
}
}

#[cfg(feature = "v2")]
#[derive(Debug)]
pub(crate) enum ParseSubdirectoryError {
MissingSubdirectory,
SubdirectoryNotBase64(bitcoin::base64::DecodeError),
SubdirectoryInvalidPubkey(crate::hpke::HpkeError),
}

#[cfg(feature = "v2")]
impl std::fmt::Display for ParseSubdirectoryError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
use ParseSubdirectoryError::*;

match &self {
MissingSubdirectory => write!(f, "subdirectory is missing"),
SubdirectoryNotBase64(e) => write!(f, "subdirectory is not valid base64: {}", e),
SubdirectoryInvalidPubkey(e) =>
write!(f, "subdirectory does not represent a valid pubkey: {}", e),
}
}
}

#[cfg(feature = "v2")]
impl std::error::Error for ParseSubdirectoryError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
use ParseSubdirectoryError::*;

match &self {
MissingSubdirectory => None,
SubdirectoryNotBase64(error) => Some(error),
SubdirectoryInvalidPubkey(error) => Some(error),
}
impl From<ParseReceiverPubkeyError> for CreateRequestError {
fn from(value: ParseReceiverPubkeyError) -> Self {
CreateRequestError(InternalCreateRequestError::ParseReceiverPubkey(value))
}
}

Expand Down
28 changes: 11 additions & 17 deletions payjoin/src/send/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ use std::str::FromStr;

#[cfg(feature = "v2")]
use bitcoin::base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
#[cfg(feature = "v2")]
use bitcoin::hashes::{sha256, Hash};
use bitcoin::psbt::Psbt;
use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight};
pub use error::{CreateRequestError, ResponseError, ValidationError};
Expand Down Expand Up @@ -322,21 +324,11 @@ impl Sender {
}

#[cfg(feature = "v2")]
fn extract_rs_pubkey(&self) -> Result<HpkePublicKey, error::ParseSubdirectoryError> {
use error::ParseSubdirectoryError;

let subdirectory = self
.endpoint
.path_segments()
.and_then(|mut segments| segments.next())
.ok_or(ParseSubdirectoryError::MissingSubdirectory)?;

let pubkey_bytes = BASE64_URL_SAFE_NO_PAD
.decode(subdirectory)
.map_err(ParseSubdirectoryError::SubdirectoryNotBase64)?;

HpkePublicKey::from_compressed_bytes(&pubkey_bytes)
.map_err(ParseSubdirectoryError::SubdirectoryInvalidPubkey)
fn extract_rs_pubkey(
&self,
) -> Result<HpkePublicKey, crate::uri::error::ParseReceiverPubkeyError> {
use crate::uri::UrlExt;
self.endpoint.receiver_pubkey()
}

pub fn endpoint(&self) -> &Url { &self.endpoint }
Expand Down Expand Up @@ -404,8 +396,10 @@ impl V2GetContext {
) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> {
use crate::uri::UrlExt;
let mut url = self.endpoint.clone();
let subdir = BASE64_URL_SAFE_NO_PAD
.encode(self.hpke_ctx.reply_pair.public_key().to_compressed_bytes());

// TODO unify with receiver's fn subdir_path_from_pubkey
let hash = sha256::Hash::hash(&self.hpke_ctx.reply_pair.public_key().to_compressed_bytes());
let subdir = BASE64_URL_SAFE_NO_PAD.encode(&hash.as_byte_array()[..8]);
url.set_path(&subdir);
let body = encrypt_message_a(
Vec::new(),
Expand Down
35 changes: 35 additions & 0 deletions payjoin/src/uri/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,41 @@ pub(crate) enum InternalPjParseError {
UnsecureEndpoint,
}

#[cfg(feature = "v2")]
#[derive(Debug)]
pub(crate) enum ParseReceiverPubkeyError {
MissingPubkey,
PubkeyNotBase64(bitcoin::base64::DecodeError),
InvalidPubkey(crate::hpke::HpkeError),
}

#[cfg(feature = "v2")]
impl std::fmt::Display for ParseReceiverPubkeyError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
use ParseReceiverPubkeyError::*;

match &self {
MissingPubkey => write!(f, "receiver public key is missing"),
PubkeyNotBase64(e) => write!(f, "receiver public is not valid base64: {}", e),
InvalidPubkey(e) =>
write!(f, "receiver public key does not represent a valid pubkey: {}", e),
}
}
}

#[cfg(feature = "v2")]
impl std::error::Error for ParseReceiverPubkeyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
use ParseReceiverPubkeyError::*;

match &self {
MissingPubkey => None,
PubkeyNotBase64(error) => Some(error),
InvalidPubkey(error) => Some(error),
}
}
}

impl From<InternalPjParseError> for PjParseError {
fn from(value: InternalPjParseError) -> Self { PjParseError(value) }
}
Expand Down
Loading