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}