Skip to main content

max / synckit-client

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