Skip to content

Commit b1779d4

Browse files
Merge pull request #10 from Spacecraft-Software/cache-persistence
feat(vault): encrypted-cache persistence + offline unlock
2 parents 658eda0 + 6ed689b commit b1779d4

9 files changed

Lines changed: 390 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,35 @@ range may break in any release.
1010

1111
### Added
1212

13+
- **Encrypted-cache persistence + offline unlock.** The agent now writes its
14+
vault to disk and can unlock without the network — the substrate for the
15+
upcoming **PIN unlock** (and useful on its own).
16+
- On an online `unlock` (and every `sync`), the agent persists
17+
`$XDG_DATA_HOME/vault/<account>/cache.json` (`<account>` = sanitized
18+
`host_email`): the `/sync` response encrypted under the user key, plus the
19+
`protected_user_key` (the login token `Key` — the user key encrypted under
20+
the master-stretched key, safe at rest) and the account `kdf` params.
21+
`vault-store` was already built for this but had never been wired in;
22+
`VaultCache` is now schema 2 (new fields are serde-defaulted, so any older
23+
file still loads).
24+
- **Offline unlock:** when a live login fails with a network error and a
25+
cache exists, `unlock` falls back to the cache — re-derives the master key
26+
locally from the cached KDF params, decrypts the `protected_user_key` (the
27+
EncString MAC check detects a wrong password → `BadPassword`), and loads
28+
ciphers from the encrypted payload. Bad password / 2FA still propagate (no
29+
fallback). Unlock now survives restart and works without connectivity once
30+
you've unlocked online once.
31+
- An offline session has **no access token**, so `Vault.client` is `None` and
32+
sync / add / edit / remove return the new typed `Error::Offline`
33+
("unlock again while online…", CLI exit code 11). Read paths (status /
34+
list / get / copy / TUI browse) work fully from cache. (Local mutations
35+
don't re-persist the cache yet, so it can lag edits until the next
36+
`sync` — tracked.)
37+
- Tests: `KdfParams` serde round-trip; `VaultCache` protected-key + kdf
38+
round-trip and legacy-v1 load; a pure `unlock_from_cache` recovery +
39+
wrong-password rejection; `account_dir_name` sanitization; and the
40+
offline-session `Error::Offline` gating. No new dependencies.
41+
1342
- **TUI text-input editing — readline keys + bracketed paste (PRD §7.2).** The
1443
`/` search, `:` command line, and every add/edit form field were
1544
append/backspace-only `String`s with no cursor and no paste. They now share a

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ agent: `status` / `unlock` / `lock` / `sync` / `list` / `get` / `add` /
2020
`edit` / `remove` / `generate` / `stop-agent` on the CLI (with `--json`
2121
everywhere), and a three-pane `vault-tui` with search, reveal/copy
2222
(agent-side clipboard, 30 s auto-clear), a generator overlay, a `:` command
23-
line, and add/edit/delete. The CLI auto-starts the agent when needed. See
24-
[PRD §12](./PRD.md#12-milestones) for the roadmap (M0 → v0.1) and
25-
[`CHANGELOG.md`](./CHANGELOG.md) for per-slice detail.
23+
line, and add/edit/delete. The CLI auto-starts the agent when needed, and
24+
once you've unlocked online at least once, `unlock` also works **offline**
25+
from an encrypted local cache (read/copy from cache; sync and edits need the
26+
network). See [PRD §12](./PRD.md#12-milestones) for the roadmap (M0 → v0.1)
27+
and [`CHANGELOG.md`](./CHANGELOG.md) for per-slice detail.
2628

2729
## Build
2830

crates/vault-agent/src/state.rs

Lines changed: 129 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,72 @@ pub struct Vault {
3333
pub ciphers: Vec<Cipher>,
3434
/// Folder id → decrypted folder name.
3535
pub folders: std::collections::HashMap<String, String>,
36-
/// Authenticated REST client, reused by `Sync`/`Remove`/`Edit`/`Add` for
37-
/// the lifetime of the unlock. Holds the access token internally; dropped
38-
/// when the agent locks.
39-
pub client: BitwardenClient,
36+
/// Authenticated REST client for `Sync`/`Remove`/`Edit`/`Add`, holding the
37+
/// access token. `Some` for an online unlock; `None` for a session unlocked
38+
/// from the local cache (offline) — server ops then return `Error::Offline`.
39+
pub client: Option<BitwardenClient>,
40+
/// The account's protected user key (login token `Key`) and KDF params,
41+
/// carried so a refresh (`resync`) can re-persist the cache without
42+
/// re-reading the file, and so offline unlock can round-trip them.
43+
pub protected_user_key: String,
44+
/// Account KDF parameters (for offline master-key derivation / re-persist).
45+
pub kdf: vault_core::kdf::KdfParams,
46+
/// Stable device id this session unlocked with (persisted to the cache).
47+
pub device_id: String,
4048
/// Most recent sync time (ISO 8601 UTC), or `None` if never synced.
4149
pub last_sync: Option<String>,
4250
}
4351

52+
impl Vault {
53+
/// Encrypt the `/sync` response under the user key and write the account's
54+
/// cache (payload + protected user key + KDF + device id) to disk
55+
/// atomically, so a later `unlock` can reconstruct this vault offline.
56+
///
57+
/// # Errors
58+
///
59+
/// Returns [`IpcError::Internal`] if the data dir can't be located, the
60+
/// `/sync` body can't be re-serialized, or the encrypted write fails.
61+
pub fn persist_cache(&self, sync: &vault_api::SyncResponse) -> Result<(), IpcError> {
62+
let dir = vault_store::default_data_dir()
63+
.ok_or_else(|| IpcError::Internal("no data directory".to_owned()))?
64+
.join(account_dir_name(&self.server, &self.email));
65+
let sync_bytes = serde_json::to_vec(sync)
66+
.map_err(|e| IpcError::Internal(format!("serialize sync: {e}")))?;
67+
let mut cache =
68+
vault_store::VaultCache::new(self.device_id.clone(), self.server.clone(), &self.email);
69+
cache
70+
.set_payload(&self.user_enc, &self.user_mac, &sync_bytes)
71+
.map_err(|e| IpcError::Internal(format!("encrypt cache: {e}")))?;
72+
cache.protected_user_key = Some(self.protected_user_key.clone());
73+
cache.kdf = Some(self.kdf);
74+
vault_store::save_to_dir(&dir, &cache)
75+
.map_err(|e| IpcError::Internal(format!("write cache: {e}")))?;
76+
Ok(())
77+
}
78+
}
79+
80+
/// Filesystem-safe per-account cache subdirectory, keyed by host + email so
81+
/// distinct accounts (and the same email on different servers) never collide.
82+
/// Any character outside `[A-Za-z0-9._-]` becomes `_`.
83+
#[must_use]
84+
pub fn account_dir_name(server: &str, email: &str) -> String {
85+
let host = server
86+
.strip_prefix("https://")
87+
.or_else(|| server.strip_prefix("http://"))
88+
.unwrap_or(server)
89+
.trim_end_matches('/');
90+
let raw = format!("{host}_{}", email.to_lowercase());
91+
raw.chars()
92+
.map(|c| {
93+
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
94+
c
95+
} else {
96+
'_'
97+
}
98+
})
99+
.collect()
100+
}
101+
44102
/// Top-level agent state.
45103
pub struct AgentState {
46104
/// `None` when locked; `Some` when unlocked.
@@ -249,6 +307,8 @@ impl AgentState {
249307
.map_err(|e| IpcError::Decrypt(e.to_string()))?
250308
.unwrap_or_else(|| "<unnamed>".to_owned());
251309
v.client
310+
.as_mut()
311+
.ok_or(IpcError::Offline)?
252312
.delete_cipher(&id)
253313
.await
254314
.map_err(|e| IpcError::Network(e.to_string()))?;
@@ -302,6 +362,8 @@ impl AgentState {
302362
let mut cipher = Cipher::from_plain(&plain, &v.user_enc, &v.user_mac);
303363
let id = v
304364
.client
365+
.as_mut()
366+
.ok_or(IpcError::Offline)?
305367
.create_cipher(&cipher)
306368
.await
307369
.map_err(|e| IpcError::Network(e.to_string()))?;
@@ -341,6 +403,8 @@ impl AgentState {
341403

342404
let id = cipher.id.clone();
343405
v.client
406+
.as_mut()
407+
.ok_or(IpcError::Offline)?
344408
.update_cipher(&id, &cipher)
345409
.await
346410
.map_err(|e| IpcError::Network(e.to_string()))?;
@@ -352,19 +416,20 @@ impl AgentState {
352416
Ok(Saved { id, name })
353417
}
354418

355-
/// Re-pull `/sync` over the existing authenticated session and replace the
356-
/// in-memory ciphers, folder map, and `last_sync` stamp. Requires an
357-
/// unlocked agent.
419+
/// Re-pull `/sync` over the existing authenticated session, replace the
420+
/// in-memory ciphers / folder map / `last_sync`, and re-persist the
421+
/// encrypted cache so an offline unlock sees the refreshed vault. Requires
422+
/// an online session (`Error::Offline` when unlocked from cache).
358423
///
359-
/// Like `unlock`, this refreshes only the in-memory vault — the on-disk
360-
/// encrypted cache is not (yet) written by the agent. Known limitation: a
361-
/// `sync` long after `unlock` can fail with a `401` once the access token
362-
/// expires; there is no refresh-token flow in M4, so that surfaces as
363-
/// `IpcError::Network` (shared with `add`/`edit`/`remove`).
424+
/// Known limitation: a `sync` long after `unlock` can fail with a `401`
425+
/// once the access token expires; there is no refresh-token flow yet, so
426+
/// that surfaces as `IpcError::Network`.
364427
pub async fn resync(&mut self) -> Result<(), IpcError> {
365428
let v = self.vault.as_mut().ok_or(IpcError::Locked)?;
366429
let sync = v
367430
.client
431+
.as_mut()
432+
.ok_or(IpcError::Offline)?
368433
.sync()
369434
.await
370435
.map_err(|e| IpcError::Network(e.to_string()))?;
@@ -373,6 +438,11 @@ impl AgentState {
373438
v.ciphers = ciphers;
374439
v.folders = folders;
375440
v.last_sync = crate::unlock::now_iso();
441+
// Refresh the on-disk cache; a write failure shouldn't fail the sync
442+
// (the in-memory vault is already updated), so it's best-effort.
443+
if let Err(e) = v.persist_cache(&sync) {
444+
eprintln!("vault-agent: cache persist after sync failed: {e}");
445+
}
376446
Ok(())
377447
}
378448

@@ -1013,8 +1083,54 @@ mod tests {
10131083
user_mac: Zeroizing::new([0u8; 32]),
10141084
ciphers: Vec::new(),
10151085
folders: std::collections::HashMap::new(),
1016-
client,
1086+
client: Some(client),
1087+
protected_user_key: String::new(),
1088+
kdf: vault_core::kdf::KdfParams {
1089+
kind: vault_core::kdf::KdfType::Pbkdf2Sha256,
1090+
iterations: 1,
1091+
memory_kib: None,
1092+
parallelism: None,
1093+
},
1094+
device_id: "00000000-0000-0000-0000-000000000000".into(),
10171095
last_sync: None,
10181096
}
10191097
}
1098+
1099+
/// A vault unlocked from cache (offline) — no token; server ops must
1100+
/// decline. Used to assert the `Error::Offline` gating.
1101+
fn offline_vault() -> Vault {
1102+
let mut v = stub_vault();
1103+
v.client = None;
1104+
v
1105+
}
1106+
1107+
#[test]
1108+
fn offline_session_declines_server_ops() {
1109+
let mut s = AgentState::new(900);
1110+
s.vault = Some(offline_vault());
1111+
// resync needs the network session → Offline.
1112+
let rt = tokio::runtime::Builder::new_current_thread()
1113+
.build()
1114+
.expect("rt");
1115+
let err = rt.block_on(s.resync()).unwrap_err();
1116+
assert!(matches!(err, IpcError::Offline), "got {err:?}");
1117+
// A read path still works (no ciphers, but not an error).
1118+
assert!(s.list_entries().is_ok());
1119+
}
1120+
1121+
#[test]
1122+
fn account_dir_name_is_filesystem_safe_and_distinct() {
1123+
let a = account_dir_name("https://vault.example.org", "Me@Example.org");
1124+
// host kept; email lower-cased; '@' sanitized to '_'.
1125+
assert_eq!(a, "vault.example.org_me_example.org");
1126+
// Different server, same email → different dir.
1127+
let b = account_dir_name("https://other.example.org/", "me@example.org");
1128+
assert_ne!(a, b);
1129+
// Anything weird is sanitized to '_'.
1130+
let c = account_dir_name("https://h/x?y", "a/b c");
1131+
assert!(
1132+
!c.contains('/') && !c.contains(' ') && !c.contains('?'),
1133+
"{c}"
1134+
);
1135+
}
10201136
}

0 commit comments

Comments
 (0)