Skip to main content

kovra_core/
keybackup.rs

1//! Vault recovery backup & restore (KOV-34, spec §10.2).
2//!
3//! A **mode-aware**, encrypted disaster-recovery backup of what a vault needs to
4//! unlock, sealed into a **standard, ASCII-armored `age`** blob under a recovery
5//! passphrase (age-scrypt). The plaintext never lands in a file or a log
6//! (I7/I12) — only the encrypted blob is emitted; and because the blob is a
7//! normal `age` file it stays recoverable with any age implementation (`age -d`)
8//! even if kovra is unavailable.
9//!
10//! The blob is **self-describing** ([`BackupKind`]): `import` restores it to the
11//! right backend regardless of the machine's current mode, and **respects the
12//! vault's mode** — it never silently migrates one mode to another:
13//!
14//! - **keyring** vaults store the 32-byte master key → backup carries the key,
15//!   restored into the OS keyring.
16//! - **passphrase** vaults *derive* the key (`Argon2(passphrase, salt)`), so an
17//!   arbitrary key cannot be stored; the recoverable material kovra holds is the
18//!   **`kdf.salt`** → backup carries the salt, restored to `kdf.salt`. The
19//!   passphrase stays with the user and is never exported.
20//!
21//! Round-tripping a backup in the same mode is **idempotent** (the same key/salt
22//! is restored).
23//!
24//! This module is **pure**: it knows nothing about the keyring backend or the
25//! filesystem. The CLI wires `export`/`import` around it.
26
27use age::secrecy::SecretString;
28use serde::{Deserialize, Serialize};
29use zeroize::Zeroizing;
30
31use crate::error::CoreError;
32
33/// What a backup blob carries, so `import` restores it to the right backend
34/// (KOV-34). Serialized inside the encrypted payload, never in the clear.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum BackupKind {
37    /// A stored 32-byte master key (keyring-mode vaults) → the OS keyring.
38    #[serde(rename = "master-key")]
39    MasterKey,
40    /// The Argon2 `kdf.salt` (passphrase-mode vaults) → the `kdf.salt` file. The
41    /// passphrase is the user's and is never part of the backup.
42    #[serde(rename = "kdf-salt")]
43    KdfSalt,
44}
45
46impl BackupKind {
47    /// A human label for prompts / audit (never a value).
48    pub fn label(self) -> &'static str {
49        match self {
50            BackupKind::MasterKey => "master key",
51            BackupKind::KdfSalt => "kdf salt",
52        }
53    }
54}
55
56/// Versioned, self-describing backup payload (encrypted before it ever exists on
57/// the wire).
58#[derive(Serialize, Deserialize)]
59struct Backup {
60    v: u8,
61    kind: BackupKind,
62    data: Vec<u8>,
63}
64
65const BACKUP_VERSION: u8 = 1;
66
67/// Encrypt `data` (a master key or a kdf salt, per `kind`) into an ASCII-armored
68/// `age` blob under `passphrase` (age-scrypt). The transient plaintext is wiped
69/// at the end of the call; only the encrypted blob is returned (I7/I12).
70pub 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
85/// Decrypt an [`export_backup`] blob with `passphrase`, returning what it carries
86/// (the caller restores it to the right backend). A wrong passphrase, a tampered
87/// blob, or an unknown format fails cleanly — never a panic, never the bytes.
88pub 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    // Round-trip both kinds: export then import recovers the same kind + bytes.
112    #[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    // A wrong passphrase fails cleanly (no panic, no bytes).
127    #[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    // A tampered blob fails (AEAD integrity).
135    #[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    // I7/I12 — the raw payload bytes never appear in the exported blob.
146    #[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}