@@ -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.
45103pub 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