Skip to main content

max / synckit

11.9 KB · 348 lines History Blame Raw
1 //! OS keychain integration for caching the master key.
2 //!
3 //! Feature-gated behind `keychain` (enabled by default).
4 //! Falls back gracefully when the keychain is unavailable.
5 //!
6 //! ## Platform backends
7 //!
8 //! - **macOS**: Keychain (via Security framework).
9 //! - **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.
12 //! - **Windows**: Credential Manager.
13
14 use crate::error::Result;
15 // Only the keychain code paths (and the test module) construct SyncKitError
16 // directly; without the feature a plain lib build would see it as unused.
17 #[cfg(any(feature = "keychain", test))]
18 use crate::error::SyncKitError;
19 // Only the keychain code paths use this top-level alias; the test module imports
20 // its own `B64` locally, so a no-keychain test build must not pull it in here.
21 use crate::ids::{AppId, UserId};
22 #[cfg(feature = "keychain")]
23 use base64::{Engine, engine::general_purpose::STANDARD as B64};
24 #[cfg(test)]
25 use uuid::Uuid;
26
27 // These keychain helpers are only referenced by the keychain code paths and the
28 // test module; gate them so a no-keychain lib build stays warning-clean.
29 #[cfg(any(feature = "keychain", test))]
30 const SERVICE_PREFIX: &str = "synckit";
31
32 /// Build the keychain service name: `"synckit:<app_id>"`.
33 ///
34 /// Each SyncKit app gets its own keychain namespace so that keys from
35 /// different apps never collide.
36 #[cfg(any(feature = "keychain", test))]
37 fn service_name(app_id: AppId) -> String {
38 format!("{SERVICE_PREFIX}:{app_id}")
39 }
40
41 /// Build the keychain user key: the `user_id` as a hyphenated UUID string.
42 ///
43 /// Combined with `service_name`, this uniquely identifies the keychain entry
44 /// for a given (app, user) pair.
45 #[cfg(any(feature = "keychain", test))]
46 fn user_key(user_id: UserId) -> String {
47 user_id.to_string()
48 }
49
50 /// Store the master key in the OS keychain.
51 #[cfg(feature = "keychain")]
52 pub fn store_key(app_id: AppId, user_id: UserId, master_key: &[u8; 32]) -> Result<()> {
53 use zeroize::Zeroize;
54 let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
55 let mut encoded = B64.encode(master_key);
56 let result = entry.set_password(&encoded);
57 encoded.zeroize();
58 result?;
59
60 tracing::debug!("Master key stored in OS keychain");
61 Ok(())
62 }
63
64 /// Load the master key from the OS keychain.
65 /// Returns None if no key is stored (not an error).
66 ///
67 /// The key is returned inside a [`ZeroizeOnDrop`](crate::crypto::ZeroizeOnDrop)
68 /// so the caller cannot leave a bare `[u8; 32]` copy of it lingering on the heap.
69 /// The only in-crate caller stores it straight into the master-key slot, and
70 /// there are no external callers, so this stays `pub(crate)`.
71 #[cfg(feature = "keychain")]
72 pub(crate) fn load_key(
73 app_id: AppId,
74 user_id: UserId,
75 ) -> Result<Option<crate::crypto::ZeroizeOnDrop>> {
76 let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
77
78 match entry.get_password() {
79 Ok(mut encoded) => {
80 use zeroize::Zeroize;
81 // `encoded` is base64 of the raw master key, zeroize it too, not just
82 // the decoded bytes, so no plaintext key encoding lingers on the heap.
83 let decoded = B64.decode(&encoded);
84 encoded.zeroize();
85 let mut bytes = decoded?;
86 if bytes.len() != 32 {
87 bytes.zeroize();
88 return Err(SyncKitError::Keychain("stored key has wrong length".into()));
89 }
90 let mut key = crate::crypto::ZeroizeOnDrop([0u8; 32]);
91 key.0.copy_from_slice(&bytes);
92 bytes.zeroize();
93 tracing::debug!("Master key loaded from OS keychain");
94 Ok(Some(key))
95 }
96 Err(keyring::Error::NoEntry) => Ok(None),
97 Err(e) => Err(e.into()),
98 }
99 }
100
101 /// Delete the master key from the OS keychain.
102 #[cfg(feature = "keychain")]
103 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))?;
105
106 match entry.delete_credential() {
107 Ok(()) => {
108 tracing::debug!("Master key deleted from OS keychain");
109 Ok(())
110 }
111 Err(keyring::Error::NoEntry) => Ok(()), // Already gone
112 Err(e) => Err(e.into()),
113 }
114 }
115
116 // ── No-op stubs when keychain feature is disabled ──
117
118 #[cfg(not(feature = "keychain"))]
119 pub fn store_key(_app_id: AppId, _user_id: UserId, _master_key: &[u8; 32]) -> Result<()> {
120 tracing::warn!("Keychain support disabled, master key not persisted");
121 Ok(())
122 }
123
124 #[cfg(not(feature = "keychain"))]
125 pub(crate) fn load_key(
126 _app_id: AppId,
127 _user_id: UserId,
128 ) -> Result<Option<crate::crypto::ZeroizeOnDrop>> {
129 Ok(None)
130 }
131
132 #[cfg(not(feature = "keychain"))]
133 pub fn delete_key(_app_id: AppId, _user_id: UserId) -> Result<()> {
134 Ok(())
135 }
136
137 // ── Tests ──
138 // The public functions (store_key, load_key, delete_key) are thin wrappers
139 // around the `keyring` crate with base64 encoding. Direct keychain access
140 // varies by OS and CI environment, so these tests focus on:
141 // - Pure helper functions (service_name, user_key)
142 // - Base64 round-trip correctness (the encoding used by store/load)
143 // - Length validation logic (the guard in load_key)
144 // - Error variant construction
145 // - No-op stub behavior (when keychain feature is disabled)
146
147 #[cfg(test)]
148 mod keystore_tests {
149 use super::*;
150 use base64::{Engine, engine::general_purpose::STANDARD as B64};
151
152 fn test_ids() -> (AppId, UserId) {
153 (
154 AppId::new(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()),
155 UserId::new(Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()),
156 )
157 }
158
159 // ── service_name ──
160
161 #[test]
162 fn service_name_format() {
163 let (app_id, _) = test_ids();
164 let name = service_name(app_id);
165 assert_eq!(name, "synckit:550e8400-e29b-41d4-a716-446655440000");
166 }
167
168 #[test]
169 fn service_name_starts_with_prefix() {
170 let (app_id, _) = test_ids();
171 let name = service_name(app_id);
172 assert!(name.starts_with("synckit:"));
173 }
174
175 #[test]
176 fn service_name_contains_app_id() {
177 let (app_id, _) = test_ids();
178 let name = service_name(app_id);
179 assert!(name.contains(&app_id.to_string()));
180 }
181
182 #[test]
183 fn service_name_different_ids_produce_different_names() {
184 let app_id1 = AppId::new(Uuid::from_u128(1));
185 let app_id2 = AppId::new(Uuid::from_u128(2));
186 assert_ne!(service_name(app_id1), service_name(app_id2));
187 }
188
189 // ── user_key ──
190
191 #[test]
192 fn user_key_is_uuid_string() {
193 let (_, user_id) = test_ids();
194 let key = user_key(user_id);
195 assert_eq!(key, "6ba7b810-9dad-11d1-80b4-00c04fd430c8");
196 }
197
198 #[test]
199 fn user_key_round_trips_through_uuid_parse() {
200 let (_, user_id) = test_ids();
201 let key = user_key(user_id);
202 let parsed = Uuid::parse_str(&key).expect("user_key should produce a valid UUID string");
203 assert_eq!(parsed, user_id.as_uuid());
204 }
205
206 // ── Base64 round-trip (mirrors store_key encode / load_key decode) ──
207
208 #[test]
209 fn base64_round_trip_32_byte_key() {
210 // Reproduces the encoding path in store_key and decoding path in load_key
211 let master_key: [u8; 32] = [
212 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
213 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
214 0x1d, 0x1e, 0x1f, 0x20,
215 ];
216
217 // Encode (as store_key does)
218 let encoded = B64.encode(master_key);
219
220 // Decode (as load_key does)
221 let bytes = B64.decode(&encoded).expect("decode should succeed");
222 assert_eq!(bytes.len(), 32);
223
224 let mut recovered = [0u8; 32];
225 recovered.copy_from_slice(&bytes);
226 assert_eq!(recovered, master_key);
227 }
228
229 #[test]
230 fn base64_round_trip_all_zeros() {
231 let master_key = [0u8; 32];
232 let encoded = B64.encode(master_key);
233 let bytes = B64.decode(&encoded).unwrap();
234 assert_eq!(bytes.len(), 32);
235 assert_eq!(bytes, master_key);
236 }
237
238 #[test]
239 fn base64_round_trip_all_ones() {
240 let master_key = [0xffu8; 32];
241 let encoded = B64.encode(master_key);
242 let bytes = B64.decode(&encoded).unwrap();
243 assert_eq!(bytes.len(), 32);
244 assert_eq!(bytes, master_key);
245 }
246
247 #[test]
248 fn base64_encoded_length_is_44_chars() {
249 // 32 bytes -> ceil(32/3)*4 = 44 base64 characters (with padding)
250 let key = [0u8; 32];
251 let encoded = B64.encode(key);
252 assert_eq!(encoded.len(), 44);
253 }
254
255 // ── Length validation (mirrors the guard in load_key) ──
256
257 #[test]
258 fn length_validation_rejects_short_key() {
259 // Simulate what load_key does when it decodes a stored value
260 let short_key = [0u8; 16];
261 let encoded = B64.encode(short_key);
262 let bytes = B64.decode(&encoded).unwrap();
263
264 // This is the same check from load_key
265 assert_ne!(bytes.len(), 32, "16-byte key should fail the length check");
266 }
267
268 #[test]
269 fn length_validation_rejects_long_key() {
270 let long_key = [0u8; 64];
271 let encoded = B64.encode(long_key);
272 let bytes = B64.decode(&encoded).unwrap();
273
274 assert_ne!(bytes.len(), 32, "64-byte key should fail the length check");
275 }
276
277 #[test]
278 fn length_validation_accepts_exact_32() {
279 let key = [0u8; 32];
280 let encoded = B64.encode(key);
281 let bytes = B64.decode(&encoded).unwrap();
282
283 assert_eq!(bytes.len(), 32, "32-byte key should pass the length check");
284 }
285
286 #[test]
287 fn length_validation_rejects_empty() {
288 let empty: [u8; 0] = [];
289 let encoded = B64.encode(empty);
290 let bytes = B64.decode(&encoded).unwrap();
291
292 assert_ne!(bytes.len(), 32, "empty key should fail the length check");
293 }
294
295 // ── Error variant construction ──
296
297 #[cfg(feature = "keychain")]
298 #[test]
299 fn keychain_error_contains_message() {
300 let err = SyncKitError::Keychain("test failure".into());
301 let msg = format!("{err}");
302 assert!(msg.contains("test failure"));
303 assert!(msg.contains("Keychain"));
304 }
305
306 #[test]
307 fn base64_decode_error_propagates() {
308 // Invalid base64 should produce a Base64 error variant
309 let result = B64.decode("not!valid!base64!!!");
310 assert!(result.is_err());
311
312 // Verify SyncKitError::Base64 can be constructed from it
313 let sync_err: SyncKitError = result.unwrap_err().into();
314 let msg = format!("{sync_err}");
315 assert!(msg.contains("Base64"));
316 }
317
318 // ── SERVICE_PREFIX constant ──
319
320 #[test]
321 fn service_prefix_is_synckit() {
322 assert_eq!(SERVICE_PREFIX, "synckit");
323 }
324
325 // ── No-op stub behavior ──
326 // These tests verify the public API contract regardless of feature flags.
327 // When keychain is enabled, they exercise the real keyring path (which may
328 // succeed or fail depending on OS keychain availability in CI).
329 // The important contract: the functions exist, accept the right types,
330 // and return the right types.
331
332 #[test]
333 fn public_api_types_compile() {
334 // Compile-time check that the public API signatures are correct.
335 // This catches accidental signature changes.
336 let (app_id, user_id) = test_ids();
337 let key = [0u8; 32];
338
339 // These may fail at runtime due to keychain unavailability,
340 // but they must compile with the correct types.
341 let _: Result<()> = store_key(app_id, user_id, &key);
342 // load_key now hands back the key inside a ZeroizeOnDrop guard so no bare
343 // [u8; 32] copy outlives the caller's move into the master-key slot.
344 let _: Result<Option<crate::crypto::ZeroizeOnDrop>> = load_key(app_id, user_id);
345 let _: Result<()> = delete_key(app_id, user_id);
346 }
347 }
348