Skip to main content

max / makenotwork

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