Skip to main content

max / makenotwork

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