diff --git a/FETCH_HEAD b/FETCH_HEAD new file mode 100644 index 000000000..e69de29bb diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 43c74f0c1..6ea403b09 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -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), diff --git a/payjoin-cli/src/app/v2/ohttp.rs b/payjoin-cli/src/app/v2/ohttp.rs index e438540ec..b31b08240 100644 --- a/payjoin-cli/src/app/v2/ohttp.rs +++ b/payjoin-cli/src/app/v2/ohttp.rs @@ -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 @@ -17,6 +17,7 @@ use anyhow::{anyhow, Result}; use payjoin::Url; use super::Config; +use crate::db::Database; #[derive(Debug, Clone)] pub struct MailroomManager { @@ -84,14 +85,43 @@ impl MailroomManager { pub(crate) async fn unwrap_ohttp_keys_or_else_fetch_from_directory( &self, directory: &Url, + db: Arc, ) -> Result { 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 { + async fn fetch_ohttp_keys_from_directory( + &self, + directory: &Url, + db: Arc, + force_refresh: bool, + ) -> Result { + 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 { loop { let selected_relay = self.choose_relay()?; @@ -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:?}" diff --git a/payjoin-cli/src/db/mod.rs b/payjoin-cli/src/db/mod.rs index f01725301..e1cf94331 100644 --- a/payjoin-cli/src/db/mod.rs +++ b/payjoin-cli/src/db/mod.rs @@ -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)] @@ -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(()) } @@ -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> { + 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>(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")]