1use crate::auth::{LEGACY_SERVICE_NAME, SERVICE_NAME};
19
20pub fn account_key(user_id: &str, device_id: &str) -> String {
21 format!("{}:{}", user_id, device_id)
22}
23
24pub fn encryption_account_key(user_id: &str) -> String {
25 format!("encryption:{}", user_id)
26}
27
28pub fn device_privkey_account_key(user_id: &str, device_id: &str) -> String {
29 format!("device-privkey:{}:{}", user_id, device_id)
30}
31
32#[derive(Debug, thiserror::Error)]
33pub enum CredstoreError {
34 #[error("no entry")]
35 NoEntry,
36 #[error("backend: {0}")]
37 Backend(String),
38}
39
40pub trait Credstore: Send + Sync {
41 fn get(&self, account: &str) -> Result<Option<String>, CredstoreError>;
42 fn set(&self, account: &str, value: &str) -> Result<(), CredstoreError>;
43 fn delete(&self, account: &str) -> Result<(), CredstoreError>;
44 fn backend_name(&self) -> &'static str;
45}
46
47pub struct KeyringStore {
51 service: &'static str,
52}
53
54impl KeyringStore {
55 pub fn canonical() -> Self {
56 Self {
57 service: SERVICE_NAME,
58 }
59 }
60
61 pub fn legacy() -> Self {
62 Self {
63 service: LEGACY_SERVICE_NAME,
64 }
65 }
66}
67
68const GO_KEYRING_BASE64_PREFIX: &str = "go-keyring-base64:";
74
75fn unwrap_go_keyring(value: String) -> String {
76 use base64::engine::general_purpose::STANDARD;
77 use base64::Engine;
78 if let Some(rest) = value.strip_prefix(GO_KEYRING_BASE64_PREFIX) {
79 if let Ok(bytes) = STANDARD.decode(rest) {
80 if let Ok(s) = String::from_utf8(bytes) {
81 return s;
82 }
83 }
84 }
85 value
86}
87
88impl Credstore for KeyringStore {
89 fn get(&self, account: &str) -> Result<Option<String>, CredstoreError> {
90 let entry = keyring::Entry::new(self.service, account)
91 .map_err(|e| CredstoreError::Backend(e.to_string()))?;
92 match entry.get_password() {
93 Ok(v) => Ok(Some(unwrap_go_keyring(v))),
94 Err(keyring::Error::NoEntry) => Ok(None),
95 Err(e) => Err(CredstoreError::Backend(e.to_string())),
96 }
97 }
98
99 fn set(&self, account: &str, value: &str) -> Result<(), CredstoreError> {
100 let entry = keyring::Entry::new(self.service, account)
101 .map_err(|e| CredstoreError::Backend(e.to_string()))?;
102 entry
103 .set_password(value)
104 .map_err(|e| CredstoreError::Backend(e.to_string()))
105 }
106
107 fn delete(&self, account: &str) -> Result<(), CredstoreError> {
108 let entry = keyring::Entry::new(self.service, account)
109 .map_err(|e| CredstoreError::Backend(e.to_string()))?;
110 match entry.delete_credential() {
111 Ok(()) => Ok(()),
112 Err(keyring::Error::NoEntry) => Ok(()),
113 Err(e) => Err(CredstoreError::Backend(e.to_string())),
114 }
115 }
116
117 fn backend_name(&self) -> &'static str {
118 if self.service == LEGACY_SERVICE_NAME {
119 "keyring-legacy"
120 } else {
121 "keyring"
122 }
123 }
124}
125
126pub struct PlaintextStore;
130
131impl Credstore for PlaintextStore {
132 fn get(&self, account: &str) -> Result<Option<String>, CredstoreError> {
133 let cfg = crate::auth::load_config().map_err(|e| CredstoreError::Backend(e.to_string()))?;
134 if account.starts_with("encryption:") {
135 let expected = encryption_account_key(&cfg.user_id);
136 if account == expected {
137 return Ok(non_empty(cfg.encryption_key));
138 }
139 return Ok(None);
140 }
141 if account.starts_with("device-privkey:") {
142 let expected = device_privkey_account_key(&cfg.user_id, &cfg.active_device_id);
143 if account == expected {
144 return Ok(non_empty(cfg.device_private_key));
145 }
146 return Ok(None);
147 }
148 let expected = account_key(&cfg.user_id, &cfg.active_device_id);
149 if account == expected {
150 return Ok(non_empty(cfg.token));
151 }
152 Ok(None)
153 }
154
155 fn set(&self, _account: &str, _value: &str) -> Result<(), CredstoreError> {
156 Err(CredstoreError::Backend(
157 "plaintext credstore writes go through client_core::auth helpers".into(),
158 ))
159 }
160
161 fn delete(&self, _account: &str) -> Result<(), CredstoreError> {
162 Err(CredstoreError::Backend(
163 "plaintext credstore deletes go through client_core::auth helpers".into(),
164 ))
165 }
166
167 fn backend_name(&self) -> &'static str {
168 "plaintext"
169 }
170}
171
172fn non_empty(s: String) -> Option<String> {
173 if s.is_empty() {
174 None
175 } else {
176 Some(s)
177 }
178}
179
180pub fn detect() -> Box<dyn Credstore> {
184 Box::new(PlaintextStore)
185}
186
187fn get_with_migration_via(
191 plaintext: &dyn Credstore,
192 fallbacks: &[&dyn Credstore],
193 plaintext_writer: impl FnOnce(&str) -> Result<(), CredstoreError>,
194 account: &str,
195) -> Option<String> {
196 if let Ok(Some(value)) = plaintext.get(account) {
197 return Some(value);
198 }
199 for fb in fallbacks {
200 if let Ok(Some(value)) = fb.get(account) {
201 let _ = plaintext_writer(&value);
204 return Some(value);
205 }
206 }
207 None
208}
209
210fn get_with_keyring_migration(
214 plaintext_writer: impl FnOnce(&str) -> Result<(), CredstoreError>,
215 account: &str,
216) -> Option<String> {
217 let canonical = KeyringStore::canonical();
218 let legacy = KeyringStore::legacy();
219 get_with_migration_via(
220 &PlaintextStore,
221 &[&canonical as &dyn Credstore, &legacy as &dyn Credstore],
222 plaintext_writer,
223 account,
224 )
225}
226
227pub fn read_encryption_key(user_id: &str) -> Option<[u8; 32]> {
229 if user_id.is_empty() {
230 return None;
231 }
232 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
233 use base64::Engine;
234 let acct = encryption_account_key(user_id);
235 let user_id_owned = user_id.to_string();
236 let copy_forward = move |value: &str| -> Result<(), CredstoreError> {
237 let bytes = URL_SAFE_NO_PAD
238 .decode(value)
239 .map_err(|e| CredstoreError::Backend(e.to_string()))?;
240 if bytes.len() != 32 {
241 return Err(CredstoreError::Backend("not 32 bytes".into()));
242 }
243 let mut key = [0u8; 32];
244 key.copy_from_slice(&bytes);
245 crate::auth::write_encryption_key(&user_id_owned, &key)
246 .map_err(|e| CredstoreError::Backend(e.to_string()))
247 };
248 let b64 = get_with_keyring_migration(copy_forward, &acct)?;
249 let bytes = URL_SAFE_NO_PAD.decode(&b64).ok()?;
250 if bytes.len() != 32 {
251 return None;
252 }
253 let mut key = [0u8; 32];
254 key.copy_from_slice(&bytes);
255 Some(key)
256}
257
258pub fn write_encryption_key(user_id: &str, key: &[u8; 32]) -> Result<&'static str, CredstoreError> {
261 crate::auth::write_encryption_key(user_id, key)
262 .map_err(|e| CredstoreError::Backend(e.to_string()))?;
263 Ok("plaintext")
264}
265
266pub fn read_device_privkey(user_id: &str, device_id: &str) -> Option<String> {
269 if user_id.is_empty() || device_id.is_empty() {
270 return None;
271 }
272 let acct = device_privkey_account_key(user_id, device_id);
273 let copy_forward = |value: &str| -> Result<(), CredstoreError> {
274 let mut cfg =
275 crate::auth::load_config().map_err(|e| CredstoreError::Backend(e.to_string()))?;
276 cfg.device_private_key = value.to_string();
277 crate::auth::save_config_to_disk(&cfg).map_err(|e| CredstoreError::Backend(e.to_string()))
278 };
279 let value = get_with_keyring_migration(copy_forward, &acct)?;
280 if value.is_empty() {
281 None
282 } else {
283 Some(value)
284 }
285}
286
287pub fn write_device_privkey(
290 user_id: &str,
291 device_id: &str,
292 privkey_b64: &str,
293) -> Result<&'static str, CredstoreError> {
294 let _ = (user_id, device_id);
295 let mut cfg = crate::auth::load_config().map_err(|e| CredstoreError::Backend(e.to_string()))?;
296 cfg.device_private_key = privkey_b64.to_string();
297 crate::auth::save_config_to_disk(&cfg).map_err(|e| CredstoreError::Backend(e.to_string()))?;
298 Ok("plaintext")
299}
300
301pub fn read_token(user_id: &str, device_id: &str) -> Option<String> {
303 if user_id.is_empty() || device_id.is_empty() {
304 return None;
305 }
306 let acct = account_key(user_id, device_id);
307 let copy_forward = |value: &str| -> Result<(), CredstoreError> {
310 let mut cfg =
311 crate::auth::load_config().map_err(|e| CredstoreError::Backend(e.to_string()))?;
312 cfg.token = value.to_string();
313 crate::auth::save_config_to_disk(&cfg).map_err(|e| CredstoreError::Backend(e.to_string()))
314 };
315 get_with_keyring_migration(copy_forward, &acct).filter(|t| !t.is_empty())
316}
317
318pub fn wipe_keyring_for(user_id: &str, device_id: &str) {
322 if user_id.is_empty() {
323 return;
324 }
325 let mut accounts = vec![encryption_account_key(user_id)];
326 if !device_id.is_empty() {
327 accounts.push(account_key(user_id, device_id));
328 accounts.push(device_privkey_account_key(user_id, device_id));
329 }
330 for service in [KeyringStore::canonical(), KeyringStore::legacy()] {
331 for acct in &accounts {
332 let _ = service.delete(acct);
333 }
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn account_keys_match_go_format() {
343 assert_eq!(account_key("u1", "d1"), "u1:d1");
344 assert_eq!(encryption_account_key("u1"), "encryption:u1");
345 assert_eq!(
346 device_privkey_account_key("u1", "d1"),
347 "device-privkey:u1:d1"
348 );
349 }
350
351 #[test]
352 fn go_keyring_unwrap_roundtrips() {
353 use base64::engine::general_purpose::STANDARD;
354 use base64::Engine;
355 let raw = "abcXYZ_-=";
356 let wrapped = format!("{}{}", GO_KEYRING_BASE64_PREFIX, STANDARD.encode(raw));
357 assert!(wrapped.starts_with(GO_KEYRING_BASE64_PREFIX));
358 assert_eq!(unwrap_go_keyring(wrapped), raw);
359 }
360
361 #[test]
362 fn go_keyring_unwrap_passthrough_for_unwrapped_values() {
363 let raw = "plain-string-without-prefix".to_string();
364 assert_eq!(unwrap_go_keyring(raw.clone()), raw);
365 }
366
367 struct InMemoryStore {
370 map: std::sync::Mutex<std::collections::HashMap<String, String>>,
371 name: &'static str,
372 }
373 impl InMemoryStore {
374 fn new(name: &'static str) -> Self {
375 Self {
376 map: Default::default(),
377 name,
378 }
379 }
380 fn seed(&self, k: &str, v: &str) {
381 self.map.lock().unwrap().insert(k.into(), v.into());
382 }
383 }
384 impl Credstore for InMemoryStore {
385 fn get(&self, account: &str) -> Result<Option<String>, CredstoreError> {
386 Ok(self.map.lock().unwrap().get(account).cloned())
387 }
388 fn set(&self, account: &str, value: &str) -> Result<(), CredstoreError> {
389 self.map
390 .lock()
391 .unwrap()
392 .insert(account.into(), value.into());
393 Ok(())
394 }
395 fn delete(&self, account: &str) -> Result<(), CredstoreError> {
396 self.map.lock().unwrap().remove(account);
397 Ok(())
398 }
399 fn backend_name(&self) -> &'static str {
400 self.name
401 }
402 }
403
404 #[test]
405 fn migration_reads_from_canonical_and_copies_forward() {
406 let plaintext = InMemoryStore::new("plaintext");
407 let canonical = InMemoryStore::new("canonical");
408 canonical.seed("encryption:u1", "AAAA");
409
410 let mut copied = None;
411 let writer = |v: &str| -> Result<(), CredstoreError> {
412 copied = Some(v.to_string());
413 Ok(())
414 };
415 let v = get_with_migration_via(
416 &plaintext,
417 &[&canonical as &dyn Credstore],
418 writer,
419 "encryption:u1",
420 );
421 assert_eq!(v.as_deref(), Some("AAAA"));
422 assert_eq!(
423 copied.as_deref(),
424 Some("AAAA"),
425 "must copy forward to plaintext"
426 );
427 }
428
429 #[test]
430 fn migration_falls_through_canonical_to_legacy() {
431 let plaintext = InMemoryStore::new("plaintext");
432 let canonical = InMemoryStore::new("canonical");
433 let legacy = InMemoryStore::new("legacy");
434 legacy.seed("encryption:u1", "BBBB");
435
436 let writer = |_: &str| -> Result<(), CredstoreError> { Ok(()) };
437 let v = get_with_migration_via(
438 &plaintext,
439 &[&canonical as &dyn Credstore, &legacy as &dyn Credstore],
440 writer,
441 "encryption:u1",
442 );
443 assert_eq!(v.as_deref(), Some("BBBB"));
444 }
445
446 #[test]
447 fn migration_skips_writer_when_plaintext_already_has_value() {
448 let plaintext = InMemoryStore::new("plaintext");
449 plaintext.seed("encryption:u1", "EXISTING");
450 let canonical = InMemoryStore::new("canonical");
451 canonical.seed("encryption:u1", "STALE");
452
453 let mut writer_called = false;
454 let writer = |_: &str| -> Result<(), CredstoreError> {
455 writer_called = true;
456 Ok(())
457 };
458 let v = get_with_migration_via(
459 &plaintext,
460 &[&canonical as &dyn Credstore],
461 writer,
462 "encryption:u1",
463 );
464 assert_eq!(v.as_deref(), Some("EXISTING"));
465 assert!(
466 !writer_called,
467 "must not overwrite plaintext when it already has the value"
468 );
469 }
470}