feat(core): recovery_qr generate + unwrap + SVG functions
This commit is contained in:
@@ -31,6 +31,6 @@ zstd = { version = "0.13", default-features = false }
|
|||||||
tar = { version = "0.4", default-features = false }
|
tar = { version = "0.4", default-features = false }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
csv = "1"
|
csv = "1"
|
||||||
qrcode = { version = "0.14", default-features = false }
|
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
129
crates/relicario-core/src/recovery_qr.rs
Normal file
129
crates/relicario-core/src/recovery_qr.rs
Normal file
@@ -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<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);
|
||||||
|
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<Zeroizing<[u8; 32]>> {
|
||||||
|
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<RecoveryQrPayload> {
|
||||||
|
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<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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unwrap_recovery_qr(
|
||||||
|
payload_bytes: &[u8],
|
||||||
|
passphrase: &str,
|
||||||
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
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<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[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::<qrcode::render::svg::Color>()
|
||||||
|
.min_dimensions(140, 140)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user