Skip to content
Open
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
Empty file added FETCH_HEAD
Empty file.
2 changes: 1 addition & 1 deletion payjoin-cli/src/app/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ impl AppTrait for App {
let directory = self.mailroom_manager.choose_directory()?;
match self
.mailroom_manager
.unwrap_ohttp_keys_or_else_fetch_from_directory(&directory)
.unwrap_ohttp_keys_or_else_fetch_from_directory(&directory, self.db.clone())
.await
{
Ok(keys) => break (directory, keys.ohttp_keys),
Expand Down
40 changes: 35 additions & 5 deletions payjoin-cli/src/app/v2/ohttp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
//! excluding them from future selections for the lifetime of the [`MailroomManager`].
//!
//! `unwrap_ohttp_keys_or_else_fetch_from_directory` returns user-supplied keys
//! when present, otherwise selects a relay at random from the configured list
//! (excluding failed relays) to fetch OHTTP keys from the given directory.
//! when present, otherwise returns cached OHTTP keys for the directory when the
//! cache entry is still valid (within three months of being stored).
//!
//! `fetch_ohttp_keys_from_directory` retries on relay failures (e.g. connection
//! errors) by selecting another relay. Once a directory is chosen for a session
Expand All @@ -17,6 +17,7 @@ use anyhow::{anyhow, Result};
use payjoin::Url;

use super::Config;
use crate::db::Database;

#[derive(Debug, Clone)]
pub struct MailroomManager {
Expand Down Expand Up @@ -84,14 +85,43 @@ impl MailroomManager {
pub(crate) async fn unwrap_ohttp_keys_or_else_fetch_from_directory(
&self,
directory: &Url,
db: Arc<Database>,
) -> Result<ValidatedOhttpKeys> {
if let Some(ohttp_keys) = self.config.v2()?.ohttp_keys.clone() {
return Ok(ValidatedOhttpKeys { ohttp_keys });
}
self.fetch_ohttp_keys_from_directory(directory).await
self.fetch_ohttp_keys_from_directory(directory, db, false).await
}

async fn fetch_ohttp_keys_from_directory(&self, directory: &Url) -> Result<ValidatedOhttpKeys> {
async fn fetch_ohttp_keys_from_directory(
&self,
directory: &Url,
db: Arc<Database>,
force_refresh: bool,
) -> Result<ValidatedOhttpKeys> {
let cached = db.get_cached_ohttp_keys(directory.as_str())?;

if force_refresh {
db.invalidate_ohttp_key_cache(directory.as_str())?;
}

if !force_refresh {
if let Some(cached) = cached.as_ref().filter(|c| !c.is_expired()) {
return Ok(ValidatedOhttpKeys { ohttp_keys: cached.keys.clone() });
}
}

let keys = self.fetch_ohttp_keys(directory).await?;
if let Some(cached) = cached.as_ref() {
if keys != cached.keys {
tracing::debug!("OHTTP keys rotated for directory {directory}");
}
}
db.store_ohttp_keys(directory.as_str(), &keys)?;
Ok(ValidatedOhttpKeys { ohttp_keys: keys })
}

async fn fetch_ohttp_keys(&self, directory: &Url) -> Result<payjoin::OhttpKeys> {
loop {
let selected_relay = self.choose_relay()?;

Expand All @@ -116,7 +146,7 @@ impl MailroomManager {
};

match ohttp_keys {
Ok(keys) => return Ok(ValidatedOhttpKeys { ohttp_keys: keys }),
Ok(keys) => return Ok(keys),
Err(payjoin::io::Error::UnexpectedStatusCode(e)) => {
tracing::debug!(
"Directory {directory} returned unexpected status via relay {selected_relay}: {e:?}"
Expand Down
92 changes: 92 additions & 0 deletions payjoin-cli/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ pub(crate) fn now() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
}

/// Approximately three months (90 days).
/// This would be dropped when key-rotation goes into effect.
#[cfg(feature = "v2")]
pub(crate) const OHTTP_KEY_CACHE_TTL_SECS: i64 = 90 * 24 * 60 * 60;

#[cfg(feature = "v2")]
#[derive(Debug, Clone)]
pub(crate) struct CachedOhttpKeys {
pub(crate) keys: payjoin::OhttpKeys,
pub(crate) expires_at: i64,
}

#[cfg(feature = "v2")]
impl CachedOhttpKeys {
pub(crate) fn is_expired(&self) -> bool { now() >= self.expires_at }
}

pub(crate) const DB_PATH: &str = "payjoin.sqlite";

#[derive(Debug)]
Expand Down Expand Up @@ -86,6 +103,27 @@ impl Database {
[],
)?;

conn.execute(
"CREATE TABLE IF NOT EXISTS ohttp_key_cache (
directory_url TEXT PRIMARY KEY,
ohttp_keys BLOB NOT NULL,
expires_at INTEGER NOT NULL
)",
[],
)?;

let has_expires_at: bool = conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('ohttp_key_cache') WHERE name = 'expires_at'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if !has_expires_at {
conn.execute(
"ALTER TABLE ohttp_key_cache ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0",
[],
)?;
}

Ok(())
}

Expand All @@ -104,6 +142,60 @@ impl Database {

Ok(was_seen_before)
}
#[cfg(feature = "v2")]
pub(crate) fn get_cached_ohttp_keys(
&self,
directory_url: &str,
) -> Result<Option<CachedOhttpKeys>> {
let conn = self.get_connection()?;
let result = conn.query_row(
"SELECT ohttp_keys, expires_at FROM ohttp_key_cache WHERE directory_url = ?1",
params![directory_url],
|row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),
);
match result {
Ok((bytes, expires_at)) => {
let keys = payjoin::OhttpKeys::decode(&bytes)
.map_err(|e| {
tracing::error!("Failed to decode ohttp keys: {}", e);
})
.ok();
Ok(keys.map(|keys| CachedOhttpKeys { keys, expires_at }))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(Error::Rusqlite(e)),
}
}

#[cfg(feature = "v2")]
pub(crate) fn store_ohttp_keys(
&self,
directory_url: &str,
keys: &payjoin::OhttpKeys,
) -> Result<()> {
let conn = self.get_connection()?;

let encoded =
keys.encode().map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
let expires_at = now() + OHTTP_KEY_CACHE_TTL_SECS;

conn.execute(
"INSERT OR REPLACE INTO ohttp_key_cache (directory_url, ohttp_keys, expires_at) VALUES (?1, ?2, ?3)",
params![directory_url, encoded, expires_at],
)?;

Ok(())
}

#[cfg(feature = "v2")]
pub(crate) fn invalidate_ohttp_key_cache(&self, directory_url: &str) -> Result<()> {
let conn = self.get_connection()?;
conn.execute(
"DELETE FROM ohttp_key_cache WHERE directory_url = ?1",
params![directory_url],
)?;
Ok(())
}
}

#[cfg(feature = "v2")]
Expand Down
Loading