//! Recovery-QR encoding for the reference image_secret. //! //! ## What this module produces //! //! Given a user-chosen recovery passphrase and the 32-byte image_secret //! (extracted from the reference JPEG via [`crate::imgsecret::extract`]), this //! module produces a 109-byte sealed payload that — at recovery time, with the //! same passphrase — yields the original image_secret back. The payload is //! intended to be rendered as a QR v40 EcLevel::M SVG via [`recovery_qr_to_svg`] //! and printed on paper, so a user who loses access to the reference JPEG can //! still unlock their vault if they remember the recovery passphrase. //! //! ## Why the format is structured this way //! //! The payload is an XChaCha20-Poly1305 envelope around the image_secret. The //! AEAD key (the "wrap key") is derived by Argon2id from a domain-separated //! input: //! //! ```text //! kdf_input = b"relicario-recovery-v1\0" //! || u64_be(len(nfc(passphrase))) //! || nfc(passphrase) //! wrap_key = Argon2id(kdf_input, kdf_salt, RECOVERY_PRODUCTION_PARAMS) -> 32 bytes //! ``` //! //! The `b"relicario-recovery-v1\0"` prefix is **domain separation**: it //! guarantees that even if the user reuses their vault passphrase as their //! recovery passphrase, the wrap key derived here can never collide with a //! vault master key derived in [`crate::crypto::derive_master_key`] (which has //! a different input shape entirely — passphrase + image_secret, no prefix). //! Without this prefix, a determined attacker who somehow recovered a wrap key //! could try it as a master key and vice versa. //! //! Both `kdf_salt` and `wrap_nonce` are freshly randomized per call to //! [`generate_recovery_qr`], so two QRs printed from the same passphrase and //! image_secret are different bytes — the printed QR does not leak whether //! the user has printed others before. //! //! ## Parameter-pinning rationale //! //! The Argon2id parameters used here are NOT [`crate::crypto::KdfParams::default`]. //! They are pinned in [`RECOVERY_PRODUCTION_PARAMS`] at the value //! `KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }` — the same values //! the default happens to have *today*, but deliberately re-stated rather than //! referenced. This is because `KdfParams::default()` may evolve as we re-tune //! Argon2 cost for newer hardware, and a recovery QR printed on paper has no //! way to negotiate parameters at decode time. Changing the pinned values here //! would silently invalidate every recovery QR a user has ever printed under //! the previous parameter set. The const lives at module scope so the //! "pinned, do not change once shipped" property is visible at every use site. use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead}; use rand::RngCore; use unicode_normalization::UnicodeNormalization; use zeroize::Zeroizing; use crate::{crypto::KdfParams, error::{RelicarioError, Result}}; // Recovery QR payload — 109 bytes total: // // byte field length // ------ -------------- ------ // 0..4 MAGIC = "RREC" 4 // 4..5 VERSION = 0x01 1 // 5..37 kdf_salt 32 (random per QR) // 37..61 wrap_nonce 24 (random per QR) // 61..109 ciphertext 48 (32 image_secret + 16 AEAD tag) // ------------------------------ // total 109 const MAGIC: &[u8; 4] = b"RREC"; const VERSION: u8 = 0x01; const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109 // Static assertion that the documented layout above and the PAYLOAD_LEN // constant cannot drift apart. If a future edit changes one without the other, // this fails to compile. const _: () = assert!(PAYLOAD_LEN == 4 + 1 + 32 + 24 + 48); // Named slice ranges derived from the layout offsets above. Used by // `unwrap_recovery_qr_with_params` so the byte-position arithmetic at the // parse site is self-documenting. const KDF_SALT_RANGE: std::ops::Range = 5..37; const WRAP_NONCE_RANGE: std::ops::Range = 37..61; const CIPHERTEXT_RANGE: std::ops::Range = 61..109; /// Pinned recovery-QR Argon2id parameters. Re-states `KdfParams::default()`'s /// values rather than referencing them, because a recovery QR printed under /// one parameter set cannot be decoded under another. **Once shipped, these /// values MUST NOT change** — doing so silently invalidates every previously /// printed QR. See the module header for full rationale. const RECOVERY_PRODUCTION_PARAMS: KdfParams = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4, }; /// A sealed 109-byte recovery payload. The bytes are an opaque package — they /// only become useful when fed back through [`unwrap_recovery_qr`] together /// with the recovery passphrase that was used to produce them. /// /// [`as_bytes`](Self::as_bytes) is the only accessor. The bytes are designed to /// travel as a single unit; the supported transport is rendering via /// [`recovery_qr_to_svg`] and printing the QR on paper, but a hex string /// (sneakernet-friendly) works equally well as long as the full 109 bytes /// are preserved. pub struct RecoveryQrPayload { bytes: [u8; PAYLOAD_LEN], } impl RecoveryQrPayload { pub fn as_bytes(&self) -> &[u8; PAYLOAD_LEN] { &self.bytes } } fn recovery_kdf_input(passphrase: &str) -> Vec { let nfc: String = passphrase.nfc().collect(); let nfc_bytes = nfc.as_bytes(); let prefix = b"relicario-recovery-v1\0"; let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len()); input.extend_from_slice(prefix); // length-prefix on nfc_bytes mirrors crypto::derive_master_key (audit H1) input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes()); input.extend_from_slice(nfc_bytes); input } fn derive_wrap_key( passphrase: &str, kdf_salt: &[u8; 32], params: &KdfParams, ) -> Result> { let input = recovery_kdf_input(passphrase); crate::crypto::derive_master_key_raw(&input, kdf_salt, params) } /// Produce a sealed [`RecoveryQrPayload`] from the recovery passphrase and the /// 32-byte image_secret. /// /// # Inputs /// /// - `passphrase`: the user's recovery passphrase (UTF-8). Independent of the /// vault passphrase, but the user may reuse them — the /// `b"relicario-recovery-v1\0"` domain-separation prefix in the KDF input /// guarantees the wrap key still cannot collide with a vault master key. /// - `image_secret`: the 32-byte secret extracted from the reference JPEG /// via [`crate::imgsecret::extract`]. /// /// # Output /// /// A [`RecoveryQrPayload`] whose 109 bytes encode `MAGIC || VERSION || kdf_salt /// || wrap_nonce || ciphertext`. Both `kdf_salt` and `wrap_nonce` are freshly /// drawn from `OsRng` on every call, so two payloads generated from the same /// `(passphrase, image_secret)` pair are distinct bit-for-bit. The printed QR /// therefore does not reveal that the user has printed others before. /// /// To render the payload as a printable SVG, see [`recovery_qr_to_svg`]. /// /// # Errors /// /// Returns [`RelicarioError::RecoveryQr`] if the AEAD wrap fails (extremely /// unlikely in practice — this can only happen if the cipher implementation /// itself errors, not on user input). pub fn generate_recovery_qr( passphrase: &str, image_secret: &[u8; 32], ) -> Result { generate_recovery_qr_with_params(passphrase, image_secret, &RECOVERY_PRODUCTION_PARAMS) } #[doc(hidden)] pub fn generate_recovery_qr_with_params( passphrase: &str, image_secret: &[u8; 32], params: &KdfParams, ) -> Result { let mut kdf_salt = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut kdf_salt); let mut wrap_nonce = [0u8; 24]; rand::rngs::OsRng.fill_bytes(&mut wrap_nonce); let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?; let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref())); let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce); let ciphertext = cipher.encrypt(nonce, image_secret.as_ref()) .map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?; let mut bytes = [0u8; PAYLOAD_LEN]; let mut pos = 0; bytes[pos..pos+4].copy_from_slice(MAGIC); pos += 4; bytes[pos] = VERSION; pos += 1; bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32; bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24; bytes[pos..pos+48].copy_from_slice(&ciphertext); Ok(RecoveryQrPayload { bytes }) } /// Decode a recovery payload back into the original 32-byte image_secret. /// /// # Inputs /// /// - `payload_bytes`: the 109 bytes produced by [`generate_recovery_qr`] (after /// the QR has been scanned, or the hex transcribed and decoded). /// - `passphrase`: the recovery passphrase that was used at generate time. /// /// # Output /// /// The recovered image_secret as `Zeroizing<[u8; 32]>` — the wrapper ensures /// the secret is wiped from memory when the binding goes out of scope, so a /// caller that immediately feeds it into [`crate::crypto::derive_master_key`] /// and then drops it never leaves a copy in process memory longer than /// strictly necessary. /// /// # Errors /// /// - [`RelicarioError::RecoveryQr`] for **format** problems: wrong length, /// bad magic, unsupported version byte. These come from inspecting the /// bytes themselves, before any cryptographic work, so they leak nothing /// about whether the passphrase is right. /// - [`RelicarioError::Decrypt`] for **AEAD** failure — wrong passphrase /// (wrong wrap key) **or** a payload tampered after the fact. The two /// cases are deliberately not distinguished, mirroring the same /// non-distinguishing rejection as [`crate::crypto::decrypt`] (audit M4): /// a Poly1305 tag failure cannot, in principle, leak which bytes were /// wrong, and the API surface preserves that property. pub fn unwrap_recovery_qr( payload_bytes: &[u8], passphrase: &str, ) -> Result> { unwrap_recovery_qr_with_params(payload_bytes, passphrase, &RECOVERY_PRODUCTION_PARAMS) } #[doc(hidden)] pub fn unwrap_recovery_qr_with_params( payload_bytes: &[u8], passphrase: &str, params: &KdfParams, ) -> Result> { if payload_bytes.len() != PAYLOAD_LEN { return Err(RelicarioError::RecoveryQr( format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len()) )); } if &payload_bytes[0..4] != MAGIC { return Err(RelicarioError::RecoveryQr("bad magic".into())); } if payload_bytes[4] != VERSION { return Err(RelicarioError::RecoveryQr( format!("unsupported version 0x{:02x}", payload_bytes[4]) )); } let kdf_salt: &[u8; 32] = payload_bytes[KDF_SALT_RANGE].try_into().expect("slice length validated above"); let wrap_nonce = &payload_bytes[WRAP_NONCE_RANGE]; let ciphertext = &payload_bytes[CIPHERTEXT_RANGE]; let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?; let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref())); let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce); let plaintext = cipher.decrypt(nonce, ciphertext) .map_err(|_| RelicarioError::Decrypt)?; let mut out = Zeroizing::new([0u8; 32]); out.copy_from_slice(&plaintext); Ok(out) } /// Render a [`RecoveryQrPayload`] as a printable QR-code SVG string. /// /// The QR is encoded at **version 40** (the largest standard symbol, 177×177 /// modules) at **error-correction level M** (~15% recoverable), with a /// minimum rendered dimension of **140×140** SVG units. The 109-byte payload /// fits comfortably inside v40 at level M — there is significant /// error-correction headroom left over, which is the point: the QR is /// expected to live on paper (where smudges, folds, and fading are normal) /// and must still scan years later. pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String { use qrcode::{QrCode, EcLevel}; let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M) .expect("109 bytes fits well within QR v40 capacity at EcLevel::M"); code.render::() .min_dimensions(140, 140) .build() }