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>
285 lines
12 KiB
Rust
285 lines
12 KiB
Rust
//! 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()
|
||
}
|