Files
relicario/crates/relicario-core/src/recovery_qr.rs
adlee-was-taken f8296fa03b docs(core): drop intra-doc link to private RECOVERY_PRODUCTION_PARAMS
Phase 3 code-quality review caught that the [`RECOVERY_PRODUCTION_PARAMS`]
form in the module header introduced a new rustdoc warning (the const is
module-private, so the link only resolves under --document-private-items).
Drop the brackets so it renders as plain backticks — same visual, no
broken link, no need to widen visibility.

Refs: docs/superpowers/specs/2026-05-04-security-polish-design.md (Phase 3)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:53:20 -04:00

285 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<usize> = 5..37;
const WRAP_NONCE_RANGE: std::ops::Range<usize> = 37..61;
const CIPHERTEXT_RANGE: std::ops::Range<usize> = 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<u8> {
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<Zeroizing<[u8; 32]>> {
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<RecoveryQrPayload> {
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<RecoveryQrPayload> {
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<Zeroizing<[u8; 32]>> {
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<Zeroizing<[u8; 32]>> {
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::<qrcode::render::svg::Color>()
.min_dimensions(140, 140)
.build()
}