Skip to main content

vta_backup/
backup_bundle_store.rs

1//! Fjall-backed storage for in-flight backup bundles.
2//!
3//! See `docs/05-design-notes/backup-descriptor-pattern.md` for the
4//! full state machine. Brief recap: every `initiate-export` /
5//! `initiate-import` mints a [`BundleRecord`], the bytes live
6//! separately on disk under `${data_dir}/backups/{bundle_id}.vtabak`,
7//! and a background sweeper transitions expired records to
8//! `Expired` (terminal) and deletes the on-disk bytes.
9//!
10//! Tokens are stored as `SHA-256(token_b64)` so a leaked database
11//! does not yield usable bearer credentials. Validation in the blob
12//! endpoint uses constant-time comparison via `subtle::ConstantTimeEq`.
13
14use std::path::PathBuf;
15
16use base64::Engine;
17use chrono::{DateTime, Utc};
18use rand::Rng;
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use subtle::ConstantTimeEq;
22use uuid::Uuid;
23use zeroize::ZeroizeOnDrop;
24
25use vti_common::error::AppError;
26use vti_common::store::KeyspaceHandle;
27
28/// Length of the raw token bytes generated by `mint_token`. 32 bytes
29/// (256 bits) is the standard for "random bearer credential the
30/// server cannot guess and that doesn't collide".
31const TOKEN_RAW_LEN: usize = 32;
32
33/// Bundle kind — export bytes flow VTA → operator, import bytes flow
34/// operator → VTA. Encoded on the record so the same keyspace can
35/// hold both directions without separate prefixes.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum BundleKind {
39    Export,
40    Import,
41}
42
43/// Per-bundle state machine. Transitions are recorded in
44/// `docs/05-design-notes/backup-descriptor-pattern.md` §"State machine".
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum BundleState {
48    /// Export: bytes are minted and waiting for download.
49    ExportReady,
50    /// Export: bytes have been downloaded once. Terminal —
51    /// blob endpoint refuses further reads (one-shot).
52    ExportDownloaded,
53    /// Export: optional `complete-export` ack received.
54    ExportAcked,
55    /// Import: upload slot minted; awaiting blob POST.
56    ImportPending,
57    /// Import: bytes received; awaiting `finalize-import`.
58    ImportReceived,
59    /// Import: `finalize-import` ran in preview mode. Bundle stays
60    /// open so the operator can re-finalize in commit mode.
61    ImportPreviewed,
62    /// Import: `finalize-import` committed. Terminal.
63    ImportCommitted,
64    /// Operator-requested cancel. Terminal.
65    Aborted,
66    /// Sweeper-driven garbage collection. Terminal.
67    Expired,
68}
69
70impl BundleState {
71    /// True when the state is terminal — the sweeper may free the
72    /// bytes and the dispatcher refuses further mutations except
73    /// retention-driven record deletion.
74    pub fn is_terminal(self) -> bool {
75        matches!(
76            self,
77            Self::ExportDownloaded
78                | Self::ExportAcked
79                | Self::ImportCommitted
80                | Self::Aborted
81                | Self::Expired
82        )
83    }
84}
85
86/// Persistent record for an in-flight backup bundle. The
87/// `token_hash` is `SHA-256(token_b64_url)` — the plaintext token
88/// is returned to the client exactly once at mint time and never
89/// stored.
90///
91/// `Zeroize` is not derived: every field is either a public
92/// identifier (`bundle_id`, `created_by`, `kind`, …) or a hash.
93/// The token plaintext lives only in the mint helper's stack frame
94/// and is dropped immediately after the descriptor is built.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct BundleRecord {
97    pub bundle_id: Uuid,
98    pub kind: BundleKind,
99    pub state: BundleState,
100    pub created_at: DateTime<Utc>,
101    pub expires_at: DateTime<Utc>,
102    /// DID of the super-admin who initiated the bundle. Every
103    /// non-`initiate-*` mutation checks `auth.did == created_by`.
104    pub created_by: String,
105    /// Transport algorithm. v1 only stores `"stream"`.
106    pub algorithm: String,
107    pub expected_sha256: String,
108    pub expected_size_bytes: u64,
109    /// `SHA-256(token_b64)`. Constant-time compared on every
110    /// blob-endpoint request.
111    pub token_hash: [u8; 32],
112    /// On-disk path to the `.vtabak` bytes. Populated:
113    ///   - for export: at descriptor mint time (bytes pre-staged)
114    ///   - for import: after a successful POST to the blob endpoint
115    pub blob_path: Option<PathBuf>,
116}
117
118/// Plaintext token returned to the client at descriptor mint time.
119/// Zeroized on drop so it doesn't linger in memory after the
120/// descriptor is built.
121///
122/// Wrapped in a newtype rather than `String` so a careless
123/// `tracing::info!(?token, …)` redacts via the `Debug` impl below.
124#[derive(Clone, Serialize, Deserialize, ZeroizeOnDrop)]
125pub struct BundleToken(pub String);
126
127impl std::fmt::Debug for BundleToken {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_tuple("BundleToken").field(&"<redacted>").finish()
130    }
131}
132
133impl BundleToken {
134    pub fn as_str(&self) -> &str {
135        &self.0
136    }
137}
138
139fn bundle_key(id: &Uuid) -> String {
140    format!("bundle:{id}")
141}
142
143/// Fetch a bundle record by id.
144pub async fn get_bundle(ks: &KeyspaceHandle, id: &Uuid) -> Result<Option<BundleRecord>, AppError> {
145    ks.get(bundle_key(id)).await
146}
147
148/// Insert or replace a bundle record. Called at every state
149/// transition (mint, blob-endpoint hit, finalize, sweeper expiry).
150pub async fn store_bundle(ks: &KeyspaceHandle, record: &BundleRecord) -> Result<(), AppError> {
151    ks.insert(bundle_key(&record.bundle_id), record).await
152}
153
154/// Remove a bundle record. Called by the sweeper after a terminal
155/// state ages out of the 24h audit retention window.
156pub async fn delete_bundle(ks: &KeyspaceHandle, id: &Uuid) -> Result<(), AppError> {
157    ks.remove(bundle_key(id)).await
158}
159
160/// Enumerate every persisted bundle. The sweeper iterates this to
161/// find candidates for TTL expiry and post-terminal cleanup.
162/// Operator audit tooling can also consult it to inspect open
163/// transfers.
164pub async fn list_bundles(ks: &KeyspaceHandle) -> Result<Vec<BundleRecord>, AppError> {
165    let raw = ks.prefix_iter_raw("bundle:").await?;
166    let mut out = Vec::with_capacity(raw.len());
167    for (_, v) in raw {
168        let record: BundleRecord = serde_json::from_slice(&v)
169            .map_err(|e| AppError::Internal(format!("bundle record decode: {e}")))?;
170        out.push(record);
171    }
172    Ok(out)
173}
174
175/// Mint a fresh bearer token for a new bundle.
176///
177/// Generates 32 random bytes from the OS CSPRNG, encodes as base64url
178/// (no padding) for transport, and returns the plaintext token paired
179/// with its `SHA-256(token_b64)` hash. The plaintext is wrapped in a
180/// [`BundleToken`] so it zeroizes on drop and redacts on `Debug`; the
181/// hash is the value persisted in [`BundleRecord::token_hash`].
182///
183/// Caller responsibility: deliver the plaintext to the client exactly
184/// once (in the descriptor response) and then drop it. The server
185/// never needs the plaintext again — every subsequent blob-endpoint
186/// request validates by re-hashing the caller-supplied header value
187/// and constant-time-comparing against the stored hash.
188pub fn mint_token() -> Result<(BundleToken, [u8; 32]), AppError> {
189    let mut raw = [0u8; TOKEN_RAW_LEN];
190    // Same OS-CSPRNG path the rest of `vta-service` already uses
191    // for crypto material (`operations::backup` for backup nonces,
192    // `keys::imported` for KEK salts). Stays inside the
193    // `aes_gcm::aead` re-export so the workspace pins one
194    // rand-core version transitively.
195    rand::rng().fill_bytes(&mut raw);
196    let token_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
197    let hash = hash_token(&token_b64);
198    Ok((BundleToken(token_b64), hash))
199}
200
201/// Hash a bearer token to the form persisted in
202/// [`BundleRecord::token_hash`]. Plain `SHA-256` over the UTF-8 bytes
203/// of the base64url-encoded token. Used both at mint time and at
204/// validation time so the two values are guaranteed to be derived
205/// the same way.
206pub fn hash_token(token_b64: &str) -> [u8; 32] {
207    let mut hasher = Sha256::new();
208    hasher.update(token_b64.as_bytes());
209    hasher.finalize().into()
210}
211
212/// Constant-time check that the caller-supplied `X-Backup-Token`
213/// matches a stored hash. Returns `true` on match. Returns `false`
214/// without revealing where the mismatch is (no early return on a
215/// per-byte loop).
216///
217/// Compares hashes, not plaintexts, so a `subtle::ConstantTimeEq`
218/// hit on the same hash bytes is the only success path. The first
219/// step (`hash_token`) is itself constant-time per-byte over the
220/// token bytes — SHA-256 has no input-dependent branches.
221pub fn verify_token(provided: &str, expected_hash: &[u8; 32]) -> bool {
222    let computed = hash_token(provided);
223    computed.ct_eq(expected_hash).into()
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use vti_common::config::StoreConfig as VtiStoreConfig;
230    use vti_common::store::Store;
231
232    async fn setup_ks() -> (tempfile::TempDir, KeyspaceHandle) {
233        let dir = tempfile::tempdir().unwrap();
234        let store = Store::open(&VtiStoreConfig {
235            data_dir: dir.path().into(),
236        })
237        .unwrap();
238        let ks = store.keyspace(crate::BACKUP_BUNDLES_TEST).unwrap();
239        (dir, ks)
240    }
241
242    #[tokio::test]
243    async fn bundle_round_trips_through_keyspace() {
244        let (_dir, ks) = setup_ks().await;
245        let id = Uuid::new_v4();
246        let record = BundleRecord {
247            bundle_id: id,
248            kind: BundleKind::Export,
249            state: BundleState::ExportReady,
250            created_at: Utc::now(),
251            expires_at: Utc::now(),
252            created_by: "did:example:admin".into(),
253            algorithm: "stream".into(),
254            expected_sha256: "deadbeef".into(),
255            expected_size_bytes: 42,
256            token_hash: [7u8; 32],
257            blob_path: Some(PathBuf::from("/var/lib/vta/backups/a.vtabak")),
258        };
259        store_bundle(&ks, &record).await.unwrap();
260        let restored = get_bundle(&ks, &id).await.unwrap().unwrap();
261        assert_eq!(restored.bundle_id, id);
262        assert_eq!(restored.state, BundleState::ExportReady);
263        assert_eq!(restored.token_hash, [7u8; 32]);
264    }
265
266    #[tokio::test]
267    async fn delete_removes_record() {
268        let (_dir, ks) = setup_ks().await;
269        let id = Uuid::new_v4();
270        let record = BundleRecord {
271            bundle_id: id,
272            kind: BundleKind::Import,
273            state: BundleState::ImportPending,
274            created_at: Utc::now(),
275            expires_at: Utc::now(),
276            created_by: "did:example:admin".into(),
277            algorithm: "stream".into(),
278            expected_sha256: "feedface".into(),
279            expected_size_bytes: 0,
280            token_hash: [0u8; 32],
281            blob_path: None,
282        };
283        store_bundle(&ks, &record).await.unwrap();
284        delete_bundle(&ks, &id).await.unwrap();
285        assert!(get_bundle(&ks, &id).await.unwrap().is_none());
286    }
287
288    #[tokio::test]
289    async fn list_returns_all_bundles_via_prefix_scan() {
290        let (_dir, ks) = setup_ks().await;
291        let make = |kind: BundleKind, state: BundleState| BundleRecord {
292            bundle_id: Uuid::new_v4(),
293            kind,
294            state,
295            created_at: Utc::now(),
296            expires_at: Utc::now(),
297            created_by: "did:example:admin".into(),
298            algorithm: "stream".into(),
299            expected_sha256: "0".into(),
300            expected_size_bytes: 0,
301            token_hash: [0u8; 32],
302            blob_path: None,
303        };
304        let a = make(BundleKind::Export, BundleState::ExportReady);
305        let b = make(BundleKind::Import, BundleState::ImportPending);
306        store_bundle(&ks, &a).await.unwrap();
307        store_bundle(&ks, &b).await.unwrap();
308        let all = list_bundles(&ks).await.unwrap();
309        assert_eq!(all.len(), 2);
310    }
311
312    #[test]
313    fn is_terminal_pins_the_state_machine_taxonomy() {
314        // Live states: blob endpoint accepts requests, sweeper
315        // candidates only by TTL.
316        assert!(!BundleState::ExportReady.is_terminal());
317        assert!(!BundleState::ImportPending.is_terminal());
318        assert!(!BundleState::ImportReceived.is_terminal());
319        assert!(!BundleState::ImportPreviewed.is_terminal());
320
321        // Terminal states: any further mutation is refused.
322        assert!(BundleState::ExportDownloaded.is_terminal());
323        assert!(BundleState::ExportAcked.is_terminal());
324        assert!(BundleState::ImportCommitted.is_terminal());
325        assert!(BundleState::Aborted.is_terminal());
326        assert!(BundleState::Expired.is_terminal());
327    }
328
329    #[test]
330    fn bundle_token_debug_redacts_secret() {
331        let token = BundleToken("super-secret-token-AAA".into());
332        let dbg = format!("{token:?}");
333        assert!(
334            dbg.contains("<redacted>"),
335            "BundleToken debug must redact secret material: {dbg}"
336        );
337        assert!(!dbg.contains("super-secret-token"));
338    }
339
340    #[test]
341    fn mint_token_produces_distinct_tokens_per_call() {
342        let (a, _) = mint_token().expect("mint a");
343        let (b, _) = mint_token().expect("mint b");
344        assert_ne!(
345            a.as_str(),
346            b.as_str(),
347            "two mint_token calls must produce different tokens \
348             (CSPRNG output collision is effectively impossible)"
349        );
350    }
351
352    #[test]
353    fn mint_token_emits_url_safe_base64() {
354        let (token, _) = mint_token().expect("mint");
355        // base64url alphabet is [A-Za-z0-9_-], no padding.
356        for ch in token.as_str().chars() {
357            assert!(
358                ch.is_ascii_alphanumeric() || ch == '_' || ch == '-',
359                "token contains non-base64url char: {ch:?} ({})",
360                token.as_str()
361            );
362        }
363        assert!(!token.as_str().contains('='));
364    }
365
366    #[test]
367    fn hash_is_deterministic_across_calls() {
368        let h1 = hash_token("AAAA-BBBB-CCCC");
369        let h2 = hash_token("AAAA-BBBB-CCCC");
370        assert_eq!(h1, h2, "SHA-256 of the same input must match");
371    }
372
373    #[test]
374    fn verify_token_accepts_matching_token() {
375        let (token, hash) = mint_token().expect("mint");
376        assert!(
377            verify_token(token.as_str(), &hash),
378            "freshly-minted token must verify against its own hash"
379        );
380    }
381
382    #[test]
383    fn verify_token_rejects_mismatched_token() {
384        let (_token, hash) = mint_token().expect("mint");
385        assert!(
386            !verify_token("not-the-right-token", &hash),
387            "arbitrary string must not validate as a freshly-minted token"
388        );
389    }
390
391    #[test]
392    fn verify_token_rejects_token_with_one_byte_flipped() {
393        let (token, hash) = mint_token().expect("mint");
394        // Flip a single character — should fail verification.
395        let mut tampered = String::from(token.as_str());
396        let first = tampered.chars().next().expect("non-empty");
397        // Replace first char with something different in the alphabet.
398        let replacement = if first == 'A' { 'B' } else { 'A' };
399        tampered.replace_range(0..1, &replacement.to_string());
400        assert!(
401            !verify_token(&tampered, &hash),
402            "single-bit-flipped token must fail verification"
403        );
404    }
405
406    #[test]
407    fn verify_token_rejects_empty_string() {
408        let (_token, hash) = mint_token().expect("mint");
409        assert!(!verify_token("", &hash), "empty string must not validate");
410    }
411
412    #[test]
413    fn mint_token_paired_hash_matches_re_hashed_token() {
414        // Pins the invariant that the hash returned by mint is the
415        // same value we'd compute later from the plaintext. Catches
416        // a refactor that accidentally hashes a different
417        // representation (e.g. raw bytes vs base64url).
418        let (token, hash) = mint_token().expect("mint");
419        let recomputed = hash_token(token.as_str());
420        assert_eq!(hash, recomputed);
421    }
422}