Skip to main content

bw/tools/
send_access_token_cache.rs

1//! Persistent cache for Send access tokens, so repeat `bw receive` / `bw send receive` calls for
2//! the same Send within a token's validity window don't re-prompt for a password or OTP.
3//!
4//! Keyed on `(resolved_host, send_id)`, not `send_id` alone. `resolved_host` is whatever
5//! `receive::resolve_urls` decided to talk to for this invocation, so a cached token can never
6//! be looked up under a host other than the one it was actually minted against. This
7//! is deliberate: legacy's own cache (`default-send-token.service.ts`) is keyed on `sendId`
8//! alone with no host component, which is part of what PM-40120 tracks — this cache does not
9//! replicate that.
10//!
11//! A malformed, unreadable, or unwritable cache file never fails the caller: the cache is an
12//! optimization over minting a fresh token, not something `bw receive` depends on to function.
13//!
14//! This module has no notion of trust — it will cache anything a caller asks it to. The
15//! restriction to trusted hosts only (matching the corresponding TS client fix) is enforced at
16//! the call site in `receive::run_receive`, gated on the `trusted` flag `resolve_urls` returns.
17
18use std::{collections::HashMap, path::Path};
19
20use serde::{Deserialize, Serialize};
21
22use crate::platform::appdata_dir;
23
24/// Milliseconds of slack subtracted from a token's real expiry before treating a cached entry as
25/// usable, so a cache hit doesn't get used moments before the server would reject it anyway.
26/// Mirrors the legacy CLI's 5-second threshold (`SendAccessToken.isExpired`).
27const EXPIRY_SLACK_MS: i64 = 5_000;
28
29#[derive(Debug, Default, Serialize, Deserialize)]
30struct CacheFile {
31    #[serde(default)]
32    entries: HashMap<String, CachedToken>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36struct CachedToken {
37    token: String,
38    expires_at: i64,
39}
40
41fn cache_key(resolved_host: &str, send_id: &str) -> String {
42    format!("{resolved_host}|{send_id}")
43}
44
45fn cache_path() -> Option<std::path::PathBuf> {
46    appdata_dir()
47        .ok()
48        .map(|dir| dir.join("send_access_tokens.json"))
49}
50
51fn read_cache_from(path: &Path) -> CacheFile {
52    match std::fs::read(path) {
53        Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
54        Err(_) => CacheFile::default(),
55    }
56}
57
58fn write_cache_to(path: &Path, cache: &CacheFile) {
59    let Ok(json) = serde_json::to_vec(cache) else {
60        return;
61    };
62    if let Some(parent) = path.parent() {
63        let _ = std::fs::create_dir_all(parent);
64    }
65    let _ = super::file_output::write_file_private(path, &json);
66}
67
68/// Returns a cached, still-valid token for `(resolved_host, send_id)`, if any.
69pub(crate) fn get(resolved_host: &str, send_id: &str) -> Option<String> {
70    let path = cache_path()?;
71    let cache = read_cache_from(&path);
72    let entry = cache.entries.get(&cache_key(resolved_host, send_id))?;
73    let now = chrono::Utc::now().timestamp_millis();
74    (entry.expires_at - EXPIRY_SLACK_MS > now).then(|| entry.token.clone())
75}
76
77/// Caches `token` for `(resolved_host, send_id)`, expiring at `expires_at` (epoch-ms, matching
78/// [`bitwarden_auth::send_access::SendAccessTokenResponse::expires_at`]).
79pub(crate) fn set(resolved_host: &str, send_id: &str, token: &str, expires_at: i64) {
80    let Some(path) = cache_path() else {
81        return;
82    };
83    let mut cache = read_cache_from(&path);
84    cache.entries.insert(
85        cache_key(resolved_host, send_id),
86        CachedToken {
87            token: token.to_string(),
88            expires_at,
89        },
90    );
91    write_cache_to(&path, &cache);
92}
93
94/// Evicts a cached entry, e.g. after the server rejects a token the cache believed was still
95/// valid (clock skew, or server-side revocation before natural expiry).
96pub(crate) fn evict(resolved_host: &str, send_id: &str) {
97    let Some(path) = cache_path() else {
98        return;
99    };
100    let mut cache = read_cache_from(&path);
101    if cache
102        .entries
103        .remove(&cache_key(resolved_host, send_id))
104        .is_some()
105    {
106        write_cache_to(&path, &cache);
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use std::path::PathBuf;
113
114    use super::*;
115
116    fn tempdir() -> PathBuf {
117        let dir = std::env::temp_dir().join(format!(
118            "bw-send-access-token-cache-test-{}-{}",
119            std::process::id(),
120            uuid::Uuid::new_v4(),
121        ));
122        std::fs::create_dir_all(&dir).expect("tempdir");
123        dir
124    }
125
126    fn cache_file_path() -> PathBuf {
127        tempdir().join("send_access_tokens.json")
128    }
129
130    fn now_ms() -> i64 {
131        chrono::Utc::now().timestamp_millis()
132    }
133
134    #[test]
135    fn get_returns_none_when_file_is_missing() {
136        let path = cache_file_path();
137        let cache = read_cache_from(&path);
138        assert!(cache.entries.is_empty());
139    }
140
141    #[test]
142    fn set_then_get_round_trips_a_token() {
143        let path = cache_file_path();
144        let mut cache = read_cache_from(&path);
145        cache.entries.insert(
146            cache_key("https://api.example.com", "send-1"),
147            CachedToken {
148                token: "tok".to_string(),
149                expires_at: now_ms() + 60_000,
150            },
151        );
152        write_cache_to(&path, &cache);
153
154        let reloaded = read_cache_from(&path);
155        let entry = reloaded
156            .entries
157            .get(&cache_key("https://api.example.com", "send-1"))
158            .unwrap();
159        assert_eq!(entry.token, "tok");
160    }
161
162    #[test]
163    fn different_hosts_do_not_collide_for_the_same_send_id() {
164        let path = cache_file_path();
165        let mut cache = read_cache_from(&path);
166        cache.entries.insert(
167            cache_key("https://real.example.com", "send-1"),
168            CachedToken {
169                token: "real-token".to_string(),
170                expires_at: now_ms() + 60_000,
171            },
172        );
173        write_cache_to(&path, &cache);
174
175        let reloaded = read_cache_from(&path);
176        assert!(
177            !reloaded
178                .entries
179                .contains_key(&cache_key("https://attacker.example.com", "send-1")),
180            "a token cached under one host must not be visible under a different host, \
181             even for the same send id"
182        );
183    }
184
185    #[test]
186    fn expired_entries_are_not_returned() {
187        let path = cache_file_path();
188        let mut cache = read_cache_from(&path);
189        cache.entries.insert(
190            cache_key("https://api.example.com", "send-1"),
191            CachedToken {
192                token: "tok".to_string(),
193                expires_at: now_ms() - 1,
194            },
195        );
196        write_cache_to(&path, &cache);
197
198        let reloaded = read_cache_from(&path);
199        let entry = reloaded
200            .entries
201            .get(&cache_key("https://api.example.com", "send-1"))
202            .unwrap();
203        assert!(entry.expires_at - EXPIRY_SLACK_MS <= now_ms());
204    }
205
206    #[test]
207    fn a_corrupt_file_is_treated_as_empty_rather_than_an_error() {
208        let path = cache_file_path();
209        std::fs::write(&path, b"not json").unwrap();
210        let cache = read_cache_from(&path);
211        assert!(cache.entries.is_empty());
212    }
213
214    #[test]
215    fn evict_removes_only_the_matching_entry() {
216        let path = cache_file_path();
217        let mut cache = read_cache_from(&path);
218        cache.entries.insert(
219            cache_key("https://api.example.com", "send-1"),
220            CachedToken {
221                token: "tok-1".to_string(),
222                expires_at: now_ms() + 60_000,
223            },
224        );
225        cache.entries.insert(
226            cache_key("https://api.example.com", "send-2"),
227            CachedToken {
228                token: "tok-2".to_string(),
229                expires_at: now_ms() + 60_000,
230            },
231        );
232        write_cache_to(&path, &cache);
233
234        cache
235            .entries
236            .remove(&cache_key("https://api.example.com", "send-1"));
237        write_cache_to(&path, &cache);
238
239        let reloaded = read_cache_from(&path);
240        assert!(
241            !reloaded
242                .entries
243                .contains_key(&cache_key("https://api.example.com", "send-1"))
244        );
245        assert!(
246            reloaded
247                .entries
248                .contains_key(&cache_key("https://api.example.com", "send-2"))
249        );
250    }
251
252    #[cfg(unix)]
253    #[test]
254    fn cache_file_is_written_owner_only() {
255        use std::os::unix::fs::PermissionsExt as _;
256
257        let path = cache_file_path();
258        write_cache_to(&path, &CacheFile::default());
259
260        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
261        assert_eq!(mode, 0o600, "send access token cache must be owner-only");
262    }
263}