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