totp.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. //! TOTP helpers (RFC 6238, ±1 step, used-code replay cache).
  2. //!
  3. //! Secrets are stored encrypted (see [`crate::keys::KeyRing`]); verification
  4. //! enforces the acceptance window and claims used codes in Redis to block
  5. //! replay. The wire format is RFC 6238 §5.2 verbatim: `T0 = 0`, `Tx = 30 s`,
  6. //! SHA-1, 6 digits, with the validator accepting one step of look-ahead and
  7. //! look-behind.
  8. //!
  9. //! The encrypted-secret blob layout mirrors `cli::secret` (`version(1) ||
  10. //! kid_len(1) || kid || nonce(12) || ciphertext+tag`, AES-256-GCM under
  11. //! `SHA256(totp_key)`), so CLI-provisioned secrets decrypt here and
  12. //! login-provisioned secrets stay CLI-readable. The two implementations are
  13. //! intentionally small duplicates: `crypto` cannot depend on `cli`.
  14. //!
  15. //! No error variant carries a secret, code, or key: failures collapse to
  16. //! static messages.
  17. use std::time::Duration;
  18. use base32::Alphabet;
  19. use qrcode::{QrCode, render::svg};
  20. use rand::{TryRng as _, rngs::SysRng};
  21. use ring::{
  22. aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
  23. rand::{SecureRandom as _, SystemRandom},
  24. };
  25. use secrecy::ExposeSecret as _;
  26. use sha2::{Digest as _, Sha256};
  27. use totp_rs::{Algorithm, Builder, Secret};
  28. use crate::{crypto::ct::ct_eq, keys::KeyRing};
  29. /// TOTP time-step in seconds (RFC 6238 §5.2 `Tx`).
  30. pub const TOTP_STEP_SECS: u64 = 30;
  31. /// Accepted clock drift in steps, each side of the current step.
  32. pub const TOTP_SKEW_STEPS: u16 = 1;
  33. /// Digits per code (RFC 6238 default).
  34. pub const TOTP_DIGITS: u8 = 6;
  35. /// How long a consumed code stays claimed: the 90 s window plus margin.
  36. pub const USED_CODE_TTL: Duration = Duration::from_secs(180);
  37. /// Recovery codes minted per enrollment.
  38. pub const RECOVERY_CODE_COUNT: usize = 10;
  39. /// Current encrypted-secret blob format version.
  40. const TOTP_BLOB_VERSION: u8 = 1;
  41. /// AES-GCM nonce length.
  42. const NONCE_LEN: usize = 12;
  43. /// Failures from TOTP enrollment and verification.
  44. ///
  45. /// Every variant renders a static message: secrets, codes, and key material
  46. /// never reach logs or clients.
  47. #[derive(Debug, thiserror::Error)]
  48. pub enum TotpError {
  49. /// The stored secret is not valid base32 or builds no compliant verifier.
  50. #[error("invalid TOTP secret")]
  51. InvalidSecret,
  52. /// The operating-system RNG failed.
  53. #[error("operating-system randomness unavailable")]
  54. UnavailableRandomness,
  55. /// Encryption failed (no TOTP key configured or the AEAD rejected input).
  56. #[error("TOTP encryption failed")]
  57. Encrypt,
  58. /// The blob is malformed, names an unknown key, or fails authentication.
  59. #[error("TOTP secret cannot be decrypted")]
  60. Decrypt,
  61. /// The enrollment URI is too long to encode as a QR code.
  62. #[error("TOTP QR rendering failed")]
  63. Qr,
  64. /// The replay store was unreachable.
  65. #[error("TOTP replay store unavailable")]
  66. Store(#[from] redis::RedisError),
  67. /// The replay store answered with an unexpected reply (fail closed).
  68. #[error("TOTP replay store returned an unexpected reply")]
  69. UnexpectedReply,
  70. }
  71. impl From<TotpError> for crate::error::AppError {
  72. /// Every TOTP failure is an internal, masked failure: the client sees
  73. /// `server_error`, never secret material or store detail.
  74. fn from(error: TotpError) -> Self {
  75. Self::internal(error)
  76. }
  77. }
  78. /// Whether a replay-marker claim saw the code for the first time.
  79. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  80. pub enum Claim {
  81. /// First presentation of this code in this step.
  82. Fresh,
  83. /// This code was already consumed in this step: reject it.
  84. Replay,
  85. }
  86. /// Generate a fresh 160-bit secret, base32 (RFC 4648, no padding).
  87. ///
  88. /// # Errors
  89. ///
  90. /// Returns [`TotpError::UnavailableRandomness`] when the OS RNG fails.
  91. pub fn generate_secret() -> Result<String, TotpError> {
  92. let mut bytes = [0u8; 20];
  93. SysRng
  94. .try_fill_bytes(&mut bytes)
  95. .map_err(|_| TotpError::UnavailableRandomness)?;
  96. Ok(base32::encode(Alphabet::Rfc4648 { padding: false }, &bytes))
  97. }
  98. /// Generate one-time recovery codes (each 80-bit, base32, no padding).
  99. ///
  100. /// Callers hash every code (Argon2id) before storage and show the plaintext
  101. /// exactly once.
  102. ///
  103. /// # Errors
  104. ///
  105. /// Returns [`TotpError::UnavailableRandomness`] when the OS RNG fails.
  106. pub fn generate_recovery_codes() -> Result<Vec<String>, TotpError> {
  107. let mut codes = Vec::with_capacity(RECOVERY_CODE_COUNT);
  108. for _ in 0..RECOVERY_CODE_COUNT {
  109. let mut bytes = [0u8; 10];
  110. SysRng
  111. .try_fill_bytes(&mut bytes)
  112. .map_err(|_| TotpError::UnavailableRandomness)?;
  113. codes.push(base32::encode(Alphabet::Rfc4648 { padding: false }, &bytes));
  114. }
  115. Ok(codes)
  116. }
  117. /// Normalize user input for comparison: trim, then require 6 ASCII digits.
  118. ///
  119. /// Returns `None` for anything else (a wrong code, never an error).
  120. fn normalize_code(code: &str) -> Option<&str> {
  121. let trimmed = code.trim();
  122. if trimmed.len() == usize::from(TOTP_DIGITS) && trimmed.bytes().all(|b| b.is_ascii_digit()) {
  123. Some(trimmed)
  124. } else {
  125. None
  126. }
  127. }
  128. /// Normalize a presented recovery code: drop separators/whitespace, uppercase.
  129. #[must_use]
  130. pub fn normalize_recovery_code(code: &str) -> String {
  131. code
  132. .chars()
  133. .filter(|c| !c.is_whitespace() && *c != '-')
  134. .flat_map(|c| c.to_uppercase())
  135. .collect()
  136. }
  137. /// Build the RFC 6238 verifier for a stored base32 secret.
  138. ///
  139. /// # Errors
  140. ///
  141. /// Returns [`TotpError::InvalidSecret`] when the secret is not decodable
  142. /// base32 or the parameters fail validation. The offending value is never
  143. /// attached to the error.
  144. pub fn build_totp(secret_b32: &str) -> Result<totp_rs::Totp, TotpError> {
  145. let upper = secret_b32.trim().to_ascii_uppercase();
  146. let raw =
  147. base32::decode(Alphabet::Rfc4648 { padding: false }, &upper).ok_or(TotpError::InvalidSecret)?;
  148. if raw.is_empty() {
  149. return Err(TotpError::InvalidSecret);
  150. }
  151. Builder::new()
  152. .with_secret(Secret::from(raw.as_slice()))
  153. .with_algorithm(Algorithm::SHA1)
  154. .with_digits(TOTP_DIGITS)
  155. .with_skew(TOTP_SKEW_STEPS)
  156. .with_step_duration(TOTP_STEP_SECS)
  157. .build()
  158. .map_err(|_| TotpError::InvalidSecret)
  159. }
  160. /// Check `code` against the ±1-step window around `now_unix`.
  161. ///
  162. /// Returns the matched time-step counter, or `None` for a wrong code. Only a
  163. /// malformed stored secret is an error; a wrong code is `Ok(None)`.
  164. ///
  165. /// # Errors
  166. ///
  167. /// Returns [`TotpError::InvalidSecret`] when the stored secret is malformed.
  168. pub fn verify_window(
  169. secret_b32: &str,
  170. code: &str,
  171. now_unix: u64,
  172. ) -> Result<Option<u64>, TotpError> {
  173. let Some(presented) = normalize_code(code) else {
  174. return Ok(None);
  175. };
  176. let totp = build_totp(secret_b32)?;
  177. let counter = now_unix / TOTP_STEP_SECS;
  178. let skew = u64::from(TOTP_SKEW_STEPS);
  179. for step in [
  180. counter.saturating_sub(skew),
  181. counter,
  182. counter.saturating_add(skew),
  183. ] {
  184. let candidate = totp
  185. .generate(step.saturating_mul(TOTP_STEP_SECS))
  186. .to_string();
  187. if ct_eq(candidate.as_bytes(), presented.as_bytes()) {
  188. return Ok(Some(step));
  189. }
  190. }
  191. Ok(None)
  192. }
  193. /// Domain-separated fingerprint binding a used code to its owner and step.
  194. ///
  195. /// The fingerprint (never the code) becomes the Redis replay-marker key.
  196. #[must_use]
  197. pub fn replay_fingerprint(user_id: &str, code: &str, step: u64) -> String {
  198. let mut hasher = Sha256::new();
  199. hasher.update(b"totp-replay-v1|");
  200. hasher.update(user_id.as_bytes());
  201. hasher.update(b"|");
  202. hasher.update(code.as_bytes());
  203. hasher.update(b"|");
  204. hasher.update(step.to_be_bytes());
  205. hex_bytes(&hasher.finalize())
  206. }
  207. /// Atomically claim a replay-marker key (`SET key 1 PX ttl NX`).
  208. ///
  209. /// `Ok(Claim::Fresh)` claims first presentation; `Ok(Claim::Replay)` reports
  210. /// a replay. Any transport failure or surprising reply is an error (fail
  211. /// closed), never a silent accept.
  212. ///
  213. /// # Errors
  214. ///
  215. /// Returns [`TotpError::Store`] when Redis fails and
  216. /// [`TotpError::UnexpectedReply`] for a non-`SET`-shaped reply.
  217. pub async fn claim_code(
  218. conn: &mut impl redis::aio::ConnectionLike,
  219. key: &str,
  220. ttl: Duration,
  221. ) -> Result<Claim, TotpError> {
  222. let millis = u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX);
  223. let cmd = redis::cmd("SET")
  224. .arg(key)
  225. .arg("1")
  226. .arg("PX")
  227. .arg(millis)
  228. .arg("NX")
  229. .to_owned();
  230. let reply: redis::Value = conn.req_packed_command(&cmd).await?;
  231. match reply {
  232. redis::Value::Okay => Ok(Claim::Fresh),
  233. redis::Value::Nil => Ok(Claim::Replay),
  234. redis::Value::SimpleString(text) if text == "OK" => Ok(Claim::Fresh),
  235. _ => Err(TotpError::UnexpectedReply),
  236. }
  237. }
  238. /// Verify `code` in the ±1-step window and atomically claim it on first use.
  239. ///
  240. /// `replay_key_for_step` maps the matched step counter to the full Redis
  241. /// replay-marker key (built by the caller from [`replay_fingerprint`] plus
  242. /// the store prefix). Returns `false` for a wrong code AND for a replayed
  243. /// one; callers must not distinguish the two.
  244. ///
  245. /// # Errors
  246. ///
  247. /// Returns [`TotpError::InvalidSecret`] for a malformed stored secret and
  248. /// [`TotpError::Store`] when the replay store fails.
  249. pub async fn verify_and_claim(
  250. conn: &mut impl redis::aio::ConnectionLike,
  251. replay_key_for_step: impl Fn(u64) -> String,
  252. secret_b32: &str,
  253. code: &str,
  254. now_unix: u64,
  255. ttl: Duration,
  256. ) -> Result<bool, TotpError> {
  257. let Some(step) = verify_window(secret_b32, code, now_unix)? else {
  258. return Ok(false);
  259. };
  260. match claim_code(conn, &replay_key_for_step(step), ttl).await? {
  261. Claim::Fresh => Ok(true),
  262. Claim::Replay => Ok(false),
  263. }
  264. }
  265. /// Encrypt a TOTP secret under the active TOTP-encryption key.
  266. ///
  267. /// Blob layout mirrors `cli::secret`: `version(1) || kid_len(1) || kid ||
  268. /// nonce(12) || ciphertext+tag`.
  269. ///
  270. /// # Errors
  271. ///
  272. /// Returns [`TotpError::Encrypt`] when no TOTP key is configured or the AEAD
  273. /// fails.
  274. pub fn encrypt_secret(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, TotpError> {
  275. let (key_id, secret) = keys.active_totp_key().ok_or(TotpError::Encrypt)?;
  276. let kid = key_id.as_str().as_bytes();
  277. if kid.len() > usize::from(u8::MAX) {
  278. return Err(TotpError::Encrypt);
  279. }
  280. let key = derive_key(secret.expose_secret().as_bytes());
  281. let mut nonce_bytes = [0u8; NONCE_LEN];
  282. SystemRandom::new()
  283. .fill(&mut nonce_bytes)
  284. .map_err(|_| TotpError::UnavailableRandomness)?;
  285. let sealing =
  286. LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &key).map_err(|_| TotpError::Encrypt)?);
  287. let mut in_out = plaintext.as_bytes().to_vec();
  288. sealing
  289. .seal_in_place_append_tag(
  290. Nonce::assume_unique_for_key(nonce_bytes),
  291. Aad::empty(),
  292. &mut in_out,
  293. )
  294. .map_err(|_| TotpError::Encrypt)?;
  295. let mut blob = Vec::with_capacity(2 + kid.len() + NONCE_LEN + in_out.len());
  296. blob.push(TOTP_BLOB_VERSION);
  297. blob.push(kid.len() as u8);
  298. blob.extend_from_slice(kid);
  299. blob.extend_from_slice(&nonce_bytes);
  300. blob.extend_from_slice(&in_out);
  301. Ok(blob)
  302. }
  303. /// Decrypt a secret previously produced by [`encrypt_secret`], trying the key
  304. /// named in the blob (retained keys keep decrypting after rotation).
  305. ///
  306. /// # Errors
  307. ///
  308. /// Returns [`TotpError::Decrypt`] for a malformed blob, an unknown key id, or
  309. /// an authentication failure. The reason is never distinguished.
  310. pub fn decrypt_secret(keys: &KeyRing, blob: &[u8]) -> Result<String, TotpError> {
  311. if blob.len() < 2 + NONCE_LEN || blob[0] != TOTP_BLOB_VERSION {
  312. return Err(TotpError::Decrypt);
  313. }
  314. let kid_len = usize::from(blob[1]);
  315. let header = 2 + kid_len;
  316. if blob.len() < header + NONCE_LEN {
  317. return Err(TotpError::Decrypt);
  318. }
  319. let kid = core::str::from_utf8(blob.get(2..header).unwrap_or_default())
  320. .map_err(|_| TotpError::Decrypt)?;
  321. let secret = keys.totp_key_by_id(kid).ok_or(TotpError::Decrypt)?;
  322. let key = derive_key(secret.expose_secret().as_bytes());
  323. let mut nonce_bytes = [0u8; NONCE_LEN];
  324. nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
  325. let mut ciphertext = blob[header + NONCE_LEN..].to_vec();
  326. let opening =
  327. LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &key).map_err(|_| TotpError::Decrypt)?);
  328. let plaintext = opening
  329. .open_in_place(
  330. Nonce::assume_unique_for_key(nonce_bytes),
  331. Aad::empty(),
  332. &mut ciphertext,
  333. )
  334. .map_err(|_| TotpError::Decrypt)?;
  335. String::from_utf8(plaintext.to_vec()).map_err(|_| TotpError::Decrypt)
  336. }
  337. /// Build an `otpauth://` enrollment URI (same shape as `cli::secret`).
  338. #[must_use]
  339. pub fn otpauth_uri(issuer: &str, account: &str, secret_b32: &str) -> String {
  340. format!(
  341. "otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits=6&period=30",
  342. percent_encode(issuer),
  343. percent_encode(account),
  344. secret_b32,
  345. percent_encode(issuer)
  346. )
  347. }
  348. /// Render an `otpauth://` enrollment URI as a self-contained SVG QR code.
  349. ///
  350. /// The output is inline SVG with no external references, no `<image>`, and no
  351. /// script, so it embeds directly in a server-rendered page under a
  352. /// `script-src 'none'` policy. An over-long URI maps to [`TotpError::Qr`]; no
  353. /// secret or URI content is logged.
  354. pub fn qr_svg(uri: &str) -> Result<String, TotpError> {
  355. let code = QrCode::new(uri.as_bytes()).map_err(|_| TotpError::Qr)?;
  356. let mut svg = code.render::<svg::Color>().min_dimensions(200, 200).build();
  357. Ok(match svg.find("<svg") {
  358. Some(start) => svg.split_off(start),
  359. None => svg,
  360. })
  361. }
  362. /// Derive the AES-256 key from configured TOTP key material (SHA-256).
  363. fn derive_key(totp_key: &[u8]) -> [u8; 32] {
  364. let digest = Sha256::digest(totp_key);
  365. let mut key = [0u8; 32];
  366. key.copy_from_slice(&digest);
  367. key
  368. }
  369. /// Lowercase hex encoding of raw bytes.
  370. fn hex_bytes(bytes: &[u8]) -> String {
  371. let mut out = String::with_capacity(bytes.len() * 2);
  372. for b in bytes {
  373. out.push_str(&format!("{b:02x}"));
  374. }
  375. out
  376. }
  377. /// Percent-encode a URI component (unreserved passthrough, uppercase hex).
  378. fn percent_encode(value: &str) -> String {
  379. let mut out = String::with_capacity(value.len());
  380. for byte in value.bytes() {
  381. if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
  382. out.push(char::from(byte));
  383. } else {
  384. out.push_str(&format!("%{byte:02X}"));
  385. }
  386. }
  387. out
  388. }
  389. #[cfg(test)]
  390. mod tests;