| 1 |
1 |
|
//! OS keychain integration for caching the master key.
|
| 2 |
2 |
|
//!
|
| 3 |
3 |
|
//! Feature-gated behind `keychain` (enabled by default).
|
| 4 |
|
- |
//! Falls back gracefully when the keychain is unavailable.
|
|
4 |
+ |
//!
|
|
5 |
+ |
//! The keychain is a *cache*, never the system of record: the master key is
|
|
6 |
+ |
//! always recoverable from the server envelope with the user's password. So a
|
|
7 |
+ |
//! keychain that is missing, locked, or absent entirely costs a password
|
|
8 |
+ |
//! re-entry, not access to the data. Callers reflect
|
|
9 |
+ |
//! that: see [`cache_key`], which reports failure rather than propagating it.
|
| 5 |
10 |
|
//!
|
| 6 |
11 |
|
//! ## Platform backends
|
| 7 |
12 |
|
//!
|
| 8 |
|
- |
//! - **macOS**: Keychain (via Security framework).
|
|
13 |
+ |
//! - **macOS**: legacy Keychain (Security framework), via `keyring`'s `v1` store.
|
|
14 |
+ |
//! - **iOS**: Protected Data store (`kSecClassGenericPassword`), installed by
|
|
15 |
+ |
//! this module. `keyring`'s `v1` helper cannot do it (its store installer is
|
|
16 |
+ |
//! cfg'd out on iOS), so [`entry`] calls `keyring_core::set_default_store`
|
|
17 |
+ |
//! directly with `apple_native_keyring_store::protected::Store`. Entries are
|
|
18 |
+ |
//! created with an explicit [`IOS_ACCESS_POLICY`] rather than the store
|
|
19 |
+ |
//! default; see that constant for why.
|
| 9 |
20 |
|
//! - **Linux**: secret-service (D-Bus). Requires a running keyring daemon such
|
| 10 |
|
- |
//! as gnome-keyring. Without a secret-service provider, `store_key` and
|
| 11 |
|
- |
//! `load_key` will return a `Keychain` error.
|
|
21 |
+ |
//! as gnome-keyring. Without a secret-service provider, entry construction
|
|
22 |
+ |
//! fails and the cache is simply unavailable.
|
| 12 |
23 |
|
//! - **Windows**: Credential Manager.
|
| 13 |
24 |
|
|
| 14 |
25 |
|
use crate::error::Result;
|
| 47 |
58 |
|
user_id.to_string()
|
| 48 |
59 |
|
}
|
| 49 |
60 |
|
|
|
61 |
+ |
/// Accessibility class for the iOS keychain item.
|
|
62 |
+ |
///
|
|
63 |
+ |
/// The store's own default is `WhenUnlocked`, which makes the item unreadable
|
|
64 |
+ |
/// the moment the screen locks. SyncKit syncs from background tasks, so that
|
|
65 |
+ |
/// default would turn every locked-device wake into a decryption failure.
|
|
66 |
+ |
/// `AfterFirstUnlock` is the class Apple documents for exactly this case:
|
|
67 |
+ |
/// readable from the first post-boot unlock onward, including while locked.
|
|
68 |
+ |
///
|
|
69 |
+ |
/// `ThisDeviceOnly` on top of it keeps the item out of encrypted iTunes/iCloud
|
|
70 |
+ |
/// backups. That is a requirement, not a preference: the master key is the whole
|
|
71 |
+ |
/// of SyncKit's end-to-end guarantee, and a backup copy of it outside the device
|
|
72 |
+ |
/// is exactly the escrow the design refuses. A restored-to-new-phone user
|
|
73 |
+ |
/// re-enters their password and recovers the key from the server envelope, which
|
|
74 |
+ |
/// is the intended path.
|
|
75 |
+ |
#[cfg(all(feature = "keychain", target_os = "ios"))]
|
|
76 |
+ |
const IOS_ACCESS_POLICY: &str = "after-first-unlock-this-device-only";
|
|
77 |
+ |
|
|
78 |
+ |
/// Build a keychain entry for this (app, user) pair.
|
|
79 |
+ |
///
|
|
80 |
+ |
/// Everything below this function works in `keyring_core` types, so the
|
|
81 |
+ |
/// platform split lives here and nowhere else.
|
|
82 |
+ |
///
|
|
83 |
+ |
/// Non-iOS: go through `keyring::Entry::new`, whose only real job is installing
|
|
84 |
+ |
/// the platform default store on first call, then unwrap to the inner
|
|
85 |
+ |
/// `keyring_core::Entry`.
|
|
86 |
+ |
///
|
|
87 |
+ |
/// A store already installed in the process wins. `keyring::Entry::new` would
|
|
88 |
+ |
/// otherwise overwrite it with the platform default on its first call, silently
|
|
89 |
+ |
/// redirecting an app that chose its own store. It also lets the tests below run
|
|
90 |
+ |
/// against `keyring_core::mock`.
|
|
91 |
+ |
#[cfg(all(feature = "keychain", not(target_os = "ios")))]
|
|
92 |
+ |
fn entry(app_id: AppId, user_id: UserId) -> Result<keyring_core::Entry> {
|
|
93 |
+ |
let (service, user) = (service_name(app_id), user_key(user_id));
|
|
94 |
+ |
if keyring_core::get_default_store().is_some() {
|
|
95 |
+ |
return Ok(keyring_core::Entry::new(&service, &user)?);
|
|
96 |
+ |
}
|
|
97 |
+ |
Ok(keyring::Entry::new(&service, &user)?.inner)
|
|
98 |
+ |
}
|
|
99 |
+ |
|
|
100 |
+ |
/// Build a keychain entry for this (app, user) pair (iOS).
|
|
101 |
+ |
///
|
|
102 |
+ |
/// `keyring`'s store installer is compiled out on iOS, so the default store
|
|
103 |
+ |
/// would never be set and every entry would fail with `NoDefaultStore`. Install
|
|
104 |
+ |
/// the Protected Data store once, then build entries against it with an explicit
|
|
105 |
+ |
/// access policy ([`IOS_ACCESS_POLICY`]).
|
|
106 |
+ |
///
|
|
107 |
+ |
/// `Store::new` (not `new_with_configuration`) selects the device-local store;
|
|
108 |
+ |
/// the cloud-synchronized variant would push the master key into iCloud
|
|
109 |
+ |
/// Keychain, and it also refuses to honour an access policy.
|
|
110 |
+ |
#[cfg(all(feature = "keychain", target_os = "ios"))]
|
|
111 |
+ |
fn entry(app_id: AppId, user_id: UserId) -> Result<keyring_core::Entry> {
|
|
112 |
+ |
use std::collections::HashMap;
|
|
113 |
+ |
use std::sync::Once;
|
|
114 |
+ |
|
|
115 |
+ |
static INSTALL: Once = Once::new();
|
|
116 |
+ |
INSTALL.call_once(|| {
|
|
117 |
+ |
match apple_native_keyring_store::protected::Store::new() {
|
|
118 |
+ |
Ok(store) => keyring_core::set_default_store(store),
|
|
119 |
+ |
// `Store::new` is infallible in practice (it only stamps an id), but
|
|
120 |
+ |
// the signature allows failure. Leaving the default store unset makes
|
|
121 |
+ |
// the next line fail with `NoDefaultStore`, which the callers treat
|
|
122 |
+ |
// as "no cache available", the correct degradation.
|
|
123 |
+ |
Err(e) => tracing::warn!(error = %e, "Could not create the iOS keychain store"),
|
|
124 |
+ |
}
|
|
125 |
+ |
});
|
|
126 |
+ |
|
|
127 |
+ |
let modifiers = HashMap::from([("access-policy", IOS_ACCESS_POLICY)]);
|
|
128 |
+ |
Ok(keyring_core::Entry::new_with_modifiers(
|
|
129 |
+ |
&service_name(app_id),
|
|
130 |
+ |
&user_key(user_id),
|
|
131 |
+ |
&modifiers,
|
|
132 |
+ |
)?)
|
|
133 |
+ |
}
|
|
134 |
+ |
|
| 50 |
135 |
|
/// Store the master key in the OS keychain.
|
|
136 |
+ |
///
|
|
137 |
+ |
/// Prefer [`cache_key`] from in-crate callers: this returns the raw error, and
|
|
138 |
+ |
/// a failure to *cache* the key is not a failure to *have* it.
|
| 51 |
139 |
|
#[cfg(feature = "keychain")]
|
| 52 |
140 |
|
pub fn store_key(app_id: AppId, user_id: UserId, master_key: &[u8; 32]) -> Result<()> {
|
| 53 |
141 |
|
use zeroize::Zeroize;
|
| 54 |
|
- |
let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
|
|
142 |
+ |
let entry = entry(app_id, user_id)?;
|
| 55 |
143 |
|
let mut encoded = B64.encode(master_key);
|
| 56 |
144 |
|
let result = entry.set_password(&encoded);
|
| 57 |
145 |
|
encoded.zeroize();
|
| 73 |
161 |
|
app_id: AppId,
|
| 74 |
162 |
|
user_id: UserId,
|
| 75 |
163 |
|
) -> Result<Option<crate::crypto::ZeroizeOnDrop>> {
|
| 76 |
|
- |
let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
|
|
164 |
+ |
let entry = match entry(app_id, user_id) {
|
|
165 |
+ |
Ok(entry) => entry,
|
|
166 |
+ |
// No usable store on this device (no secret-service daemon on Linux, no
|
|
167 |
+ |
// default store installed on iOS). That is "nothing cached", not an
|
|
168 |
+ |
// error: the caller falls through to the password path.
|
|
169 |
+ |
Err(e) => {
|
|
170 |
+ |
tracing::debug!(error = %e, "OS keychain unavailable, no cached master key");
|
|
171 |
+ |
return Ok(None);
|
|
172 |
+ |
}
|
|
173 |
+ |
};
|
| 77 |
174 |
|
|
| 78 |
175 |
|
match entry.get_password() {
|
| 79 |
176 |
|
Ok(mut encoded) => {
|
| 98 |
195 |
|
}
|
| 99 |
196 |
|
}
|
| 100 |
197 |
|
|
|
198 |
+ |
/// Cache the master key in the OS keychain, best-effort.
|
|
199 |
+ |
///
|
|
200 |
+ |
/// Returns whether the key was cached. A `false` costs the user a password
|
|
201 |
+ |
/// re-entry on next cold launch and nothing else, so callers holding a valid
|
|
202 |
+ |
/// master key must carry on rather than abort. The key itself is fine; only the
|
|
203 |
+ |
/// convenience cache is not.
|
|
204 |
+ |
///
|
|
205 |
+ |
/// Without the `keychain` feature `store_key` is a no-op that returns `Ok`, so
|
|
206 |
+ |
/// this reports `true` for a key that was never written. That is deliberate: the
|
|
207 |
+ |
/// return value says "nothing went wrong", and a build with no keychain has
|
|
208 |
+ |
/// nothing to go wrong.
|
|
209 |
+ |
pub(crate) fn cache_key(app_id: AppId, user_id: UserId, master_key: &[u8; 32]) -> bool {
|
|
210 |
+ |
match store_key(app_id, user_id, master_key) {
|
|
211 |
+ |
Ok(()) => true,
|
|
212 |
+ |
Err(e) => {
|
|
213 |
+ |
tracing::warn!(
|
|
214 |
+ |
error = %e,
|
|
215 |
+ |
"Could not cache the master key in the OS keychain; \
|
|
216 |
+ |
the password will be required on next launch"
|
|
217 |
+ |
);
|
|
218 |
+ |
false
|
|
219 |
+ |
}
|
|
220 |
+ |
}
|
|
221 |
+ |
}
|
|
222 |
+ |
|
| 101 |
223 |
|
/// Delete the master key from the OS keychain.
|
| 102 |
224 |
|
#[cfg(feature = "keychain")]
|
| 103 |
225 |
|
pub fn delete_key(app_id: AppId, user_id: UserId) -> Result<()> {
|
| 104 |
|
- |
let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
|
|
226 |
+ |
let entry = entry(app_id, user_id)?;
|
| 105 |
227 |
|
|
| 106 |
228 |
|
match entry.delete_credential() {
|
| 107 |
229 |
|
Ok(()) => {
|
| 345 |
467 |
|
let _: Result<()> = delete_key(app_id, user_id);
|
| 346 |
468 |
|
}
|
| 347 |
469 |
|
}
|
|
470 |
+ |
|
|
471 |
+ |
// ── Behavioural tests against a mock credential store ──
|
|
472 |
+ |
//
|
|
473 |
+ |
// The tests above cover the pure helpers and the encoding arithmetic. These
|
|
474 |
+ |
// cover the parts that actually talk to a store: the round trip, the absent-entry
|
|
475 |
+ |
// and corrupt-entry paths, and `cache_key`'s promise that a store failure is
|
|
476 |
+ |
// reported rather than propagated.
|
|
477 |
+ |
//
|
|
478 |
+ |
// `keyring_core::mock` gives a platform-independent in-memory store, so this runs
|
|
479 |
+ |
// the same on a CI box with no secret-service daemon as on a developer machine.
|
|
480 |
+ |
// It is installed as the process default store, which `entry()` honours in
|
|
481 |
+ |
// preference to the platform store.
|
|
482 |
+ |
//
|
|
483 |
+ |
// One test function, not several: the default store is process-global, so
|
|
484 |
+ |
// splitting these up would let the harness run them concurrently against shared
|
|
485 |
+ |
// state. The mock is also installed for the life of the process, which is why
|
|
486 |
+ |
// this cannot double as a test of the real platform store.
|
|
487 |
+ |
//
|
|
488 |
+ |
// iOS is excluded: `entry()` there installs the Protected Data store
|
|
489 |
+ |
// unconditionally, and the mock rejects the access-policy modifier it passes.
|
|
490 |
+ |
// The iOS path is validated by compiling for the target (scripts/check-mobile-targets.sh)
|
|
491 |
+ |
// and by an on-device run.
|
|
492 |
+ |
#[cfg(all(test, feature = "keychain", not(target_os = "ios")))]
|
|
493 |
+ |
mod mock_store_tests {
|
|
494 |
+ |
use super::*;
|
|
495 |
+ |
use keyring_core::mock;
|
|
496 |
+ |
|
|
497 |
+ |
/// Distinct ids per assertion block, so entries never collide in the store.
|
|
498 |
+ |
fn ids(n: u128) -> (AppId, UserId) {
|
|
499 |
+ |
(
|
|
500 |
+ |
AppId::new(Uuid::from_u128(n)),
|
|
501 |
+ |
UserId::new(Uuid::from_u128(n + 1000)),
|
|
502 |
+ |
)
|
|
503 |
+ |
}
|
|
504 |
+ |
|
|
505 |
+ |
#[test]
|
|
506 |
+ |
fn keychain_behaviour_against_mock_store() {
|
|
507 |
+ |
keyring_core::set_default_store(mock::Store::new().expect("mock store"));
|
|
508 |
+ |
|
|
509 |
+ |
// ── Round trip ──
|
|
510 |
+ |
let (app_id, user_id) = ids(1);
|
|
511 |
+ |
let key = [7u8; 32];
|
|
512 |
+ |
store_key(app_id, user_id, &key).expect("store into the mock");
|
|
513 |
+ |
let loaded = load_key(app_id, user_id)
|
|
514 |
+ |
.expect("load from the mock")
|
|
515 |
+ |
.expect("a key was stored");
|
|
516 |
+ |
assert_eq!(loaded.0, key, "the key must round-trip byte for byte");
|
|
517 |
+ |
|
|
518 |
+ |
// ── Absent entry is None, not an error ──
|
|
519 |
+ |
let (absent_app, absent_user) = ids(2);
|
|
520 |
+ |
assert!(
|
|
521 |
+ |
load_key(absent_app, absent_user)
|
|
522 |
+ |
.expect("an absent entry is not an error")
|
|
523 |
+ |
.is_none()
|
|
524 |
+ |
);
|
|
525 |
+ |
|
|
526 |
+ |
// ── Delete ──
|
|
527 |
+ |
delete_key(app_id, user_id).expect("delete an existing entry");
|
|
528 |
+ |
assert!(
|
|
529 |
+ |
load_key(app_id, user_id)
|
|
530 |
+ |
.expect("load after delete")
|
|
531 |
+ |
.is_none(),
|
|
532 |
+ |
"a deleted key must not come back"
|
|
533 |
+ |
);
|
|
534 |
+ |
delete_key(app_id, user_id).expect("deleting an absent entry is a no-op");
|
|
535 |
+ |
|
|
536 |
+ |
// ── A stored value of the wrong length is rejected, not truncated ──
|
|
537 |
+ |
let (bad_app, bad_user) = ids(3);
|
|
538 |
+ |
entry(bad_app, bad_user)
|
|
539 |
+ |
.expect("build entry")
|
|
540 |
+ |
.set_password(&B64.encode([0u8; 16]))
|
|
541 |
+ |
.expect("seed a 16-byte value");
|
|
542 |
+ |
let err = load_key(bad_app, bad_user).expect_err("a 16-byte key must be rejected");
|
|
543 |
+ |
assert!(
|
|
544 |
+ |
format!("{err}").contains("wrong length"),
|
|
545 |
+ |
"expected a wrong-length error, got: {err}"
|
|
546 |
+ |
);
|
|
547 |
+ |
|
|
548 |
+ |
// ── cache_key reports a store failure instead of propagating it ──
|
|
549 |
+ |
let (fail_app, fail_user) = ids(4);
|
|
550 |
+ |
let probe = entry(fail_app, fail_user).expect("build entry");
|
|
551 |
+ |
let cred: &mock::Cred = probe
|
|
552 |
+ |
.as_any()
|
|
553 |
+ |
.downcast_ref()
|
|
554 |
+ |
.expect("the mock store yields mock credentials");
|
|
555 |
+ |
cred.set_error(keyring_core::Error::NoStorageAccess(Box::new(
|
|
556 |
+ |
std::io::Error::other("keychain is locked"),
|
|
557 |
+ |
)));
|
|
558 |
+ |
assert!(
|
|
559 |
+ |
!cache_key(fail_app, fail_user, &key),
|
|
560 |
+ |
"a failed write must report false, not panic or propagate"
|
|
561 |
+ |
);
|
|
562 |
+ |
|
|
563 |
+ |
// The mock clears its error after one call, so the next write succeeds.
|
|
564 |
+ |
assert!(
|
|
565 |
+ |
cache_key(fail_app, fail_user, &key),
|
|
566 |
+ |
"a successful write must report true"
|
|
567 |
+ |
);
|
|
568 |
+ |
}
|
|
569 |
+ |
}
|