solid_pod_rs/auth/replay_store.rs
1//! The `ReplayStore` seam (ADR-060 Decision 2).
2//!
3//! A NIP-98 `Authorization` token is a signed, time-boxed, one-shot
4//! credential. The structural verifier in [`crate::auth::nip98`] proves a token is
5//! well-formed, in-window and correctly signed, but holds no state — so a
6//! captured token can be **replayed** for the whole tolerance window. Closing
7//! that window needs a single-use nonce store keyed on the canonical NIP-01
8//! event id. `trait ReplayStore` is that store's contract, extracted from the
9//! process-local reference implementation
10//! ([`crate::auth::replay::Nip98ReplayCache`]) so the nonce semantics are named once
11//! and reused rather than re-derived per tier.
12//!
13//! # Why the trait lives here, not inside the reference cache
14//!
15//! The reference cache pulls `lru` + the tokio runtime (async mutex) behind
16//! the `nip98-replay` feature. This module carries neither: the trait and its
17//! error are compiled on every build of the crate (including the pure-logic
18//! `core` surface the wasm32 / CF-Workers consumers use, per ADR-076/078). A
19//! tier that cannot afford the in-process LRU — a forum/edge deployment
20//! backed by a shared KV or Redis store — can therefore depend on this
21//! crate's nonce contract and supply its own implementor, instead of
22//! re-deriving replay handling and reintroducing the duplication the NIP-98
23//! consolidation exists to remove.
24//!
25//! # Edge-local exception (ADR-060 Decision 2)
26//!
27//! In THIS repository every tier consumes the trait: the native
28//! single-process server drives [`crate::auth::replay::Nip98ReplayCache`] through
29//! it. The out-of-repo forum/CF tier keeps a different datastore
30//! ([`crate::auth::replay`] "Tier persistence limit") and lives in a sibling
31//! repository, so it cannot be consolidated or verified from here. It is the
32//! documented **edge-local exception**: it would implement `ReplayStore`
33//! against its own store if and when it is wired, and only then does the
34//! cross-tier seam reach `integrated`. Until a second tier consumes the
35//! trait, the seam is `standalone` — a published contract with one reference
36//! implementor — and the per-replica replay-window caveat
37//! ([`crate::auth::replay`]) holds, so no cross-ecosystem claim may assume one
38//! shared verification boundary.
39
40use std::time::Duration;
41
42use async_trait::async_trait;
43use thiserror::Error;
44
45/// Error returned by [`ReplayStore::check_and_record`] when a credential id is
46/// re-presented inside its replay window. Fail closed: treat the request as
47/// unauthenticated.
48#[derive(Debug, Error)]
49pub enum ReplayError {
50 /// The event id was already recorded within the TTL window — a client
51 /// re-presented a NIP-98 token.
52 #[error("NIP-98 token already used within replay window ({ttl:?})")]
53 Replayed { ttl: Duration },
54}
55
56/// Single-use nonce store for one-shot credentials.
57///
58/// An implementor remembers a credential id (the canonical NIP-01 event id
59/// for NIP-98) for a bounded window and rejects a second sighting of the same
60/// id inside that window. [`crate::auth::replay::Nip98ReplayCache`] is the reference
61/// implementor: a process-local bounded LRU with per-entry TTL. Other tiers
62/// (a KV/Redis-backed edge tier) implement the same contract over shared
63/// storage.
64///
65/// The `Send + Sync` bound lets a single store instance be shared across every
66/// request path of an async server.
67#[async_trait]
68pub trait ReplayStore: Send + Sync {
69 /// Check whether `event_id` has been seen within the store's window; if
70 /// not, record it and return `Ok(())`. If already seen within the window,
71 /// return [`ReplayError::Replayed`] without refreshing the entry (so a
72 /// flood of replays cannot pin an id past its natural expiry).
73 async fn check_and_record(&self, event_id: &str) -> Result<(), ReplayError>;
74}