diff --git a/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt b/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt index 8327a18..1d6d6a1 100644 --- a/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt @@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.lifecycle.lifecycleScope +import com.vnidrop.app.core.initializeAndroidCoreRuntime import com.vnidrop.app.feature.receive.ExternalInvitationController import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes import com.vnidrop.app.feature.receive.VniDropInvitationExtension @@ -22,6 +23,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + initializeAndroidCoreRuntime(applicationContext) setContent { App(rememberAndroidAppDependencies(this, externalInvitations)) } diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index 797ceed..d9d4b2f 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -53,9 +53,10 @@ struct NativeCoreBindingFactory: CoreBindingFactory { case .localOnly: nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: []) } - return try VnidropCore.initializeWithNetworkConfig( + return try VnidropCore.initializeWithExperimentalSavedDevices( appDataDir: appDataDir, eventSink: eventSink, + limits: defaultCoreLimits(), networkConfig: nativeConfiguration ) } diff --git a/apple/VniDrop/UI/Feedback/UserFacingError.swift b/apple/VniDrop/UI/Feedback/UserFacingError.swift index ebbf2ea..3784f94 100644 --- a/apple/VniDrop/UI/Feedback/UserFacingError.swift +++ b/apple/VniDrop/UI/Feedback/UserFacingError.swift @@ -38,6 +38,12 @@ extension Error { return .resource(L10n.Error.generic) case .InvalidInput: return .resource(L10n.Error.invalidInput) + case .InvalidTransition: + return .resource(L10n.Error.invalidInput) + case .SecureStorageLocked, .SecureStorageUnavailable: + return .resource(L10n.Error.startingUp) + case .SecureStorageMissing, .SecureStorageCorrupted: + return .resource(L10n.Error.generic) case .Initialization(let reason): return initializationUiText(reason) case .Internal(let reason): @@ -83,7 +89,9 @@ extension Error { case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r), .DestinationExists(let r), .StorageFull(let r), .Network(let r), .Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r), - .InvalidInput(let r), .Internal(let r): + .InvalidInput(let r), .InvalidTransition(let r), .SecureStorageLocked(let r), + .SecureStorageMissing(let r), .SecureStorageCorrupted(let r), + .SecureStorageUnavailable(let r), .Internal(let r): return r } } diff --git a/crates/vnidrop/src/error.rs b/crates/vnidrop/src/error.rs index 05d4181..f8d0145 100644 --- a/crates/vnidrop/src/error.rs +++ b/crates/vnidrop/src/error.rs @@ -42,9 +42,7 @@ pub enum VnidropError { impl VnidropError { pub(crate) fn initialization(error: impl Into) -> Self { - Self::Initialization { - reason: error.into().to_string(), - } + Self::from_error(error.into(), |reason| Self::Initialization { reason }) } pub(crate) fn ticket(error: impl Into) -> Self { diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index 34dd0d0..b370659 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -3,7 +3,7 @@ use std::{future::Future, path::PathBuf, sync::Arc}; use anyhow::Context; use serde_json::json; -use super::CoreInner; +use super::{CoreInner, IdentityMode}; use crate::{ api::{ ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, @@ -14,6 +14,7 @@ use crate::{ }, error::VnidropError, filesystem::platform_path, + secure_secret::{lock_profile, platform_secret_store}, ticket::parse_transfer_ticket_with_limits, transfer_state::{TransferDirection, TransferStatus}, }; @@ -35,6 +36,34 @@ impl VnidropCore { fn block_on(&self, future: F) -> F::Output { self.runtime.handle().block_on(future) } + + fn initialize_with_identity_mode( + app_data_dir: String, + event_sink: Arc, + limits: CoreLimits, + network_config: CoreNetworkConfig, + identity_mode: IdentityMode, + ) -> Result, VnidropError> { + limits.validate().map_err(VnidropError::initialization)?; + let relay_urls = network_config + .validated_relay_urls() + .map_err(VnidropError::initialization)?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("vnidrop") + .build()?; + let inner = runtime + .block_on(CoreInner::start( + PathBuf::from(app_data_dir), + event_sink, + limits, + network_config.mode, + relay_urls, + identity_mode, + )) + .map_err(VnidropError::initialization)?; + Ok(Arc::new(Self { runtime, inner })) + } } #[uniffi::export] @@ -87,25 +116,39 @@ impl VnidropCore { limits: CoreLimits, network_config: CoreNetworkConfig, ) -> Result, VnidropError> { - limits.validate().map_err(VnidropError::initialization)?; - let relay_urls = network_config - .validated_relay_urls() - .map_err(VnidropError::initialization)?; - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_name("vnidrop") - .build()?; - let app_data_dir = PathBuf::from(app_data_dir); - let inner = runtime - .block_on(CoreInner::start( - app_data_dir, - event_sink, - limits, - network_config.mode, - relay_urls, - )) - .map_err(VnidropError::initialization)?; - Ok(Arc::new(Self { runtime, inner })) + Self::initialize_with_identity_mode( + app_data_dir, + event_sink, + limits, + network_config, + IdentityMode::Legacy, + ) + } + + /// Starts the experimental saved-device core with a platform-protected identity. + #[uniffi::constructor] + pub fn initialize_with_experimental_saved_devices( + app_data_dir: String, + event_sink: Arc, + limits: CoreLimits, + network_config: CoreNetworkConfig, + ) -> Result, VnidropError> { + let app_data_path = PathBuf::from(app_data_dir); + std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?; + let app_data_path = + std::fs::canonicalize(app_data_path).map_err(VnidropError::filesystem)?; + let profile_lock = lock_profile(&app_data_path)?; + let store = platform_secret_store(&app_data_path)?; + Self::initialize_with_identity_mode( + app_data_path.to_string_lossy().into_owned(), + event_sink, + limits, + network_config, + IdentityMode::Protected { + store, + profile_lock, + }, + ) } pub fn status(&self) -> RuntimeStatus { diff --git a/crates/vnidrop/src/runtime/lifecycle.rs b/crates/vnidrop/src/runtime/lifecycle.rs index 42a2e37..aee363e 100644 --- a/crates/vnidrop/src/runtime/lifecycle.rs +++ b/crates/vnidrop/src/runtime/lifecycle.rs @@ -186,7 +186,7 @@ impl CoreInner { Ok(()) } - pub(super) async fn shutdown(&self) { + pub(crate) async fn shutdown(&self) { if self.shutdown_started.swap(true, Ordering::SeqCst) { return; } diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index f18fa30..3c42ac6 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -64,6 +64,7 @@ use crate::{ pairing::PairingService, repository::Repository, secret::load_or_create_secret, + secure_secret::{start_endpoint_identity, ProfileLock, SecretCustody, SecureSecretStore}, ticket::ticket_matches_relay_profile, transfer_state::{TransferDirection, TransferStatus}, }; @@ -99,6 +100,8 @@ pub(super) struct CoreInner { pub(super) router: Router, pub(super) store: FsStore, pub(super) repository: Repository, + _secret_custody: Option, + _profile_lock: Option, pub(super) event_hub: Arc, pub(super) approval: ApprovalService, pub(super) pairing: PairingService, @@ -130,6 +133,14 @@ pub(super) struct ActiveTransfer { pub(super) cancel: oneshot::Sender<()>, } +pub(super) enum IdentityMode { + Legacy, + Protected { + store: Arc, + profile_lock: ProfileLock, + }, +} + impl CoreInner { pub(super) async fn start( app_data_dir: PathBuf, @@ -137,11 +148,26 @@ impl CoreInner { limits: CoreLimits, relay_mode: CoreRelayMode, relay_urls: Vec, + identity_mode: IdentityMode, ) -> Result> { tokio::fs::create_dir_all(&app_data_dir).await?; init_logging(&app_data_dir)?; - let secret_key = load_or_create_secret(&app_data_dir).await?; let repository = Repository::open(&app_data_dir).await?; + let (secret_key, secret_custody, profile_lock) = match identity_mode { + IdentityMode::Legacy => (load_or_create_secret(&app_data_dir).await?, None, None), + IdentityMode::Protected { + store, + profile_lock, + } => { + let (secret_key, custody) = start_endpoint_identity( + repository.protected_secrets(), + store, + &app_data_dir.join("iroh.secret"), + ) + .await?; + (secret_key, Some(custody), Some(profile_lock)) + } + }; let store_root = app_data_dir.join("blobs"); let mut store_options = FsStoreOptions::new(&store_root); store_options.gc = Some(GcConfig { @@ -366,6 +392,8 @@ impl CoreInner { router, store, repository, + _secret_custody: secret_custody, + _profile_lock: profile_lock, event_hub, approval, pairing, diff --git a/crates/vnidrop/src/secure_secret.rs b/crates/vnidrop/src/secure_secret.rs index 985bb24..b683e3c 100644 --- a/crates/vnidrop/src/secure_secret.rs +++ b/crates/vnidrop/src/secure_secret.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, fmt, io, path::Path, sync::Arc}; +use std::{collections::HashSet, fmt, io, path::Path, sync::Arc, time::Duration}; #[cfg(test)] use std::{collections::HashMap, sync::Mutex}; @@ -11,13 +11,18 @@ use uuid::Uuid; use crate::{error::VnidropError, util::now_ms}; #[cfg(any(test, target_os = "android"))] -mod android; +pub(crate) mod android; #[cfg(any(target_os = "macos", target_os = "ios"))] -mod apple; +pub(crate) mod apple; #[cfg(any(test, target_os = "linux"))] -mod linux; +pub(crate) mod linux; +mod platform; #[cfg(target_os = "windows")] -mod windows; +pub(crate) mod windows; + +#[cfg(test)] +pub(crate) use platform::scope_store; +pub(crate) use platform::{lock_profile, platform_secret_store, ProfileLock}; const SECRET_BYTES: usize = 32; const HANDLE_NAMESPACE: &str = "vnidrop"; @@ -40,6 +45,11 @@ impl SecretMaterial { let bytes: [u8; SECRET_BYTES] = self.0.as_slice().try_into().expect("validated length"); SecretKey::from_bytes(&bytes).public().to_string() } + + fn into_secret_key(self) -> SecretKey { + let bytes: [u8; SECRET_BYTES] = self.0.try_into().expect("validated length"); + SecretKey::from_bytes(&bytes) + } } impl fmt::Debug for SecretMaterial { @@ -74,6 +84,11 @@ impl fmt::Debug for SecretHandle { } } +#[cfg(test)] +pub(crate) fn secret_handle_for_test(value: String) -> SecretHandle { + SecretHandle(value) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SecretKind { EndpointIdentity, @@ -129,38 +144,6 @@ pub(crate) trait SecureSecretStore: Send + Sync { fn list_handles(&self) -> Result, SecureSecretStoreError>; } -#[cfg(any(target_os = "macos", target_os = "ios"))] -pub(crate) fn platform_secret_store( - _app_data_dir: &Path, -) -> Result, VnidropError> { - Ok(Arc::new(apple::AppleKeychainSecretStore::new())) -} - -#[cfg(target_os = "android")] -pub(crate) fn platform_secret_store( - _app_data_dir: &Path, -) -> Result, VnidropError> { - android::native::create_store_from_android_runtime().map_err(map_store_error) -} - -#[cfg(target_os = "windows")] -pub(crate) fn platform_secret_store( - app_data_dir: &Path, -) -> Result, VnidropError> { - windows::WindowsDpapiSecretStore::new(app_data_dir.join("protected-secrets-v1")) - .map(|store| Arc::new(store) as Arc) - .map_err(map_store_error) -} - -#[cfg(target_os = "linux")] -pub(crate) fn platform_secret_store( - _app_data_dir: &Path, -) -> Result, VnidropError> { - linux::LinuxSecretServiceStore::connect() - .map(|store| Arc::new(store) as Arc) - .map_err(map_store_error) -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SecretMetadataState { Staged, @@ -212,6 +195,15 @@ pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> { ) .execute(pool) .await?; + sqlx::query( + r#" + CREATE UNIQUE INDEX IF NOT EXISTS protected_secret_one_endpoint_identity + ON protected_secret_refs(kind) + WHERE kind = 'endpoint-identity' AND state != 'disabled' + "#, + ) + .execute(pool) + .await?; Ok(()) } @@ -314,6 +306,15 @@ impl SecretMetadataStore { .map_err(VnidropError::repository)?; row.map(row_to_metadata).transpose() } + + async fn contains_kind(&self, kind: SecretKind) -> Result { + let row = sqlx::query("SELECT 1 FROM protected_secret_refs WHERE kind = ?1 LIMIT 1") + .bind(kind.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(row.is_some()) + } } fn row_to_metadata(row: sqlx::sqlite::SqliteRow) -> Result { @@ -332,6 +333,19 @@ pub(crate) struct SecretCustody { crash_point: Mutex>, } +pub(crate) async fn start_endpoint_identity( + metadata: SecretMetadataStore, + store: Arc, + legacy_path: &Path, +) -> Result<(SecretKey, SecretCustody), VnidropError> { + let (custody, _) = SecretCustody::start(metadata, store).await?; + let secret_key = custody + .initialize_endpoint_identity(legacy_path) + .await? + .into_secret_key(); + Ok((secret_key, custody)) +} + impl SecretCustody { pub(crate) async fn start( metadata: SecretMetadataStore, @@ -376,9 +390,10 @@ impl SecretCustody { }); } validate_material(kind, &stored, expected_identity)?; - self.metadata - .stage(&handle, kind, expected_identity) - .await?; + if let Err(error) = self.metadata.stage(&handle, kind, expected_identity).await { + self.delete_if_present(&handle)?; + return Err(error); + } #[cfg(test)] self.maybe_crash(CustodyCrashPoint::MetadataStage)?; self.metadata.activate(&handle).await?; @@ -450,6 +465,71 @@ impl SecretCustody { Ok(handle) } + pub(crate) async fn initialize_endpoint_identity( + &self, + legacy_path: &Path, + ) -> Result { + if self + .metadata + .find_active_kind(SecretKind::EndpointIdentity) + .await? + .is_some() + { + let handle = self.migrate_legacy_endpoint_identity(legacy_path).await?; + return self.load(&handle).await; + } + if self + .metadata + .contains_kind(SecretKind::EndpointIdentity) + .await? + { + return Err(VnidropError::SecureStorageUnavailable { + reason: "protected endpoint identity is disabled".to_string(), + }); + } + match tokio::fs::try_exists(legacy_path).await { + Ok(true) => { + let handle = self.migrate_legacy_endpoint_identity(legacy_path).await?; + self.load(&handle).await + } + Ok(false) => { + let secret = SecretKey::generate(); + let material = SecretMaterial::new(secret.to_bytes().to_vec())?; + let endpoint_id = material.endpoint_id(); + match self + .protect( + SecretKind::EndpointIdentity, + material, + Some(endpoint_id.as_str()), + ) + .await + { + Ok(handle) => self.load(&handle).await, + Err(error) => { + let winner = tokio::time::timeout(Duration::from_secs(1), async { + loop { + if let Some(active) = self + .metadata + .find_active_kind(SecretKind::EndpointIdentity) + .await? + { + return self.load(&active.handle).await; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + match winner { + Ok(result) => result, + Err(_) => Err(error), + } + } + } + } + Err(error) => Err(VnidropError::filesystem(error)), + } + } + pub(crate) async fn reconcile(&self) -> Result { let metadata = self.metadata.list().await?; let stored_handles = self.store.list_handles().map_err(map_store_error)?; diff --git a/crates/vnidrop/src/secure_secret/android.rs b/crates/vnidrop/src/secure_secret/android.rs index 3fb5099..1f827a6 100644 --- a/crates/vnidrop/src/secure_secret/android.rs +++ b/crates/vnidrop/src/secure_secret/android.rs @@ -2,7 +2,7 @@ use std::{ fs::{self, File, OpenOptions}, io::{Read, Write}, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, Mutex}, }; use data_encoding::HEXLOWER; @@ -48,6 +48,7 @@ pub(crate) trait AndroidKeystore: Send + Sync { pub(crate) struct AndroidSecureSecretStore { records_dir: PathBuf, keystore: Arc, + mutation_lock: Mutex<()>, } impl AndroidSecureSecretStore { @@ -64,6 +65,7 @@ impl AndroidSecureSecretStore { Ok(Self { records_dir, keystore, + mutation_lock: Mutex::new(()), }) } @@ -112,6 +114,24 @@ impl AndroidSecureSecretStore { .map_err(map_io_error)?; decode_record(&bytes, handle) } + + #[cfg(test)] + pub(crate) fn record_path_for_test(&self, handle: &SecretHandle) -> PathBuf { + self.record_path(handle) + } + + #[cfg(test)] + pub(crate) fn stage_for_test( + &self, + handle: &SecretHandle, + ) -> Result<(), SecureSecretStoreError> { + self.write_record(handle, RECORD_STAGED, None) + } +} + +#[cfg(test)] +pub(crate) fn secret_handle_for_test(value: &str) -> SecretHandle { + SecretHandle(value.to_string()) } impl SecureSecretStore for AndroidSecureSecretStore { @@ -120,7 +140,18 @@ impl SecureSecretStore for AndroidSecureSecretStore { handle: &SecretHandle, material: SecretMaterial, ) -> Result<(), SecureSecretStoreError> { - self.write_record(handle, RECORD_STAGED, None)?; + let _mutation = self + .mutation_lock + .lock() + .map_err(|_| SecureSecretStoreError::Unavailable)?; + let record_exists = match fs::metadata(self.record_path(handle)) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(map_io_error(error)), + }; + if !record_exists { + self.write_record(handle, RECORD_STAGED, None)?; + } let alias = Self::alias(handle); let sealed = self.keystore.seal(&alias, &material.0)?; if sealed.nonce.is_empty() || sealed.ciphertext.is_empty() { @@ -136,6 +167,10 @@ impl SecureSecretStore for AndroidSecureSecretStore { } fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> { + let _mutation = self + .mutation_lock + .lock() + .map_err(|_| SecureSecretStoreError::Unavailable)?; match self.keystore.delete(&Self::alias(handle)) { Ok(()) | Err(SecureSecretStoreError::Missing) => {} Err(error) => return Err(error), @@ -281,150 +316,3 @@ fn set_private_file_permissions(_path: &Path) -> Result<(), SecureSecretStoreErr #[cfg(target_os = "android")] #[path = "android_native.rs"] pub(crate) mod native; - -#[cfg(test)] -mod tests { - use std::{collections::HashMap, sync::Mutex}; - - use tempfile::TempDir; - - use super::*; - use crate::secure_secret::SECRET_BYTES; - - #[derive(Default)] - struct FakeKeystore { - keys: Mutex>, - delete_failure: Mutex>, - } - - impl AndroidKeystore for FakeKeystore { - fn seal( - &self, - alias: &str, - plaintext: &[u8], - ) -> Result { - let mask = 0xa7; - self.keys.lock().unwrap().insert(alias.to_string(), mask); - Ok(AndroidSealedValue { - nonce: vec![4; 12], - ciphertext: plaintext.iter().map(|byte| byte ^ mask).collect(), - }) - } - - fn open( - &self, - alias: &str, - sealed: &AndroidSealedValue, - ) -> Result, SecureSecretStoreError> { - let mask = *self - .keys - .lock() - .unwrap() - .get(alias) - .ok_or(SecureSecretStoreError::Missing)?; - Ok(sealed.ciphertext.iter().map(|byte| byte ^ mask).collect()) - } - - fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> { - if let Some(error) = self.delete_failure.lock().unwrap().take() { - return Err(error); - } - self.keys.lock().unwrap().remove(alias); - Ok(()) - } - } - - fn fixture() -> (TempDir, AndroidSecureSecretStore, Arc) { - let directory = TempDir::new().unwrap(); - let keystore = Arc::new(FakeKeystore::default()); - let store = AndroidSecureSecretStore::new(directory.path(), keystore.clone()).unwrap(); - (directory, store, keystore) - } - - fn handle() -> SecretHandle { - SecretHandle("vnidrop/v1/endpoint-identity/test".to_string()) - } - - #[test] - fn adapter_round_trips_lists_and_deletes_without_plaintext_persistence() { - let (directory, store, keystore) = fixture(); - let handle = handle(); - let plaintext = vec![0x5a; SECRET_BYTES]; - - store - .put(&handle, SecretMaterial::new(plaintext.clone()).unwrap()) - .unwrap(); - - let persisted = fs::read(store.record_path(&handle)).unwrap(); - assert!(!persisted - .windows(plaintext.len()) - .any(|window| window == plaintext)); - - drop(store); - let restarted = AndroidSecureSecretStore::new(directory.path(), keystore).unwrap(); - assert_eq!(restarted.list_handles().unwrap(), vec![handle.clone()]); - assert_eq!(restarted.get(&handle).unwrap().0, plaintext); - - restarted.delete(&handle).unwrap(); - assert!(restarted.list_handles().unwrap().is_empty()); - assert!(matches!( - restarted.get(&handle), - Err(SecureSecretStoreError::Missing) - )); - } - - #[test] - fn staged_crash_record_remains_discoverable_and_fails_closed() { - let (_directory, store, _keystore) = fixture(); - let handle = handle(); - store.write_record(&handle, RECORD_STAGED, None).unwrap(); - - assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]); - assert!(matches!( - store.get(&handle), - Err(SecureSecretStoreError::Corrupted) - )); - store.delete(&handle).unwrap(); - assert!(store.list_handles().unwrap().is_empty()); - } - - #[test] - fn tampering_and_missing_keystore_keys_are_distinct_failures() { - let (_directory, store, keystore) = fixture(); - let handle = handle(); - store - .put(&handle, SecretMaterial::new(vec![9; SECRET_BYTES]).unwrap()) - .unwrap(); - - keystore.keys.lock().unwrap().clear(); - assert!(matches!( - store.get(&handle), - Err(SecureSecretStoreError::Missing) - )); - - fs::write(store.record_path(&handle), b"tampered").unwrap(); - assert!(matches!( - store.get(&handle), - Err(SecureSecretStoreError::Corrupted) - )); - } - - #[test] - fn failed_key_deletion_retains_the_record_for_safe_retry() { - let (_directory, store, keystore) = fixture(); - let handle = handle(); - store - .put(&handle, SecretMaterial::new(vec![7; SECRET_BYTES]).unwrap()) - .unwrap(); - *keystore.delete_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked); - - assert!(matches!( - store.delete(&handle), - Err(SecureSecretStoreError::Locked) - )); - assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]); - - store.delete(&handle).unwrap(); - assert!(store.list_handles().unwrap().is_empty()); - } -} diff --git a/crates/vnidrop/src/secure_secret/android_native.rs b/crates/vnidrop/src/secure_secret/android_native.rs index 0100e98..82f7abe 100644 --- a/crates/vnidrop/src/secure_secret/android_native.rs +++ b/crates/vnidrop/src/secure_secret/android_native.rs @@ -1,8 +1,10 @@ use jni::{ errors::Error as JniError, - objects::{JByteArray, JObject, JString, JValue}, + objects::{GlobalRef, JByteArray, JObject, JString, JValue}, + sys::jboolean, JNIEnv, JavaVM, }; +use std::{panic::AssertUnwindSafe, sync::Mutex}; use super::*; @@ -10,6 +12,48 @@ const ANDROID_KEYSTORE: &str = "AndroidKeyStore"; const AES: &str = "AES"; const TRANSFORMATION: &str = "AES/GCM/NoPadding"; +static ANDROID_APPLICATION_CONTEXT: Mutex> = Mutex::new(None); + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_vnidrop_app_core_AndroidCoreRuntime_initialize( + mut env: JNIEnv<'_>, + _receiver: JObject<'_>, + context: JObject<'_>, +) -> jboolean { + std::panic::catch_unwind(AssertUnwindSafe(|| { + initialize_android_context(&mut env, context) + })) + .ok() + .and_then(Result::ok) + .map_or(0, |()| 1) +} + +fn initialize_android_context( + env: &mut JNIEnv<'_>, + context: JObject<'_>, +) -> Result<(), SecureSecretStoreError> { + let mut stored = ANDROID_APPLICATION_CONTEXT + .lock() + .map_err(|_| SecureSecretStoreError::Unavailable)?; + if stored.is_some() { + return Ok(()); + } + let vm = env + .get_java_vm() + .map_err(|_| SecureSecretStoreError::Unavailable)?; + let context = env + .new_global_ref(context) + .map_err(|_| SecureSecretStoreError::Unavailable)?; + unsafe { + ndk_context::initialize_android_context( + vm.get_java_vm_pointer().cast(), + context.as_obj().as_raw().cast(), + ); + } + *stored = Some(context); + Ok(()) +} + /// JNI-backed Android Keystore engine. The VM pointer comes from the Android /// runtime; no Context or secret bytes are exposed through UniFFI. pub(crate) struct AndroidJniKeystore { @@ -51,8 +95,7 @@ impl AndroidJniKeystore { if context.is_null() { return Err(SecureSecretStoreError::Unavailable); } - // ndk-context retains this process Context for the Android runtime lifetime. - let context = unsafe { JObject::from_raw(context.cast()) }; + let context = local_ref_from_process_context(env, context.cast())?; let directory = env .call_method(&context, "getNoBackupFilesDir", "()Ljava/io/File;", &[]) .map_err(|error| map_jni_error(env, error))? @@ -429,3 +472,23 @@ fn map_jni_error(env: &mut JNIEnv<'_>, _error: JniError) -> SecureSecretStoreErr fn is_instance_of(env: &mut JNIEnv<'_>, object: &JObject<'_>, class: &str) -> bool { env.is_instance_of(object, class).unwrap_or(false) } + +fn local_ref_from_process_context<'local>( + env: &mut JNIEnv<'local>, + context: jni::sys::jobject, +) -> Result, SecureSecretStoreError> { + let interface = env.get_native_interface(); + // ndk-context retains a process-wide global Context reference. JNI NewLocalRef + // is required before representing it as a frame-bound JObject. + let context = unsafe { + let new_local_ref = (**interface) + .NewLocalRef + .ok_or(SecureSecretStoreError::Unavailable)?; + new_local_ref(interface, context) + }; + if context.is_null() { + return Err(SecureSecretStoreError::Unavailable); + } + // NewLocalRef created this reference in the currently attached JNI frame. + Ok(unsafe { JObject::from_raw(context) }) +} diff --git a/crates/vnidrop/src/secure_secret/apple.rs b/crates/vnidrop/src/secure_secret/apple.rs index 49cc3f7..531f2d2 100644 --- a/crates/vnidrop/src/secure_secret/apple.rs +++ b/crates/vnidrop/src/secure_secret/apple.rs @@ -25,7 +25,7 @@ enum AppleAccessibility { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct AppleKeychainPolicy { +pub(crate) struct AppleKeychainPolicy { accessibility: AppleAccessibility, synchronizable: bool, data_protection_keychain: bool, @@ -41,7 +41,7 @@ impl Default for AppleKeychainPolicy { } } -trait AppleKeychainApi: Send + Sync { +pub(crate) trait AppleKeychainApi: Send + Sync { fn put( &self, service: &str, @@ -136,7 +136,7 @@ impl AppleKeychainSecretStore { } #[cfg(test)] - fn with_api(api: impl AppleKeychainApi + 'static) -> Self { + pub(crate) fn with_api(api: impl AppleKeychainApi + 'static) -> Self { Self { api: Arc::new(api) } } } @@ -187,186 +187,21 @@ fn map_status(status: i32) -> SecureSecretStoreError { } #[cfg(test)] -mod tests { - use std::{collections::HashMap, sync::Mutex}; - - use super::*; - - #[derive(Clone, Default)] - struct RecordingKeychain { - state: Arc>, - } - - #[derive(Default)] - struct RecordingState { - entries: HashMap<(String, String), Vec>, - last_policy: Option, - } - - impl AppleKeychainApi for RecordingKeychain { - fn put( - &self, - service: &str, - account: &str, - material: &[u8], - policy: AppleKeychainPolicy, - ) -> Result<(), i32> { - let mut state = self.state.lock().unwrap(); - state.last_policy = Some(policy); - state.entries.insert( - (service.to_string(), account.to_string()), - material.to_vec(), - ); - Ok(()) - } - - fn get(&self, service: &str, account: &str) -> Result, i32> { - self.state - .lock() - .unwrap() - .entries - .get(&(service.to_string(), account.to_string())) - .cloned() - .ok_or(ERR_SEC_ITEM_NOT_FOUND) - } - - fn delete(&self, service: &str, account: &str) -> Result<(), i32> { - self.state - .lock() - .unwrap() - .entries - .remove(&(service.to_string(), account.to_string())) - .map(|_| ()) - .ok_or(ERR_SEC_ITEM_NOT_FOUND) - } - - fn list_accounts(&self, service: &str) -> Result, i32> { - Ok(self - .state - .lock() - .unwrap() - .entries - .keys() - .filter(|(entry_service, _)| entry_service == service) - .map(|(_, account)| account.clone()) - .collect()) - } - } - - fn handle(value: &str) -> SecretHandle { - SecretHandle(value.to_string()) - } - - #[test] - fn adapter_creates_replaces_reads_lists_and_deletes_only_its_service() { - let api = RecordingKeychain::default(); - api.state.lock().unwrap().entries.insert( - ("com.example.unrelated".to_string(), "leave-me".to_string()), - vec![0x77; 32], - ); - let store = AppleKeychainSecretStore::with_api(api.clone()); - let owned = handle("vnidrop/v1/endpoint-identity/apple-test"); - - store - .put(&owned, SecretMaterial::new(vec![0x31; 32]).unwrap()) - .unwrap(); - drop(store); - - let reopened_store = AppleKeychainSecretStore::with_api(api.clone()); - assert_eq!( - reopened_store.get(&owned).unwrap(), - SecretMaterial::new(vec![0x31; 32]).unwrap() - ); - reopened_store - .put(&owned, SecretMaterial::new(vec![0x42; 32]).unwrap()) - .unwrap(); - - assert_eq!( - reopened_store.get(&owned).unwrap(), - SecretMaterial::new(vec![0x42; 32]).unwrap() - ); - assert_eq!(reopened_store.list_handles().unwrap(), vec![owned.clone()]); - reopened_store.delete(&owned).unwrap(); - assert!(matches!( - reopened_store.get(&owned), - Err(SecureSecretStoreError::Missing) - )); - assert!(api - .state - .lock() - .unwrap() - .entries - .contains_key(&("com.example.unrelated".to_string(), "leave-me".to_string()))); - } - - #[test] - fn adapter_always_requests_device_local_non_synchronizing_protection() { - let api = RecordingKeychain::default(); - let store = AppleKeychainSecretStore::with_api(api.clone()); - - store - .put( - &handle("vnidrop/v1/relationship-grant/apple-policy"), - SecretMaterial::new(vec![0x51; 32]).unwrap(), - ) - .unwrap(); - - assert_eq!( - api.state.lock().unwrap().last_policy, - Some(AppleKeychainPolicy { - accessibility: AppleAccessibility::AfterFirstUnlockThisDeviceOnly, - synchronizable: false, - data_protection_keychain: true, - }) - ); - } - - #[test] - fn apple_statuses_map_to_fail_closed_contract_outcomes() { - assert!(matches!( - map_status(ERR_SEC_INTERACTION_NOT_ALLOWED), - SecureSecretStoreError::Locked - )); - assert!(matches!( - map_status(ERR_SEC_AUTH_FAILED), - SecureSecretStoreError::Locked - )); - assert!(matches!( - map_status(ERR_SEC_ITEM_NOT_FOUND), - SecureSecretStoreError::Missing - )); - assert!(matches!( - map_status(ERR_SEC_DECODE), - SecureSecretStoreError::Corrupted - )); - assert!(matches!( - map_status(ERR_SEC_NOT_AVAILABLE), - SecureSecretStoreError::Unavailable - )); - assert!(matches!( - map_status(-1), - SecureSecretStoreError::Unavailable - )); - } - - #[test] - fn malformed_keychain_values_are_corrupted_without_diagnostic_disclosure() { - let api = RecordingKeychain::default(); - let secret = vec![0x6d; 31]; - api.state.lock().unwrap().entries.insert( - ( - SERVICE.to_string(), - "vnidrop/v1/pairing-eligibility/corrupt".to_string(), - ), - secret.clone(), - ); - let store = AppleKeychainSecretStore::with_api(api); - - let error = store - .get(&handle("vnidrop/v1/pairing-eligibility/corrupt")) - .unwrap_err(); - - assert!(matches!(&error, SecureSecretStoreError::Corrupted)); - assert!(!format!("{error:?}").contains(&data_encoding::HEXLOWER.encode(&secret))); - } +pub(crate) fn expected_policy_for_test() -> AppleKeychainPolicy { + AppleKeychainPolicy::default() +} + +#[cfg(test)] +pub(crate) fn service_for_test() -> &'static str { + SERVICE +} + +#[cfg(test)] +pub(crate) fn handle_for_test(value: &str) -> SecretHandle { + SecretHandle(value.to_string()) +} + +#[cfg(test)] +pub(crate) fn map_status_for_test(status: i32) -> SecureSecretStoreError { + map_status(status) } diff --git a/crates/vnidrop/src/secure_secret/linux.rs b/crates/vnidrop/src/secure_secret/linux.rs index 657221a..d52ac1f 100644 --- a/crates/vnidrop/src/secure_secret/linux.rs +++ b/crates/vnidrop/src/secure_secret/linux.rs @@ -12,7 +12,7 @@ const ATTRIBUTE_HANDLE: &str = "vnidrop-handle"; const APPLICATION_ID: &str = "com.vnidrop.VniDrop"; const ITEM_LABEL: &str = "VniDrop protected secret"; -trait LinuxSecretServiceApi: Send + Sync { +pub(crate) trait LinuxSecretServiceApi: Send + Sync { fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError>; fn get(&self, handle: &str) -> Result, SecureSecretStoreError>; fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError>; @@ -114,7 +114,7 @@ impl LinuxSecretServiceApi for SystemLinuxSecretService { } } -pub(super) struct LinuxSecretServiceStore { +pub(crate) struct LinuxSecretServiceStore { api: Arc, } @@ -126,7 +126,7 @@ impl LinuxSecretServiceStore { } #[cfg(test)] - fn with_api(api: Arc) -> Self { + pub(crate) fn with_api(api: Arc) -> Self { Self { api } } } @@ -168,143 +168,14 @@ impl SecureSecretStore for LinuxSecretServiceStore { } } -fn map_error(error: Error) -> SecureSecretStoreError { +pub(crate) fn map_error(error: Error) -> SecureSecretStoreError { match error { Error::Locked | Error::Prompt => SecureSecretStoreError::Locked, Error::NoResult => SecureSecretStoreError::Missing, - Error::Crypto(_) | Error::Zvariant(_) => SecureSecretStoreError::Corrupted, - Error::Unavailable | Error::Zbus(_) | Error::ZbusFdo(_) => { + Error::Crypto(_) => SecureSecretStoreError::Corrupted, + Error::Unavailable | Error::Zvariant(_) | Error::Zbus(_) | Error::ZbusFdo(_) => { SecureSecretStoreError::Unavailable } _ => SecureSecretStoreError::Unavailable, } } - -#[cfg(test)] -mod tests { - use std::sync::Mutex; - - use super::*; - - #[derive(Default)] - struct RecordingSecretService { - values: Mutex>>, - failure: Mutex>, - } - - impl RecordingSecretService { - fn failure(&self) -> Result<(), SecureSecretStoreError> { - match &*self.failure.lock().unwrap() { - Some(SecureSecretStoreError::Locked) => Err(SecureSecretStoreError::Locked), - Some(SecureSecretStoreError::Missing) => Err(SecureSecretStoreError::Missing), - Some(SecureSecretStoreError::Corrupted) => Err(SecureSecretStoreError::Corrupted), - Some(SecureSecretStoreError::Unavailable) => { - Err(SecureSecretStoreError::Unavailable) - } - None => Ok(()), - } - } - } - - impl LinuxSecretServiceApi for RecordingSecretService { - fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> { - self.failure()?; - self.values - .lock() - .unwrap() - .insert(handle.to_string(), material.to_vec()); - Ok(()) - } - - fn get(&self, handle: &str) -> Result, SecureSecretStoreError> { - self.failure()?; - self.values - .lock() - .unwrap() - .get(handle) - .cloned() - .ok_or(SecureSecretStoreError::Missing) - } - - fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> { - self.failure()?; - self.values - .lock() - .unwrap() - .remove(handle) - .map(|_| ()) - .ok_or(SecureSecretStoreError::Missing) - } - - fn list_handles(&self) -> Result, SecureSecretStoreError> { - self.failure()?; - Ok(self.values.lock().unwrap().keys().cloned().collect()) - } - } - - fn handle(suffix: &str) -> SecretHandle { - SecretHandle(format!("vnidrop/v1/relationship-grant/{suffix}")) - } - - #[test] - fn adapter_survives_restart_and_deletes_only_the_selected_item() { - let api = Arc::new(RecordingSecretService::default()); - let first = handle("first"); - let second = handle("second"); - let material = SecretMaterial::new(vec![0x5a; 32]).unwrap(); - let store = LinuxSecretServiceStore::with_api(api.clone()); - store.put(&first, material.clone()).unwrap(); - store - .put(&second, SecretMaterial::new(vec![0x6b; 32]).unwrap()) - .unwrap(); - - let restarted = LinuxSecretServiceStore::with_api(api); - assert_eq!(restarted.get(&first).unwrap(), material); - assert_eq!( - restarted.list_handles().unwrap(), - vec![first.clone(), second] - ); - restarted.delete(&first).unwrap(); - assert!(matches!( - restarted.get(&first), - Err(SecureSecretStoreError::Missing) - )); - } - - #[test] - fn failures_are_typed_and_secret_material_is_redacted() { - let api = Arc::new(RecordingSecretService::default()); - let store = LinuxSecretServiceStore::with_api(api.clone()); - let secret = SecretMaterial::new(vec![0x7c; 32]).unwrap(); - assert_eq!(format!("{secret:?}"), "SecretMaterial(redacted)"); - - for failure in [ - SecureSecretStoreError::Locked, - SecureSecretStoreError::Unavailable, - SecureSecretStoreError::Corrupted, - ] { - *api.failure.lock().unwrap() = Some(failure); - assert!(store.get(&handle("failure")).is_err()); - } - } - - #[test] - fn secret_service_errors_map_without_exposing_details() { - assert!(matches!( - map_error(Error::Locked), - SecureSecretStoreError::Locked - )); - assert!(matches!( - map_error(Error::NoResult), - SecureSecretStoreError::Missing - )); - assert!(matches!( - map_error(Error::Crypto("distinctive-secret")), - SecureSecretStoreError::Corrupted - )); - assert!(matches!( - map_error(Error::Unavailable), - SecureSecretStoreError::Unavailable - )); - } -} diff --git a/crates/vnidrop/src/secure_secret/platform.rs b/crates/vnidrop/src/secure_secret/platform.rs new file mode 100644 index 0000000..1f969ba --- /dev/null +++ b/crates/vnidrop/src/secure_secret/platform.rs @@ -0,0 +1,138 @@ +use std::{ + fs::{File, OpenOptions}, + path::Path, + sync::Arc, +}; + +#[cfg(any(target_os = "android", target_os = "windows", target_os = "linux"))] +use super::map_store_error; +use super::{ + SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError, HANDLE_NAMESPACE, + HANDLE_VERSION, +}; +use crate::error::VnidropError; + +struct ScopedSecretStore { + inner: Arc, + physical_prefix: String, +} + +pub(crate) struct ProfileLock { + _file: File, +} + +pub(crate) fn lock_profile(app_data_dir: &Path) -> Result { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(app_data_dir.join("protected-secrets.lock")) + .map_err(VnidropError::filesystem)?; + file.try_lock() + .map_err(|_| VnidropError::SecureStorageUnavailable { + reason: "another protected core is already using this profile".to_string(), + })?; + Ok(ProfileLock { _file: file }) +} + +impl ScopedSecretStore { + fn new(app_data_dir: &Path, inner: Arc) -> Self { + let profile = blake3::hash(app_data_dir.to_string_lossy().as_bytes()).to_hex(); + Self { + inner, + physical_prefix: format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/scope-{profile}/"), + } + } + + fn physical_handle( + &self, + handle: &SecretHandle, + ) -> Result { + let logical_prefix = format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/"); + let suffix = handle + .as_str() + .strip_prefix(&logical_prefix) + .ok_or(SecureSecretStoreError::Corrupted)?; + Ok(SecretHandle(format!("{}{suffix}", self.physical_prefix))) + } +} + +impl SecureSecretStore for ScopedSecretStore { + fn put( + &self, + handle: &SecretHandle, + material: SecretMaterial, + ) -> Result<(), SecureSecretStoreError> { + self.inner.put(&self.physical_handle(handle)?, material) + } + + fn get(&self, handle: &SecretHandle) -> Result { + self.inner.get(&self.physical_handle(handle)?) + } + + fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> { + self.inner.delete(&self.physical_handle(handle)?) + } + + fn list_handles(&self) -> Result, SecureSecretStoreError> { + let handles = self + .inner + .list_handles()? + .into_iter() + .filter_map(|handle| { + handle + .as_str() + .strip_prefix(&self.physical_prefix) + .map(|suffix| { + SecretHandle(format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/{suffix}")) + }) + }) + .collect(); + Ok(handles) + } +} + +pub(crate) fn scope_store( + app_data_dir: &Path, + store: Arc, +) -> Arc { + Arc::new(ScopedSecretStore::new(app_data_dir, store)) +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +pub(crate) fn platform_secret_store( + app_data_dir: &Path, +) -> Result, VnidropError> { + Ok(scope_store( + app_data_dir, + Arc::new(super::apple::AppleKeychainSecretStore::new()), + )) +} + +#[cfg(target_os = "android")] +pub(crate) fn platform_secret_store( + app_data_dir: &Path, +) -> Result, VnidropError> { + super::android::native::create_store_from_android_runtime() + .map(|store| scope_store(app_data_dir, store)) + .map_err(map_store_error) +} + +#[cfg(target_os = "windows")] +pub(crate) fn platform_secret_store( + app_data_dir: &Path, +) -> Result, VnidropError> { + super::windows::WindowsDpapiSecretStore::new(app_data_dir.join("protected-secrets-v1")) + .map(|store| scope_store(app_data_dir, Arc::new(store))) + .map_err(map_store_error) +} + +#[cfg(target_os = "linux")] +pub(crate) fn platform_secret_store( + app_data_dir: &Path, +) -> Result, VnidropError> { + super::linux::LinuxSecretServiceStore::connect() + .map(|store| scope_store(app_data_dir, Arc::new(store))) + .map_err(map_store_error) +} diff --git a/crates/vnidrop/src/secure_secret/windows.rs b/crates/vnidrop/src/secure_secret/windows.rs index b6f21b9..5ddae7a 100644 --- a/crates/vnidrop/src/secure_secret/windows.rs +++ b/crates/vnidrop/src/secure_secret/windows.rs @@ -19,11 +19,14 @@ use windows_sys::Win32::{ Security::Cryptography::{ CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, }, - Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH}, + Storage::FileSystem::{MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH}, }; use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError}; +#[cfg(test)] +use super::SecretKind; + const ENVELOPE_MAGIC: &[u8; 8] = b"VNIDPAPI"; const ENVELOPE_VERSION: u8 = 1; const FILE_EXTENSION: &str = "dpapi"; @@ -65,17 +68,25 @@ impl WindowsDpapiSecretStore { } #[cfg(test)] - fn with_protector_for_test( + pub(crate) fn with_context_for_test( directory: impl AsRef, - protector: Arc, + context: &[u8], ) -> Result { - Self::with_protector(directory, protector) + Self::with_protector( + directory, + Arc::new(DpapiProtector::with_context_for_test(context)), + ) } #[cfg(test)] - fn path_for_test(&self, handle: &SecretHandle) -> PathBuf { + pub(crate) fn path_for_test(&self, handle: &SecretHandle) -> PathBuf { self.path_for(handle) } + + #[cfg(test)] + pub(crate) fn relationship_handle_for_test() -> SecretHandle { + SecretHandle::generate(SecretKind::RelationshipGrant) + } } impl SecureSecretStore for WindowsDpapiSecretStore { @@ -85,9 +96,12 @@ impl SecureSecretStore for WindowsDpapiSecretStore { material: SecretMaterial, ) -> Result<(), SecureSecretStoreError> { let destination = self.path_for(handle); - if destination.exists() { - return ensure_same_value(self, handle, &material); - } + let replace_existing = match self.get(handle) { + Ok(existing) if existing == material => return Ok(()), + Ok(_) => true, + Err(SecureSecretStoreError::Missing) => false, + Err(error) => return Err(error), + }; let ciphertext = self.protector.protect(handle, &material.0)?; let envelope = encode_envelope(handle, &ciphertext)?; @@ -109,7 +123,7 @@ impl SecureSecretStore for WindowsDpapiSecretStore { file.sync_all().map_err(map_io_error)?; drop(file); - match move_write_through(temporary_guard.path(), &destination) { + match move_write_through(temporary_guard.path(), &destination, replace_existing) { Ok(()) => { temporary_guard.disarm(); Ok(()) @@ -120,7 +134,16 @@ impl SecureSecretStore for WindowsDpapiSecretStore { Some(ERROR_ALREADY_EXISTS) | Some(ERROR_FILE_EXISTS) ) => { - ensure_same_value(self, handle, &material) + match self.get(handle) { + Ok(existing) if existing == material => Ok(()), + Ok(_) => { + move_write_through(temporary_guard.path(), &destination, true) + .map_err(map_io_error)?; + temporary_guard.disarm(); + Ok(()) + } + Err(error) => Err(error), + } } Err(error) => Err(map_io_error(error)), } @@ -147,7 +170,7 @@ impl SecureSecretStore for WindowsDpapiSecretStore { continue; } let envelope = fs::read(&path).map_err(map_io_error)?; - let handle = decode_handle(&envelope)?; + let (handle, _) = decode_envelope_parts(&envelope)?; if self.path_for(&handle) != path || !unique.insert(handle.clone()) { return Err(SecureSecretStoreError::Corrupted); } @@ -158,18 +181,6 @@ impl SecureSecretStore for WindowsDpapiSecretStore { } } -fn ensure_same_value( - store: &WindowsDpapiSecretStore, - handle: &SecretHandle, - expected: &SecretMaterial, -) -> Result<(), SecureSecretStoreError> { - if store.get(handle)? == *expected { - Ok(()) - } else { - Err(SecureSecretStoreError::Corrupted) - } -} - fn encode_envelope( handle: &SecretHandle, ciphertext: &[u8], @@ -193,11 +204,6 @@ fn encode_envelope( Ok(envelope) } -fn decode_handle(envelope: &[u8]) -> Result { - let (handle, _) = decode_envelope_parts(envelope)?; - Ok(handle) -} - fn decode_envelope<'a>( envelope: &'a [u8], expected_handle: &SecretHandle, @@ -276,17 +282,16 @@ impl Drop for TemporaryFile { } } -fn move_write_through(source: &Path, destination: &Path) -> io::Result<()> { +fn move_write_through(source: &Path, destination: &Path, replace_existing: bool) -> io::Result<()> { let source = wide_path(source); let destination = wide_path(destination); // The files share a directory, so MoveFileEx publishes the fully flushed blob as one rename. - let moved = unsafe { - MoveFileExW( - source.as_ptr(), - destination.as_ptr(), - MOVEFILE_WRITE_THROUGH, - ) + let flags = if replace_existing { + MOVEFILE_WRITE_THROUGH | MOVEFILE_REPLACE_EXISTING + } else { + MOVEFILE_WRITE_THROUGH }; + let moved = unsafe { MoveFileExW(source.as_ptr(), destination.as_ptr(), flags) }; if moved == 0 { Err(io::Error::last_os_error()) } else { @@ -466,7 +471,3 @@ fn map_io_error(error: io::Error) -> SecureSecretStoreError { _ => SecureSecretStoreError::Unavailable, } } - -#[cfg(test)] -#[path = "windows_tests.rs"] -mod tests; diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 7cc04b3..27f5ab2 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -22,8 +22,18 @@ mod repository_tests; mod runtime_tests; #[path = "tests/secret.rs"] mod secret_tests; +#[path = "tests/secure_secret_android.rs"] +mod secure_secret_android_tests; +#[cfg(any(target_os = "macos", target_os = "ios"))] +#[path = "tests/secure_secret_apple.rs"] +mod secure_secret_apple_tests; +#[path = "tests/secure_secret_linux.rs"] +mod secure_secret_linux_tests; #[path = "tests/secure_secret.rs"] mod secure_secret_tests; +#[cfg(target_os = "windows")] +#[path = "tests/secure_secret_windows.rs"] +mod secure_secret_windows_tests; #[path = "tests/ticket.rs"] mod ticket_tests; #[path = "tests/transfer_state.rs"] diff --git a/crates/vnidrop/src/tests/error.rs b/crates/vnidrop/src/tests/error.rs index 9545c49..cffa6d3 100644 --- a/crates/vnidrop/src/tests/error.rs +++ b/crates/vnidrop/src/tests/error.rs @@ -44,6 +44,22 @@ fn transfer_boundary_preserves_typed_errors_through_context() { assert_eq!(classified.code(), "network"); } +#[test] +fn initialization_boundary_preserves_secure_storage_failures() { + let error = anyhow::Error::new(VnidropError::SecureStorageLocked { + reason: "credential store is locked".to_string(), + }) + .context("endpoint identity could not be loaded"); + + let classified = VnidropError::initialization(error); + + assert!(matches!( + classified, + VnidropError::SecureStorageLocked { ref reason } + if reason == "endpoint identity could not be loaded" + )); +} + #[test] fn transfer_boundary_classifies_database_failures() { let transfer = VnidropError::transfer(sqlx::Error::RowNotFound); diff --git a/crates/vnidrop/src/tests/runtime.rs b/crates/vnidrop/src/tests/runtime.rs index 895a448..bafd01e 100644 --- a/crates/vnidrop/src/tests/runtime.rs +++ b/crates/vnidrop/src/tests/runtime.rs @@ -10,9 +10,10 @@ use iroh_blobs::{ use crate::{ repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert}, - runtime::{consume_request_updates, RequestStreamOutcome}, + runtime::{consume_request_updates, CoreInner, IdentityMode, RequestStreamOutcome}, + secure_secret::{lock_profile, FaultInjectingSecretStore}, transfer_state::{TransferDirection, TransferStatus}, - CoreEvent, CoreEventSink, VnidropCore, VnidropError, + CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode, VnidropCore, VnidropError, }; struct TestSink; @@ -65,6 +66,46 @@ fn initializes_and_reports_endpoint() { core.shutdown(); } +#[tokio::test] +async fn protected_runtime_restart_preserves_identity_without_plaintext_fallback() { + let temp = tempfile::tempdir().unwrap(); + let store = Arc::new(FaultInjectingSecretStore::default()); + let first = CoreInner::start( + temp.path().to_path_buf(), + Arc::new(TestSink), + CoreLimits::default(), + CoreRelayMode::LocalOnly, + Vec::new(), + IdentityMode::Protected { + store: store.clone(), + profile_lock: lock_profile(temp.path()).unwrap(), + }, + ) + .await + .unwrap(); + let endpoint_id = first.endpoint.id(); + first.shutdown().await; + drop(first); + + let restarted = CoreInner::start( + temp.path().to_path_buf(), + Arc::new(TestSink), + CoreLimits::default(), + CoreRelayMode::LocalOnly, + Vec::new(), + IdentityMode::Protected { + store, + profile_lock: lock_profile(temp.path()).unwrap(), + }, + ) + .await + .unwrap(); + + assert_eq!(restarted.endpoint.id(), endpoint_id); + assert!(!temp.path().join("iroh.secret").exists()); + restarted.shutdown().await; +} + #[test] fn invalid_receive_ticket_is_typed_and_persisted_as_event() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/vnidrop/src/tests/secure_secret.rs b/crates/vnidrop/src/tests/secure_secret.rs index 9641340..62e4dc3 100644 --- a/crates/vnidrop/src/tests/secure_secret.rs +++ b/crates/vnidrop/src/tests/secure_secret.rs @@ -9,8 +9,8 @@ use iroh::SecretKey; use crate::{ repository::Repository, secure_secret::{ - CustodyCrashPoint, FaultInjectingSecretStore, ReferenceStoreFailure, SecretCustody, - SecretKind, SecretMaterial, + lock_profile, scope_store, CustodyCrashPoint, FaultInjectingSecretStore, + ReferenceStoreFailure, SecretCustody, SecretKind, SecretMaterial, SecureSecretStore, }, VnidropError, }; @@ -31,6 +31,68 @@ impl Write for CapturedWriter { } } +#[test] +fn a_profile_allows_only_one_protected_core_mutator() { + let temp = tempfile::tempdir().unwrap(); + let first = lock_profile(temp.path()).unwrap(); + + assert!(matches!( + lock_profile(temp.path()), + Err(VnidropError::SecureStorageUnavailable { .. }) + )); + + drop(first); + assert!(lock_profile(temp.path()).is_ok()); +} + +#[tokio::test] +async fn reconciliation_is_scoped_to_one_application_profile() { + let root = tempfile::tempdir().unwrap(); + let first_dir = root.path().join("first"); + let second_dir = root.path().join("second"); + std::fs::create_dir_all(&first_dir).unwrap(); + std::fs::create_dir_all(&second_dir).unwrap(); + let shared_platform_store = Arc::new(FaultInjectingSecretStore::default()); + let first_store = scope_store(&first_dir, shared_platform_store.clone()); + let second_store = scope_store(&second_dir, shared_platform_store); + let first_repository = Repository::open(&first_dir).await.unwrap(); + let second_repository = Repository::open(&second_dir).await.unwrap(); + let first = SecretCustody::new(first_repository.protected_secrets(), first_store.clone()); + let second = SecretCustody::new(second_repository.protected_secrets(), second_store.clone()); + let first_handle = first + .protect( + SecretKind::RelationshipGrant, + SecretMaterial::new(vec![0x31; 32]).unwrap(), + None, + ) + .await + .unwrap(); + let second_handle = second + .protect( + SecretKind::RelationshipGrant, + SecretMaterial::new(vec![0x42; 32]).unwrap(), + None, + ) + .await + .unwrap(); + + drop(first); + let (restarted, summary) = + SecretCustody::start(first_repository.protected_secrets(), first_store) + .await + .unwrap(); + + assert_eq!(summary.orphans_deleted, 0); + assert_eq!( + restarted.load(&first_handle).await.unwrap(), + SecretMaterial::new(vec![0x31; 32]).unwrap() + ); + assert_eq!( + second.load(&second_handle).await.unwrap(), + SecretMaterial::new(vec![0x42; 32]).unwrap() + ); +} + #[tokio::test] async fn custody_maps_reference_store_failures_to_typed_core_errors() { let temp = tempfile::tempdir().unwrap(); @@ -220,6 +282,70 @@ async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacem )); } +#[tokio::test] +async fn first_install_identity_is_protected_once_and_never_silently_replaced() { + let temp = tempfile::tempdir().unwrap(); + let legacy_path = temp.path().join("iroh.secret"); + let repository = Repository::open(temp.path()).await.unwrap(); + let store = Arc::new(FaultInjectingSecretStore::default()); + let (custody, _) = SecretCustody::start(repository.protected_secrets(), store.clone()) + .await + .unwrap(); + + let original = custody + .initialize_endpoint_identity(&legacy_path) + .await + .unwrap(); + assert!(!legacy_path.exists()); + let handle = store.only_handle_for_test(); + drop(custody); + drop(repository); + + let repository = Repository::open(temp.path()).await.unwrap(); + let (custody, _) = SecretCustody::start(repository.protected_secrets(), store.clone()) + .await + .unwrap(); + assert_eq!( + custody + .initialize_endpoint_identity(&legacy_path) + .await + .unwrap(), + original + ); + + store.remove_for_test(&handle); + drop(custody); + drop(repository); + let repository = Repository::open(temp.path()).await.unwrap(); + let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone()) + .await + .unwrap(); + assert_eq!(summary.disabled, 1); + assert!(matches!( + custody.initialize_endpoint_identity(&legacy_path).await, + Err(VnidropError::SecureStorageUnavailable { .. }) + )); + assert!(store.list_handles().unwrap().is_empty()); +} + +#[tokio::test] +async fn concurrent_first_starts_converge_on_one_protected_endpoint_identity() { + let temp = tempfile::tempdir().unwrap(); + let legacy_path = temp.path().join("iroh.secret"); + let repository = Repository::open(temp.path()).await.unwrap(); + let store = Arc::new(FaultInjectingSecretStore::default()); + let first = SecretCustody::new(repository.protected_secrets(), store.clone()); + let second = SecretCustody::new(repository.protected_secrets(), store.clone()); + + let (first_identity, second_identity) = tokio::join!( + first.initialize_endpoint_identity(&legacy_path), + second.initialize_endpoint_identity(&legacy_path), + ); + + assert_eq!(first_identity.unwrap(), second_identity.unwrap()); + assert_eq!(store.list_handles().unwrap().len(), 1); +} + #[tokio::test] async fn protected_material_is_absent_from_database_and_diagnostics() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/vnidrop/src/tests/secure_secret_android.rs b/crates/vnidrop/src/tests/secure_secret_android.rs new file mode 100644 index 0000000..32d03a3 --- /dev/null +++ b/crates/vnidrop/src/tests/secure_secret_android.rs @@ -0,0 +1,180 @@ +use std::{ + collections::HashMap, + fs, + sync::{Arc, Mutex}, +}; + +use tempfile::TempDir; + +use crate::secure_secret::{ + android::{ + secret_handle_for_test, AndroidKeystore, AndroidSealedValue, AndroidSecureSecretStore, + }, + SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError, +}; + +const TEST_SECRET_BYTES: usize = 32; + +#[derive(Default)] +struct FakeKeystore { + keys: Mutex>, + seal_failure: Mutex>, + delete_failure: Mutex>, +} + +impl AndroidKeystore for FakeKeystore { + fn seal( + &self, + alias: &str, + plaintext: &[u8], + ) -> Result { + if let Some(error) = self.seal_failure.lock().unwrap().take() { + return Err(error); + } + let mask = 0xa7; + self.keys.lock().unwrap().insert(alias.to_string(), mask); + Ok(AndroidSealedValue { + nonce: vec![4; 12], + ciphertext: plaintext.iter().map(|byte| byte ^ mask).collect(), + }) + } + + fn open( + &self, + alias: &str, + sealed: &AndroidSealedValue, + ) -> Result, SecureSecretStoreError> { + let mask = *self + .keys + .lock() + .unwrap() + .get(alias) + .ok_or(SecureSecretStoreError::Missing)?; + Ok(sealed.ciphertext.iter().map(|byte| byte ^ mask).collect()) + } + + fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> { + if let Some(error) = self.delete_failure.lock().unwrap().take() { + return Err(error); + } + self.keys.lock().unwrap().remove(alias); + Ok(()) + } +} + +fn fixture() -> (TempDir, AndroidSecureSecretStore, Arc) { + let directory = TempDir::new().unwrap(); + let keystore = Arc::new(FakeKeystore::default()); + let store = AndroidSecureSecretStore::new(directory.path(), keystore.clone()).unwrap(); + (directory, store, keystore) +} + +fn handle() -> SecretHandle { + secret_handle_for_test("vnidrop/v1/endpoint-identity/test") +} + +#[test] +fn adapter_round_trips_lists_and_deletes_without_plaintext_persistence() { + let (directory, store, keystore) = fixture(); + let handle = handle(); + let plaintext = vec![0x5a; TEST_SECRET_BYTES]; + let material = SecretMaterial::new(plaintext.clone()).unwrap(); + + store.put(&handle, material.clone()).unwrap(); + + let persisted = fs::read(store.record_path_for_test(&handle)).unwrap(); + assert!(!persisted + .windows(plaintext.len()) + .any(|window| window == plaintext)); + + drop(store); + let restarted = AndroidSecureSecretStore::new(directory.path(), keystore).unwrap(); + assert_eq!(restarted.list_handles().unwrap(), vec![handle.clone()]); + assert_eq!(restarted.get(&handle).unwrap(), material); + + restarted.delete(&handle).unwrap(); + assert!(restarted.list_handles().unwrap().is_empty()); + assert!(matches!( + restarted.get(&handle), + Err(SecureSecretStoreError::Missing) + )); +} + +#[test] +fn staged_crash_record_remains_discoverable_and_fails_closed() { + let (_directory, store, _keystore) = fixture(); + let handle = handle(); + store.stage_for_test(&handle).unwrap(); + + assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]); + assert!(matches!( + store.get(&handle), + Err(SecureSecretStoreError::Corrupted) + )); + store.delete(&handle).unwrap(); + assert!(store.list_handles().unwrap().is_empty()); +} + +#[test] +fn tampering_and_missing_keystore_keys_are_distinct_failures() { + let (_directory, store, keystore) = fixture(); + let handle = handle(); + store + .put( + &handle, + SecretMaterial::new(vec![9; TEST_SECRET_BYTES]).unwrap(), + ) + .unwrap(); + + keystore.keys.lock().unwrap().clear(); + assert!(matches!( + store.get(&handle), + Err(SecureSecretStoreError::Missing) + )); + + fs::write(store.record_path_for_test(&handle), b"tampered").unwrap(); + assert!(matches!( + store.get(&handle), + Err(SecureSecretStoreError::Corrupted) + )); +} + +#[test] +fn failed_key_deletion_retains_the_record_for_safe_retry() { + let (_directory, store, keystore) = fixture(); + let handle = handle(); + store + .put( + &handle, + SecretMaterial::new(vec![7; TEST_SECRET_BYTES]).unwrap(), + ) + .unwrap(); + *keystore.delete_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked); + + assert!(matches!( + store.delete(&handle), + Err(SecureSecretStoreError::Locked) + )); + assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]); + + store.delete(&handle).unwrap(); + assert!(store.list_handles().unwrap().is_empty()); +} + +#[test] +fn failed_replacement_keeps_the_previous_secret_readable() { + let (_directory, store, keystore) = fixture(); + let handle = handle(); + let original = SecretMaterial::new(vec![3; TEST_SECRET_BYTES]).unwrap(); + store.put(&handle, original.clone()).unwrap(); + *keystore.seal_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked); + + assert!(matches!( + store.put( + &handle, + SecretMaterial::new(vec![8; TEST_SECRET_BYTES]).unwrap() + ), + Err(SecureSecretStoreError::Locked) + )); + assert_eq!(store.get(&handle).unwrap(), original); +} diff --git a/crates/vnidrop/src/tests/secure_secret_apple.rs b/crates/vnidrop/src/tests/secure_secret_apple.rs new file mode 100644 index 0000000..7623105 --- /dev/null +++ b/crates/vnidrop/src/tests/secure_secret_apple.rs @@ -0,0 +1,188 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use crate::secure_secret::{ + apple::{ + expected_policy_for_test, handle_for_test, map_status_for_test, service_for_test, + AppleKeychainApi, AppleKeychainPolicy, AppleKeychainSecretStore, + }, + SecretMaterial, SecureSecretStore, SecureSecretStoreError, +}; + +const ERR_SEC_AUTH_FAILED: i32 = -25_293; +const ERR_SEC_NOT_AVAILABLE: i32 = -25_291; +const ERR_SEC_ITEM_NOT_FOUND: i32 = -25_300; +const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308; +const ERR_SEC_DECODE: i32 = -26_275; + +#[derive(Clone, Default)] +struct RecordingKeychain { + state: Arc>, +} + +#[derive(Default)] +struct RecordingState { + entries: HashMap<(String, String), Vec>, + last_policy: Option, +} + +impl AppleKeychainApi for RecordingKeychain { + fn put( + &self, + service: &str, + account: &str, + material: &[u8], + policy: AppleKeychainPolicy, + ) -> Result<(), i32> { + let mut state = self.state.lock().unwrap(); + state.last_policy = Some(policy); + state.entries.insert( + (service.to_string(), account.to_string()), + material.to_vec(), + ); + Ok(()) + } + + fn get(&self, service: &str, account: &str) -> Result, i32> { + self.state + .lock() + .unwrap() + .entries + .get(&(service.to_string(), account.to_string())) + .cloned() + .ok_or(ERR_SEC_ITEM_NOT_FOUND) + } + + fn delete(&self, service: &str, account: &str) -> Result<(), i32> { + self.state + .lock() + .unwrap() + .entries + .remove(&(service.to_string(), account.to_string())) + .map(|_| ()) + .ok_or(ERR_SEC_ITEM_NOT_FOUND) + } + + fn list_accounts(&self, service: &str) -> Result, i32> { + Ok(self + .state + .lock() + .unwrap() + .entries + .keys() + .filter(|(entry_service, _)| entry_service == service) + .map(|(_, account)| account.clone()) + .collect()) + } +} + +#[test] +fn adapter_creates_replaces_reads_lists_and_deletes_only_its_service() { + let api = RecordingKeychain::default(); + api.state.lock().unwrap().entries.insert( + ("com.example.unrelated".to_string(), "leave-me".to_string()), + vec![0x77; 32], + ); + let store = AppleKeychainSecretStore::with_api(api.clone()); + let owned = handle_for_test("vnidrop/v1/endpoint-identity/apple-test"); + + store + .put(&owned, SecretMaterial::new(vec![0x31; 32]).unwrap()) + .unwrap(); + drop(store); + + let reopened_store = AppleKeychainSecretStore::with_api(api.clone()); + assert_eq!( + reopened_store.get(&owned).unwrap(), + SecretMaterial::new(vec![0x31; 32]).unwrap() + ); + reopened_store + .put(&owned, SecretMaterial::new(vec![0x42; 32]).unwrap()) + .unwrap(); + + assert_eq!( + reopened_store.get(&owned).unwrap(), + SecretMaterial::new(vec![0x42; 32]).unwrap() + ); + assert_eq!(reopened_store.list_handles().unwrap(), vec![owned.clone()]); + reopened_store.delete(&owned).unwrap(); + assert!(matches!( + reopened_store.get(&owned), + Err(SecureSecretStoreError::Missing) + )); + assert!(api + .state + .lock() + .unwrap() + .entries + .contains_key(&("com.example.unrelated".to_string(), "leave-me".to_string()))); +} + +#[test] +fn adapter_always_requests_device_local_non_synchronizing_protection() { + let api = RecordingKeychain::default(); + let store = AppleKeychainSecretStore::with_api(api.clone()); + + store + .put( + &handle_for_test("vnidrop/v1/relationship-grant/apple-policy"), + SecretMaterial::new(vec![0x51; 32]).unwrap(), + ) + .unwrap(); + + assert_eq!( + api.state.lock().unwrap().last_policy, + Some(expected_policy_for_test()) + ); +} + +#[test] +fn apple_statuses_map_to_fail_closed_contract_outcomes() { + assert!(matches!( + map_status_for_test(ERR_SEC_INTERACTION_NOT_ALLOWED), + SecureSecretStoreError::Locked + )); + assert!(matches!( + map_status_for_test(ERR_SEC_AUTH_FAILED), + SecureSecretStoreError::Locked + )); + assert!(matches!( + map_status_for_test(ERR_SEC_ITEM_NOT_FOUND), + SecureSecretStoreError::Missing + )); + assert!(matches!( + map_status_for_test(ERR_SEC_DECODE), + SecureSecretStoreError::Corrupted + )); + assert!(matches!( + map_status_for_test(ERR_SEC_NOT_AVAILABLE), + SecureSecretStoreError::Unavailable + )); + assert!(matches!( + map_status_for_test(-1), + SecureSecretStoreError::Unavailable + )); +} + +#[test] +fn malformed_keychain_values_are_corrupted_without_diagnostic_disclosure() { + let api = RecordingKeychain::default(); + let secret = vec![0x6d; 31]; + api.state.lock().unwrap().entries.insert( + ( + service_for_test().to_string(), + "vnidrop/v1/pairing-eligibility/corrupt".to_string(), + ), + secret.clone(), + ); + let store = AppleKeychainSecretStore::with_api(api); + + let error = store + .get(&handle_for_test("vnidrop/v1/pairing-eligibility/corrupt")) + .unwrap_err(); + + assert!(matches!(&error, SecureSecretStoreError::Corrupted)); + assert!(!format!("{error:?}").contains(&data_encoding::HEXLOWER.encode(&secret))); +} diff --git a/crates/vnidrop/src/tests/secure_secret_linux.rs b/crates/vnidrop/src/tests/secure_secret_linux.rs new file mode 100644 index 0000000..ec3241c --- /dev/null +++ b/crates/vnidrop/src/tests/secure_secret_linux.rs @@ -0,0 +1,169 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use secret_service::Error; + +use crate::{ + repository::Repository, + secure_secret::{ + linux::{map_error, LinuxSecretServiceApi, LinuxSecretServiceStore}, + SecretCustody, SecretHandle, SecretKind, SecretMaterial, SecureSecretStore, + SecureSecretStoreError, + }, + VnidropError, +}; + +#[derive(Default)] +struct RecordingSecretService { + values: Mutex>>, + failure: Mutex>, +} + +impl RecordingSecretService { + fn failure(&self) -> Result<(), SecureSecretStoreError> { + match &*self.failure.lock().unwrap() { + Some(SecureSecretStoreError::Locked) => Err(SecureSecretStoreError::Locked), + Some(SecureSecretStoreError::Missing) => Err(SecureSecretStoreError::Missing), + Some(SecureSecretStoreError::Corrupted) => Err(SecureSecretStoreError::Corrupted), + Some(SecureSecretStoreError::Unavailable) => Err(SecureSecretStoreError::Unavailable), + None => Ok(()), + } + } +} + +impl LinuxSecretServiceApi for RecordingSecretService { + fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> { + self.failure()?; + self.values + .lock() + .unwrap() + .insert(handle.to_string(), material.to_vec()); + Ok(()) + } + + fn get(&self, handle: &str) -> Result, SecureSecretStoreError> { + self.failure()?; + self.values + .lock() + .unwrap() + .get(handle) + .cloned() + .ok_or(SecureSecretStoreError::Missing) + } + + fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> { + self.failure()?; + self.values + .lock() + .unwrap() + .remove(handle) + .map(|_| ()) + .ok_or(SecureSecretStoreError::Missing) + } + + fn list_handles(&self) -> Result, SecureSecretStoreError> { + self.failure()?; + Ok(self.values.lock().unwrap().keys().cloned().collect()) + } +} + +fn handle(suffix: &str) -> SecretHandle { + crate::secure_secret::secret_handle_for_test(format!("vnidrop/v1/relationship-grant/{suffix}")) +} + +#[test] +fn adapter_survives_restart_and_deletes_only_the_selected_item() { + let api = Arc::new(RecordingSecretService::default()); + let first = handle("first"); + let second = handle("second"); + let material = SecretMaterial::new(vec![0x5a; 32]).unwrap(); + let store = LinuxSecretServiceStore::with_api(api.clone()); + store.put(&first, material.clone()).unwrap(); + store + .put(&second, SecretMaterial::new(vec![0x6b; 32]).unwrap()) + .unwrap(); + + let restarted = LinuxSecretServiceStore::with_api(api); + assert_eq!(restarted.get(&first).unwrap(), material); + assert_eq!( + restarted.list_handles().unwrap(), + vec![first.clone(), second] + ); + restarted.delete(&first).unwrap(); + assert!(matches!( + restarted.get(&first), + Err(SecureSecretStoreError::Missing) + )); +} + +#[tokio::test] +async fn transient_backend_failures_do_not_delete_protected_metadata_or_material() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + let api = Arc::new(RecordingSecretService::default()); + let store = Arc::new(LinuxSecretServiceStore::with_api(api.clone())); + let custody = SecretCustody::new(repository.protected_secrets(), store.clone()); + let protected = custody + .protect( + SecretKind::RelationshipGrant, + SecretMaterial::new(vec![0x7c; 32]).unwrap(), + None, + ) + .await + .unwrap(); + + *api.failure.lock().unwrap() = Some(SecureSecretStoreError::Unavailable); + drop(custody); + assert!(matches!( + SecretCustody::start(repository.protected_secrets(), store.clone()).await, + Err(VnidropError::SecureStorageUnavailable { .. }) + )); + + *api.failure.lock().unwrap() = None; + let (restarted, _) = SecretCustody::start(repository.protected_secrets(), store) + .await + .unwrap(); + assert_eq!( + restarted.load(&protected).await.unwrap(), + SecretMaterial::new(vec![0x7c; 32]).unwrap() + ); +} + +#[test] +fn failures_are_typed_and_secret_material_is_redacted() { + let api = Arc::new(RecordingSecretService::default()); + let store = LinuxSecretServiceStore::with_api(api.clone()); + let secret = SecretMaterial::new(vec![0x7c; 32]).unwrap(); + assert_eq!(format!("{secret:?}"), "SecretMaterial(redacted)"); + + for failure in [ + SecureSecretStoreError::Locked, + SecureSecretStoreError::Unavailable, + SecureSecretStoreError::Corrupted, + ] { + *api.failure.lock().unwrap() = Some(failure); + assert!(store.get(&handle("failure")).is_err()); + } +} + +#[test] +fn secret_service_errors_map_without_exposing_details() { + assert!(matches!( + map_error(Error::Locked), + SecureSecretStoreError::Locked + )); + assert!(matches!( + map_error(Error::NoResult), + SecureSecretStoreError::Missing + )); + assert!(matches!( + map_error(Error::Crypto("distinctive-secret")), + SecureSecretStoreError::Corrupted + )); + assert!(matches!( + map_error(Error::Unavailable), + SecureSecretStoreError::Unavailable + )); +} diff --git a/crates/vnidrop/src/secure_secret/windows_tests.rs b/crates/vnidrop/src/tests/secure_secret_windows.rs similarity index 78% rename from crates/vnidrop/src/secure_secret/windows_tests.rs rename to crates/vnidrop/src/tests/secure_secret_windows.rs index b6356fc..858459e 100644 --- a/crates/vnidrop/src/secure_secret/windows_tests.rs +++ b/crates/vnidrop/src/tests/secure_secret_windows.rs @@ -3,18 +3,17 @@ use std::{fs, sync::Arc}; use data_encoding::HEXLOWER; use iroh::SecretKey; -use super::{DpapiProtector, WindowsDpapiSecretStore}; use crate::{ repository::Repository, secure_secret::{ - CustodyCrashPoint, SecretCustody, SecretHandle, SecretKind, SecretMaterial, + windows::WindowsDpapiSecretStore, CustodyCrashPoint, SecretCustody, SecretMaterial, SecureSecretStore, SecureSecretStoreError, }, VnidropError, }; -fn handle(suffix: &str) -> SecretHandle { - SecretHandle(format!("vnidrop/v1/relationship-grant/{suffix}")) +fn handle() -> crate::secure_secret::SecretHandle { + WindowsDpapiSecretStore::relationship_handle_for_test() } fn material(seed: u8) -> SecretMaterial { @@ -24,7 +23,7 @@ fn material(seed: u8) -> SecretMaterial { #[test] fn round_trip_survives_adapter_restart_and_never_persists_plaintext() { let directory = tempfile::tempdir().unwrap(); - let handle = handle("restart"); + let handle = handle(); let secret = material(0xa7); WindowsDpapiSecretStore::new(directory.path()) @@ -46,8 +45,8 @@ fn round_trip_survives_adapter_restart_and_never_persists_plaintext() { fn delete_removes_only_the_selected_protected_value() { let directory = tempfile::tempdir().unwrap(); let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); - let retained = handle("retained"); - let removed = handle("removed"); + let retained = handle(); + let removed = handle(); store.put(&retained, material(1)).unwrap(); store.put(&removed, material(2)).unwrap(); @@ -62,25 +61,28 @@ fn delete_removes_only_the_selected_protected_value() { } #[test] -fn repeated_put_is_idempotent_but_cannot_replace_secret_material() { +fn repeated_put_is_idempotent_and_atomically_updates_changed_material() { let directory = tempfile::tempdir().unwrap(); let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); - let handle = handle("immutable"); + let handle = handle(); store.put(&handle, material(6)).unwrap(); + let first_blob = fs::read(store.path_for_test(&handle)).unwrap(); store.put(&handle, material(6)).unwrap(); + assert_eq!(fs::read(store.path_for_test(&handle)).unwrap(), first_blob); - assert!(matches!( - store.put(&handle, material(7)), - Err(SecureSecretStoreError::Corrupted) - )); - assert_eq!(store.get(&handle).unwrap(), material(6)); + store.put(&handle, material(7)).unwrap(); + assert_eq!(store.get(&handle).unwrap(), material(7)); + assert!(!fs::read(store.path_for_test(&handle)) + .unwrap() + .windows(32) + .any(|window| window == [7; 32])); } #[test] fn missing_corrupt_and_wrong_context_values_fail_closed() { let directory = tempfile::tempdir().unwrap(); - let handle = handle("failure-mapping"); + let handle = handle(); let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); assert!(matches!( store.get(&handle), @@ -95,17 +97,11 @@ fn missing_corrupt_and_wrong_context_values_fail_closed() { )); let isolated = tempfile::tempdir().unwrap(); - let original = WindowsDpapiSecretStore::with_protector_for_test( - isolated.path(), - Arc::new(DpapiProtector::with_context_for_test(b"first-context")), - ) - .unwrap(); + let original = + WindowsDpapiSecretStore::with_context_for_test(isolated.path(), b"first-context").unwrap(); original.put(&handle, material(4)).unwrap(); - let wrong_context = WindowsDpapiSecretStore::with_protector_for_test( - isolated.path(), - Arc::new(DpapiProtector::with_context_for_test(b"second-context")), - ) - .unwrap(); + let wrong_context = + WindowsDpapiSecretStore::with_context_for_test(isolated.path(), b"second-context").unwrap(); assert!(matches!( wrong_context.get(&handle), Err(SecureSecretStoreError::Corrupted) @@ -113,15 +109,18 @@ fn missing_corrupt_and_wrong_context_values_fail_closed() { } #[test] -fn incomplete_temporary_writes_are_removed_on_restart() { +fn interrupted_replacement_preserves_the_previous_value() { let directory = tempfile::tempdir().unwrap(); - let temporary = directory.path().join("interrupted.tmp-123"); - fs::write(&temporary, material(5).0).unwrap(); - + let handle = handle(); let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); + store.put(&handle, material(5)).unwrap(); + let temporary = directory.path().join("interrupted.tmp-123"); + fs::write(&temporary, b"incomplete protected replacement").unwrap(); + + let restarted = WindowsDpapiSecretStore::new(directory.path()).unwrap(); assert!(!temporary.exists()); - assert!(store.list_handles().unwrap().is_empty()); + assert_eq!(restarted.get(&handle).unwrap(), material(5)); } #[test] diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/AndroidCoreRuntime.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/AndroidCoreRuntime.kt new file mode 100644 index 0000000..e61a52c --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/AndroidCoreRuntime.kt @@ -0,0 +1,17 @@ +package com.vnidrop.app.core + +import android.content.Context + +internal object AndroidCoreRuntime { + init { + System.loadLibrary("vnidrop") + } + + external fun initialize(context: Context): Boolean +} + +fun initializeAndroidCoreRuntime(context: Context) { + check(AndroidCoreRuntime.initialize(context.applicationContext)) { + "The protected VniDrop runtime could not initialize" + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 46689e7..3c9c5a0 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -31,10 +31,12 @@ import uniffi.vnidrop.TransferMetadata import uniffi.vnidrop.TransferAccessMode import uniffi.vnidrop.VnidropCore import uniffi.vnidrop.clearInactiveTransferCache +import uniffi.vnidrop.defaultCoreLimits import uniffi.vnidrop.defaultCoreNetworkConfig -class CoreRepository( +class CoreRepository internal constructor( private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val coreFactory: CoreFactory = ProtectedCoreFactory, ) : CoreGateway { private val _state = MutableStateFlow(CoreState()) override val state: StateFlow = _state.asStateFlow() @@ -79,7 +81,7 @@ class CoreRepository( _state.update { it.copy(isInitialized = false, status = null) } core = null disposeCore(previousCore) - core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative()) + core = createCore(appDataDir, relaySettings) currentAppDataDir = appDataDir currentRelaySettings = relaySettings refreshSnapshot(requireCore()) @@ -190,7 +192,7 @@ class CoreRepository( try { reclaimed = clearInactiveTransferCache(appDataDir) } finally { - core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative()) + core = createCore(appDataDir, relaySettings) refreshSnapshot(requireCore()) _state.update { it.copy(isInitialized = true) } } @@ -207,6 +209,9 @@ class CoreRepository( } } + private fun createCore(appDataDir: String, relaySettings: RelaySettings): VnidropCore = + coreFactory.create(appDataDir, sink, relaySettings) + override suspend fun receivedArtifacts(): Result> = runCore { activeCore -> activeCore.listReceivedArtifacts().map { artifact -> ReceivedArtifactModel( @@ -330,6 +335,23 @@ class CoreRepository( } } +internal fun interface CoreFactory { + fun create(appDataDir: String, eventSink: CoreEventSink, relaySettings: RelaySettings): VnidropCore +} + +private object ProtectedCoreFactory : CoreFactory { + override fun create( + appDataDir: String, + eventSink: CoreEventSink, + relaySettings: RelaySettings, + ): VnidropCore = VnidropCore.initializeWithExperimentalSavedDevices( + appDataDir, + eventSink, + defaultCoreLimits(), + relaySettings.toNative(), + ) +} + private fun RelaySettings.toNative(): CoreNetworkConfig = when (mode) { RelayMode.Automatic -> defaultCoreNetworkConfig() RelayMode.StrictCustom -> CoreNetworkConfig( diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt index 4665b54..608c439 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt @@ -6,13 +6,24 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import uniffi.vnidrop.CoreNetworkConfig +import uniffi.vnidrop.CoreRelayMode +import uniffi.vnidrop.VnidropCore class CoreRepositoryStorageTest { @Test fun cacheClearWaitsForActiveSharesThenRestartsWithTheSameIdentity() = runTest { val appData = createTempDirectory("vnidrop-cache-clear") val source = Files.write(appData.resolve("source.bin"), ByteArray(64 * 1024) { 5 }) - val repository = CoreRepository() + val repository = CoreRepository( + coreFactory = CoreFactory { appDataDir, eventSink, _ -> + VnidropCore.initializeWithNetworkConfig( + appDataDir, + eventSink, + CoreNetworkConfig(CoreRelayMode.LOCAL_ONLY, emptyList()), + ) + }, + ) try { assertTrue( repository.initialize(