totp.rs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  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`. Fresh writes use
  10. //! the v3 envelope (`version(3) || kid_len(1) || kid || wrap_nonce(12) ||
  11. //! wrapped_dek(32+16) || data_nonce(12) || ciphertext+tag`): a random 32-byte
  12. //! DEK per row, wrapped under HKDF-SHA256(`totp_key`, info `totp-kek-v1`).
  13. //! The wrap AAD binds the data nonce; the data AAD carries only the format
  14. //! tag plus version, never the kid, so `totp rewrap` can move a DEK to a new
  15. //! key without touching the data ciphertext. The writer is version-gated
  16. //! (`envelope = !pii_dual_write`) so old replicas keep reading v2 during the
  17. //! rolling upgrade. Version-2 blobs (HKDF `totp-gcm-v2`, empty AAD) and
  18. //! version-1 blobs (bare `SHA256(totp_key)`) still decrypt. The two
  19. //! implementations are intentionally small duplicates: `crypto` cannot depend
  20. //! on `cli`.
  21. //!
  22. //! No error variant carries a secret, code, or key: failures collapse to
  23. //! static messages.
  24. use std::time::Duration;
  25. use base32::Alphabet;
  26. use qrcode::{QrCode, render::svg};
  27. use rand::{TryRng as _, rngs::SysRng};
  28. use ring::{
  29. aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
  30. hkdf,
  31. rand::{SecureRandom as _, SystemRandom},
  32. };
  33. use secrecy::ExposeSecret as _;
  34. use sha2::{Digest as _, Sha256};
  35. use totp_rs::{Algorithm, Builder, Secret};
  36. use zeroize::Zeroizing;
  37. use crate::{crypto::ct::ct_eq, keys::KeyRing};
  38. /// TOTP time-step in seconds (RFC 6238 §5.2 `Tx`).
  39. pub const TOTP_STEP_SECS: u64 = 30;
  40. /// Accepted clock drift in steps, each side of the current step.
  41. pub const TOTP_SKEW_STEPS: u16 = 1;
  42. /// Digits per code (RFC 6238 default).
  43. pub const TOTP_DIGITS: u8 = 6;
  44. /// How long a consumed code stays claimed: the 90 s window plus margin.
  45. pub const USED_CODE_TTL: Duration = Duration::from_secs(180);
  46. /// Recovery codes minted per enrollment.
  47. pub const RECOVERY_CODE_COUNT: usize = 10;
  48. /// Current encrypted-secret blob format version (HKDF-SHA256 writes).
  49. const TOTP_BLOB_VERSION: u8 = 2;
  50. /// Envelope-encrypted blob version (per-row DEK): written when the caller
  51. /// passes `envelope = true`.
  52. const TOTP_BLOB_V3: u8 = 3;
  53. /// Legacy blob version (bare-`SHA256(totp_key)` writes): decrypt-only.
  54. const TOTP_BLOB_V1: u8 = 1;
  55. /// HKDF domain-separation label binding the derived key to TOTP AES-GCM use.
  56. const HKDF_INFO: &[u8] = b"totp-gcm-v2";
  57. /// HKDF domain-separation label for the key-encryption key that wraps v3 DEKs.
  58. const KEK_INFO: &[u8] = b"totp-kek-v1";
  59. /// AAD sealing the v3 data ciphertext: format tag plus version only. It must
  60. /// NOT name the wrapping key, so a DEK re-wrap changes the wrap without
  61. /// touching this ciphertext.
  62. const DATA_AAD: &[u8] = b"totp-v3\x03";
  63. /// AES-GCM nonce length.
  64. const NONCE_LEN: usize = 12;
  65. /// Wrapped-DEK length: a 32-byte DEK sealed under AES-256-GCM (16-byte tag).
  66. const WRAPPED_DEK_LEN: usize = 32 + 16;
  67. /// Failures from TOTP enrollment and verification.
  68. ///
  69. /// Every variant renders a static message: secrets, codes, and key material
  70. /// never reach logs or clients.
  71. #[derive(Debug, thiserror::Error)]
  72. pub enum TotpError {
  73. /// The stored secret is not valid base32 or builds no compliant verifier.
  74. #[error("invalid TOTP secret")]
  75. InvalidSecret,
  76. /// The operating-system RNG failed.
  77. #[error("operating-system randomness unavailable")]
  78. UnavailableRandomness,
  79. /// Encryption failed (no TOTP key configured or the AEAD rejected input).
  80. #[error("TOTP encryption failed")]
  81. Encrypt,
  82. /// The blob is malformed, names an unknown key, or fails authentication.
  83. #[error("TOTP secret cannot be decrypted")]
  84. Decrypt,
  85. /// The enrollment URI is too long to encode as a QR code.
  86. #[error("TOTP QR rendering failed")]
  87. Qr,
  88. /// The replay store was unreachable.
  89. #[error("TOTP replay store unavailable")]
  90. Store(#[from] redis::RedisError),
  91. /// The replay store answered with an unexpected reply (fail closed).
  92. #[error("TOTP replay store returned an unexpected reply")]
  93. UnexpectedReply,
  94. }
  95. impl From<TotpError> for crate::error::AppError {
  96. /// Fail-closed mapping: a stored ciphertext that fails to open is
  97. /// [`AppError::PiiDecrypt`] (503 + `pii.decrypt_failure` at the route
  98. /// layer); store and sealing failures are `temporarily_unavailable` (503),
  99. /// never a 500 and never fail-open. Only QR rendering — a server-side
  100. /// rendering defect, not a decrypt or dependency failure — stays
  101. /// `server_error`. Every target is payload-free: no secret, code, key, or
  102. /// store detail reaches the client.
  103. fn from(error: TotpError) -> Self {
  104. match error {
  105. TotpError::Decrypt => Self::PiiDecrypt,
  106. TotpError::Store(_)
  107. | TotpError::UnexpectedReply
  108. | TotpError::Encrypt
  109. | TotpError::UnavailableRandomness
  110. | TotpError::InvalidSecret => Self::temporarily_unavailable(),
  111. TotpError::Qr => Self::server_error(),
  112. }
  113. }
  114. }
  115. /// Whether a replay-marker claim saw the code for the first time.
  116. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  117. pub enum Claim {
  118. /// First presentation of this code in this step.
  119. Fresh,
  120. /// This code was already consumed in this step: reject it.
  121. Replay,
  122. }
  123. /// Generate a fresh 160-bit secret, base32 (RFC 4648, no padding).
  124. ///
  125. /// # Errors
  126. ///
  127. /// Returns [`TotpError::UnavailableRandomness`] when the OS RNG fails.
  128. pub fn generate_secret() -> Result<String, TotpError> {
  129. let mut bytes = [0u8; 20];
  130. SysRng
  131. .try_fill_bytes(&mut bytes)
  132. .map_err(|_| TotpError::UnavailableRandomness)?;
  133. Ok(base32::encode(Alphabet::Rfc4648 { padding: false }, &bytes))
  134. }
  135. /// Generate one-time recovery codes (each 80-bit, base32, no padding).
  136. ///
  137. /// Callers hash every code (Argon2id) before storage and show the plaintext
  138. /// exactly once.
  139. ///
  140. /// # Errors
  141. ///
  142. /// Returns [`TotpError::UnavailableRandomness`] when the OS RNG fails.
  143. pub fn generate_recovery_codes() -> Result<Vec<String>, TotpError> {
  144. let mut codes = Vec::with_capacity(RECOVERY_CODE_COUNT);
  145. for _ in 0..RECOVERY_CODE_COUNT {
  146. let mut bytes = [0u8; 10];
  147. SysRng
  148. .try_fill_bytes(&mut bytes)
  149. .map_err(|_| TotpError::UnavailableRandomness)?;
  150. codes.push(base32::encode(Alphabet::Rfc4648 { padding: false }, &bytes));
  151. }
  152. Ok(codes)
  153. }
  154. /// Normalize user input for comparison: trim, then require 6 ASCII digits.
  155. ///
  156. /// Returns `None` for anything else (a wrong code, never an error).
  157. fn normalize_code(code: &str) -> Option<&str> {
  158. let trimmed = code.trim();
  159. if trimmed.len() == usize::from(TOTP_DIGITS) && trimmed.bytes().all(|b| b.is_ascii_digit()) {
  160. Some(trimmed)
  161. } else {
  162. None
  163. }
  164. }
  165. /// Normalize a presented recovery code: drop separators/whitespace, uppercase.
  166. #[must_use]
  167. pub fn normalize_recovery_code(code: &str) -> String {
  168. code
  169. .chars()
  170. .filter(|c| !c.is_whitespace() && *c != '-')
  171. .flat_map(|c| c.to_uppercase())
  172. .collect()
  173. }
  174. /// Build the RFC 6238 verifier for a stored base32 secret.
  175. ///
  176. /// # Errors
  177. ///
  178. /// Returns [`TotpError::InvalidSecret`] when the secret is not decodable
  179. /// base32 or the parameters fail validation. The offending value is never
  180. /// attached to the error.
  181. pub fn build_totp(secret_b32: &str) -> Result<totp_rs::Totp, TotpError> {
  182. let upper = secret_b32.trim().to_ascii_uppercase();
  183. let raw =
  184. base32::decode(Alphabet::Rfc4648 { padding: false }, &upper).ok_or(TotpError::InvalidSecret)?;
  185. if raw.is_empty() {
  186. return Err(TotpError::InvalidSecret);
  187. }
  188. Builder::new()
  189. .with_secret(Secret::from(raw.as_slice()))
  190. .with_algorithm(Algorithm::SHA1)
  191. .with_digits(TOTP_DIGITS)
  192. .with_skew(TOTP_SKEW_STEPS)
  193. .with_step_duration(TOTP_STEP_SECS)
  194. .build()
  195. .map_err(|_| TotpError::InvalidSecret)
  196. }
  197. /// Check `code` against the ±1-step window around `now_unix`.
  198. ///
  199. /// Returns the matched time-step counter, or `None` for a wrong code. Only a
  200. /// malformed stored secret is an error; a wrong code is `Ok(None)`.
  201. ///
  202. /// # Errors
  203. ///
  204. /// Returns [`TotpError::InvalidSecret`] when the stored secret is malformed.
  205. pub fn verify_window(
  206. secret_b32: &str,
  207. code: &str,
  208. now_unix: u64,
  209. ) -> Result<Option<u64>, TotpError> {
  210. let Some(presented) = normalize_code(code) else {
  211. return Ok(None);
  212. };
  213. let totp = build_totp(secret_b32)?;
  214. let counter = now_unix / TOTP_STEP_SECS;
  215. let skew = u64::from(TOTP_SKEW_STEPS);
  216. for step in [
  217. counter.saturating_sub(skew),
  218. counter,
  219. counter.saturating_add(skew),
  220. ] {
  221. let candidate = totp
  222. .generate(step.saturating_mul(TOTP_STEP_SECS))
  223. .to_string();
  224. if ct_eq(candidate.as_bytes(), presented.as_bytes()) {
  225. return Ok(Some(step));
  226. }
  227. }
  228. Ok(None)
  229. }
  230. /// Domain-separated fingerprint binding a used code to its owner and step.
  231. ///
  232. /// HMAC-SHA256 under a token pepper over the domain-separated
  233. /// message, so the Redis replay marker reveals nothing to brute force: a
  234. /// dump of the marker key cannot be reversed into the 6-digit code within
  235. /// its TTL without the pepper. The fingerprint (never the code) becomes the
  236. /// Redis replay-marker key. Markers are stored under the active pepper;
  237. /// verification probes every retained pepper generation newest-first (see
  238. /// [`replay_fingerprints`] and [`verify_and_claim_with_ring`]), so a code
  239. /// claimed before a rotation still counts as a replay after it within the
  240. /// 180 s claim window.
  241. ///
  242. /// The HMAC key schedule is RFC 2104 §2 verbatim (long peppers are hashed,
  243. /// short peppers zero-padded), infallible by construction, mirroring
  244. /// `store::hmac_sha256` without depending on `store`. It is byte-equivalent
  245. /// with `Hmac::<Sha256>::new_from_slice` over the same message.
  246. #[must_use]
  247. pub fn replay_fingerprint(pepper: &[u8], user_id: &str, code: &str, step: u64) -> String {
  248. use hmac::digest::{KeyInit, Mac as _};
  249. use sha2::digest::Key;
  250. let mut block = Zeroizing::new([0u8; 64]);
  251. if pepper.len() > block.len() {
  252. let hashed = Sha256::digest(pepper);
  253. block[..hashed.len()].copy_from_slice(&hashed);
  254. } else {
  255. block[..pepper.len()].copy_from_slice(pepper);
  256. }
  257. let key: &Key<hmac::Hmac<Sha256>> = (&*block).into();
  258. let mut mac = <hmac::Hmac<Sha256> as KeyInit>::new(key);
  259. mac.update(b"totp-replay-v1|");
  260. mac.update(user_id.as_bytes());
  261. mac.update(b"|");
  262. mac.update(code.as_bytes());
  263. mac.update(b"|");
  264. mac.update(&step.to_be_bytes());
  265. hex_bytes(&mac.finalize().into_bytes())
  266. }
  267. /// Fingerprints for one code under every retained pepper generation,
  268. /// newest-first (one entry per [`KeyRing::peppers_newest_first`] item).
  269. ///
  270. /// Callers store new markers under the first entry (the active pepper) and
  271. /// treat any entry as a replay hit, so a pepper rotation inside the claim
  272. /// window cannot reopen a consumed code.
  273. #[must_use]
  274. pub fn replay_fingerprints(keys: &KeyRing, user_id: &str, code: &str, step: u64) -> Vec<String> {
  275. keys
  276. .peppers_newest_first()
  277. .map(|(_, secret)| replay_fingerprint(secret.expose_secret().as_bytes(), user_id, code, step))
  278. .collect()
  279. }
  280. /// Atomically claim a replay-marker key (`SET key 1 PX ttl NX`).
  281. ///
  282. /// `Ok(Claim::Fresh)` claims first presentation; `Ok(Claim::Replay)` reports
  283. /// a replay. Any transport failure or surprising reply is an error (fail
  284. /// closed), never a silent accept.
  285. ///
  286. /// # Errors
  287. ///
  288. /// Returns [`TotpError::Store`] when Redis fails and
  289. /// [`TotpError::UnexpectedReply`] for a non-`SET`-shaped reply.
  290. pub async fn claim_code(
  291. conn: &mut impl redis::aio::ConnectionLike,
  292. key: &str,
  293. ttl: Duration,
  294. ) -> Result<Claim, TotpError> {
  295. let millis = u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX);
  296. let cmd = redis::cmd("SET")
  297. .arg(key)
  298. .arg("1")
  299. .arg("PX")
  300. .arg(millis)
  301. .arg("NX")
  302. .to_owned();
  303. let reply: redis::Value = conn.req_packed_command(&cmd).await?;
  304. match reply {
  305. redis::Value::Okay => Ok(Claim::Fresh),
  306. redis::Value::Nil => Ok(Claim::Replay),
  307. redis::Value::SimpleString(text) if text == "OK" => Ok(Claim::Fresh),
  308. _ => Err(TotpError::UnexpectedReply),
  309. }
  310. }
  311. /// Verify `code` in the ±1-step window and atomically claim it on first use.
  312. ///
  313. /// `replay_key_for_step` maps the matched step counter to the full Redis
  314. /// replay-marker key (built by the caller from [`replay_fingerprint`] plus
  315. /// the store prefix). Returns `false` for a wrong code AND for a replayed
  316. /// one; callers must not distinguish the two.
  317. ///
  318. /// # Errors
  319. ///
  320. /// Returns [`TotpError::InvalidSecret`] for a malformed stored secret and
  321. /// [`TotpError::Store`] when the replay store fails.
  322. pub async fn verify_and_claim(
  323. conn: &mut impl redis::aio::ConnectionLike,
  324. replay_key_for_step: impl Fn(u64) -> String,
  325. secret_b32: &str,
  326. code: &str,
  327. now_unix: u64,
  328. ttl: Duration,
  329. ) -> Result<bool, TotpError> {
  330. let Some(step) = verify_window(secret_b32, code, now_unix)? else {
  331. return Ok(false);
  332. };
  333. match claim_code(conn, &replay_key_for_step(step), ttl).await? {
  334. Claim::Fresh => Ok(true),
  335. Claim::Replay => Ok(false),
  336. }
  337. }
  338. /// One TOTP presentation to verify and claim across pepper generations.
  339. pub struct TotpClaim<'a> {
  340. /// Owner label bound into the replay fingerprint (user id string).
  341. pub user_id: &'a str,
  342. /// Stored base32 secret to verify against.
  343. pub secret_b32: &'a str,
  344. /// Presented code (whitespace trimmed before fingerprinting).
  345. pub code: &'a str,
  346. /// Current Unix time selecting the ±1-step window.
  347. pub now_unix: u64,
  348. /// How long a consumed code stays claimed.
  349. pub ttl: Duration,
  350. }
  351. /// Verify `code` and claim it across every retained pepper generation.
  352. ///
  353. /// The matched step's fingerprint is computed under each pepper newest-first
  354. /// (see [`replay_fingerprints`]); any hit is a replay. New markers are stored
  355. /// under the active pepper only. `replay_key` maps a fingerprint hex to the
  356. /// full Redis key (store prefix). Returns `false` for a wrong code AND for a
  357. /// replayed one; callers must not distinguish the two.
  358. ///
  359. /// Ordering (F7): older generations are probed read-only (`EXISTS`) before
  360. /// the active generation is claimed atomically (`SET key 1 PX ttl NX`). This
  361. /// order is deliberate. The only shared write is the single `SET NX` on the
  362. /// active key, so concurrent presentations of the same code collapse there:
  363. /// exactly one observes `Fresh`, every other observes `Replay`, and no
  364. /// interleaving can accept the same code twice. Probing older generations
  365. /// first also avoids writing a spurious active marker when the code was
  366. /// already claimed before a pepper rotation (the pre-rotation fingerprint
  367. /// still hits within the claim window). Reversing the order — claim first,
  368. /// then probe — would widen that race and leave junk markers on replays.
  369. ///
  370. /// # Errors
  371. ///
  372. /// Returns [`TotpError::InvalidSecret`] for a malformed stored secret,
  373. /// [`TotpError::Encrypt`] when the ring holds no pepper, and
  374. /// [`TotpError::Store`] when the replay store fails.
  375. pub async fn verify_and_claim_with_ring(
  376. conn: &mut impl redis::aio::ConnectionLike,
  377. keys: &KeyRing,
  378. replay_key: impl Fn(&str) -> String,
  379. claim: &TotpClaim<'_>,
  380. ) -> Result<bool, TotpError> {
  381. let Some(step) = verify_window(claim.secret_b32, claim.code, claim.now_unix)? else {
  382. return Ok(false);
  383. };
  384. let trimmed = claim.code.trim();
  385. let fingerprints = replay_fingerprints(keys, claim.user_id, trimmed, step);
  386. let Some(active) = fingerprints.first() else {
  387. return Err(TotpError::Encrypt);
  388. };
  389. for fingerprint in fingerprints.iter().skip(1) {
  390. let exists: i64 = redis::cmd("EXISTS")
  391. .arg(replay_key(fingerprint))
  392. .query_async(conn)
  393. .await?;
  394. if exists != 0 {
  395. return Ok(false);
  396. }
  397. }
  398. match claim_code(conn, &replay_key(active), claim.ttl).await? {
  399. Claim::Fresh => Ok(true),
  400. Claim::Replay => Ok(false),
  401. }
  402. }
  403. /// Encrypt a TOTP secret under the active TOTP-encryption key.
  404. ///
  405. /// The writer is version-gated for the rolling upgrade: `envelope = false`
  406. /// (while `pii_dual_write` is on) writes the legacy v2 blob so old replicas
  407. /// keep reading; `envelope = true` writes the v3 per-row-DEK envelope.
  408. /// Callers pass `!config.pii_dual_write`.
  409. ///
  410. /// # Errors
  411. ///
  412. /// Returns [`TotpError::Encrypt`] when no TOTP key is configured or the AEAD
  413. /// fails.
  414. pub fn encrypt_secret(
  415. keys: &KeyRing,
  416. plaintext: &str,
  417. envelope: bool,
  418. ) -> Result<Vec<u8>, TotpError> {
  419. if envelope {
  420. encrypt_v3(keys, plaintext)
  421. } else {
  422. encrypt_v2(keys, plaintext)
  423. }
  424. }
  425. /// Legacy v2 write: `version(2) || kid_len(1) || kid || nonce(12) ||
  426. /// ciphertext+tag`, AES-256-GCM under HKDF-SHA256 with empty AAD.
  427. fn encrypt_v2(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, TotpError> {
  428. let (key_id, secret) = keys.active_totp_key().ok_or(TotpError::Encrypt)?;
  429. let kid = key_id.as_str().as_bytes();
  430. if kid.len() > usize::from(u8::MAX) {
  431. return Err(TotpError::Encrypt);
  432. }
  433. let key = derive_key(secret.expose_secret().as_bytes()).map_err(|_| TotpError::Encrypt)?;
  434. let mut nonce_bytes = [0u8; NONCE_LEN];
  435. SystemRandom::new()
  436. .fill(&mut nonce_bytes)
  437. .map_err(|_| TotpError::UnavailableRandomness)?;
  438. let sealing =
  439. LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &key).map_err(|_| TotpError::Encrypt)?);
  440. let mut in_out = plaintext.as_bytes().to_vec();
  441. sealing
  442. .seal_in_place_append_tag(
  443. Nonce::assume_unique_for_key(nonce_bytes),
  444. Aad::empty(),
  445. &mut in_out,
  446. )
  447. .map_err(|_| TotpError::Encrypt)?;
  448. let mut blob = Vec::with_capacity(2 + kid.len() + NONCE_LEN + in_out.len());
  449. blob.push(TOTP_BLOB_VERSION);
  450. blob.push(kid.len() as u8);
  451. blob.extend_from_slice(kid);
  452. blob.extend_from_slice(&nonce_bytes);
  453. blob.extend_from_slice(&in_out);
  454. Ok(blob)
  455. }
  456. /// Envelope write: `version(3) || kid_len(1) || kid || wrap_nonce(12) ||
  457. /// wrapped_dek(32+16) || data_nonce(12) || secret_ct+tag`.
  458. ///
  459. /// A random 32-byte DEK is generated per write, wrapped under a KEK derived
  460. /// via HKDF-SHA256 (info `totp-kek-v1`) from the active TOTP key, and the
  461. /// secret is sealed under the DEK. The data nonce is generated FIRST because
  462. /// the wrap AAD binds it: a wrapped DEK lifted from another row names the
  463. /// wrong nonce and fails to unwrap.
  464. fn encrypt_v3(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, TotpError> {
  465. let (key_id, secret) = keys.active_totp_key().ok_or(TotpError::Encrypt)?;
  466. let kid = key_id.as_str().as_bytes();
  467. if kid.len() > usize::from(u8::MAX) {
  468. return Err(TotpError::Encrypt);
  469. }
  470. let kek = Zeroizing::new(
  471. derive_labeled(secret.expose_secret().as_bytes(), KEK_INFO).map_err(|_| TotpError::Encrypt)?,
  472. );
  473. let rng = SystemRandom::new();
  474. let mut data_nonce = [0u8; NONCE_LEN];
  475. rng
  476. .fill(&mut data_nonce)
  477. .map_err(|_| TotpError::UnavailableRandomness)?;
  478. let mut wrap_nonce = [0u8; NONCE_LEN];
  479. rng
  480. .fill(&mut wrap_nonce)
  481. .map_err(|_| TotpError::UnavailableRandomness)?;
  482. let mut dek = Zeroizing::new([0u8; 32]);
  483. rng
  484. .fill(&mut dek[..])
  485. .map_err(|_| TotpError::UnavailableRandomness)?;
  486. let mut wrapped: Vec<u8> = dek.to_vec();
  487. seal_with_aad(
  488. &kek[..],
  489. wrap_nonce,
  490. &wrap_aad(kid, &data_nonce),
  491. &mut wrapped,
  492. )?;
  493. let mut data_ct = plaintext.as_bytes().to_vec();
  494. seal_with_aad(&dek[..], data_nonce, DATA_AAD, &mut data_ct)?;
  495. let mut blob =
  496. Vec::with_capacity(2 + kid.len() + NONCE_LEN + WRAPPED_DEK_LEN + NONCE_LEN + data_ct.len());
  497. blob.push(TOTP_BLOB_V3);
  498. blob.push(kid.len() as u8);
  499. blob.extend_from_slice(kid);
  500. blob.extend_from_slice(&wrap_nonce);
  501. blob.extend_from_slice(&wrapped);
  502. blob.extend_from_slice(&data_nonce);
  503. blob.extend_from_slice(&data_ct);
  504. Ok(blob)
  505. }
  506. /// Wrap AAD binding a wrapped DEK to its row: format tag, version, wrapping
  507. /// kid, and the data nonce the DEK was sealed for.
  508. fn wrap_aad(kid: &[u8], data_nonce: &[u8; NONCE_LEN]) -> Vec<u8> {
  509. let mut aad = Vec::with_capacity(DATA_AAD.len() + 1 + kid.len() + NONCE_LEN);
  510. aad.extend_from_slice(DATA_AAD);
  511. aad.push(kid.len() as u8);
  512. aad.extend_from_slice(kid);
  513. aad.extend_from_slice(data_nonce);
  514. aad
  515. }
  516. /// Seal `in_out` in place under `key`, appending the GCM tag.
  517. ///
  518. /// # Errors
  519. ///
  520. /// Returns [`TotpError::Encrypt`] when the AEAD rejects the key.
  521. fn seal_with_aad(
  522. key: &[u8],
  523. nonce_bytes: [u8; NONCE_LEN],
  524. aad: &[u8],
  525. in_out: &mut Vec<u8>,
  526. ) -> Result<(), TotpError> {
  527. let sealing =
  528. LessSafeKey::new(UnboundKey::new(&AES_256_GCM, key).map_err(|_| TotpError::Encrypt)?);
  529. sealing
  530. .seal_in_place_append_tag(
  531. Nonce::assume_unique_for_key(nonce_bytes),
  532. Aad::from(aad),
  533. in_out,
  534. )
  535. .map_err(|_| TotpError::Encrypt)
  536. }
  537. /// Decrypt a secret previously produced by [`encrypt_secret`], trying the key
  538. /// named in the blob (retained keys keep decrypting after rotation).
  539. ///
  540. /// Version-3 envelopes unwrap the per-row DEK under the HKDF-SHA256 KEK, then
  541. /// open the data ciphertext; version-2 blobs open under the HKDF-SHA256 key;
  542. /// legacy version-1 blobs first try the HKDF key, then fall back to the
  543. /// bare-`SHA256(totp_key)` key, so rows written before the HKDF migration
  544. /// keep decrypting.
  545. ///
  546. /// # Errors
  547. ///
  548. /// Returns [`TotpError::Decrypt`] for a malformed blob, an unknown key id, or
  549. /// an authentication failure. The reason is never distinguished.
  550. pub fn decrypt_secret(keys: &KeyRing, blob: &[u8]) -> Result<String, TotpError> {
  551. if blob.len() < 2 + NONCE_LEN
  552. || (blob[0] != TOTP_BLOB_V3 && blob[0] != TOTP_BLOB_VERSION && blob[0] != TOTP_BLOB_V1)
  553. {
  554. return Err(TotpError::Decrypt);
  555. }
  556. let version = blob[0];
  557. let kid_len = usize::from(blob[1]);
  558. let header = 2 + kid_len;
  559. if blob.len() < header + NONCE_LEN {
  560. return Err(TotpError::Decrypt);
  561. }
  562. let kid_bytes = blob.get(2..header).ok_or(TotpError::Decrypt)?;
  563. let kid = core::str::from_utf8(kid_bytes).map_err(|_| TotpError::Decrypt)?;
  564. let secret = keys.totp_key_by_id(kid).ok_or(TotpError::Decrypt)?;
  565. let ikm = secret.expose_secret().as_bytes();
  566. if version == TOTP_BLOB_V3 {
  567. return decrypt_v3(ikm, kid_bytes, &blob[header..]);
  568. }
  569. let mut nonce_bytes = [0u8; NONCE_LEN];
  570. nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
  571. let ciphertext = &blob[header + NONCE_LEN..];
  572. if version == TOTP_BLOB_VERSION {
  573. let key = derive_key(ikm).map_err(|_| TotpError::Decrypt)?;
  574. return open_with_key(&key, &nonce_bytes, ciphertext);
  575. }
  576. if let Ok(key) = derive_key(ikm)
  577. && let Ok(plaintext) = open_with_key(&key, &nonce_bytes, ciphertext)
  578. {
  579. return Ok(plaintext);
  580. }
  581. open_with_key(&derive_key_legacy(ikm), &nonce_bytes, ciphertext)
  582. }
  583. /// Open a v3 envelope body: `wrap_nonce(12) || wrapped_dek(32+16) ||
  584. /// data_nonce(12) || secret_ct+tag`.
  585. ///
  586. /// The KEK is re-derived from the key named in the blob; the wrap AAD
  587. /// rebinds that kid plus the stored data nonce, so a wrapped DEK copied from
  588. /// another row fails closed.
  589. ///
  590. /// # Errors
  591. ///
  592. /// Returns [`TotpError::Decrypt`] for a malformed body, an unknown key, or
  593. /// an authentication failure. The reason is never distinguished.
  594. fn decrypt_v3(ikm: &[u8], kid: &[u8], body: &[u8]) -> Result<String, TotpError> {
  595. if body.len() < NONCE_LEN + WRAPPED_DEK_LEN + NONCE_LEN + 16 {
  596. return Err(TotpError::Decrypt);
  597. }
  598. let (wrap_nonce, rest) = body.split_at(NONCE_LEN);
  599. let (wrapped_dek, rest) = rest.split_at(WRAPPED_DEK_LEN);
  600. let (data_nonce, ciphertext) = rest.split_at(NONCE_LEN);
  601. let mut wrap_nonce_bytes = [0u8; NONCE_LEN];
  602. wrap_nonce_bytes.copy_from_slice(wrap_nonce);
  603. let mut data_nonce_bytes = [0u8; NONCE_LEN];
  604. data_nonce_bytes.copy_from_slice(data_nonce);
  605. let kek = Zeroizing::new(derive_labeled(ikm, KEK_INFO).map_err(|_| TotpError::Decrypt)?);
  606. let dek_bytes = open_with_aad(
  607. &kek[..],
  608. &wrap_nonce_bytes,
  609. &wrap_aad(kid, &data_nonce_bytes),
  610. wrapped_dek,
  611. )?;
  612. if dek_bytes.len() != 32 {
  613. return Err(TotpError::Decrypt);
  614. }
  615. let mut dek_key = [0u8; 32];
  616. dek_key.copy_from_slice(&dek_bytes);
  617. let dek = Zeroizing::new(dek_key);
  618. let plaintext = open_with_aad(&dek[..], &data_nonce_bytes, DATA_AAD, ciphertext)?;
  619. String::from_utf8(plaintext).map_err(|_| TotpError::Decrypt)
  620. }
  621. /// Open `ciphertext` (with trailing GCM tag) under `key`, returning UTF-8.
  622. ///
  623. /// # Errors
  624. ///
  625. /// Returns [`TotpError::Decrypt`] for an authentication failure or
  626. /// non-UTF-8 plaintext. The reason is never distinguished.
  627. fn open_with_key(
  628. key: &[u8; 32],
  629. nonce_bytes: &[u8; NONCE_LEN],
  630. ciphertext: &[u8],
  631. ) -> Result<String, TotpError> {
  632. let plaintext = open_with_aad(key, nonce_bytes, &[], ciphertext)?;
  633. String::from_utf8(plaintext).map_err(|_| TotpError::Decrypt)
  634. }
  635. /// Open `ciphertext` (with trailing GCM tag) under `key` and `aad`,
  636. /// returning the raw plaintext.
  637. ///
  638. /// # Errors
  639. ///
  640. /// Returns [`TotpError::Decrypt`] for an authentication failure. The reason
  641. /// is never distinguished.
  642. fn open_with_aad(
  643. key: &[u8],
  644. nonce_bytes: &[u8; NONCE_LEN],
  645. aad: &[u8],
  646. ciphertext: &[u8],
  647. ) -> Result<Vec<u8>, TotpError> {
  648. let mut in_out = ciphertext.to_vec();
  649. let opening =
  650. LessSafeKey::new(UnboundKey::new(&AES_256_GCM, key).map_err(|_| TotpError::Decrypt)?);
  651. let plaintext = opening
  652. .open_in_place(
  653. Nonce::assume_unique_for_key(*nonce_bytes),
  654. Aad::from(aad),
  655. &mut in_out,
  656. )
  657. .map_err(|_| TotpError::Decrypt)?;
  658. Ok(plaintext.to_vec())
  659. }
  660. /// Build an `otpauth://` enrollment URI (same shape as `cli::secret`).
  661. #[must_use]
  662. pub fn otpauth_uri(issuer: &str, account: &str, secret_b32: &str) -> String {
  663. format!(
  664. "otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits=6&period=30",
  665. percent_encode(issuer),
  666. percent_encode(account),
  667. secret_b32,
  668. percent_encode(issuer)
  669. )
  670. }
  671. /// Render an `otpauth://` enrollment URI as a self-contained SVG QR code.
  672. ///
  673. /// The output is inline SVG with no external references, no `<image>`, and no
  674. /// script, so it embeds directly in a server-rendered page under a
  675. /// `script-src 'none'` policy. An over-long URI maps to [`TotpError::Qr`]; no
  676. /// secret or URI content is logged.
  677. pub fn qr_svg(uri: &str) -> Result<String, TotpError> {
  678. let code = QrCode::new(uri.as_bytes()).map_err(|_| TotpError::Qr)?;
  679. let mut svg = code.render::<svg::Color>().min_dimensions(200, 200).build();
  680. Ok(match svg.find("<svg") {
  681. Some(start) => svg.split_off(start),
  682. None => svg,
  683. })
  684. }
  685. /// Derive the AES-256 key from configured TOTP key material via HKDF-SHA256
  686. /// (RFC 5869, empty salt, info `totp-gcm-v2` domain-separating TOTP use).
  687. fn derive_key(totp_key: &[u8]) -> Result<[u8; 32], ring::error::Unspecified> {
  688. derive_labeled(totp_key, HKDF_INFO)
  689. }
  690. /// HKDF-SHA256 (RFC 5869, empty salt) under an explicit domain-separation
  691. /// label: `totp-gcm-v2` seals data directly, `totp-kek-v1` wraps v3 DEKs.
  692. fn derive_labeled(totp_key: &[u8], info: &[u8]) -> Result<[u8; 32], ring::error::Unspecified> {
  693. let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(totp_key);
  694. let info_labels = [info];
  695. let okm = prk.expand(&info_labels, HkdfAesKey)?;
  696. let mut key = [0u8; 32];
  697. okm.fill(&mut key)?;
  698. Ok(key)
  699. }
  700. /// Legacy derivation (bare `SHA256(totp_key)`): decrypt-only fallback for
  701. /// version-1 blobs written before the HKDF migration.
  702. fn derive_key_legacy(totp_key: &[u8]) -> [u8; 32] {
  703. let digest = Sha256::digest(totp_key);
  704. let mut key = [0u8; 32];
  705. key.copy_from_slice(&digest);
  706. key
  707. }
  708. /// `ring::hkdf` output-length marker for a 256-bit AES key.
  709. struct HkdfAesKey;
  710. impl hkdf::KeyType for HkdfAesKey {
  711. fn len(&self) -> usize {
  712. 32
  713. }
  714. }
  715. /// Lowercase hex encoding of raw bytes.
  716. fn hex_bytes(bytes: &[u8]) -> String {
  717. let mut out = String::with_capacity(bytes.len() * 2);
  718. for b in bytes {
  719. out.push_str(&format!("{b:02x}"));
  720. }
  721. out
  722. }
  723. /// Percent-encode a URI component (unreserved passthrough, uppercase hex).
  724. fn percent_encode(value: &str) -> String {
  725. let mut out = String::with_capacity(value.len());
  726. for byte in value.bytes() {
  727. if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
  728. out.push(char::from(byte));
  729. } else {
  730. out.push_str(&format!("%{byte:02X}"));
  731. }
  732. }
  733. out
  734. }
  735. #[cfg(test)]
  736. mod tests;