mod.rs 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971
  1. //! Redis ephemeral store: codes, tokens, grants, sessions, PAR, device.
  2. //!
  3. //! All short-lived state lives here under hash-tagged keys (single-slot
  4. //! atomicity), keyspace-separated from rate-limit counters. Rotation and
  5. //! replay-marker writes are idempotent and durability-barriered.
  6. //!
  7. //! Key layout (every key below carries the `{state}` hash tag through the
  8. //! configured token prefix, default `oauth:{state}:`, so every key touched by
  9. //! one atomic Lua op shares a hash slot):
  10. //!
  11. //! - `oauth:{state}:code:<hash>` — single-use authorization codes.
  12. //! - `oauth:{state}:access:<hash>` — access-token records.
  13. //! - `oauth:{state}:access:<hash>:act` — token-exchange `act` claim sidecar.
  14. //! - `oauth:{state}:refresh:<hash>` — refresh-token records and tombstones.
  15. //! - `oauth:{state}:grant:<id>` — grant-chain records.
  16. //! - `oauth:{state}:grant:<id>:tokens` — per-grant token-member sets.
  17. //! - `oauth:{state}:session:<id>` — server-side login sessions.
  18. //! - `oauth:{state}:session:<id>:meta` — session sidecar: CSRF secret, bound
  19. //! `request_id`, and post-logout client/URI (see `purge_subject`).
  20. //! - `oauth:{state}:par:<id>` — PAR `request_uri` records.
  21. //! - `oauth:{state}:pending:<id>` — validated pending-auth JSON records.
  22. //! - `oauth:{state}:device:<hash>` — device-code records.
  23. //! - `oauth:{state}:device:<hash>:cnf` — device sender-binding sidecar.
  24. //! - `oauth:{state}:device:<hash>:poll` — device poll-throttle marker.
  25. //! - `oauth:{state}:user_code:<code-hash>` — user-code to device mapping.
  26. //! - `oauth:{state}:dcr_iat:<hash>` — DCR initial-access-token records.
  27. //! - `oauth:{state}:dpop_jti:<hash>` — DPoP `jti` replay markers.
  28. //! - `oauth:{state}:client_assertion_jti:<hash>` — `private_key_jwt` replay markers.
  29. //! - `oauth:{state}:rotation:<request-id>` — rotation idempotency mappings.
  30. //! - `oauth:{state}:loginfail:<sha256>` — per-account failed-login counter.
  31. //! - `oauth:{state}:loginfail:ip:<sha256>:<sha256>` — composite per-IP counter.
  32. //! - `oauth:{state}:loginfail:unknown:<sha256>` — unknown-label IP bucket.
  33. //! - `oauth:{state}:totp_replay:<fingerprint>` — TOTP used-code replay marker.
  34. //! - `oauth:{rl}:ratelimit:<bucket-hash>` — rate-limit counters (own keyspace).
  35. //!
  36. //! Registration access tokens are NOT here: their hash lives only in Postgres
  37. //! (`oauth_clients.registration_access_token_hash`) so client management
  38. //! survives a Redis loss. Raw tokens are never keys: every `<hash>` above is
  39. //! the hex/base64url form of a keyed hash (see [`crate::crypto::tokens`]).
  40. //!
  41. //! Durability: [`durability_barrier`] issues `WAIT` (replicated topologies) or
  42. //! `WAITAOF` (single-node) as a SEPARATE command AFTER the Lua write returns
  43. //! and BEFORE tokens are handed out — never inside a script or MULTI/EXEC,
  44. //! where `WAIT`/`WAITAOF` do not block. A barrier below the required level is
  45. //! [`StoreError::Durability`] (fail closed, `503`-mappable).
  46. //!
  47. //! Retries: only idempotent reads and `SET NX`/Lua compare-and-set are
  48. //! retried, and only on [`ErrorClass::Transient`] errors (see
  49. //! [`may_retry_op`]). Non-idempotent token operations are never blindly
  50. //! replayed: on an indeterminate post-`EXEC` result the caller re-reads state
  51. //! (mapping key, then the record) instead of re-issuing the write.
  52. use std::{collections::HashMap, sync::LazyLock, time::Duration};
  53. use redis::{ErrorKind, RedisError, Script, ServerErrorKind};
  54. use sha2::{Digest as _, Sha256};
  55. use crate::{
  56. config::{DurabilityMode, RedisConfig},
  57. state::RedisConn,
  58. };
  59. pub mod codes;
  60. pub mod dcr;
  61. pub mod device;
  62. pub mod grants;
  63. pub mod par;
  64. pub mod pending;
  65. pub mod ratelimit;
  66. pub mod sessions;
  67. pub mod tokens;
  68. #[cfg(test)]
  69. mod tests_store;
  70. // ---------------------------------------------------------------------------
  71. // Error model
  72. // ---------------------------------------------------------------------------
  73. /// Fail-closed store failures for the Redis ephemeral state.
  74. #[derive(Debug, thiserror::Error)]
  75. pub enum StoreError {
  76. /// Redis was unreachable or a command failed.
  77. #[error("redis unavailable")]
  78. Redis(#[from] RedisError),
  79. /// A stored record did not parse (never carries secret material).
  80. #[error("stored record is malformed")]
  81. InvalidData,
  82. /// The post-write durability barrier was not met (fail closed, 503).
  83. #[error("durability barrier not met")]
  84. Durability,
  85. /// The presented credential does not match the stored record.
  86. #[error("credential does not match stored record")]
  87. Mismatch,
  88. }
  89. impl From<StoreError> for crate::error::AppError {
  90. /// Map a store failure to a client-safe error: genuine Redis infrastructure
  91. /// failures (transient timeouts/connections, `OOM`, `READONLY`,
  92. /// `CROSSSLOT`, auth, and connection-type errors) and unmet durability
  93. /// barriers render `503 temporarily_unavailable` (fail closed);
  94. /// programming-error classes (`Parse`, `UnexpectedReturnType`, `Client`,
  95. /// `WRONGTYPE` type errors surfaced as `Extension` or `Server`, and generic
  96. /// Lua-runtime `ERR`/`EXECABORT` server failures) are
  97. /// masked as `server_error` (500) so 500-alerts still fire on real bugs.
  98. /// Genuinely internal bugs (`InvalidData`, `Mismatch`) are likewise masked
  99. /// as `server_error`.
  100. fn from(error: StoreError) -> Self {
  101. match error {
  102. StoreError::Durability => Self::temporarily_unavailable(),
  103. StoreError::Redis(inner) if is_infra_redis_error(&inner) => Self::temporarily_unavailable(),
  104. StoreError::Redis(inner) => Self::internal(inner),
  105. other => Self::internal(other),
  106. }
  107. }
  108. }
  109. /// Whether a Redis error is genuine infrastructure (503) rather than a
  110. /// programming error (500): transient errors plus `OOM`/`READONLY`/
  111. /// `CROSSSLOT`/auth/connection-type errors fail closed, while `Parse`,
  112. /// `UnexpectedReturnType`, `Client`, `WRONGTYPE` type errors, and generic
  113. /// Lua-runtime `ERR`/`EXECABORT` server failures stay visible as masked
  114. /// `server_error`.
  115. fn is_infra_redis_error(error: &RedisError) -> bool {
  116. if is_wrongtype(error) {
  117. return false;
  118. }
  119. // Generic Lua/script-runtime failures (`ERR` class, `EXECABORT`) are bugs
  120. // in the script or its arguments, not sick infrastructure: report them as
  121. // masked `server_error` (500) so 500-alerts fire. Transient kinds,
  122. // `NOSCRIPT` (self-healed), `OOM`/`READONLY`/`CROSSSLOT`, auth, and
  123. // connection errors keep their 503 fail-closed mapping below.
  124. if matches!(
  125. error.kind(),
  126. ErrorKind::Server(ServerErrorKind::ResponseError | ServerErrorKind::ExecAbort)
  127. ) {
  128. return false;
  129. }
  130. !matches!(
  131. error.kind(),
  132. ErrorKind::Parse | ErrorKind::UnexpectedReturnType | ErrorKind::Client
  133. )
  134. }
  135. /// Whether a Redis error is a `WRONGTYPE` type error: an `Extension` with code
  136. /// `WRONGTYPE`, or any server error whose code or detail carries it (the
  137. /// `WRONGTYPE ... wrong kind of value` detail on a generic `ERR`).
  138. fn is_wrongtype(error: &RedisError) -> bool {
  139. if error
  140. .code()
  141. .is_some_and(|code| code.eq_ignore_ascii_case("WRONGTYPE"))
  142. {
  143. return true;
  144. }
  145. error.detail().is_some_and(|detail| {
  146. let lower = detail.to_ascii_lowercase();
  147. lower.contains("wrongtype") || lower.contains("wrong kind")
  148. })
  149. }
  150. /// Whether a Redis error is worth retrying (idempotent ops only) or must fail
  151. /// closed immediately.
  152. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  153. pub enum ErrorClass {
  154. /// I/O, dropped connections, timeouts, `LOADING`/`TRYAGAIN`/`MASTERDOWN`/
  155. /// `CLUSTERDOWN`: retryable for idempotent reads and `SET NX`/Lua CAS only.
  156. Transient,
  157. /// `OOM` under noeviction, `READONLY`, auth failures, `CROSSSLOT`, type
  158. /// errors, and everything unknown: fail closed, never retry.
  159. Permanent,
  160. }
  161. /// Classify a Redis error as [`ErrorClass::Transient`] or
  162. /// [`ErrorClass::Permanent`].
  163. #[must_use]
  164. pub fn classify_redis(error: &RedisError) -> ErrorClass {
  165. if error.is_timeout() || error.is_connection_dropped() || error.is_io_error() {
  166. return ErrorClass::Transient;
  167. }
  168. match error.kind() {
  169. ErrorKind::Io => ErrorClass::Transient,
  170. ErrorKind::Server(
  171. ServerErrorKind::BusyLoading
  172. | ServerErrorKind::TryAgain
  173. | ServerErrorKind::MasterDown
  174. | ServerErrorKind::ClusterDown
  175. | ServerErrorKind::Moved
  176. | ServerErrorKind::Ask,
  177. ) => ErrorClass::Transient,
  178. // `READONLY`, `CROSSSLOT`, auth/ACL, type errors and anything
  179. // unrecognized (non-exhaustive enum): fail closed.
  180. ErrorKind::Server(_) => ErrorClass::Permanent,
  181. ErrorKind::Extension => match error.code() {
  182. Some("LOADING") | Some("TRYAGAIN") | Some("MASTERDOWN") | Some("CLUSTERDOWN") => {
  183. ErrorClass::Transient
  184. }
  185. // `NOSCRIPT` self-heals inside `Script::invoke_async`; a surfaced one is
  186. // still safe to retry once behind an idempotent script.
  187. Some("NOSCRIPT") => ErrorClass::Transient,
  188. _ => ErrorClass::Permanent,
  189. },
  190. ErrorKind::ClusterConnectionNotFound
  191. | ErrorKind::MasterNameNotFoundBySentinel
  192. | ErrorKind::NoValidReplicasFoundBySentinel => ErrorClass::Transient,
  193. ErrorKind::Parse
  194. | ErrorKind::AuthenticationFailed
  195. | ErrorKind::UnexpectedReturnType
  196. | ErrorKind::InvalidClientConfig
  197. | ErrorKind::Client
  198. | ErrorKind::EmptySentinelList
  199. | ErrorKind::RESP3NotSupported => ErrorClass::Permanent,
  200. // `ErrorKind` is non-exhaustive: fail closed on anything unrecognized.
  201. _ => ErrorClass::Permanent,
  202. }
  203. }
  204. /// Which operations may be retried, and only on transient errors.
  205. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  206. pub enum OpClass {
  207. /// Plain reads (`GET`, `HGETALL`, `SMEMBERS`): safe to retry.
  208. IdempotentRead,
  209. /// `SET NX` and idempotent Lua compare-and-set (rotation, replay markers):
  210. /// safe to retry.
  211. ConditionalWrite,
  212. /// Destructive single-use consumes and `INCR` counters: NEVER retried; on an
  213. /// indeterminate result the caller re-reads instead of replaying.
  214. NonIdempotent,
  215. }
  216. /// Whether `error` may be retried for an operation of class `op`.
  217. #[must_use]
  218. pub fn may_retry_op(op: OpClass, error: &RedisError) -> bool {
  219. matches!(op, OpClass::IdempotentRead | OpClass::ConditionalWrite)
  220. && classify_redis(error) == ErrorClass::Transient
  221. }
  222. /// Run `$op` (an async block evaluated fresh per attempt) against `$conn`
  223. /// with a bounded retry budget, retrying only transient errors.
  224. ///
  225. /// A `macro_rules!` instead of a generic helper: the operation borrows
  226. /// `&mut` connection state per attempt, which cannot escape an `FnMut`
  227. /// closure body, and the inline expansion keeps every future transparently
  228. /// `Send` for spawned tasks. Evaluates to `Result<_, redis::RedisError>`.
  229. ///
  230. /// Callers must pass idempotent reads or `SET NX`/Lua compare-and-set only;
  231. /// non-idempotent token operations must never reach this macro.
  232. macro_rules! retry_op {
  233. ($conn:expr, $budget:expr, $op:expr) => {{
  234. let budget__: u32 = $budget;
  235. let mut attempt__: u32 = 0;
  236. loop {
  237. match ($op).await {
  238. Ok(value__) => break Ok::<_, redis::RedisError>(value__),
  239. Err(error__) => {
  240. if !matches!(
  241. $crate::store::classify_redis(&error__),
  242. $crate::store::ErrorClass::Transient
  243. ) || attempt__ >= budget__
  244. {
  245. break Err(error__);
  246. }
  247. attempt__ += 1;
  248. let backoff__ = 50_u64.saturating_mul(1_u64 << attempt__.min(4));
  249. tokio::time::sleep(std::time::Duration::from_millis(backoff__.min(500))).await;
  250. }
  251. }
  252. }
  253. }};
  254. }
  255. pub(crate) use retry_op;
  256. // ---------------------------------------------------------------------------
  257. // Store handle and key layout
  258. // ---------------------------------------------------------------------------
  259. /// Handle for the Redis ephemeral state: key layout plus the durability
  260. /// contract selected from the topology.
  261. #[derive(Debug, Clone)]
  262. pub struct Store {
  263. token_prefix: String,
  264. ratelimit_prefix: String,
  265. durability: DurabilityMode,
  266. barrier_timeout: Duration,
  267. retry_budget: u32,
  268. }
  269. impl Store {
  270. /// Build a store handle from the validated Redis configuration.
  271. #[must_use]
  272. pub fn from_config(redis: &RedisConfig) -> Self {
  273. Self {
  274. token_prefix: redis.token_key_prefix.clone(),
  275. ratelimit_prefix: redis.ratelimit_key_prefix.clone(),
  276. durability: redis.durability,
  277. barrier_timeout: redis.response_timeout,
  278. retry_budget: redis.retry_budget,
  279. }
  280. }
  281. /// Build a store handle from explicit parts (tests and callers that already
  282. /// validated the prefixes).
  283. #[must_use]
  284. pub fn from_parts(
  285. token_prefix: String,
  286. ratelimit_prefix: String,
  287. durability: DurabilityMode,
  288. ) -> Self {
  289. Self {
  290. token_prefix,
  291. ratelimit_prefix,
  292. durability,
  293. barrier_timeout: Duration::from_secs(2),
  294. retry_budget: 6,
  295. }
  296. }
  297. /// The durability mode this store barriers on.
  298. #[must_use]
  299. pub fn durability(&self) -> DurabilityMode {
  300. self.durability
  301. }
  302. /// Retry budget for idempotent operations.
  303. #[must_use]
  304. pub fn retry_budget(&self) -> u32 {
  305. self.retry_budget
  306. }
  307. /// Assert the token and rate-limit keyspaces are separated and carry their
  308. /// cluster hash tags (`{state}` / `{rl}`), so counters can never evict
  309. /// tokens and multi-key Lua ops stay single-slot.
  310. pub fn assert_keyspace_separation(&self) -> Result<(), StoreError> {
  311. if self.token_prefix == self.ratelimit_prefix
  312. || !self.token_prefix.contains("{state}")
  313. || !self.ratelimit_prefix.contains("{rl}")
  314. {
  315. return Err(StoreError::InvalidData);
  316. }
  317. Ok(())
  318. }
  319. fn token_key(&self, kind: &str, id: &str) -> String {
  320. format!("{}{kind}:{id}", self.token_prefix)
  321. }
  322. /// Authorization-code record key for a code hash.
  323. #[must_use]
  324. pub fn code_key(&self, code_hash: &str) -> String {
  325. self.token_key("code", code_hash)
  326. }
  327. /// Access-token record key for a token hash.
  328. #[must_use]
  329. pub fn access_key(&self, token_hash: &str) -> String {
  330. self.token_key("access", token_hash)
  331. }
  332. /// Refresh-token record (or tombstone) key for a token hash.
  333. #[must_use]
  334. pub fn refresh_key(&self, token_hash: &str) -> String {
  335. self.token_key("refresh", token_hash)
  336. }
  337. /// Grant-chain record key.
  338. #[must_use]
  339. pub fn grant_key(&self, grant_id: &str) -> String {
  340. self.token_key("grant", grant_id)
  341. }
  342. /// Per-grant token-member set key (revocation index; no `KEYS` scans).
  343. #[must_use]
  344. pub fn grant_tokens_key(&self, grant_id: &str) -> String {
  345. format!("{}grant:{grant_id}:tokens", self.token_prefix)
  346. }
  347. /// Server-side session record key.
  348. #[must_use]
  349. pub fn session_key(&self, session_id: &str) -> String {
  350. self.token_key("session", session_id)
  351. }
  352. /// PAR `request_uri` record key.
  353. #[must_use]
  354. pub fn par_key(&self, request_id: &str) -> String {
  355. self.token_key("par", request_id)
  356. }
  357. /// Device-code record key for a device-code hash.
  358. #[must_use]
  359. pub fn device_key(&self, device_hash: &str) -> String {
  360. self.token_key("device", device_hash)
  361. }
  362. /// User-code to device-code mapping key (value holds the device hash).
  363. #[must_use]
  364. pub fn user_code_key(&self, user_code_hash: &str) -> String {
  365. self.token_key("user_code", user_code_hash)
  366. }
  367. /// DCR initial-access-token record key.
  368. #[must_use]
  369. pub fn dcr_iat_key(&self, token_hash: &str) -> String {
  370. self.token_key("dcr_iat", token_hash)
  371. }
  372. /// DPoP `jti` replay-marker key.
  373. #[must_use]
  374. pub fn dpop_jti_key(&self, jti_hash: &str) -> String {
  375. self.token_key("dpop_jti", jti_hash)
  376. }
  377. /// `private_key_jwt` (`client_assertion`) `jti` replay-marker key.
  378. #[must_use]
  379. pub fn client_assertion_jti_key(&self, jti_hash: &str) -> String {
  380. self.token_key("client_assertion_jti", jti_hash)
  381. }
  382. /// Rotation idempotency-mapping key for a deterministic `rotation_request_id`.
  383. #[must_use]
  384. pub fn rotation_map_key(&self, request_id: &str) -> String {
  385. self.token_key("rotation", request_id)
  386. }
  387. /// Failed-login counter key for a hashed account label (never a raw email).
  388. #[must_use]
  389. pub fn login_fail_key(&self, account_hash: &str) -> String {
  390. self.token_key("loginfail", account_hash)
  391. }
  392. /// Composite failed-login counter key for one source IP and one account.
  393. ///
  394. /// Both halves are hashes (never a raw IP or email). The `ip:` infix keeps
  395. /// these keys disjoint from the per-account `loginfail:<hash>` keys so an
  396. /// admin sweep can match `loginfail:ip:*:<account-hash>`.
  397. #[must_use]
  398. pub fn login_fail_ip_key(&self, ip_hash: &str, account_hash: &str) -> String {
  399. format!("{}ip:{ip_hash}:{account_hash}", self.login_fail_prefix())
  400. }
  401. /// Unknown-label failed-login bucket key for one source IP (never a label).
  402. ///
  403. /// Attempts for absent accounts share this single per-IP bucket instead of
  404. /// minting one key per probed label, so label enumeration cannot spray the
  405. /// keyspace.
  406. #[must_use]
  407. pub fn login_fail_unknown_key(&self, ip_hash: &str) -> String {
  408. format!("{}unknown:{ip_hash}", self.login_fail_prefix())
  409. }
  410. /// Key prefix covering every failed-login counter (account, composite, and
  411. /// unknown buckets).
  412. #[must_use]
  413. pub(crate) fn login_fail_prefix(&self) -> String {
  414. format!("{}loginfail:", self.token_prefix)
  415. }
  416. /// Key prefix covering the composite per-IP counters.
  417. #[must_use]
  418. pub(crate) fn login_fail_ip_prefix(&self) -> String {
  419. format!("{}ip:", self.login_fail_prefix())
  420. }
  421. /// TOTP used-code replay-marker key for a fingerprint hash (task 25).
  422. ///
  423. /// Genuine gap noted by the login task: the replay cache needs a
  424. /// single-slot key under the `{state}` prefix, like every other ephemeral
  425. /// record. Pure addition; no existing key layout changes.
  426. #[must_use]
  427. pub fn totp_replay_key(&self, fingerprint: &str) -> String {
  428. self.token_key("totp_replay", fingerprint)
  429. }
  430. /// Rate-limit counter key for a hashed bucket (own `{rl}` keyspace).
  431. #[must_use]
  432. pub fn ratelimit_key(&self, bucket_hash: &str) -> String {
  433. format!("{}ratelimit:{bucket_hash}", self.ratelimit_prefix)
  434. }
  435. /// Key prefixes used to rebuild member keys inside the grant-revoke script.
  436. #[must_use]
  437. pub(crate) fn refresh_key_prefix(&self) -> String {
  438. format!("{}refresh:", self.token_prefix)
  439. }
  440. /// Key prefixes used to rebuild member keys inside the grant-revoke script.
  441. #[must_use]
  442. pub(crate) fn access_key_prefix(&self) -> String {
  443. format!("{}access:", self.token_prefix)
  444. }
  445. /// Key prefix for server-side login session records (`session:<id>`).
  446. #[must_use]
  447. pub(crate) fn session_key_prefix(&self) -> String {
  448. format!("{}session:", self.token_prefix)
  449. }
  450. /// Key prefix for authorization-code records (`code:<hash>`).
  451. #[must_use]
  452. pub(crate) fn code_key_prefix(&self) -> String {
  453. format!("{}code:", self.token_prefix)
  454. }
  455. /// Key prefix for device-code records (`device:<hash>`).
  456. #[must_use]
  457. pub(crate) fn device_key_prefix(&self) -> String {
  458. format!("{}device:", self.token_prefix)
  459. }
  460. /// Key prefix for user-code to device-hash mappings (`user_code:<hash>`).
  461. #[must_use]
  462. pub(crate) fn user_code_key_prefix(&self) -> String {
  463. format!("{}user_code:", self.token_prefix)
  464. }
  465. /// Key prefix for grant-chain records (`grant:<id>`).
  466. #[must_use]
  467. pub(crate) fn grant_key_prefix(&self) -> String {
  468. format!("{}grant:", self.token_prefix)
  469. }
  470. }
  471. /// SHA-256 hex of `input` (key/bucket hashing; never a raw secret or token).
  472. #[must_use]
  473. pub fn sha256_hex(input: &[u8]) -> String {
  474. let digest = Sha256::digest(input);
  475. let mut out = String::with_capacity(64);
  476. for byte in digest {
  477. out.push_str(&format!("{byte:02x}"));
  478. }
  479. out
  480. }
  481. /// HMAC-SHA256 with an RFC 2104 §2 key schedule (long peppers are hashed,
  482. /// short peppers zero-padded), infallible by construction.
  483. pub(crate) fn hmac_sha256(pepper: &[u8], message: &[u8]) -> [u8; 32] {
  484. use hmac::digest::{KeyInit, Mac as _};
  485. use sha2::digest::Key;
  486. let mut block = [0u8; 64];
  487. if pepper.len() > block.len() {
  488. let hashed = Sha256::digest(pepper);
  489. block[..hashed.len()].copy_from_slice(&hashed);
  490. } else {
  491. block[..pepper.len()].copy_from_slice(pepper);
  492. }
  493. let key: &Key<hmac::Hmac<Sha256>> = (&block).into();
  494. let mut mac = <hmac::Hmac<Sha256> as KeyInit>::new(key);
  495. mac.update(message);
  496. let out = mac.finalize().into_bytes();
  497. let mut digest = [0u8; 32];
  498. digest.copy_from_slice(&out);
  499. digest
  500. }
  501. // ---------------------------------------------------------------------------
  502. // Typed hash-keyed access
  503. // ---------------------------------------------------------------------------
  504. /// Write a record as one atomic `HSET` + `PEXPIRE` script (idempotent
  505. /// rewrite; safe to retry on transient errors via [`retry_op`]). The fields
  506. /// and the expiry land together, so no crash can leave an immortal key.
  507. pub(crate) async fn hset_record(
  508. conn: &mut RedisConn,
  509. key: &str,
  510. pairs: &[(String, String)],
  511. ttl: Duration,
  512. ) -> Result<(), StoreError> {
  513. // A field-less `HSET` is a Redis arity error; with nothing to write, the
  514. // TTL touch alone remains (a no-op on a missing key, a refresh otherwise).
  515. if pairs.is_empty() {
  516. let _: () = redis::cmd("PEXPIRE")
  517. .arg(key)
  518. .arg(ttl.as_millis() as u64)
  519. .query_async(conn)
  520. .await
  521. .map_err(StoreError::Redis)?;
  522. return Ok(());
  523. }
  524. let mut invocation = HSET_TTL.prepare_invoke();
  525. invocation.key(key).arg(ttl.as_millis() as u64);
  526. for (field, value) in pairs {
  527. invocation.arg(field).arg(value);
  528. }
  529. let _: i64 = invocation
  530. .invoke_async(conn)
  531. .await
  532. .map_err(StoreError::Redis)?;
  533. Ok(())
  534. }
  535. /// Read a record hash; `Ok(None)` when the key is absent.
  536. pub(crate) async fn hget_record(
  537. conn: &mut RedisConn,
  538. key: &str,
  539. ) -> Result<Option<HashMap<String, String>>, StoreError> {
  540. let flat: Vec<String> = redis::cmd("HGETALL")
  541. .arg(key)
  542. .query_async(conn)
  543. .await
  544. .map_err(StoreError::Redis)?;
  545. if flat.is_empty() {
  546. return Ok(None);
  547. }
  548. if !flat.len().is_multiple_of(2) {
  549. return Err(StoreError::InvalidData);
  550. }
  551. let mut map = HashMap::with_capacity(flat.len() / 2);
  552. let mut iter = flat.into_iter();
  553. while let (Some(field), Some(value)) = (iter.next(), iter.next()) {
  554. map.insert(field, value);
  555. }
  556. Ok(Some(map))
  557. }
  558. /// Parse an `HGETALL` flat array (as returned by the consume scripts) into a map.
  559. pub(crate) fn flat_to_map(flat: Vec<String>) -> Result<HashMap<String, String>, StoreError> {
  560. if flat.is_empty() || !flat.len().is_multiple_of(2) {
  561. return Err(StoreError::InvalidData);
  562. }
  563. let mut map = HashMap::with_capacity(flat.len() / 2);
  564. let mut iter = flat.into_iter();
  565. while let (Some(field), Some(value)) = (iter.next(), iter.next()) {
  566. map.insert(field, value);
  567. }
  568. Ok(map)
  569. }
  570. // ---------------------------------------------------------------------------
  571. // Lua registry (every multi-key op is single-slot via the `{state}` tag)
  572. // ---------------------------------------------------------------------------
  573. /// Atomic record write: `HSET` the fields, then `PEXPIRE` the key, before the
  574. /// script returns — the record can never be observed (or survive a crash)
  575. /// without its TTL.
  576. ///
  577. /// KEYS: record. ARGV: TTL ms, then the flattened field/value pairs (never
  578. /// empty: [`hset_record`] guards that case).
  579. pub(crate) static HSET_TTL: LazyLock<Script> = LazyLock::new(|| {
  580. Script::new(
  581. r"
  582. redis.call('HSET', KEYS[1], unpack(ARGV, 2))
  583. redis.call('PEXPIRE', KEYS[1], ARGV[1])
  584. return 1
  585. ",
  586. )
  587. });
  588. /// Atomic grant-member write: `HSET` the record fields, `PEXPIRE` the
  589. /// record, `SADD` the member into the grant set, then arm-or-extend the set
  590. /// TTL (never shorten), before the script returns.
  591. ///
  592. /// KEYS: record, grant token set. ARGV: record TTL ms, member, then the
  593. /// flattened record field/value pairs. The record and its index entry land
  594. /// together, so a revoke drain cannot interleave between the record write and
  595. /// the index write and leave an orphan in either direction.
  596. pub(crate) static SCRIPT_PUT_MEMBER: LazyLock<Script> = LazyLock::new(|| {
  597. Script::new(
  598. r"
  599. if #ARGV > 2 then
  600. redis.call('HSET', KEYS[1], unpack(ARGV, 3))
  601. redis.call('PEXPIRE', KEYS[1], ARGV[1])
  602. end
  603. redis.call('SADD', KEYS[2], ARGV[2])
  604. local set_pttl = redis.call('PTTL', KEYS[2])
  605. if set_pttl < 0 or set_pttl < tonumber(ARGV[1]) then
  606. redis.call('PEXPIRE', KEYS[2], ARGV[1])
  607. end
  608. return 1
  609. ",
  610. )
  611. });
  612. /// Single-use consume with `GETDEL` semantics: return the hash fields and
  613. /// delete the key atomically, or `false` when absent.
  614. pub(crate) static SCRIPT_CONSUME_HASH: LazyLock<Script> = LazyLock::new(|| {
  615. Script::new(
  616. r"
  617. local data = redis.call('HGETALL', KEYS[1])
  618. if #data == 0 then
  619. return false
  620. end
  621. redis.call('DEL', KEYS[1])
  622. return data
  623. ",
  624. )
  625. });
  626. /// Device verdict conditional write (RFC 8628 user decision).
  627. ///
  628. /// KEYS: device record. ARGV: status, subject, grant id. Sets the verdict
  629. /// fields only when the record still exists, so a verdict racing the atomic
  630. /// consume can never resurrect a zombie record (no lost update). The key TTL
  631. /// is left untouched. Returns 1 when applied, 0 when the record is gone.
  632. pub(crate) static SCRIPT_DEVICE_DECIDE: LazyLock<Script> = LazyLock::new(|| {
  633. Script::new(
  634. r"
  635. if redis.call('EXISTS', KEYS[1]) == 0 then
  636. return 0
  637. end
  638. redis.call('HSET', KEYS[1], 'status', ARGV[1], 'subject', ARGV[2], 'grant_id', ARGV[3])
  639. return 1
  640. ",
  641. )
  642. });
  643. /// Device poll-interval compare-and-set (RFC 8628 §3.5 `slow_down`).
  644. ///
  645. /// KEYS: poll sidecar. ARGV: now (Unix seconds), interval (seconds), TTL ms.
  646. /// Reads the last attempt, compares, and stamps this attempt in one step, so
  647. /// concurrent polls cannot both pass the gate. The stamp advances on every
  648. /// path, so fast polls keep backing off instead of racing the window edge.
  649. /// Returns 0 when this attempt is allowed, the whole seconds to wait when it
  650. /// is too fast, or -1 when the stored marker is corrupt.
  651. pub(crate) static SCRIPT_DEVICE_POLL_CAS: LazyLock<Script> = LazyLock::new(|| {
  652. Script::new(
  653. r"
  654. local last = redis.call('GET', KEYS[1])
  655. if not last then
  656. redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3])
  657. return 0
  658. end
  659. local last_secs = tonumber(last)
  660. if not last_secs then
  661. return -1
  662. end
  663. local elapsed = tonumber(ARGV[1]) - last_secs
  664. if elapsed < 0 then
  665. elapsed = 0
  666. end
  667. local interval_secs = tonumber(ARGV[2])
  668. if elapsed < interval_secs then
  669. redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3])
  670. local wait = interval_secs - elapsed
  671. if wait < 1 then
  672. wait = 1
  673. end
  674. return wait
  675. end
  676. redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3])
  677. return 0
  678. ",
  679. )
  680. });
  681. /// Refresh rotation compare-and-set.
  682. ///
  683. /// KEYS: old refresh, new refresh, new access, grant token set, request map,
  684. /// grant record. ARGV: expected client, new-refresh JSON, new-refresh TTL ms,
  685. /// new-access JSON, new-access TTL ms, request id, grant id, new refresh
  686. /// member, new access member, tombstone TTL ms, map TTL ms.
  687. ///
  688. /// Returns `{TAG, ...}`: `ROTATED`, `REPLAY` (idempotent retry: same tokens),
  689. /// `REUSE` (tombstoned: rotated-to member), `NOGRANT` (grant record gone: a
  690. /// mint racing a cascade is fenced before writing, fail closed with no
  691. /// cascade), `ABSENT`, or `MISMATCH`.
  692. ///
  693. /// Precedence is intentional: the idempotency-map `REPLAY` check runs before
  694. /// the `NOGRANT` grant-record fence, so a retried request whose grant was
  695. /// revoked mid-cascade still resolves to its already-minted pair instead of
  696. /// locking out. `REPLAY` mints nothing new — the re-resolved hashes name
  697. /// records the cascade already deleted, so the service layer safely discards
  698. /// them — and only map-miss rotations reach the fence, which writes nothing,
  699. /// indexes nothing, and cascades nothing.
  700. pub(crate) static SCRIPT_ROTATE_REFRESH: LazyLock<Script> = LazyLock::new(|| {
  701. Script::new(
  702. r"
  703. local mapped = redis.call('GET', KEYS[5])
  704. if mapped then
  705. local parts = cjson.decode(mapped)
  706. return {'REPLAY', parts['refresh'], parts['access']}
  707. end
  708. if redis.call('EXISTS', KEYS[6]) == 0 then
  709. return {'NOGRANT'}
  710. end
  711. local old = redis.call('HGETALL', KEYS[1])
  712. if #old == 0 then
  713. return {'ABSENT'}
  714. end
  715. local h = {}
  716. for i = 1, #old, 2 do
  717. h[old[i]] = old[i + 1]
  718. end
  719. if h['used'] == 'true' then
  720. return {'REUSE', h['rotated_to'] or ''}
  721. end
  722. if h['client_id'] ~= ARGV[1] then
  723. return {'MISMATCH'}
  724. end
  725. local new_refresh = cjson.decode(ARGV[2])
  726. local new_access = cjson.decode(ARGV[4])
  727. local rargs = {KEYS[2]}
  728. for k, v in pairs(new_refresh) do
  729. rargs[#rargs + 1] = k
  730. rargs[#rargs + 1] = v
  731. end
  732. redis.call('HSET', unpack(rargs))
  733. redis.call('PEXPIRE', KEYS[2], ARGV[3])
  734. local aargs = {KEYS[3]}
  735. for k, v in pairs(new_access) do
  736. aargs[#aargs + 1] = k
  737. aargs[#aargs + 1] = v
  738. end
  739. redis.call('HSET', unpack(aargs))
  740. redis.call('PEXPIRE', KEYS[3], ARGV[5])
  741. redis.call('SADD', KEYS[4], ARGV[8], ARGV[9])
  742. local set_pttl = redis.call('PTTL', KEYS[4])
  743. if set_pttl < 0 or set_pttl < tonumber(ARGV[3]) then
  744. redis.call('PEXPIRE', KEYS[4], ARGV[3])
  745. end
  746. local pttl = redis.call('PTTL', KEYS[1])
  747. local tomb_ttl = tonumber(ARGV[10])
  748. if pttl > 0 and pttl < tomb_ttl then
  749. tomb_ttl = pttl
  750. end
  751. redis.call('HSET', KEYS[1], 'used', 'true', 'rotated_to', ARGV[8])
  752. redis.call('PEXPIRE', KEYS[1], tomb_ttl)
  753. local mapping = cjson.encode({refresh = ARGV[8], access = ARGV[9]})
  754. redis.call('SET', KEYS[5], mapping, 'PX', ARGV[11])
  755. return {'ROTATED', ARGV[8], ARGV[9]}
  756. ",
  757. )
  758. });
  759. /// Grant cascade revoke: delete every member token key plus the grant set and
  760. /// record. Member keys are rebuilt from the key prefixes (same hash slot).
  761. /// KEYS: grant token set, grant record. ARGV: refresh prefix, access prefix.
  762. pub(crate) static SCRIPT_GRANT_REVOKE: LazyLock<Script> = LazyLock::new(|| {
  763. Script::new(
  764. r"
  765. local members = redis.call('SMEMBERS', KEYS[1])
  766. local deleted = 0
  767. for _, member in ipairs(members) do
  768. deleted = deleted + redis.call('DEL', ARGV[1] .. member, ARGV[2] .. member)
  769. end
  770. redis.call('DEL', KEYS[1], KEYS[2])
  771. return deleted
  772. ",
  773. )
  774. });
  775. /// Fixed-window rate-limit counter: `INCR` plus `PEXPIRE`-on-first.
  776. pub(crate) static SCRIPT_RATELIMIT_INCR: LazyLock<Script> = LazyLock::new(|| {
  777. Script::new(
  778. r"
  779. local count = redis.call('INCR', KEYS[1])
  780. if count == 1 then
  781. redis.call('PEXPIRE', KEYS[1], ARGV[1])
  782. end
  783. return count
  784. ",
  785. )
  786. });
  787. /// Decrement-on-use for DCR initial-access tokens (`uses` budget).
  788. /// KEYS: iat record. Returns the record fields, or `false` when absent or
  789. /// exhausted. The final use deletes the record.
  790. pub(crate) static SCRIPT_CONSUME_IAT: LazyLock<Script> = LazyLock::new(|| {
  791. Script::new(
  792. r"
  793. local data = redis.call('HGETALL', KEYS[1])
  794. if #data == 0 then
  795. return false
  796. end
  797. local remaining = nil
  798. for i = 1, #data, 2 do
  799. if data[i] == 'uses_remaining' then
  800. remaining = tonumber(data[i + 1])
  801. end
  802. end
  803. if remaining == nil or remaining <= 0 then
  804. return false
  805. end
  806. if remaining == 1 then
  807. redis.call('DEL', KEYS[1])
  808. else
  809. redis.call('HSET', KEYS[1], 'uses_remaining', remaining - 1)
  810. end
  811. return data
  812. ",
  813. )
  814. });
  815. // ---------------------------------------------------------------------------
  816. // Durability barrier (separate command, after the write, before tokens out)
  817. // ---------------------------------------------------------------------------
  818. /// Apply the topology-selected durability barrier after a rotation/replay
  819. /// write: `WAIT 1 <timeout>` where replicas exist, `WAITAOF 1 0 <timeout>` on
  820. /// single-node, or an `appendfsync always` assertion for the fallback mode.
  821. ///
  822. /// The server-side wait leaves 500 ms of headroom under the client response
  823. /// timeout, so an expired barrier surfaces as [`StoreError::Durability`]
  824. /// rather than an ambiguous client timeout. A barrier below the required
  825. /// level returns [`StoreError::Durability`] (fail closed). This must never run
  826. /// inside a Lua script or MULTI/EXEC.
  827. pub async fn durability_barrier(conn: &mut RedisConn, store: &Store) -> Result<(), StoreError> {
  828. // Server-side wait, strictly inside the client response timeout.
  829. let server_wait_ms = store
  830. .barrier_timeout
  831. .as_millis()
  832. .saturating_sub(500)
  833. .max(500) as u64;
  834. match store.durability {
  835. DurabilityMode::Wait => {
  836. let acked: i64 = redis::cmd("WAIT")
  837. .arg(1)
  838. .arg(server_wait_ms)
  839. .query_async(conn)
  840. .await
  841. .map_err(StoreError::Redis)?;
  842. if acked >= 1 {
  843. Ok(())
  844. } else {
  845. Err(StoreError::Durability)
  846. }
  847. }
  848. DurabilityMode::WaitAof => {
  849. match redis::cmd("WAITAOF")
  850. .arg(1)
  851. .arg(0)
  852. .arg(server_wait_ms)
  853. .query_async::<Vec<i64>>(conn)
  854. .await
  855. {
  856. Ok(acked) => {
  857. if acked.first().is_some_and(|local| *local >= 1) {
  858. Ok(())
  859. } else {
  860. Err(StoreError::Durability)
  861. }
  862. }
  863. Err(error) => {
  864. // Pre-7.2 servers lack `WAITAOF`: accept only the documented
  865. // fallback (instance-wide `appendfsync always`), else fail closed.
  866. if is_unknown_command(&error) && appendfsync_is_always(conn).await? {
  867. Ok(())
  868. } else {
  869. Err(StoreError::Redis(error))
  870. }
  871. }
  872. }
  873. }
  874. DurabilityMode::AppendFsyncAlways => {
  875. if appendfsync_is_always(conn).await? {
  876. Ok(())
  877. } else {
  878. Err(StoreError::Durability)
  879. }
  880. }
  881. }
  882. }
  883. /// Whether a Redis error reports an unknown command (pre-7.2 `WAITAOF`).
  884. fn is_unknown_command(error: &RedisError) -> bool {
  885. error.code() == Some("ERR")
  886. && error
  887. .detail()
  888. .is_some_and(|detail| detail.contains("unknown command"))
  889. }
  890. /// Whether the server runs instance-wide `appendfsync always`.
  891. async fn appendfsync_is_always(conn: &mut RedisConn) -> Result<bool, StoreError> {
  892. let pair: Vec<String> = redis::cmd("CONFIG")
  893. .arg("GET")
  894. .arg("appendfsync")
  895. .query_async(conn)
  896. .await
  897. .map_err(StoreError::Redis)?;
  898. Ok(pair.into_iter().nth(1).as_deref() == Some("always"))
  899. }