Skip to content
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
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions payjoin-cli/src/app/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ impl AppTrait for App {
self.config.pj_directory.clone(),
ohttp_keys.clone(),
self.config.ohttp_relay.clone(),
std::time::Duration::from_secs(60 * 60),
None,
);
let (req, ctx) =
initializer.extract_req().map_err(|e| anyhow!("Failed to extract request {}", e))?;
Expand Down Expand Up @@ -246,8 +246,7 @@ impl App {
session: &mut payjoin::receive::v2::ActiveSession,
) -> Result<payjoin::receive::v2::UncheckedProposal> {
loop {
let (req, context) =
session.extract_req().map_err(|_| anyhow!("Failed to extract request"))?;
let (req, context) = session.extract_req()?;
println!("Polling receive request...");
let http = http_agent()?;
let ohttp_response = http
Expand Down
3 changes: 1 addition & 2 deletions payjoin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ exclude = ["tests"]
send = []
receive = ["bitcoin/rand"]
base64 = ["bitcoin/base64"]
v2 = ["bitcoin/rand", "bitcoin/serde", "chacha20poly1305", "dep:http", "bhttp", "ohttp", "dep:percent-encoding", "serde", "url/serde"]
v2 = ["bitcoin/rand", "bitcoin/serde", "chacha20poly1305", "dep:http", "bhttp", "ohttp", "serde", "url/serde"]
io = ["reqwest/rustls-tls"]
danger-local-https = ["io", "reqwest/rustls-tls", "rustls"]

Expand All @@ -31,7 +31,6 @@ log = { version = "0.4.14"}
http = { version = "1", optional = true }
bhttp = { version = "=0.5.1", optional = true }
ohttp = { version = "0.5.1", optional = true }
percent-encoding = { version = "0.1.3", optional = true, package = "percent-encoding-rfc3986" }
serde = { version = "1.0.186", default-features = false, optional = true }
reqwest = { version = "0.12", default-features = false, optional = true }
rustls = { version = "0.22.2", optional = true }
Expand Down
44 changes: 44 additions & 0 deletions payjoin/src/receive/v2/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
use core::fmt;
use std::error;

use crate::v2::OhttpEncapsulationError;

#[derive(Debug)]
pub struct SessionError(InternalSessionError);

#[derive(Debug)]
pub(crate) enum InternalSessionError {
/// The session has expired
Expired(std::time::SystemTime),
/// OHTTP Encapsulation failed
OhttpEncapsulationError(OhttpEncapsulationError),
}

impl fmt::Display for SessionError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self.0 {
InternalSessionError::Expired(expiry) => write!(f, "Session expired at {:?}", expiry),
InternalSessionError::OhttpEncapsulationError(e) =>
write!(f, "OHTTP Encapsulation Error: {}", e),
}
}
}

impl error::Error for SessionError {
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
match &self.0 {
InternalSessionError::Expired(_) => None,
InternalSessionError::OhttpEncapsulationError(e) => Some(e),
}
}
}

impl From<InternalSessionError> for SessionError {
fn from(e: InternalSessionError) -> Self { SessionError(e) }
}

impl From<OhttpEncapsulationError> for SessionError {
fn from(e: OhttpEncapsulationError) -> Self {
SessionError(InternalSessionError::OhttpEncapsulationError(e))
}
}
30 changes: 21 additions & 9 deletions payjoin/src/receive/v2.rs → payjoin/src/receive/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize, Serializer};
use url::Url;

use super::v2::error::{InternalSessionError, SessionError};
use super::{Error, InternalRequestError, RequestError, SelectionError};
use crate::psbt::PsbtExt;
use crate::receive::optional_parameters::Params;
use crate::v2::OhttpEncapsulationError;
use crate::{OhttpKeys, PjUriBuilder, Request};

/// The state for a payjoin V2 receive session, including necessary
/// information for communication and cryptographic operations.
pub(crate) mod error;

static TWENTY_FOUR_HOURS_DEFAULT_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24);

#[derive(Debug, Clone, PartialEq, Eq)]
struct SessionContext {
address: Address,
Expand Down Expand Up @@ -58,7 +62,7 @@ impl SessionInitializer {
directory: Url,
ohttp_keys: OhttpKeys,
ohttp_relay: Url,
expire_after: Duration,
expire_after: Option<Duration>,
) -> Self {
let secp = bitcoin::secp256k1::Secp256k1::new();
let (sk, _) = secp.generate_keypair(&mut rand::rngs::OsRng);
Expand All @@ -68,7 +72,8 @@ impl SessionInitializer {
directory,
ohttp_keys,
ohttp_relay,
expiry: SystemTime::now() + expire_after,
expiry: SystemTime::now()
+ expire_after.unwrap_or(TWENTY_FOUR_HOURS_DEFAULT_EXPIRY),
s: bitcoin::secp256k1::KeyPair::from_secret_key(&secp, &sk),
e: None,
},
Expand Down Expand Up @@ -116,8 +121,12 @@ pub struct ActiveSession {
}

impl ActiveSession {
pub fn extract_req(&mut self) -> Result<(Request, ohttp::ClientResponse), Error> {
let (body, ohttp_ctx) = self.fallback_req_body()?;
pub fn extract_req(&mut self) -> Result<(Request, ohttp::ClientResponse), SessionError> {
if SystemTime::now() > self.context.expiry {
return Err(InternalSessionError::Expired(self.context.expiry).into());
}
let (body, ohttp_ctx) =
self.fallback_req_body().map_err(InternalSessionError::OhttpEncapsulationError)?;
let url = self.context.ohttp_relay.clone();
let req = Request { url, body };
Ok((req, ohttp_ctx))
Expand Down Expand Up @@ -146,14 +155,16 @@ impl ActiveSession {
}
}

fn fallback_req_body(&mut self) -> Result<(Vec<u8>, ohttp::ClientResponse), Error> {
fn fallback_req_body(
&mut self,
) -> Result<(Vec<u8>, ohttp::ClientResponse), OhttpEncapsulationError> {
let fallback_target = self.pj_url();
Ok(crate::v2::ohttp_encapsulate(
crate::v2::ohttp_encapsulate(
&mut self.context.ohttp_keys,
"GET",
fallback_target.as_str(),
None,
)?)
)
}

fn extract_proposal_from_v1(&mut self, response: String) -> Result<UncheckedProposal, Error> {
Expand Down Expand Up @@ -203,6 +214,7 @@ impl ActiveSession {
self.context.address.clone(),
self.pj_url(),
Some(self.context.ohttp_keys.clone()),
Some(self.context.expiry),
)
}

Expand Down
6 changes: 3 additions & 3 deletions payjoin/src/send/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ pub(crate) enum InternalCreateRequestError {
#[cfg(feature = "v2")]
MissingOhttpConfig,
#[cfg(feature = "v2")]
PercentEncoding,
Expired(std::time::SystemTime),
}

impl fmt::Display for CreateRequestError {
Expand Down Expand Up @@ -227,7 +227,7 @@ impl fmt::Display for CreateRequestError {
#[cfg(feature = "v2")]
MissingOhttpConfig => write!(f, "no ohttp configuration with which to make a v2 request available"),
#[cfg(feature = "v2")]
PercentEncoding => write!(f, "fragment is not RFC 3986 percent-encoded"),
Expired(expiry) => write!(f, "session expired at {:?}", expiry),
}
}
}
Expand Down Expand Up @@ -260,7 +260,7 @@ impl std::error::Error for CreateRequestError {
#[cfg(feature = "v2")]
MissingOhttpConfig => None,
#[cfg(feature = "v2")]
PercentEncoding => None,
Expired(_) => None,
}
}
}
Expand Down
13 changes: 8 additions & 5 deletions payjoin/src/send/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,12 @@ impl RequestContext {
ohttp_relay: Url,
) -> Result<(Request, ContextV2), CreateRequestError> {
use crate::uri::UrlExt;

if let Some(expiry) = self.endpoint.exp() {
if std::time::SystemTime::now() > expiry {
return Err(InternalCreateRequestError::Expired(expiry).into());
}
}
let rs = Self::rs_pubkey_from_dir_endpoint(&self.endpoint)?;
let url = self.endpoint.clone();
let body = serialize_v2_body(
Expand All @@ -325,11 +331,8 @@ impl RequestContext {
)?;
let body = crate::v2::encrypt_message_a(body, self.e, rs)
.map_err(InternalCreateRequestError::Hpke)?;
let mut ohttp = self
.endpoint
.ohttp()
.map_err(|_| InternalCreateRequestError::PercentEncoding)?
.ok_or(InternalCreateRequestError::MissingOhttpConfig)?;
let mut ohttp =
self.endpoint.ohttp().ok_or(InternalCreateRequestError::MissingOhttpConfig)?;
let (body, ohttp_res) =
crate::v2::ohttp_encapsulate(&mut ohttp, "POST", url.as_str(), Some(&body))
.map_err(InternalCreateRequestError::OhttpEncapsulation)?;
Expand Down
8 changes: 7 additions & 1 deletion payjoin/src/uri/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,19 @@ impl PjUriBuilder {
/// - `address`: Represents a bitcoin address.
/// - `origin`: Represents either the payjoin endpoint in v1 or the directory in v2.
/// - `ohttp_keys`: Optional OHTTP keys for v2 (only available if the "v2" feature is enabled).
/// - `expiry`: Optional non-default expiry for the payjoin session (only available if the "v2" feature is enabled).
pub fn new(
address: Address,
origin: Url,
#[cfg(feature = "v2")] ohttp_keys: Option<OhttpKeys>,
#[cfg(feature = "v2")] expiry: Option<std::time::SystemTime>,
Comment on lines 114 to +115

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Github won't let me add a comment on line 109 but we should have a docstring for expiry.

Relatedly, it might be clearer if we extract these params into a custom struct for v2-only params? e.g.:

#[cfg(feature = "v2")]
struct V2Params {
    /// Optional OHTTP keys for v2 (only available if the "v2" feature is enabled).
    ohttp_keys: Option<OhttpKeys>,
    /// Optional expiry time for the payjoin session.
    expiry: Option<std::time::SystemTime>,
}

impl PjUriBuilder {
    pub fn new(
        address: Address,
        origin: Url,
        #[cfg(feature = "v2")] v2_params: V2Params,
    ) -> Self {
        ...
        #[cfg(feature = "v2")]
        pj.set_v2_params(v2_params);
    }
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think this could work. Where would v2_params live?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You mean the V2Params struct? It specifically relates to URI params so it should live in uri/mod.rs I would think.

) -> Self {
#[allow(unused_mut)]
let mut pj = origin;
#[cfg(feature = "v2")]
let _ = pj.set_ohttp(ohttp_keys);
pj.set_ohttp(ohttp_keys);
#[cfg(feature = "v2")]
pj.set_exp(expiry);
Self { address, amount: None, message: None, label: None, pj, pjos: false }
}
/// Set the amount you want to receive.
Expand Down Expand Up @@ -352,6 +356,8 @@ mod tests {
Url::parse(pj).unwrap(),
#[cfg(feature = "v2")]
None,
#[cfg(feature = "v2")]
None,
)
.amount(amount)
.message("message".to_string())
Expand Down
Loading