//! OS keychain integration for caching the master key. //! //! Feature-gated behind `keychain` (enabled by default). //! Falls back gracefully when the keychain is unavailable. //! //! ## Platform backends //! //! - **macOS**: Keychain (via Security framework). //! - **Linux**: secret-service (D-Bus). Requires a running keyring daemon such //! as gnome-keyring. Without a secret-service provider, `store_key` and //! `load_key` will return a `Keychain` error. //! - **Windows**: Credential Manager. use crate::error::Result; // Only the keychain code paths (and the test module) construct SyncKitError // directly; without the feature a plain lib build would see it as unused. #[cfg(any(feature = "keychain", test))] use crate::error::SyncKitError; #[cfg(any(feature = "keychain", test))] use base64::{engine::general_purpose::STANDARD as B64, Engine}; use uuid::Uuid; // These keychain helpers are only referenced by the keychain code paths and the // test module; gate them so a no-keychain lib build stays warning-clean. #[cfg(any(feature = "keychain", test))] const SERVICE_PREFIX: &str = "synckit"; /// Build the keychain service name: `"synckit:"`. /// /// Each SyncKit app gets its own keychain namespace so that keys from /// different apps never collide. #[cfg(any(feature = "keychain", test))] fn service_name(app_id: Uuid) -> String { format!("{SERVICE_PREFIX}:{app_id}") } /// Build the keychain user key: the `user_id` as a hyphenated UUID string. /// /// Combined with `service_name`, this uniquely identifies the keychain entry /// for a given (app, user) pair. #[cfg(any(feature = "keychain", test))] fn user_key(user_id: Uuid) -> String { user_id.to_string() } /// Store the master key in the OS keychain. #[cfg(feature = "keychain")] pub fn store_key(app_id: Uuid, user_id: Uuid, master_key: &[u8; 32]) -> Result<()> { use zeroize::Zeroize; let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?; let mut encoded = B64.encode(master_key); let result = entry.set_password(&encoded); encoded.zeroize(); result?; tracing::debug!("Master key stored in OS keychain"); Ok(()) } /// Load the master key from the OS keychain. /// Returns None if no key is stored (not an error). #[cfg(feature = "keychain")] pub fn load_key(app_id: Uuid, user_id: Uuid) -> Result> { let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?; match entry.get_password() { Ok(encoded) => { use zeroize::Zeroize; let mut bytes = B64.decode(&encoded)?; if bytes.len() != 32 { bytes.zeroize(); return Err(SyncKitError::Keychain( "stored key has wrong length".into(), )); } let mut key = [0u8; 32]; key.copy_from_slice(&bytes); bytes.zeroize(); tracing::debug!("Master key loaded from OS keychain"); Ok(Some(key)) } Err(keyring::Error::NoEntry) => Ok(None), Err(e) => Err(e.into()), } } /// Delete the master key from the OS keychain. #[cfg(feature = "keychain")] pub fn delete_key(app_id: Uuid, user_id: Uuid) -> Result<()> { let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?; match entry.delete_credential() { Ok(()) => { tracing::debug!("Master key deleted from OS keychain"); Ok(()) } Err(keyring::Error::NoEntry) => Ok(()), // Already gone Err(e) => Err(e.into()), } } // ── No-op stubs when keychain feature is disabled ── #[cfg(not(feature = "keychain"))] pub fn store_key(_app_id: Uuid, _user_id: Uuid, _master_key: &[u8; 32]) -> Result<()> { tracing::warn!("Keychain support disabled — master key not persisted"); Ok(()) } #[cfg(not(feature = "keychain"))] pub fn load_key(_app_id: Uuid, _user_id: Uuid) -> Result> { Ok(None) } #[cfg(not(feature = "keychain"))] pub fn delete_key(_app_id: Uuid, _user_id: Uuid) -> Result<()> { Ok(()) } // ── Tests ── // The public functions (store_key, load_key, delete_key) are thin wrappers // around the `keyring` crate with base64 encoding. Direct keychain access // varies by OS and CI environment, so these tests focus on: // - Pure helper functions (service_name, user_key) // - Base64 round-trip correctness (the encoding used by store/load) // - Length validation logic (the guard in load_key) // - Error variant construction // - No-op stub behavior (when keychain feature is disabled) #[cfg(test)] mod keystore_tests { use super::*; use base64::{engine::general_purpose::STANDARD as B64, Engine}; fn test_ids() -> (Uuid, Uuid) { ( Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(), ) } // ── service_name ── #[test] fn service_name_format() { let (app_id, _) = test_ids(); let name = service_name(app_id); assert_eq!(name, "synckit:550e8400-e29b-41d4-a716-446655440000"); } #[test] fn service_name_starts_with_prefix() { let (app_id, _) = test_ids(); let name = service_name(app_id); assert!(name.starts_with("synckit:")); } #[test] fn service_name_contains_app_id() { let (app_id, _) = test_ids(); let name = service_name(app_id); assert!(name.contains(&app_id.to_string())); } #[test] fn service_name_different_ids_produce_different_names() { let (app_id1, app_id2) = test_ids(); assert_ne!(service_name(app_id1), service_name(app_id2)); } // ── user_key ── #[test] fn user_key_is_uuid_string() { let (_, user_id) = test_ids(); let key = user_key(user_id); assert_eq!(key, "6ba7b810-9dad-11d1-80b4-00c04fd430c8"); } #[test] fn user_key_round_trips_through_uuid_parse() { let (_, user_id) = test_ids(); let key = user_key(user_id); let parsed = Uuid::parse_str(&key).expect("user_key should produce a valid UUID string"); assert_eq!(parsed, user_id); } // ── Base64 round-trip (mirrors store_key encode / load_key decode) ── #[test] fn base64_round_trip_32_byte_key() { // Reproduces the encoding path in store_key and decoding path in load_key let master_key: [u8; 32] = [ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, ]; // Encode (as store_key does) let encoded = B64.encode(master_key); // Decode (as load_key does) let bytes = B64.decode(&encoded).expect("decode should succeed"); assert_eq!(bytes.len(), 32); let mut recovered = [0u8; 32]; recovered.copy_from_slice(&bytes); assert_eq!(recovered, master_key); } #[test] fn base64_round_trip_all_zeros() { let master_key = [0u8; 32]; let encoded = B64.encode(master_key); let bytes = B64.decode(&encoded).unwrap(); assert_eq!(bytes.len(), 32); assert_eq!(bytes, master_key); } #[test] fn base64_round_trip_all_ones() { let master_key = [0xffu8; 32]; let encoded = B64.encode(master_key); let bytes = B64.decode(&encoded).unwrap(); assert_eq!(bytes.len(), 32); assert_eq!(bytes, master_key); } #[test] fn base64_encoded_length_is_44_chars() { // 32 bytes -> ceil(32/3)*4 = 44 base64 characters (with padding) let key = [0u8; 32]; let encoded = B64.encode(key); assert_eq!(encoded.len(), 44); } // ── Length validation (mirrors the guard in load_key) ── #[test] fn length_validation_rejects_short_key() { // Simulate what load_key does when it decodes a stored value let short_key = [0u8; 16]; let encoded = B64.encode(short_key); let bytes = B64.decode(&encoded).unwrap(); // This is the same check from load_key assert_ne!(bytes.len(), 32, "16-byte key should fail the length check"); } #[test] fn length_validation_rejects_long_key() { let long_key = [0u8; 64]; let encoded = B64.encode(long_key); let bytes = B64.decode(&encoded).unwrap(); assert_ne!(bytes.len(), 32, "64-byte key should fail the length check"); } #[test] fn length_validation_accepts_exact_32() { let key = [0u8; 32]; let encoded = B64.encode(key); let bytes = B64.decode(&encoded).unwrap(); assert_eq!(bytes.len(), 32, "32-byte key should pass the length check"); } #[test] fn length_validation_rejects_empty() { let empty: [u8; 0] = []; let encoded = B64.encode(empty); let bytes = B64.decode(&encoded).unwrap(); assert_ne!(bytes.len(), 32, "empty key should fail the length check"); } // ── Error variant construction ── #[cfg(feature = "keychain")] #[test] fn keychain_error_contains_message() { let err = SyncKitError::Keychain("test failure".into()); let msg = format!("{err}"); assert!(msg.contains("test failure")); assert!(msg.contains("Keychain")); } #[test] fn base64_decode_error_propagates() { // Invalid base64 should produce a Base64 error variant let result = B64.decode("not!valid!base64!!!"); assert!(result.is_err()); // Verify SyncKitError::Base64 can be constructed from it let sync_err: SyncKitError = result.unwrap_err().into(); let msg = format!("{sync_err}"); assert!(msg.contains("Base64")); } // ── SERVICE_PREFIX constant ── #[test] fn service_prefix_is_synckit() { assert_eq!(SERVICE_PREFIX, "synckit"); } // ── No-op stub behavior ── // These tests verify the public API contract regardless of feature flags. // When keychain is enabled, they exercise the real keyring path (which may // succeed or fail depending on OS keychain availability in CI). // The important contract: the functions exist, accept the right types, // and return the right types. #[test] fn public_api_types_compile() { // Compile-time check that the public API signatures are correct. // This catches accidental signature changes. let (app_id, user_id) = test_ids(); let key = [0u8; 32]; // These may fail at runtime due to keychain unavailability, // but they must compile with the correct types. let _: Result<()> = store_key(app_id, user_id, &key); let _: Result> = load_key(app_id, user_id); let _: Result<()> = delete_key(app_id, user_id); } }