diff --git a/crates/relicario-core/Cargo.toml b/crates/relicario-core/Cargo.toml index 7f49598..6b0af65 100644 --- a/crates/relicario-core/Cargo.toml +++ b/crates/relicario-core/Cargo.toml @@ -31,6 +31,6 @@ zstd = { version = "0.13", default-features = false } tar = { version = "0.4", default-features = false } base64 = "0.22" csv = "1" -qrcode = { version = "0.14", default-features = false } +qrcode = { version = "0.14", default-features = false, features = ["svg"] } [dev-dependencies] diff --git a/crates/relicario-core/src/recovery_qr.rs b/crates/relicario-core/src/recovery_qr.rs new file mode 100644 index 0000000..15becad --- /dev/null +++ b/crates/relicario-core/src/recovery_qr.rs @@ -0,0 +1,129 @@ +use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead}; +use rand::RngCore; +use unicode_normalization::UnicodeNormalization; +use zeroize::Zeroizing; +use crate::{crypto::KdfParams, error::{RelicarioError, Result}}; + +const MAGIC: &[u8; 4] = b"RREC"; +const VERSION: u8 = 0x01; +const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109 + +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); + input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes()); + input.extend_from_slice(nfc_bytes); + input +} + +fn production_params() -> KdfParams { + KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 } +} + +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) +} + +pub fn generate_recovery_qr( + passphrase: &str, + image_secret: &[u8; 32], +) -> Result { + generate_recovery_qr_with_params(passphrase, image_secret, &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 }) +} + +pub fn unwrap_recovery_qr( + payload_bytes: &[u8], + passphrase: &str, +) -> Result> { + unwrap_recovery_qr_with_params(payload_bytes, passphrase, &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[5..37].try_into().unwrap(); + let wrap_nonce = &payload_bytes[37..61]; + let ciphertext = &payload_bytes[61..109]; + + 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) +} + +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-byte payload always fits QR version 6"); + code.render::() + .min_dimensions(140, 140) + .build() +}