Skip to main content

cheers_axum/
revocation_set.rs

1//! `GET /.well-known/revocation-set.json` — the issuer's current signed
2//! revocation set (R732-F6; noisetable W235 §5.1, C6).
3//!
4//! Offline peers fetch this whenever they are online, adopt it into a
5//! `cheers_verify::ReplicatedRevocations` (which re-verifies the signature and
6//! refuses an older epoch), persist the token, and gossip it to peers that have
7//! not been online since. The route is unauthenticated, like the JWKS: the
8//! token is the trust, not the transport.
9//!
10//! Body:
11//!
12//! ```json
13//! {"issuer":"https://cheers.example","epoch":1791234567,"set":"v4.public.…"}
14//! ```
15//!
16//! `issuer` and `epoch` are copies of what the signed `set` carries, there so a
17//! poller can skip a set it already holds without verifying it. A fetcher must
18//! never adopt on them — only on the verified token. A strong `ETag` makes an
19//! unchanged poll a `304`; `Cache-Control: no-cache` keeps every cache
20//! revalidating, because a stale set is a revocation that has not arrived.
21
22use std::sync::Arc;
23
24use axum::Router;
25use axum::extract::State;
26use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
27use axum::response::Response;
28use axum::routing::get;
29use serde::{Deserialize, Serialize};
30
31use cheers_server::{RevocationPublisher, RevocationWriter};
32
33use crate::error::RouteError;
34use crate::jwks::strong_etag;
35
36/// Where [`router`] mounts the publish route.
37pub const REVOCATION_SET_PATH: &str = "/.well-known/revocation-set.json";
38
39const CACHE_CONTROL_VALUE: &str = "no-cache";
40
41/// The JSON body of [`REVOCATION_SET_PATH`].
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[non_exhaustive]
44pub struct RevocationSetBody {
45    pub issuer: String,
46    pub epoch: u64,
47    /// The signed `RevocationSet` — PASETO v4.public under the issuer key.
48    pub set: String,
49}
50
51/// Mount `GET` [`REVOCATION_SET_PATH`] over `publisher`.
52pub fn router<W>(publisher: Arc<RevocationPublisher<W>>) -> Router
53where
54    W: RevocationWriter + 'static,
55{
56    Router::new()
57        .route(REVOCATION_SET_PATH, get(revocation_set::<W>))
58        .with_state(publisher)
59}
60
61/// `GET` [`REVOCATION_SET_PATH`].
62pub async fn revocation_set<W>(
63    State(publisher): State<Arc<RevocationPublisher<W>>>,
64    headers: HeaderMap,
65) -> Result<Response, RouteError>
66where
67    W: RevocationWriter,
68{
69    let signed = publisher.current(now_unix()).await?;
70    let body = RevocationSetBody {
71        issuer: publisher.issuer().to_owned(),
72        epoch: signed.epoch,
73        set: signed.token,
74    };
75    let body = serde_json::to_vec(&body).map_err(|e| RouteError::Store(e.to_string()))?;
76    let etag = strong_etag(&body);
77    let etag_value = HeaderValue::from_str(&etag).expect("etag bytes are ascii");
78    let cache_control = HeaderValue::from_static(CACHE_CONTROL_VALUE);
79
80    if headers
81        .get(header::IF_NONE_MATCH)
82        .is_some_and(|inm| inm.as_bytes() == etag.as_bytes())
83    {
84        return Ok(Response::builder()
85            .status(StatusCode::NOT_MODIFIED)
86            .header(header::CACHE_CONTROL, cache_control)
87            .header(header::ETAG, etag_value)
88            .body(axum::body::Body::empty())
89            .expect("304 response builds with static headers"));
90    }
91
92    Ok(Response::builder()
93        .status(StatusCode::OK)
94        .header(header::CONTENT_TYPE, "application/json")
95        .header(header::CACHE_CONTROL, cache_control)
96        .header(header::ETAG, etag_value)
97        .body(axum::body::Body::from(body))
98        .expect("revocation-set response builds with static headers"))
99}
100
101fn now_unix() -> i64 {
102    use std::time::{SystemTime, UNIX_EPOCH};
103    SystemTime::now()
104        .duration_since(UNIX_EPOCH)
105        .map(|d| d.as_secs() as i64)
106        .unwrap_or_default()
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use axum::body::Body;
113    use axum::http::Request;
114    use cheers_core::{DeviceId, PrincipalId, Revoked};
115    use cheers_server::{MemoryOwnershipStore, MemoryRevocationStore, OwnershipStore, PasetoV4SecretMinter};
116    use cheers_verify::{AdoptError, IssuerTrust, KeySetVerifier, ReplicatedRevocations, RevocationReader};
117    use http_body_util::BodyExt;
118    use tower::ServiceExt;
119
120    const ISS: &str = "https://cheers.test";
121    const KID: &str = "platform-1";
122
123    fn rig() -> (Router, MemoryRevocationStore, MemoryOwnershipStore, KeySetVerifier) {
124        let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
125        let public: [u8; 32] = verifier.public_key().as_bytes().try_into().unwrap();
126        let store = MemoryRevocationStore::default();
127        let ownership = MemoryOwnershipStore::new();
128        let publisher = RevocationPublisher::new(store.clone(), Arc::new(ownership.clone()), minter, ISS, KID);
129        (
130            router(Arc::new(publisher)),
131            store,
132            ownership,
133            KeySetVerifier::from_issuer_key(KID, &public, ISS),
134        )
135    }
136
137    async fn get(app: &Router, if_none_match: Option<&str>) -> Response {
138        let mut req = Request::builder().method("GET").uri(REVOCATION_SET_PATH);
139        if let Some(etag) = if_none_match {
140            req = req.header(header::IF_NONE_MATCH, etag);
141        }
142        app.clone().oneshot(req.body(Body::empty()).unwrap()).await.unwrap()
143    }
144
145    async fn body(resp: Response) -> RevocationSetBody {
146        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
147        serde_json::from_slice(&bytes).unwrap()
148    }
149
150    #[tokio::test]
151    async fn publishes_a_set_a_replica_adopts_and_queries() {
152        let (app, store, ownership, keys) = rig();
153        store.revoke(&Revoked::jti("j1", None)).await.unwrap();
154        store.revoke(&Revoked::device("phone", 10)).await.unwrap();
155        store
156            .revoke(&Revoked::membership("namespace", "ns-1", PrincipalId::user("alice"), 3))
157            .await
158            .unwrap();
159
160        let resp = get(&app, None).await;
161        assert_eq!(resp.status(), StatusCode::OK);
162        assert_eq!(resp.headers()[header::CONTENT_TYPE], "application/json");
163        assert_eq!(resp.headers()[header::CACHE_CONTROL], CACHE_CONTROL_VALUE);
164        assert!(resp.headers().contains_key(header::ETAG));
165        let published = body(resp).await;
166        assert_eq!(published.issuer, ISS);
167        assert_eq!(published.epoch, store.snapshot().await.unwrap().epoch);
168
169        let replica = ReplicatedRevocations::new(IssuerTrust::key_set(ISS, keys));
170        replica.adopt(&published.set).await.unwrap();
171        assert_eq!(replica.epoch(), Some(published.epoch));
172        assert!(replica.is_revoked("j1").await.unwrap());
173        assert!(replica.is_device_revoked(&DeviceId::new("phone"), 9).await.unwrap());
174        // The public set names the membership only by its tag (R732-T10).
175        let key = ownership.revocation_key("namespace", "ns-1").await.unwrap();
176        assert!(replica
177            .is_membership_revoked(&key, "namespace", "ns-1", &PrincipalId::user("alice"), 2)
178            .await
179            .unwrap());
180    }
181
182    #[tokio::test]
183    async fn unchanged_set_revalidates_to_304_and_a_revoke_moves_the_etag() {
184        let (app, store, _, keys) = rig();
185        let first = get(&app, None).await;
186        let etag = first.headers()[header::ETAG].to_str().unwrap().to_owned();
187        let first = body(first).await;
188
189        let again = get(&app, Some(&etag)).await;
190        assert_eq!(again.status(), StatusCode::NOT_MODIFIED);
191        assert_eq!(again.headers()[header::ETAG], etag.as_str());
192
193        store.revoke(&Revoked::device("phone", 1)).await.unwrap();
194        let moved = get(&app, Some(&etag)).await;
195        assert_eq!(moved.status(), StatusCode::OK);
196        assert_ne!(moved.headers()[header::ETAG], etag.as_str());
197        let second = body(moved).await;
198        assert!(second.epoch > first.epoch);
199
200        // A replica that took the newer set refuses the older one replayed.
201        let replica = ReplicatedRevocations::new(IssuerTrust::key_set(ISS, keys));
202        replica.adopt(&second.set).await.unwrap();
203        assert!(matches!(replica.adopt(&first.set).await, Err(AdoptError::Stale { .. })));
204    }
205}