use age::secrecy::SecretString;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::error::CoreError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BackupKind {
#[serde(rename = "master-key")]
MasterKey,
#[serde(rename = "kdf-salt")]
KdfSalt,
}
impl BackupKind {
pub fn label(self) -> &'static str {
match self {
BackupKind::MasterKey => "master key",
BackupKind::KdfSalt => "kdf salt",
}
}
}
#[derive(Serialize, Deserialize)]
struct Backup {
v: u8,
kind: BackupKind,
data: Vec<u8>,
}
const BACKUP_VERSION: u8 = 1;
pub fn export_backup(kind: BackupKind, data: &[u8], passphrase: &str) -> Result<String, CoreError> {
let payload = Backup {
v: BACKUP_VERSION,
kind,
data: data.to_vec(),
};
let plaintext = Zeroizing::new(
serde_json::to_vec(&payload)
.map_err(|e| CoreError::Keyring(format!("backup encode: {e}")))?,
);
let recipient = age::scrypt::Recipient::new(SecretString::from(passphrase.to_owned()));
age::encrypt_and_armor(&recipient, plaintext.as_slice())
.map_err(|e| CoreError::Keyring(format!("backup export failed: {e}")))
}
pub fn import_backup(
armored: &str,
passphrase: &str,
) -> Result<(BackupKind, Zeroizing<Vec<u8>>), CoreError> {
let identity = age::scrypt::Identity::new(SecretString::from(passphrase.to_owned()));
let plaintext = Zeroizing::new(age::decrypt(&identity, armored.as_bytes()).map_err(|_| {
CoreError::Keyring("backup import failed: wrong passphrase or corrupt backup".into())
})?);
let payload: Backup = serde_json::from_slice(&plaintext)
.map_err(|_| CoreError::Keyring("backup import failed: not a kovra key backup".into()))?;
if payload.v != BACKUP_VERSION {
return Err(CoreError::Keyring(format!(
"backup import failed: unsupported backup version {}",
payload.v
)));
}
Ok((payload.kind, Zeroizing::new(payload.data)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn export_import_round_trips_both_kinds() {
for (kind, data) in [
(BackupKind::MasterKey, vec![0x42u8; 32]),
(BackupKind::KdfSalt, vec![0x9au8; 16]),
] {
let armored = export_backup(kind, &data, "recover-me-please").unwrap();
assert!(armored.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"));
let (got_kind, got_data) = import_backup(&armored, "recover-me-please").unwrap();
assert_eq!(got_kind, kind);
assert_eq!(got_data.as_slice(), data.as_slice());
}
}
#[test]
fn wrong_passphrase_fails() {
let armored = export_backup(BackupKind::MasterKey, &[7u8; 32], "the-real-one").unwrap();
let err = import_backup(&armored, "not-the-one").unwrap_err();
assert!(format!("{err}").contains("wrong passphrase or corrupt backup"));
}
#[test]
fn tampered_blob_fails() {
let mut armored = export_backup(BackupKind::KdfSalt, &[0x11u8; 16], "pw").unwrap();
let mid = armored.len() / 2;
let b = armored.as_bytes()[mid];
let repl = if b == b'A' { 'B' } else { 'A' };
armored.replace_range(mid..mid + 1, &repl.to_string());
assert!(import_backup(&armored, "pw").is_err());
}
#[test]
fn export_does_not_leak_payload_bytes() {
let data = [0x42u8; 32];
let armored = export_backup(BackupKind::MasterKey, &data, "pw").unwrap();
assert!(
!armored.as_bytes().windows(32).any(|w| w == data),
"the armored backup must not contain the raw payload"
);
}
}