1use age::secrecy::SecretString;
28use serde::{Deserialize, Serialize};
29use zeroize::Zeroizing;
30
31use crate::error::CoreError;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum BackupKind {
37 #[serde(rename = "master-key")]
39 MasterKey,
40 #[serde(rename = "kdf-salt")]
43 KdfSalt,
44}
45
46impl BackupKind {
47 pub fn label(self) -> &'static str {
49 match self {
50 BackupKind::MasterKey => "master key",
51 BackupKind::KdfSalt => "kdf salt",
52 }
53 }
54}
55
56#[derive(Serialize, Deserialize)]
59struct Backup {
60 v: u8,
61 kind: BackupKind,
62 data: Vec<u8>,
63}
64
65const BACKUP_VERSION: u8 = 1;
66
67pub fn export_backup(kind: BackupKind, data: &[u8], passphrase: &str) -> Result<String, CoreError> {
71 let payload = Backup {
72 v: BACKUP_VERSION,
73 kind,
74 data: data.to_vec(),
75 };
76 let plaintext = Zeroizing::new(
77 serde_json::to_vec(&payload)
78 .map_err(|e| CoreError::Keyring(format!("backup encode: {e}")))?,
79 );
80 let recipient = age::scrypt::Recipient::new(SecretString::from(passphrase.to_owned()));
81 age::encrypt_and_armor(&recipient, plaintext.as_slice())
82 .map_err(|e| CoreError::Keyring(format!("backup export failed: {e}")))
83}
84
85pub fn import_backup(
89 armored: &str,
90 passphrase: &str,
91) -> Result<(BackupKind, Zeroizing<Vec<u8>>), CoreError> {
92 let identity = age::scrypt::Identity::new(SecretString::from(passphrase.to_owned()));
93 let plaintext = Zeroizing::new(age::decrypt(&identity, armored.as_bytes()).map_err(|_| {
94 CoreError::Keyring("backup import failed: wrong passphrase or corrupt backup".into())
95 })?);
96 let payload: Backup = serde_json::from_slice(&plaintext)
97 .map_err(|_| CoreError::Keyring("backup import failed: not a kovra key backup".into()))?;
98 if payload.v != BACKUP_VERSION {
99 return Err(CoreError::Keyring(format!(
100 "backup import failed: unsupported backup version {}",
101 payload.v
102 )));
103 }
104 Ok((payload.kind, Zeroizing::new(payload.data)))
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
113 fn export_import_round_trips_both_kinds() {
114 for (kind, data) in [
115 (BackupKind::MasterKey, vec![0x42u8; 32]),
116 (BackupKind::KdfSalt, vec![0x9au8; 16]),
117 ] {
118 let armored = export_backup(kind, &data, "recover-me-please").unwrap();
119 assert!(armored.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"));
120 let (got_kind, got_data) = import_backup(&armored, "recover-me-please").unwrap();
121 assert_eq!(got_kind, kind);
122 assert_eq!(got_data.as_slice(), data.as_slice());
123 }
124 }
125
126 #[test]
128 fn wrong_passphrase_fails() {
129 let armored = export_backup(BackupKind::MasterKey, &[7u8; 32], "the-real-one").unwrap();
130 let err = import_backup(&armored, "not-the-one").unwrap_err();
131 assert!(format!("{err}").contains("wrong passphrase or corrupt backup"));
132 }
133
134 #[test]
136 fn tampered_blob_fails() {
137 let mut armored = export_backup(BackupKind::KdfSalt, &[0x11u8; 16], "pw").unwrap();
138 let mid = armored.len() / 2;
139 let b = armored.as_bytes()[mid];
140 let repl = if b == b'A' { 'B' } else { 'A' };
141 armored.replace_range(mid..mid + 1, &repl.to_string());
142 assert!(import_backup(&armored, "pw").is_err());
143 }
144
145 #[test]
147 fn export_does_not_leak_payload_bytes() {
148 let data = [0x42u8; 32];
149 let armored = export_backup(BackupKind::MasterKey, &data, "pw").unwrap();
150 assert!(
151 !armored.as_bytes().windows(32).any(|w| w == data),
152 "the armored backup must not contain the raw payload"
153 );
154 }
155}