Skip to content
Closed
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
4 changes: 3 additions & 1 deletion payjoin/src/core/receive/v2/error.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use core::fmt;
use std::error;

use bitcoin::absolute::Time;

use super::Error::V2;
use crate::hpke::HpkeError;
use crate::ohttp::{DirectoryResponseError, OhttpEncapsulationError};
Expand All @@ -27,7 +29,7 @@ pub(crate) enum InternalSessionError {
/// Url parsing failed
ParseUrl(crate::into_url::Error),
/// The session has expired
Expired(std::time::SystemTime),
Expired(Time),
/// OHTTP Encapsulation failed
OhttpEncapsulation(OhttpEncapsulationError),
/// Hybrid Public Key Encryption failed
Expand Down
76 changes: 44 additions & 32 deletions payjoin/src/core/receive/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
//! but request reuse makes correlation trivial for the relay.

use std::str::FromStr;
use std::time::{Duration, SystemTime};
use std::time::Duration;

use bitcoin::absolute::Time;
use bitcoin::hashes::{sha256, Hash};
use bitcoin::psbt::Psbt;
use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut};
Expand Down Expand Up @@ -70,7 +71,7 @@ pub struct SessionContext {
directory: url::Url,
mailbox: Option<url::Url>,
ohttp_keys: OhttpKeys,
expiry: SystemTime,
expiry: Time,
amount: Option<Amount>,
s: HpkeKeyPair,
e: Option<HpkePublicKey>,
Expand Down Expand Up @@ -236,7 +237,8 @@ fn extract_err_req(
ohttp_relay: impl IntoUrl,
session_context: &SessionContext,
) -> Result<(Request, ohttp::ClientResponse), SessionError> {
if SystemTime::now() > session_context.expiry {
let now = crate::uri::v2::now();
if now >= session_context.expiry {
return Err(InternalSessionError::Expired(session_context.expiry).into());
}
let mailbox = mailbox_endpoint(&session_context.directory, &session_context.id());
Expand Down Expand Up @@ -278,12 +280,14 @@ impl ReceiverBuilder {
ohttp_keys: OhttpKeys,
) -> Result<Self, IntoUrlError> {
let directory = directory.into_url()?;
let now_seconds = crate::uri::v2::now_as_unix_seconds();
let expiry_seconds = now_seconds + TWENTY_FOUR_HOURS_DEFAULT_EXPIRY.as_secs() as u32;
Comment on lines +283 to +284
Copy link
Collaborator

@nothingmuch nothingmuch Aug 29, 2025

Choose a reason for hiding this comment

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

Since part of the goal of this change is using the typesystem to represent the time values we support more accurately, I think would prefer if instead of a allowing raw u32s for this, you defined a newtype Time(bitcoin::absolute::Time) which supports addition with our own Duration(u32) type, which has TryFrom<std::time::Duration> that forbids subsecond resolution.

Copy link
Contributor

Choose a reason for hiding this comment

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

agree

let session_context = SessionContext {
address,
directory,
ohttp_keys,
s: HpkeKeyPair::gen_keypair(),
expiry: SystemTime::now() + TWENTY_FOUR_HOURS_DEFAULT_EXPIRY,
expiry: Time::from_consensus(expiry_seconds).expect("Valid timestamp"),
amount: None,
mailbox: None,
e: None,
Expand All @@ -292,7 +296,12 @@ impl ReceiverBuilder {
}

pub fn with_expiry(self, expiry: Duration) -> Self {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is not a suggestion, at least not yet, but perhaps this could take an expiry: IntoTime where IntoTime is a sealed trait like IntoUri, and then Duration could convert to Time by being added to now(). Having such a conversion seems unwise for general time handling but i think since the only purpose is specifying timeouts in our API that should be fine. Then the conversion and use of the clock can be feature gated for nostd builds without requiring two different APIs, in std builds you can pass a duration or an absolute time, and in e.g. wasm builds you have to provide an abs time.

@DanGould what do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd rather get this across the line with the simplest type (Time?) in the API instead of Duration and then am open to a follow up with a sealed trait that lets you pass Duration as IntoTime for std builds.

Copy link
Collaborator

Choose a reason for hiding this comment

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

i think that would be fine from a semver PoV as strictly speaking it would only expand the set of types that are allowed to appear in that argument position

Self(SessionContext { expiry: SystemTime::now() + expiry, ..self.0 })
let now_seconds = crate::uri::v2::now_as_unix_seconds();
let expiry_seconds = now_seconds + expiry.as_secs() as u32;
Self(SessionContext {
expiry: Time::from_consensus(expiry_seconds).expect("Valid timestamp"),
..self.0
})
}

pub fn with_amount(self, amount: Amount) -> Self {
Expand Down Expand Up @@ -322,7 +331,8 @@ impl Receiver<Initialized> {
&mut self,
ohttp_relay: impl IntoUrl,
) -> Result<(Request, ohttp::ClientResponse), Error> {
if SystemTime::now() > self.context.expiry {
let now = crate::uri::v2::now();
if self.state.context.expiry <= now {
return Err(InternalSessionError::Expired(self.context.expiry).into());
}
let (body, ohttp_ctx) =
Expand Down Expand Up @@ -442,7 +452,8 @@ impl Receiver<Initialized> {
event: Original,
reply_key: Option<HpkePublicKey>,
) -> Result<ReceiveSession, InternalReplayError> {
if self.state.context.expiry < SystemTime::now() {
let now = crate::uri::v2::now();
if self.state.context.expiry <= now {
// Session is expired, close the session
return Err(InternalReplayError::SessionExpired(self.state.context.expiry));
}
Expand Down Expand Up @@ -1102,19 +1113,23 @@ pub mod test {
use crate::receive::{v2, ReplyableError};
use crate::ImplementationError;

pub(crate) static SHARED_CONTEXT: Lazy<SessionContext> = Lazy::new(|| SessionContext {
address: Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4")
.expect("valid address")
.assume_checked(),
directory: EXAMPLE_URL.clone(),
mailbox: None,
ohttp_keys: OhttpKeys(
ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
),
expiry: SystemTime::now() + Duration::from_secs(60),
s: HpkeKeyPair::gen_keypair(),
e: None,
amount: None,
pub(crate) static SHARED_CONTEXT: Lazy<SessionContext> = Lazy::new(|| {
let now_seconds = crate::uri::v2::now_as_unix_seconds();
let expiry_seconds = now_seconds + 60;
SessionContext {
address: Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4")
.expect("valid address")
.assume_checked(),
directory: EXAMPLE_URL.clone(),
mailbox: None,
ohttp_keys: OhttpKeys(
ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
),
expiry: Time::from_consensus(expiry_seconds).expect("Valid timestamp"),
s: HpkeKeyPair::gen_keypair(),
e: None,
amount: None,
}
});

pub(crate) fn unchecked_proposal_v2_from_test_vector() -> UncheckedProposal {
Expand Down Expand Up @@ -1313,7 +1328,7 @@ pub mod test {

#[test]
fn test_extract_err_req_expiry() -> Result<(), BoxError> {
let now = SystemTime::now();
let now = crate::uri::v2::now();
let noop_persister = NoopSessionPersister::default();
let context = SessionContext { expiry: now, ..SHARED_CONTEXT.clone() };
let receiver = Receiver {
Expand Down Expand Up @@ -1348,7 +1363,7 @@ pub mod test {

#[test]
fn default_expiry() {
let now = SystemTime::now();
let now_seconds = crate::uri::v2::now_as_unix_seconds();
let noop_persister = NoopSessionPersister::default();

let session = ReceiverBuilder::new(
Expand All @@ -1360,17 +1375,16 @@ pub mod test {
.build()
.save(&noop_persister)
.expect("Noop persister shouldn't fail");
let session_expiry = session.context.expiry.duration_since(now).unwrap().as_secs();
let session_expiry_seconds = session.context.expiry.to_consensus_u32();
let default_expiry = Duration::from_secs(86400);
if let Some(expected_expiry) = now.checked_add(default_expiry) {
assert_eq!(TWENTY_FOUR_HOURS_DEFAULT_EXPIRY, default_expiry);
assert_eq!(session_expiry, expected_expiry.duration_since(now).unwrap().as_secs());
}
let expected_expiry_seconds = now_seconds + default_expiry.as_secs() as u32;
assert_eq!(TWENTY_FOUR_HOURS_DEFAULT_EXPIRY, default_expiry);
assert_eq!(session_expiry_seconds, expected_expiry_seconds);
}

#[test]
fn build_receiver_with_non_default_expiry() {
let now = SystemTime::now();
let now_seconds = crate::uri::v2::now_as_unix_seconds();
let expiry = Duration::from_secs(60);
let noop_persister = NoopSessionPersister::default();
let receiver = ReceiverBuilder::new(
Expand All @@ -1383,10 +1397,8 @@ pub mod test {
.build()
.save(&noop_persister)
.expect("Noop persister shouldn't fail");
assert_eq!(
receiver.context.expiry.duration_since(now).unwrap().as_secs(),
expiry.as_secs()
);
let expected_expiry_seconds = now_seconds + expiry.as_secs() as u32;
assert_eq!(receiver.context.expiry.to_consensus_u32(), expected_expiry_seconds);
}

#[test]
Expand Down
7 changes: 3 additions & 4 deletions payjoin/src/core/receive/v2/session.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use std::time::SystemTime;

use bitcoin::absolute::Time;
use serde::{Deserialize, Serialize};

use super::{ReceiveSession, SessionContext};
Expand Down Expand Up @@ -35,7 +34,7 @@ impl From<InternalReplayError> for ReplayError {
#[derive(Debug)]
pub(crate) enum InternalReplayError {
/// Session expired
SessionExpired(SystemTime),
SessionExpired(Time),
/// Invalid combination of state and event
InvalidStateAndEvent(Box<ReceiveSession>, Box<SessionEvent>),
/// Application storage error
Expand Down Expand Up @@ -324,7 +323,7 @@ mod tests {

#[test]
fn test_replaying_unchecked_proposal_expiry() {
let now = SystemTime::now();
let now = crate::uri::v2::now();
let context = SessionContext { expiry: now, ..SHARED_CONTEXT.clone() };
let original = original_from_test_vector();

Expand Down
1 change: 1 addition & 0 deletions payjoin/src/core/send/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ mod tests {
use super::*;

#[test]
#[cfg(feature = "v1")]
fn test_parse_json() {
let known_str_error = r#"{"errorCode":"version-unsupported", "message":"custom message here", "supported": [1, 2]}"#;
match ResponseError::parse(known_str_error) {
Expand Down
4 changes: 3 additions & 1 deletion payjoin/src/core/send/v2/error.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use core::fmt;

use bitcoin::absolute::Time;

use crate::ohttp::DirectoryResponseError;

/// Error returned when request could not be created.
Expand All @@ -15,7 +17,7 @@ pub(crate) enum InternalCreateRequestError {
Url(crate::into_url::Error),
Hpke(crate::hpke::HpkeError),
OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError),
Expired(std::time::SystemTime),
Expired(Time),
}

impl fmt::Display for CreateRequestError {
Expand Down
22 changes: 15 additions & 7 deletions payjoin/src/core/send/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@ impl Sender<WithReplyKey> {
&self,
ohttp_relay: impl IntoUrl,
) -> Result<(Request, V2PostContext), CreateRequestError> {
if std::time::SystemTime::now() > self.pj_param.expiration() {
let now = crate::uri::v2::now();
if now > self.pj_param.expiration() {
return Err(InternalCreateRequestError::Expired(self.pj_param.expiration()).into());
}

Expand Down Expand Up @@ -510,8 +511,8 @@ impl Sender<V2GetContext> {
#[cfg(test)]
mod test {
use std::str::FromStr;
use std::time::{Duration, SystemTime};

use bitcoin::absolute::Time;
use bitcoin::hex::FromHex;
use bitcoin::Address;
use payjoin_test_utils::{BoxError, EXAMPLE_URL, KEM, KEY_ID, PARSED_ORIGINAL_PSBT, SYMMETRIC};
Expand All @@ -524,7 +525,7 @@ mod test {
const SERIALIZED_BODY_V2: &str = "63484e696450384241484d43414141414159386e757447674a647959475857694245623435486f65396c5747626b78682f36624e694f4a6443447544414141414141442b2f2f2f2f41747956754155414141414146366b554865684a38476e536442554f4f7636756a584c72576d734a5244434867495165414141414141415871525233514a62627a30686e513849765130667074476e2b766f746e656f66544141414141414542494b6762317755414141414146366b55336b34656b47484b57524e6241317256357452356b455644564e4348415163584667415578347046636c4e56676f31575741644e3153594e583874706854414243477343527a424541694238512b41366465702b527a393276687932366c5430416a5a6e3450524c6938426639716f422f434d6b30774967502f526a3250575a3367456a556b546c6844524e415130675877544f3774396e2b563134705a366f6c6a554249514d566d7341616f4e5748564d5330324c6654536530653338384c4e697450613155515a794f6968592b464667414241425941464562324769753663344b4f35595730706677336c4770396a4d55554141413d0a763d32";

fn create_sender_context(
expiration: SystemTime,
expiration: Time,
) -> Result<super::Sender<super::WithReplyKey>, BoxError> {
let endpoint = Url::parse("http://localhost:1234")?;
let pj_param = crate::uri::v2::PjParam::new(
Expand Down Expand Up @@ -553,7 +554,9 @@ mod test {

#[test]
fn test_serialize_v2() -> Result<(), BoxError> {
let sender = create_sender_context(SystemTime::now() + Duration::from_secs(60))?;
let expiration = Time::from_consensus(crate::uri::v2::now_as_unix_seconds() + 3600)
.expect("Valid timestamp");
let sender = create_sender_context(expiration)?;
let body = serialize_v2_body(
&sender.psbt_ctx.original_psbt,
sender.psbt_ctx.output_substitution,
Expand All @@ -566,7 +569,9 @@ mod test {

#[test]
fn test_extract_v2_success() -> Result<(), BoxError> {
let sender = create_sender_context(SystemTime::now() + Duration::from_secs(60))?;
let expiration = Time::from_consensus(crate::uri::v2::now_as_unix_seconds() + 3600)
.expect("Valid timestamp");
let sender = create_sender_context(expiration)?;
let ohttp_relay = EXAMPLE_URL.clone();
let result = sender.create_v2_post_request(ohttp_relay);
let (request, context) = result.expect("Result should be ok");
Expand All @@ -581,8 +586,11 @@ mod test {

#[test]
fn test_extract_v2_fails_when_expired() -> Result<(), BoxError> {
let expected_error = "session expired at SystemTime";
let sender = create_sender_context(SystemTime::now() - Duration::from_secs(60))?;
let expected_error = "session expired at Time";
// Create a sender with an already expired timestamp
let expiration = Time::from_consensus(crate::uri::v2::now_as_unix_seconds() - 3600)
.expect("Valid timestamp");
let sender = create_sender_context(expiration)?;
let ohttp_relay = EXAMPLE_URL.clone();
let result = sender.create_v2_post_request(ohttp_relay);
assert!(result.is_err(), "Extract v2 expected expiry error, but it succeeded");
Expand Down
11 changes: 9 additions & 2 deletions payjoin/src/core/send/v2/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,14 @@ pub enum SessionEvent {

#[cfg(test)]
mod tests {
use bitcoin::absolute::Time;
use bitcoin::{FeeRate, ScriptBuf};
use payjoin_test_utils::{KEM, KEY_ID, PARSED_ORIGINAL_PSBT, SYMMETRIC};

use super::*;
use crate::output_substitution::OutputSubstitution;
use crate::persist::test_utils::InMemoryTestPersister;
#[cfg(feature = "v1")]
use crate::send::v1::SenderBuilder;
use crate::send::v2::Sender;
use crate::send::PsbtContext;
Expand All @@ -118,10 +120,12 @@ mod tests {
let keypair = HpkeKeyPair::gen_keypair();
let id = crate::uri::ShortId::try_from(&b"12345670"[..]).expect("valid short id");
let endpoint = url::Url::parse("http://localhost:1234").expect("valid url");
let now_seconds = crate::uri::v2::now_as_unix_seconds();
let expiry = Time::from_consensus(now_seconds + 60).expect("Valid timestamp");
let pj_param = crate::uri::v2::PjParam::new(
endpoint,
id,
std::time::SystemTime::now() + std::time::Duration::from_secs(60),
expiry,
crate::OhttpKeys(
ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
),
Expand Down Expand Up @@ -191,6 +195,7 @@ mod tests {
}

#[test]
#[cfg(feature = "v1")]
fn test_sender_session_history_with_reply_key_event() {
let psbt = PARSED_ORIGINAL_PSBT.clone();
let sender = SenderBuilder::new(
Expand All @@ -207,10 +212,12 @@ mod tests {
let endpoint = sender.endpoint().clone();
let fallback_tx = sender.psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate();
let id = crate::uri::ShortId::try_from(&b"12345670"[..]).expect("valid short id");
let now_seconds = crate::uri::v2::now_as_unix_seconds();
let expiry = Time::from_consensus(now_seconds + 60).expect("Valid timestamp");
let pj_param = crate::uri::v2::PjParam::new(
endpoint,
id,
std::time::SystemTime::now() + std::time::Duration::from_secs(60),
expiry,
crate::OhttpKeys(
ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
),
Expand Down
1 change: 1 addition & 0 deletions payjoin/src/core/uri/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ mod tests {

/// Test that rejects HTTP URLs that are not onion addresses
#[test]
#[cfg(feature = "v1")]
fn test_http_non_onion_rejected() {
// HTTP to regular domain should be rejected
let url = "http://example.com";
Expand Down
Loading