Skip to main content

cairn_mod/auth/
mod.rs

1//! ATProto service-auth verification for moderator-authenticated
2//! endpoints (§5.2).
3//!
4//! Exposes a single high-level entry point, [`AuthContext::verify_service_auth`],
5//! that implements the §5.2 pipeline end-to-end:
6//!
7//! 1. Alg allowlist (ES256K only — rejects `none`, `HS256`, `RS256`,
8//!    `ES256`, `EdDSA`, and everything else).
9//! 2. Structural: JWT has 3 base64url segments, header and payload
10//!    parse, all required claims present.
11//! 3. Signature: resolve the `iss` DID doc (cached), match the
12//!    `#atproto` verification method, decode the `publicKeyMultibase`
13//!    via proto-blue, verify the ES256K signature over the raw
14//!    `header.payload` bytes.
15//! 4. Claims: `aud == config.service_did` exact, `exp` in the future
16//!    within skew, `iat` not more than max_iat_future in the future,
17//!    `lxm == expected_lxm` exact.
18//! 5. Replay: `(iss, jti)` not already in the cache; insert with the
19//!    token's remaining TTL.
20//!
21//! Authorization (step 6 of §5.2 — "iss in moderators table with
22//! required role") is **not** handled here; it lives with the admin
23//! endpoints (#14–17) which own the `moderators` schema. Keeping
24//! `verify_service_auth` pool-free lets it be called without any
25//! storage dependency.
26//!
27//! Error taxonomy: [`AuthError`] has a specific variant per failure
28//! category for internal logging only. At the HTTP boundary, all
29//! variants should map to a single generic `AuthenticationRequired`
30//! response per the §4 non-enumeration principle — per-case detail
31//! must not leak to clients.
32
33pub mod cache;
34pub mod did;
35pub mod jwt;
36pub mod ssrf;
37
38use std::num::NonZeroUsize;
39use std::sync::Arc;
40use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
41
42use proto_blue_crypto::{K256Keypair, Verifier as _, k256_compress_pubkey, parse_multikey};
43
44use cache::{CachedResolve, DidDocCache, JtiCache};
45use did::{DidResolver, HttpDidResolver, ResolveError};
46
47/// Identifier fragment for the moderator repo key in a DID document.
48/// Moderator service-auth JWTs are signed by the key published at
49/// `#atproto`; do NOT confuse with `#atproto_label` which is the
50/// labeler's own signing key (see §5.1).
51const MODERATOR_KEY_FRAGMENT: &str = "#atproto";
52
53/// Multikey types Cairn's verifier accepts. v1 is ES256K only —
54/// rotating this constant would require auditing every caller.
55const ACCEPTED_JWT_ALG: &str = "ES256K";
56
57/// Runtime configuration for the auth layer.
58#[derive(Debug, Clone)]
59pub struct AuthConfig {
60    /// Cairn's own service DID. JWT `aud` must match this exactly.
61    pub service_did: String,
62    /// Base URL of the PLC directory. Default per §5.4.
63    pub plc_directory_url: String,
64    /// HTTP timeout for DID resolution (connect + read).
65    pub resolver_timeout: Duration,
66    /// Positive DID-doc cache TTL (§5.4: 60s).
67    pub positive_cache_ttl: Duration,
68    /// Negative DID-doc cache TTL (§5.4: 5s).
69    pub negative_cache_ttl: Duration,
70    /// Max entries in the DID-doc cache.
71    pub doc_cache_size: NonZeroUsize,
72    /// Max entries in the jti replay cache (§5.2: 100_000).
73    pub jti_cache_size: NonZeroUsize,
74    /// Clock skew tolerance for `exp` (§5.2: ±30s).
75    pub clock_skew: Duration,
76    /// Max amount `iat` may be in the future (§5.2: 30s).
77    pub max_iat_future: Duration,
78}
79
80impl Default for AuthConfig {
81    fn default() -> Self {
82        Self {
83            service_did: String::new(),
84            plc_directory_url: "https://plc.directory".to_string(),
85            resolver_timeout: Duration::from_secs(5),
86            positive_cache_ttl: Duration::from_secs(60),
87            negative_cache_ttl: Duration::from_secs(5),
88            doc_cache_size: NonZeroUsize::new(1024).expect("non-zero"),
89            jti_cache_size: NonZeroUsize::new(100_000).expect("non-zero"),
90            clock_skew: Duration::from_secs(30),
91            max_iat_future: Duration::from_secs(30),
92        }
93    }
94}
95
96/// Successful verification result. `#[non_exhaustive]` so future
97/// additions (e.g., token-issued-at for audit correlation) don't break
98/// downstream matching.
99#[derive(Debug, Clone)]
100#[non_exhaustive]
101pub struct VerifiedCaller {
102    /// The issuing DID (moderator's DID). Used for role lookup and
103    /// audit-log attribution.
104    pub iss: String,
105    /// The `id` of the verification method that signed this token —
106    /// e.g. `did:plc:abc#atproto`. Captured for audit logs that want
107    /// to record "caller X verified via key Y" so a key rotation is
108    /// traceable after the fact.
109    pub key_id: String,
110}
111
112/// Failure categories. Every variant must, at the HTTP boundary, map
113/// to one uniform `AuthenticationRequired` response — the specific
114/// reason is for internal logs only.
115#[derive(Debug, thiserror::Error)]
116pub enum AuthError {
117    /// JWT declared an `alg` not in §5.2's allowlist.
118    #[error("JWT alg not allowed: {0}")]
119    AlgRejected(String),
120
121    /// JWT failed structural parse (header / payload / signature
122    /// sections or claim shape).
123    #[error("JWT structure invalid: {0}")]
124    Structural(#[from] jwt::JwtParseError),
125
126    /// DID resolution (plc.directory or did:web) failed.
127    #[error("resolver error: {0}")]
128    Resolve(#[from] ResolveError),
129
130    /// Caller's DID document has no `#atproto` verification method.
131    #[error("DID doc has no `{MODERATOR_KEY_FRAGMENT}` verification method")]
132    NoVerificationMethod,
133
134    /// Verification method exists but isn't `Multikey` ES256K.
135    #[error("verification method has wrong key type (expected Multikey ES256K)")]
136    WrongKeyType,
137
138    /// Signature did not verify against the resolved pubkey.
139    #[error("signature verification failed")]
140    SignatureInvalid,
141
142    /// One of `aud` / `exp` / `iat` / `lxm` didn't match expected
143    /// values. The inner `&str` names the offending claim.
144    #[error("claim mismatch: {0}")]
145    ClaimMismatch(&'static str),
146
147    /// `jti` for this `iss` was already observed inside the replay
148    /// cache TTL.
149    #[error("replay detected (iss={iss}, jti={jti})")]
150    Replay {
151        /// Caller DID whose token was replayed.
152        iss: String,
153        /// The replayed `jti`.
154        jti: String,
155    },
156
157    /// Cryptographic primitive (hash, signature parse, etc.) failed.
158    #[error("crypto error: {0}")]
159    Crypto(#[from] proto_blue_crypto::CryptoError),
160
161    /// System clock is before the Unix epoch — unreachable on any
162    /// reasonable host.
163    #[error("system clock before unix epoch")]
164    Clock,
165}
166
167/// Shareable auth handle. Construct once at server startup, clone
168/// freely into per-request handlers via axum State/Extension.
169pub struct AuthContext {
170    config: AuthConfig,
171    resolver: Arc<dyn DidResolver>,
172    doc_cache: DidDocCache,
173    jti_cache: JtiCache,
174}
175
176impl AuthContext {
177    /// Construct with an HTTP resolver backed by rustls + SSRF-filtering
178    /// DNS. Prefer [`AuthContext::with_resolver`] in tests.
179    pub fn new(config: AuthConfig) -> Self {
180        let resolver: Arc<dyn DidResolver> = Arc::new(HttpDidResolver::new(
181            config.plc_directory_url.clone(),
182            config.resolver_timeout,
183        ));
184        Self::with_resolver(config, resolver)
185    }
186
187    /// Dependency-inject a resolver. Test code substitutes a
188    /// canned-document resolver; production uses [`AuthContext::new`].
189    pub fn with_resolver(config: AuthConfig, resolver: Arc<dyn DidResolver>) -> Self {
190        let clock: Arc<dyn cache::Clock> = Arc::new(cache::SystemClock);
191        let doc_cache = DidDocCache::new(
192            config.doc_cache_size,
193            config.positive_cache_ttl,
194            config.negative_cache_ttl,
195            clock.clone(),
196        );
197        let jti_cache = JtiCache::new(config.jti_cache_size, clock);
198        Self {
199            config,
200            resolver,
201            doc_cache,
202            jti_cache,
203        }
204    }
205
206    /// Primary entry point. Pipeline matches §5.2 exactly; see module
207    /// docs for the ordering rationale.
208    pub async fn verify_service_auth(
209        &self,
210        token: &str,
211        expected_lxm: &str,
212    ) -> Result<VerifiedCaller, AuthError> {
213        // 1. Alg allowlist. First so a malformed JWT with alg:none
214        // can never reach any downstream step.
215        let parsed = jwt::parse(token)?;
216        if parsed.header.alg != ACCEPTED_JWT_ALG {
217            return Err(AuthError::AlgRejected(parsed.header.alg));
218        }
219
220        // 2. Structural presence is handled by `jwt::parse` — missing
221        // claims surface as JwtParseError::PayloadJson.
222
223        // 3. Signature. Resolve DID doc (cached), match the moderator
224        // key fragment, decode, verify. This happens BEFORE claim
225        // checks to prevent timing-based token scanning (§5.2).
226        let doc = self.resolve_did(&parsed.payload.iss).await?;
227        let vm = doc
228            .find_verification_method(MODERATOR_KEY_FRAGMENT)
229            .ok_or(AuthError::NoVerificationMethod)?;
230        let parsed_key = parse_multikey(&vm.public_key_multibase)?;
231        if parsed_key.jwt_alg != ACCEPTED_JWT_ALG {
232            return Err(AuthError::WrongKeyType);
233        }
234        let compressed = k256_compress_pubkey(&parsed_key.key_bytes)?;
235        let verifier = K256Keypair::verifier_from_compressed(&compressed)?;
236        let sig_ok = verifier.verify(&parsed.signing_input, &parsed.signature)?;
237        if !sig_ok {
238            return Err(AuthError::SignatureInvalid);
239        }
240
241        // 4. Claims.
242        if parsed.payload.aud != self.config.service_did {
243            return Err(AuthError::ClaimMismatch("aud"));
244        }
245        let now = unix_seconds()?;
246        let skew = self.config.clock_skew.as_secs() as i64;
247        if parsed.payload.exp < now.saturating_sub(skew) {
248            return Err(AuthError::ClaimMismatch("exp"));
249        }
250        let max_iat_future = self.config.max_iat_future.as_secs() as i64;
251        if parsed.payload.iat > now.saturating_add(max_iat_future) {
252            return Err(AuthError::ClaimMismatch("iat"));
253        }
254        if parsed.payload.lxm != expected_lxm {
255            return Err(AuthError::ClaimMismatch("lxm"));
256        }
257
258        // 5. Replay. TTL is the token's remaining validity — when the
259        // token expires, the jti can be reused (impossible in practice
260        // because ATProto PDSes never reissue the same jti, but the
261        // cache need not remember it longer than the token is usable).
262        let ttl_secs = (parsed.payload.exp - now).max(0) as u64;
263        let expires_at = Instant::now() + Duration::from_secs(ttl_secs);
264        self.jti_cache
265            .check_and_record(&parsed.payload.iss, &parsed.payload.jti, expires_at)
266            .map_err(|_| AuthError::Replay {
267                iss: parsed.payload.iss.clone(),
268                jti: parsed.payload.jti.clone(),
269            })?;
270
271        Ok(VerifiedCaller {
272            iss: parsed.payload.iss,
273            key_id: vm.id.clone(),
274        })
275    }
276
277    /// Resolve a DID, consulting the cache first. Negative-cache hits
278    /// return a generic `Network` error — the specific underlying
279    /// failure is not preserved because the cache entry is the
280    /// decision, not the diagnosis.
281    async fn resolve_did(&self, did: &str) -> Result<did::DidDocument, AuthError> {
282        if let Some(cached) = self.doc_cache.get(did) {
283            return match cached {
284                CachedResolve::Ok(doc) => Ok(doc),
285                CachedResolve::Err => Err(AuthError::Resolve(ResolveError::Network(
286                    "negatively cached".into(),
287                ))),
288            };
289        }
290        match self.resolver.resolve(did).await {
291            Ok(doc) => {
292                self.doc_cache.insert_ok(did.to_owned(), doc.clone());
293                Ok(doc)
294            }
295            Err(e) => {
296                self.doc_cache.insert_err(did.to_owned());
297                Err(AuthError::Resolve(e))
298            }
299        }
300    }
301}
302
303fn unix_seconds() -> Result<i64, AuthError> {
304    SystemTime::now()
305        .duration_since(UNIX_EPOCH)
306        .map(|d| d.as_secs() as i64)
307        .map_err(|_| AuthError::Clock)
308}