Skip to content

Commit aa33a74

Browse files
committed
OHTTP keys should be rotated
This pr addresses #445. It implements OHTTP-key rotation to payjoin-mailroom Mailroom operators can now decide the time interval for keys to be rotated. Also if a key has expired, a 422 error is returned to clients. Clients can handle they key-rotation via the cach-control header returned by the directory.
1 parent 388e803 commit aa33a74

File tree

5 files changed

+339
-53
lines changed

5 files changed

+339
-53
lines changed

payjoin-mailroom/src/config.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ pub struct Config {
1212
pub storage_dir: PathBuf,
1313
#[serde(deserialize_with = "deserialize_duration_secs")]
1414
pub timeout: Duration,
15+
#[serde(deserialize_with = "deserialize_optional_duration_secs")]
16+
pub ohttp_keys_max_age: Option<Duration>,
1517
pub v1: Option<V1Config>,
1618
#[cfg(feature = "telemetry")]
1719
pub telemetry: Option<TelemetryConfig>,
@@ -85,6 +87,7 @@ impl Default for Config {
8587
listener: "[::]:8080".parse().expect("valid default listener address"),
8688
storage_dir: PathBuf::from("./data"),
8789
timeout: Duration::from_secs(30),
90+
ohttp_keys_max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)),
8891
v1: None,
8992
#[cfg(feature = "telemetry")]
9093
telemetry: None,
@@ -104,17 +107,33 @@ where
104107
Ok(Duration::from_secs(secs))
105108
}
106109

110+
fn deserialize_optional_duration_secs<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
111+
where
112+
D: serde::Deserializer<'de>,
113+
{
114+
let secs: Option<u64> = Option::deserialize(deserializer)?;
115+
match secs {
116+
None => Ok(None),
117+
Some(0) => Err(<D::Error as serde::de::Error>::custom(
118+
"ohttp_keys_max_age must be greater than 0 seconds when set",
119+
)),
120+
Some(s) => Ok(Some(Duration::from_secs(s))),
121+
}
122+
}
123+
107124
impl Config {
108125
pub fn new(
109126
listener: ListenerAddress,
110127
storage_dir: PathBuf,
111128
timeout: Duration,
129+
ohttp_keys_max_age: Option<Duration>,
112130
v1: Option<V1Config>,
113131
) -> Self {
114132
Self {
115133
listener,
116134
storage_dir,
117135
timeout,
136+
ohttp_keys_max_age,
118137
v1,
119138
#[cfg(feature = "telemetry")]
120139
telemetry: None,

payjoin-mailroom/src/directory.rs

Lines changed: 154 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
use std::path::PathBuf;
12
use std::pin::Pin;
23
use std::str::FromStr;
34
use std::sync::Arc;
45
use std::task::{Context, Poll};
6+
use std::time::{Duration, Instant};
57

68
use anyhow::Result;
79
use axum::body::{Body, Bytes};
8-
use axum::http::header::{HeaderValue, ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE};
10+
use axum::http::header::{HeaderValue, ACCESS_CONTROL_ALLOW_ORIGIN, CACHE_CONTROL, CONTENT_TYPE};
911
use axum::http::{Method, Request, Response, StatusCode, Uri};
1012
use http_body_util::BodyExt;
1113
use payjoin::directory::{ShortId, ShortIdError, ENCAPSULATED_MESSAGE_BYTES};
14+
use tokio::sync::RwLock;
1215
use tracing::{debug, error, trace, warn};
1316

1417
use crate::db::{Db, Error as DbError, SendableError};
@@ -28,6 +31,90 @@ const V1_VERSION_UNSUPPORTED_RES_JSON: &str =
2831

2932
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
3033

34+
/// Two-slot OHTTP key set supporting rotation overlap.
35+
///
36+
/// Key IDs alternate between 1 and 2. The current key is served to new
37+
/// clients; both slots are accepted for decapsulation so that clients
38+
/// with a cached previous key still work during the overlap window.
39+
#[derive(Debug)]
40+
pub struct KeyRotatingServer {
41+
keys: [Option<ohttp::Server>; 2],
42+
current_key_id: u8,
43+
current_key_created_at: Instant,
44+
}
45+
46+
impl KeyRotatingServer {
47+
pub fn from_single(server: ohttp::Server, key_id: u8) -> Self {
48+
assert!(key_id == 1 || key_id == 2, "key_id must be 1 or 2");
49+
let mut keys = [None, None];
50+
keys[(key_id - 1) as usize] = Some(server);
51+
Self { current_key_id: key_id, keys, current_key_created_at: Instant::now() }
52+
}
53+
54+
pub fn from_pair(
55+
current: (u8, ohttp::Server),
56+
previous: Option<(u8, ohttp::Server)>,
57+
current_key_age: Duration,
58+
) -> Self {
59+
assert!(current.0 == 1 || current.0 == 2, "key_id must be 1 or 2");
60+
let mut keys = [None, None];
61+
keys[(current.0 - 1) as usize] = Some(current.1);
62+
if let Some((id, server)) = previous {
63+
assert!(id == 1 || id == 2, "key_id must be 1 or 2");
64+
keys[(id - 1) as usize] = Some(server);
65+
}
66+
let created_at = Instant::now().checked_sub(current_key_age).unwrap_or_else(Instant::now);
67+
Self { current_key_id: current.0, keys, current_key_created_at: created_at }
68+
}
69+
70+
pub fn current_key_id(&self) -> u8 { self.current_key_id }
71+
pub fn current_key_created_at(&self) -> Instant { self.current_key_created_at }
72+
pub fn next_key_id(&self) -> u8 {
73+
if self.current_key_id == 1 {
74+
2
75+
} else {
76+
1
77+
}
78+
}
79+
80+
/// Look up the server matching the key_id in an OHTTP message and
81+
/// decapsulate. The first byte of an OHTTP encapsulated request is the
82+
/// key identifier (RFC 9458 Section 4.3).
83+
pub fn decapsulate(
84+
&self,
85+
ohttp_body: &[u8],
86+
) -> std::result::Result<(Vec<u8>, ohttp::ServerResponse), ohttp::Error> {
87+
let key_id = ohttp_body.first().copied().unwrap_or(0);
88+
let server = key_id
89+
.checked_sub(1)
90+
.filter(|&i| (i as usize) < 2)
91+
.and_then(|i| self.keys[i as usize].as_ref());
92+
match server {
93+
Some(s) => s.decapsulate(ohttp_body),
94+
None => Err(ohttp::Error::Truncated),
95+
}
96+
}
97+
98+
/// Encode the current key's config for serving to clients.
99+
pub fn encode_current(&self) -> std::result::Result<Vec<u8>, ohttp::Error> {
100+
self.keys[(self.current_key_id - 1) as usize]
101+
.as_ref()
102+
.expect("current key must exist")
103+
.config()
104+
.encode()
105+
}
106+
107+
/// Install a new key as current, displacing whatever occupied that slot.
108+
///
109+
/// The old current key remains in its slot for overlap decapsulation.
110+
pub fn rotate(&mut self, server: ohttp::Server) {
111+
let new_key_id = self.next_key_id();
112+
self.keys[(new_key_id - 1) as usize] = Some(server);
113+
self.current_key_id = new_key_id;
114+
self.current_key_created_at = Instant::now();
115+
}
116+
}
117+
31118
/// Opaque blocklist of Bitcoin addresses stored as script pubkeys.
32119
///
33120
/// Addresses are converted to `ScriptBuf` at parse time so that
@@ -91,7 +178,9 @@ fn parse_address_lines(text: &str) -> std::collections::HashSet<bitcoin::ScriptB
91178
#[derive(Clone)]
92179
pub struct Service<D: Db> {
93180
db: D,
94-
ohttp: ohttp::Server,
181+
ohttp: Arc<RwLock<KeyRotatingServer>>,
182+
ohttp_keys_max_age: Option<Duration>,
183+
ohttp_keys_dir: Option<PathBuf>,
95184
sentinel_tag: SentinelTag,
96185
v1: Option<V1>,
97186
}
@@ -117,8 +206,15 @@ where
117206
}
118207

119208
impl<D: Db> Service<D> {
120-
pub fn new(db: D, ohttp: ohttp::Server, sentinel_tag: SentinelTag, v1: Option<V1>) -> Self {
121-
Self { db, ohttp, sentinel_tag, v1 }
209+
pub fn new(
210+
db: D,
211+
ohttp: Arc<RwLock<KeyRotatingServer>>,
212+
ohttp_keys_max_age: Option<Duration>,
213+
ohttp_keys_dir: Option<PathBuf>,
214+
sentinel_tag: SentinelTag,
215+
v1: Option<V1>,
216+
) -> Self {
217+
Self { db, ohttp, ohttp_keys_max_age, ohttp_keys_dir, sentinel_tag, v1 }
122218
}
123219

124220
async fn serve_request<B>(&self, req: Request<B>) -> Result<Response<Body>>
@@ -200,9 +296,9 @@ impl<D: Db> Service<D> {
200296
.map_err(|e| HandlerError::BadRequest(anyhow::anyhow!(e.into())))?
201297
.to_bytes();
202298

203-
// Decapsulate OHTTP request
204-
let (bhttp_req, res_ctx) = self
205-
.ohttp
299+
// Decapsulate OHTTP request using the key matching the message's key_id
300+
let keyset = self.ohttp.read().await;
301+
let (bhttp_req, res_ctx) = keyset
206302
.decapsulate(&ohttp_body)
207303
.map_err(|e| HandlerError::OhttpKeyRejection(e.into()))?;
208304
let mut cursor = std::io::Cursor::new(bhttp_req);
@@ -377,14 +473,52 @@ impl<D: Db> Service<D> {
377473
}
378474
}
379475

476+
async fn maybe_rotate_keys(&self) -> Result<(), HandlerError> {
477+
let max_age = match self.ohttp_keys_max_age {
478+
Some(m) => m,
479+
None => return Ok(()),
480+
};
481+
if self.ohttp.read().await.current_key_created_at().elapsed() < max_age {
482+
return Ok(());
483+
}
484+
let mut keyset = self.ohttp.write().await;
485+
if keyset.current_key_created_at().elapsed() < max_age {
486+
return Ok(());
487+
}
488+
let new_key_id = keyset.next_key_id();
489+
if let Some(dir) = &self.ohttp_keys_dir {
490+
let _ = tokio::fs::remove_file(dir.join(format!("{new_key_id}.ikm"))).await;
491+
}
492+
let config = crate::key_config::gen_ohttp_server_config_with_id(new_key_id)
493+
.map_err(HandlerError::InternalServerError)?;
494+
if let Some(dir) = &self.ohttp_keys_dir {
495+
crate::key_config::persist_key_config(&config, dir)
496+
.map_err(HandlerError::InternalServerError)?;
497+
}
498+
let old_key_id = keyset.current_key_id();
499+
keyset.rotate(config.into_server());
500+
tracing::info!("Rotated OHTTP keys: key_id {old_key_id} -> {new_key_id}");
501+
Ok(())
502+
}
503+
380504
async fn get_ohttp_keys(&self) -> Result<Response<Body>, HandlerError> {
381-
let ohttp_keys = self
382-
.ohttp
383-
.config()
384-
.encode()
385-
.map_err(|e| HandlerError::InternalServerError(e.into()))?;
505+
self.maybe_rotate_keys().await?;
506+
let keyset = self.ohttp.read().await;
507+
let ohttp_keys =
508+
keyset.encode_current().map_err(|e| HandlerError::InternalServerError(e.into()))?;
386509
let mut res = Response::new(full(ohttp_keys));
387510
res.headers_mut().insert(CONTENT_TYPE, HeaderValue::from_static("application/ohttp-keys"));
511+
if let Some(max_age) = self.ohttp_keys_max_age {
512+
let remaining = max_age.saturating_sub(keyset.current_key_created_at().elapsed());
513+
res.headers_mut().insert(
514+
CACHE_CONTROL,
515+
HeaderValue::from_str(&format!(
516+
"public, s-maxage={}, immutable",
517+
remaining.as_secs()
518+
))
519+
.expect("valid header value"),
520+
);
521+
}
388522
Ok(res)
389523
}
390524

@@ -485,8 +619,8 @@ impl HandlerError {
485619
}
486620
HandlerError::OhttpKeyRejection(e) => {
487621
const OHTTP_KEY_REJECTION_RES_JSON: &str = r#"{"type":"https://iana.org/assignments/http-problem-types#ohttp-key", "title": "key identifier unknown"}"#;
488-
warn!("Bad request: Key configuration rejected: {}", e);
489-
*res.status_mut() = StatusCode::BAD_REQUEST;
622+
warn!("Key configuration rejected: {}", e);
623+
*res.status_mut() = StatusCode::UNPROCESSABLE_ENTITY;
490624
res.headers_mut()
491625
.insert(CONTENT_TYPE, HeaderValue::from_static("application/problem+json"));
492626
*res.body_mut() = full(OHTTP_KEY_REJECTION_RES_JSON);
@@ -592,9 +726,9 @@ mod tests {
592726
async fn test_service(v1: Option<V1>) -> Service<FilesDb> {
593727
let dir = tempfile::tempdir().expect("tempdir");
594728
let db = FilesDb::init(Duration::from_millis(100), dir.keep()).await.expect("db init");
595-
let ohttp: ohttp::Server =
596-
crate::key_config::gen_ohttp_server_config().expect("ohttp config").into();
597-
Service::new(db, ohttp, SentinelTag::new([0u8; 32]), v1)
729+
let config = crate::key_config::gen_ohttp_server_config().expect("ohttp config");
730+
let keyset = Arc::new(RwLock::new(KeyRotatingServer::from_single(config.into_server(), 1)));
731+
Service::new(db, keyset, None, None, SentinelTag::new([0u8; 32]), v1)
598732
}
599733

600734
/// A valid ShortId encoded as bech32 for use in URL paths.
@@ -826,9 +960,9 @@ mod tests {
826960
let dir = tempfile::tempdir().expect("tempdir");
827961
let db = FilesDb::init(Duration::from_millis(100), dir.keep()).await.expect("db init");
828962
let db = MetricsDb::new(db, metrics);
829-
let ohttp: ohttp::Server =
830-
crate::key_config::gen_ohttp_server_config().expect("ohttp config").into();
831-
let svc = Service::new(db, ohttp, SentinelTag::new([0u8; 32]), None);
963+
let config = crate::key_config::gen_ohttp_server_config().expect("ohttp config");
964+
let keyset = Arc::new(RwLock::new(KeyRotatingServer::from_single(config.into_server(), 1)));
965+
let svc = Service::new(db, keyset, None, None, SentinelTag::new([0u8; 32]), None);
832966

833967
let id = valid_short_id_path();
834968
let res = svc

0 commit comments

Comments
 (0)