codlet_core/auth/code.rs
1//! Code authentication manager (RFC-013).
2//!
3//! [`CodeAuth`] composes the primitives from `code`, `hashing`, `rng`,
4//! `store`, `audit`, and `clock` into the safe redemption flow described in
5//! RFC-013 §10.3:
6//!
7//! 1. rate-limit check;
8//! 2. input normalization + validation;
9//! 3. code lookup (`find_redeemable`);
10//! 4. atomic claim (`claim_code`);
11//! 5. host callback (creates / resolves subject);
12//! 6. audit event;
13//! 7. return [`RedeemSuccess`].
14//!
15//! Steps 1–3 can fail without consuming the code. Only step 4 is
16//! irreversible. Session issuance requires the [`RedeemSuccess`] proof, which
17//! is only constructible when the claim returns `Won`.
18
19use std::future::Future;
20
21use crate::audit::{AuditSink, CodeAuthEvent};
22use crate::clock::Clock;
23use crate::code::{CodePolicy, validate_code_input};
24use crate::error::PublicRedemptionError;
25use crate::error::RedemptionFailReason;
26use crate::hashing::{KeyProvider, SecretDomain, SecretHasher};
27use crate::secret::{CodeId, SubjectId};
28use crate::store::code::{
29 ClaimRequest, CodeRecord, CodeStore, RedeemableCode, expires_at_from_ttl,
30};
31use crate::store::ratelimit::{RateLimitKey, RateLimitOutcome, RateLimitPolicy, RateLimitStore};
32
33use super::error::{ClaimProof, RedeemError, RedeemSuccess};
34
35/// Manages one-time code issuance, validation, and redemption (RFC-013 §3).
36///
37/// Generic over:
38/// - `CS` — the [`CodeStore`] backend;
39/// - `RL` — the [`RateLimitStore`] backend (use `()` to opt out);
40/// - `K` — the [`KeyProvider`];
41/// - `C` — the [`Clock`];
42/// - `A` — the [`AuditSink`].
43pub struct CodeAuth<CS, RL, K, C, A> {
44 store: CS,
45 rate_limit_store: RL,
46 hasher: SecretHasher<K>,
47 clock: C,
48 audit: A,
49 policy: CodePolicy,
50 rate_limit_policy: Option<RateLimitPolicy>,
51}
52
53impl<CS, RL, K, C, A> CodeAuth<CS, RL, K, C, A>
54where
55 CS: CodeStore,
56 RL: RateLimitStore,
57 K: KeyProvider,
58 C: Clock,
59 A: AuditSink,
60{
61 /// Construct a `CodeAuth` with a rate-limit store and policy.
62 #[must_use]
63 pub fn new(
64 store: CS,
65 rate_limit_store: RL,
66 hasher: SecretHasher<K>,
67 clock: C,
68 audit: A,
69 policy: CodePolicy,
70 rate_limit_policy: RateLimitPolicy,
71 ) -> Self {
72 Self {
73 store,
74 rate_limit_store,
75 hasher,
76 clock,
77 audit,
78 policy,
79 rate_limit_policy: Some(rate_limit_policy),
80 }
81 }
82
83 // ── Issue ────────────────────────────────────────────────────────────────
84
85 /// Issue a new one-time code and insert it into the store.
86 ///
87 /// Returns the [`CodeId`] (for audit/admin) and the plaintext code (for
88 /// delivery to the recipient). The plaintext must not be logged or stored.
89 ///
90 /// `rng` must be a fresh CSPRNG; `ttl` overrides the policy TTL if needed.
91 /// `scope` and `grant` are host-owned and not interpreted by codlet.
92 ///
93 /// # Errors
94 /// Returns [`RedeemError::Internal`] if the RNG or store fails.
95 pub async fn issue_code<R: crate::rng::RandomSource>(
96 &self,
97 rng: &mut R,
98 id: CodeId,
99 purpose: Option<String>,
100 scope: Option<String>,
101 grant: Option<String>,
102 ) -> Result<(CodeId, crate::secret::PlainCode), RedeemError> {
103 let plain =
104 crate::code::generate_code(&self.policy, rng).map_err(|e| RedeemError::Internal {
105 cause: format!("rng: {e}"),
106 public: PublicRedemptionError::TemporarilyUnavailable,
107 })?;
108
109 let normalized = plain.expose().to_string(); // already in canonical form
110 let (lookup_key, key_version) = self
111 .hasher
112 .lookup_key(SecretDomain::Code, &normalized)
113 .map_err(RedeemError::from_key)?;
114
115 let now = self.clock.unix_now();
116 let expires_at = expires_at_from_ttl(now, self.policy.ttl());
117
118 let record = CodeRecord {
119 id: id.clone(),
120 lookup_key,
121 key_version,
122 purpose,
123 scope,
124 grant,
125 created_at: now,
126 expires_at,
127 };
128 self.store
129 .insert_code(record)
130 .await
131 .map_err(RedeemError::from_store)?;
132
133 self.audit.record(CodeAuthEvent::CodeIssued {
134 code_id: id.clone(),
135 purpose: None,
136 });
137
138 Ok((id, plain))
139 }
140
141 // ── Two-step redemption ──────────────────────────────────────────────────
142
143 /// Step 1: validate and look up a submitted code without claiming it.
144 ///
145 /// Returns a [`RedeemableCode`] that the caller can inspect (e.g. to
146 /// display a confirmation or collect additional user input) before
147 /// committing the claim in [`Self::claim`].
148 ///
149 /// Rate limiting is applied here if configured.
150 ///
151 /// # Errors
152 /// Returns [`RedeemError`] on validation failure, rate limit, or lookup miss.
153 pub async fn find(
154 &self,
155 raw_input: &str,
156 rate_key: Option<&RateLimitKey>,
157 ) -> Result<RedeemableCode, RedeemError> {
158 // Step 1: rate-limit check. Honour unavailable policy on store error.
159 if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
160 match self.rate_limit_store.check(key, rl_policy).await {
161 Ok(RateLimitOutcome::Deny) => {
162 self.audit.record(CodeAuthEvent::RateLimitHit {
163 key_fingerprint: key.fingerprint().to_string(),
164 purpose: None,
165 });
166 return Err(RedeemError::RateLimited {
167 public: PublicRedemptionError::RateLimited,
168 });
169 }
170 Ok(RateLimitOutcome::Allow) => {}
171 Err(_) => {
172 // Rate-limit store unavailable: apply configured policy.
173 match rl_policy.unavailable {
174 crate::store::ratelimit::RateLimitUnavailable::FailClosed => {
175 self.audit.record(CodeAuthEvent::RateLimitHit {
176 key_fingerprint: key.fingerprint().to_string(),
177 purpose: None,
178 });
179 return Err(RedeemError::RateLimited {
180 public: PublicRedemptionError::RateLimited,
181 });
182 }
183 crate::store::ratelimit::RateLimitUnavailable::FailOpen => {}
184 }
185 }
186 }
187 }
188
189 // Step 2: input normalization + validation.
190 let normalized = match validate_code_input(raw_input, &self.policy) {
191 Ok(n) => n,
192 Err(_) => {
193 self.audit.record(CodeAuthEvent::RedemptionFailed {
194 reason: RedemptionFailReason::InvalidFormat,
195 });
196 // Invalid-format guesses count toward the rate limit (RFC-B).
197 if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
198 let _ = self.rate_limit_store.record_failure(key, rl_policy).await;
199 }
200 return Err(RedeemError::InvalidInput {
201 reason: RedemptionFailReason::InvalidFormat,
202 public: PublicRedemptionError::from_reason(
203 &RedemptionFailReason::InvalidFormat,
204 ),
205 });
206 }
207 };
208
209 // Step 3: derive one candidate per held key (RFC-A) and find the record.
210 let candidates: Vec<_> = self
211 .hasher
212 .lookup_key_candidates(SecretDomain::Code, &normalized)
213 .map_err(RedeemError::from_key)?
214 .into_iter()
215 .map(|(lk, _)| lk)
216 .collect();
217
218 let now = self.clock.unix_now();
219 let record = self
220 .store
221 .find_redeemable(&candidates, now, None)
222 .await
223 .map_err(RedeemError::from_store)?
224 .ok_or_else(|| {
225 self.audit.record(CodeAuthEvent::RedemptionFailed {
226 reason: RedemptionFailReason::NotFound,
227 });
228 RedeemError::NotRedeemable {
229 reason: RedemptionFailReason::NotFound,
230 public: PublicRedemptionError::InvalidOrExpired,
231 }
232 });
233
234 // Not-found guesses count toward the rate limit (RFC-B).
235 if record.is_err() {
236 if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
237 let _ = self.rate_limit_store.record_failure(key, rl_policy).await;
238 }
239 }
240 let record = record?;
241
242 Ok(record)
243 }
244
245 /// Step 2: atomically claim a [`RedeemableCode`] found by [`Self::find`].
246 ///
247 /// Returns a [`RedeemSuccess`] proof only if `claim_code` returns `Won`.
248 /// A `Lost` result means a concurrent caller already claimed the code.
249 ///
250 /// Rate-limit failures are recorded on a failed claim, and cleared on a
251 /// successful one, when a `rate_key` is provided.
252 ///
253 /// # Errors
254 /// Returns [`RedeemError::ClaimLost`] if the atomic claim was lost, or
255 /// [`RedeemError::Internal`] on store failure.
256 pub async fn claim(
257 &self,
258 record: &RedeemableCode,
259 subject: SubjectId,
260 rate_key: Option<&RateLimitKey>,
261 ) -> Result<RedeemSuccess, RedeemError> {
262 let now = self.clock.unix_now();
263 let outcome = self
264 .store
265 .claim_code(&ClaimRequest {
266 code_id: &record.id,
267 subject: &subject,
268 now,
269 // Pass purpose/scope from the found record so adapters can
270 // enforce cross-flow isolation in the UPDATE WHERE (RFC-C).
271 purpose: record.purpose.as_deref(),
272 scope: record.scope.as_deref(),
273 })
274 .await
275 .map_err(RedeemError::from_store)?;
276
277 match ClaimProof::new(outcome) {
278 Some(proof) => {
279 // Clear rate-limit counter on success.
280 if let Some(key) = rate_key {
281 if self.rate_limit_policy.is_some() {
282 let _ = self.rate_limit_store.clear_failures(key).await;
283 }
284 }
285 self.audit.record(CodeAuthEvent::CodeRedeemed {
286 code_id: record.id.clone(),
287 subject_id: subject.clone(),
288 });
289 Ok(RedeemSuccess {
290 subject,
291 grant: record.grant.clone(),
292 _claim_proof: proof,
293 })
294 }
295 None => {
296 // Record failure in rate limiter for a lost claim too.
297 if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
298 let _ = self.rate_limit_store.record_failure(key, rl_policy).await;
299 }
300 self.audit.record(CodeAuthEvent::RedemptionFailed {
301 reason: RedemptionFailReason::AlreadyUsed,
302 });
303 Err(RedeemError::ClaimLost {
304 public: PublicRedemptionError::InvalidOrExpired,
305 })
306 }
307 }
308 }
309
310 // ── Single-call callback flow (RFC-013 §4) ───────────────────────────────
311
312 /// Validate, look up, and claim a code in one call, invoking `on_won` as
313 /// the host callback that creates or resolves the subject.
314 ///
315 /// Enforces RFC-013 §10.3 step order. `on_won` is called only after a
316 /// confirmed won claim; its error aborts the flow without a session.
317 ///
318 /// # Errors
319 /// Returns [`RedeemError`] on any failure. If `on_won` fails, returns
320 /// [`RedeemError::Internal`] and the claim is already consumed (the host
321 /// must decide on compensation if needed — RFC-013 §5).
322 ///
323 /// # Production warning
324 ///
325 /// **Experimental (RFC-D).** This method claims the code before the host
326 /// callback returns the real subject, leaving `used_by_subject = "__pending__"`
327 /// in the database until the callback completes. If the callback fails, the
328 /// code is permanently consumed with no subject recorded, and the audit event
329 /// and database state disagree on who claimed it.
330 ///
331 /// For production audit-sensitive deployments, use the explicit two-step
332 /// flow: [`Self::find`] → host creates/resolves subject → [`Self::claim`].
333 #[deprecated(
334 note = "experimental: DB and audit state diverge if callback fails. Use find() + host subject creation + claim() for production."
335 )]
336 pub async fn redeem_with_callback<F, Fut, E>(
337 &self,
338 raw_input: &str,
339 rate_key: Option<&RateLimitKey>,
340 on_won: F,
341 ) -> Result<RedeemSuccess, RedeemError>
342 where
343 F: FnOnce(&RedeemableCode) -> Fut,
344 Fut: Future<Output = Result<SubjectId, E>>,
345 E: std::fmt::Display,
346 {
347 let record = self.find(raw_input, rate_key).await?;
348 let now = self.clock.unix_now();
349
350 // Attempt claim before invoking host callback (fail-fast on race).
351 // WARNING: redeem_with_callback() is experimental (RFC-D). The DB record
352 // will store the real subject once the callback returns, but the interim
353 // state is a won claim with no subject yet. Use find()+claim() for
354 // production audit-sensitive deployments.
355 let outcome = self
356 .store
357 .claim_code(&ClaimRequest {
358 code_id: &record.id,
359 subject: &SubjectId::new("__pending__".into()),
360 now,
361 purpose: record.purpose.as_deref(),
362 scope: record.scope.as_deref(),
363 })
364 .await
365 .map_err(RedeemError::from_store)?;
366
367 let proof = ClaimProof::new(outcome).ok_or_else(|| {
368 self.audit.record(CodeAuthEvent::RedemptionFailed {
369 reason: RedemptionFailReason::AlreadyUsed,
370 });
371 RedeemError::ClaimLost {
372 public: PublicRedemptionError::InvalidOrExpired,
373 }
374 })?;
375
376 // Claim won — now invoke host callback.
377 let subject = on_won(&record).await.map_err(|e| RedeemError::Internal {
378 cause: format!("host callback failed: {e}"),
379 public: PublicRedemptionError::TemporarilyUnavailable,
380 })?;
381
382 if let Some(key) = rate_key {
383 if self.rate_limit_policy.is_some() {
384 let _ = self.rate_limit_store.clear_failures(key).await;
385 }
386 }
387 self.audit.record(CodeAuthEvent::CodeRedeemed {
388 code_id: record.id.clone(),
389 subject_id: subject.clone(),
390 });
391
392 Ok(RedeemSuccess {
393 subject,
394 grant: record.grant.clone(),
395 _claim_proof: proof,
396 })
397 }
398
399 /// Revoke a code by its record ID. Scoped to `scope` when provided.
400 ///
401 /// # Errors
402 /// Returns [`RedeemError::Internal`] on store failure.
403 pub async fn revoke_code(
404 &self,
405 code_id: &CodeId,
406 scope: Option<&str>,
407 ) -> Result<(), RedeemError> {
408 let now = self.clock.unix_now();
409 self.store
410 .revoke_code(code_id, scope, now)
411 .await
412 .map_err(RedeemError::from_store)?;
413 self.audit.record(CodeAuthEvent::CodeRevoked {
414 code_id: code_id.clone(),
415 scope: scope.map(str::to_string),
416 });
417 Ok(())
418 }
419}
420
421/// Convenience impl: construct a [`CodeAuth`] with no rate-limit store.
422///
423/// Uses `NoRateLimit` as the `RL` type parameter so callers don't need to
424/// spell out the full generic signature when rate limiting is handled elsewhere.
425impl<CS, K, C, A> CodeAuth<CS, super::norate::NoRateLimit, K, C, A>
426where
427 CS: CodeStore,
428 K: KeyProvider,
429 C: Clock,
430 A: AuditSink,
431{
432 /// Construct without a rate-limit store. Equivalent to passing
433 /// `NoRateLimit` explicitly.
434 #[must_use]
435 pub fn without_rate_limit(
436 store: CS,
437 hasher: SecretHasher<K>,
438 clock: C,
439 audit: A,
440 policy: CodePolicy,
441 ) -> Self {
442 Self {
443 store,
444 rate_limit_store: super::norate::NoRateLimit,
445 hasher,
446 clock,
447 audit,
448 policy,
449 rate_limit_policy: None,
450 }
451 }
452}