From f603f5fb8d253a147faa3487691bea3b293b9a2b Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 11 Aug 2026 05:11:26 +0200 Subject: [PATCH] feat(windows): exercise saved-device core contract harness Prove the DPAPI-backed Windows bridge can drive the full public saved-device and targeted-transfer contract via an injectable API fake on non-Windows hosts and real DPAPI under cfg(windows), without product UI. Co-authored-by: Cursor --- crates/vnidrop/src/secure_secret.rs | 2 +- crates/vnidrop/src/secure_secret/windows.rs | 359 +++++++--- crates/vnidrop/src/tests.rs | 2 + .../src/tests/platform_contract_windows.rs | 642 ++++++++++++++++++ .../app/WindowsSavedDeviceCoreContractTest.kt | 88 +++ 5 files changed, 1006 insertions(+), 87 deletions(-) create mode 100644 crates/vnidrop/src/tests/platform_contract_windows.rs create mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/WindowsSavedDeviceCoreContractTest.kt diff --git a/crates/vnidrop/src/secure_secret.rs b/crates/vnidrop/src/secure_secret.rs index bb4049a..0d6961f 100644 --- a/crates/vnidrop/src/secure_secret.rs +++ b/crates/vnidrop/src/secure_secret.rs @@ -17,7 +17,7 @@ pub(crate) mod apple; #[cfg(any(test, target_os = "linux"))] pub(crate) mod linux; mod platform; -#[cfg(target_os = "windows")] +#[cfg(any(test, target_os = "windows"))] pub(crate) mod windows; #[cfg(test)] diff --git a/crates/vnidrop/src/secure_secret/windows.rs b/crates/vnidrop/src/secure_secret/windows.rs index 5ddae7a..a5e6148 100644 --- a/crates/vnidrop/src/secure_secret/windows.rs +++ b/crates/vnidrop/src/secure_secret/windows.rs @@ -3,13 +3,21 @@ use std::{ ffi::OsStr, fs::{self, OpenOptions}, io::{self, Write}, - os::windows::ffi::OsStrExt, path::{Path, PathBuf}, - ptr, sync::Arc, }; use data_encoding::HEXLOWER; + +use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError}; + +#[cfg(test)] +use super::SecretKind; + +#[cfg(target_os = "windows")] +use std::{os::windows::ffi::OsStrExt, ptr}; + +#[cfg(target_os = "windows")] use windows_sys::Win32::{ Foundation::{ GetLastError, LocalFree, ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, @@ -22,11 +30,6 @@ use windows_sys::Win32::{ 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"; @@ -34,28 +37,41 @@ const MAX_ENVELOPE_BYTES: usize = 64 * 1024; const DEFAULT_CONTEXT: &[u8] = b"com.vnidrop.secure-secret.dpapi.v1.current-user"; const PROTECTED_PAYLOAD_MAGIC: &[u8] = b"VNIDROP-SECRET-V1"; +/// Current-user DPAPI (or injectable stand-in) used by [`WindowsDpapiSecretStore`]. +pub(crate) trait WindowsDpapiApi: Send + Sync { + fn protect( + &self, + handle: &SecretHandle, + plaintext: &[u8], + ) -> Result, SecureSecretStoreError>; + + fn unprotect( + &self, + handle: &SecretHandle, + ciphertext: &[u8], + ) -> Result, SecureSecretStoreError>; +} + /// Current-user DPAPI storage backed by atomically published protected blobs. pub(crate) struct WindowsDpapiSecretStore { directory: PathBuf, - protector: Arc, + api: Arc, } impl WindowsDpapiSecretStore { + #[cfg(target_os = "windows")] pub(crate) fn new(directory: impl AsRef) -> Result { - Self::with_protector(directory, Arc::new(DpapiProtector::new())) + Self::with_api(directory, Arc::new(SystemWindowsDpapiApi::new())) } - fn with_protector( + pub(crate) fn with_api( directory: impl AsRef, - protector: Arc, + api: Arc, ) -> Result { let directory = directory.as_ref().to_path_buf(); fs::create_dir_all(&directory).map_err(map_io_error)?; cleanup_interrupted_writes(&directory)?; - Ok(Self { - directory, - protector, - }) + Ok(Self { directory, api }) } fn path_for(&self, handle: &SecretHandle) -> PathBuf { @@ -67,14 +83,25 @@ impl WindowsDpapiSecretStore { )) } - #[cfg(test)] + #[cfg(all(test, target_os = "windows"))] pub(crate) fn with_context_for_test( directory: impl AsRef, context: &[u8], ) -> Result { - Self::with_protector( + Self::with_api( directory, - Arc::new(DpapiProtector::with_context_for_test(context)), + Arc::new(SystemWindowsDpapiApi::with_context_for_test(context)), + ) + } + + #[cfg(all(test, not(target_os = "windows")))] + pub(crate) fn with_context_for_test( + directory: impl AsRef, + context: &[u8], + ) -> Result { + Self::with_api( + directory, + Arc::new(FakeWindowsDpapiApi::with_context(context)), ) } @@ -103,7 +130,7 @@ impl SecureSecretStore for WindowsDpapiSecretStore { Err(error) => return Err(error), }; - let ciphertext = self.protector.protect(handle, &material.0)?; + let ciphertext = self.api.protect(handle, &material.0)?; let envelope = encode_envelope(handle, &ciphertext)?; let temporary = self.directory.join(format!( "{}.tmp-{}", @@ -131,8 +158,8 @@ impl SecureSecretStore for WindowsDpapiSecretStore { Err(error) if matches!( error.raw_os_error().map(|code| code as u32), - Some(ERROR_ALREADY_EXISTS) | Some(ERROR_FILE_EXISTS) - ) => + Some(ERROR_ALREADY_EXISTS_CODE) | Some(ERROR_FILE_EXISTS_CODE) + ) || error.kind() == io::ErrorKind::AlreadyExists => { match self.get(handle) { Ok(existing) if existing == material => Ok(()), @@ -152,7 +179,7 @@ impl SecureSecretStore for WindowsDpapiSecretStore { fn get(&self, handle: &SecretHandle) -> Result { let envelope = fs::read(self.path_for(handle)).map_err(map_io_error)?; let ciphertext = decode_envelope(&envelope, handle)?; - let plaintext = self.protector.unprotect(handle, ciphertext)?; + let plaintext = self.api.unprotect(handle, ciphertext)?; SecretMaterial::new(plaintext).map_err(|_| SecureSecretStoreError::Corrupted) } @@ -282,32 +309,112 @@ impl Drop for TemporaryFile { } } +#[cfg(target_os = "windows")] +const ERROR_ALREADY_EXISTS_CODE: u32 = ERROR_ALREADY_EXISTS; +#[cfg(target_os = "windows")] +const ERROR_FILE_EXISTS_CODE: u32 = ERROR_FILE_EXISTS; +#[cfg(not(target_os = "windows"))] +const ERROR_ALREADY_EXISTS_CODE: u32 = 183; +#[cfg(not(target_os = "windows"))] +const ERROR_FILE_EXISTS_CODE: u32 = 80; + 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 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 { - Ok(()) + #[cfg(target_os = "windows")] + { + 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 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 { + Ok(()) + } + } + #[cfg(not(target_os = "windows"))] + { + if !replace_existing && destination.exists() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "destination already exists", + )); + } + fs::rename(source, destination) } } +#[cfg(target_os = "windows")] fn wide_path(path: &Path) -> Vec { path.as_os_str().encode_wide().chain(Some(0)).collect() } -struct DpapiProtector { +fn dpapi_entropy(context: &[u8], handle: &SecretHandle) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(context); + hasher.update(&[0]); + hasher.update(handle.as_str().as_bytes()); + *hasher.finalize().as_bytes() +} + +fn encode_protected_payload(plaintext: &[u8]) -> Result, SecureSecretStoreError> { + let length = u32::try_from(plaintext.len()).map_err(|_| SecureSecretStoreError::Unavailable)?; + let mut payload = Vec::with_capacity(PROTECTED_PAYLOAD_MAGIC.len() + 4 + plaintext.len() + 32); + payload.extend_from_slice(PROTECTED_PAYLOAD_MAGIC); + payload.extend_from_slice(&length.to_le_bytes()); + payload.extend_from_slice(plaintext); + let digest = blake3::hash(&payload); + payload.extend_from_slice(digest.as_bytes()); + Ok(payload) +} + +fn decode_protected_payload(payload: &[u8]) -> Result<&[u8], SecureSecretStoreError> { + let header_end = PROTECTED_PAYLOAD_MAGIC.len() + 4; + if payload.len() < header_end + 32 || !payload.starts_with(PROTECTED_PAYLOAD_MAGIC) { + return Err(SecureSecretStoreError::Corrupted); + } + let length = u32::from_le_bytes( + payload[PROTECTED_PAYLOAD_MAGIC.len()..header_end] + .try_into() + .map_err(|_| SecureSecretStoreError::Corrupted)?, + ) as usize; + let material_end = header_end + .checked_add(length) + .ok_or(SecureSecretStoreError::Corrupted)?; + if material_end + .checked_add(32) + .ok_or(SecureSecretStoreError::Corrupted)? + != payload.len() + { + return Err(SecureSecretStoreError::Corrupted); + } + let expected = blake3::hash(&payload[..material_end]); + if expected.as_bytes() != &payload[material_end..] { + return Err(SecureSecretStoreError::Corrupted); + } + Ok(&payload[header_end..material_end]) +} + +fn map_io_error(error: io::Error) -> SecureSecretStoreError { + match error.kind() { + io::ErrorKind::NotFound => SecureSecretStoreError::Missing, + io::ErrorKind::PermissionDenied => SecureSecretStoreError::Locked, + io::ErrorKind::InvalidData => SecureSecretStoreError::Corrupted, + _ => SecureSecretStoreError::Unavailable, + } +} + +#[cfg(target_os = "windows")] +struct SystemWindowsDpapiApi { context: Vec, } -impl DpapiProtector { +#[cfg(target_os = "windows")] +impl SystemWindowsDpapiApi { fn new() -> Self { Self { context: DEFAULT_CONTEXT.to_vec(), @@ -322,13 +429,12 @@ impl DpapiProtector { } fn entropy(&self, handle: &SecretHandle) -> [u8; 32] { - let mut hasher = blake3::Hasher::new(); - hasher.update(&self.context); - hasher.update(&[0]); - hasher.update(handle.as_str().as_bytes()); - *hasher.finalize().as_bytes() + dpapi_entropy(&self.context, handle) } +} +#[cfg(target_os = "windows")] +impl WindowsDpapiApi for SystemWindowsDpapiApi { fn protect( &self, handle: &SecretHandle, @@ -390,44 +496,7 @@ impl DpapiProtector { } } -fn encode_protected_payload(plaintext: &[u8]) -> Result, SecureSecretStoreError> { - let length = u32::try_from(plaintext.len()).map_err(|_| SecureSecretStoreError::Unavailable)?; - let mut payload = Vec::with_capacity(PROTECTED_PAYLOAD_MAGIC.len() + 4 + plaintext.len() + 32); - payload.extend_from_slice(PROTECTED_PAYLOAD_MAGIC); - payload.extend_from_slice(&length.to_le_bytes()); - payload.extend_from_slice(plaintext); - let digest = blake3::hash(&payload); - payload.extend_from_slice(digest.as_bytes()); - Ok(payload) -} - -fn decode_protected_payload(payload: &[u8]) -> Result<&[u8], SecureSecretStoreError> { - let header_end = PROTECTED_PAYLOAD_MAGIC.len() + 4; - if payload.len() < header_end + 32 || !payload.starts_with(PROTECTED_PAYLOAD_MAGIC) { - return Err(SecureSecretStoreError::Corrupted); - } - let length = u32::from_le_bytes( - payload[PROTECTED_PAYLOAD_MAGIC.len()..header_end] - .try_into() - .map_err(|_| SecureSecretStoreError::Corrupted)?, - ) as usize; - let material_end = header_end - .checked_add(length) - .ok_or(SecureSecretStoreError::Corrupted)?; - if material_end - .checked_add(32) - .ok_or(SecureSecretStoreError::Corrupted)? - != payload.len() - { - return Err(SecureSecretStoreError::Corrupted); - } - let expected = blake3::hash(&payload[..material_end]); - if expected.as_bytes() != &payload[material_end..] { - return Err(SecureSecretStoreError::Corrupted); - } - Ok(&payload[header_end..material_end]) -} - +#[cfg(target_os = "windows")] fn blob(bytes: &[u8]) -> Result { Ok(CRYPT_INTEGER_BLOB { cbData: u32::try_from(bytes.len()).map_err(|_| SecureSecretStoreError::Unavailable)?, @@ -435,6 +504,7 @@ fn blob(bytes: &[u8]) -> Result { }) } +#[cfg(target_os = "windows")] fn copy_and_free( output: CRYPT_INTEGER_BLOB, clear_before_free: bool, @@ -453,6 +523,7 @@ fn copy_and_free( Ok(result) } +#[cfg(target_os = "windows")] fn map_dpapi_error(unprotecting: bool) -> SecureSecretStoreError { let code = unsafe { GetLastError() }; match code { @@ -463,11 +534,127 @@ fn map_dpapi_error(unprotecting: bool) -> SecureSecretStoreError { } } -fn map_io_error(error: io::Error) -> SecureSecretStoreError { - match error.kind() { - io::ErrorKind::NotFound => SecureSecretStoreError::Missing, - io::ErrorKind::PermissionDenied => SecureSecretStoreError::Locked, - io::ErrorKind::InvalidData => SecureSecretStoreError::Corrupted, - _ => SecureSecretStoreError::Unavailable, +/// Host-portable stand-in for current-user DPAPI used by contract harnesses. +/// +/// Blobs are bound to an injectable user context so wrong-user decryption fails +/// closed the same way real DPAPI fails across Windows accounts. +#[cfg(any(test, not(target_os = "windows")))] +pub(crate) struct FakeWindowsDpapiApi { + context: Vec, + failure: std::sync::Mutex>, + unavailable_handle_substrings: std::sync::Mutex>, +} + +#[cfg(any(test, not(target_os = "windows")))] +impl FakeWindowsDpapiApi { + pub(crate) fn new() -> Self { + Self::with_context(DEFAULT_CONTEXT) + } + + pub(crate) fn with_context(context: &[u8]) -> Self { + Self { + context: context.to_vec(), + failure: std::sync::Mutex::new(None), + unavailable_handle_substrings: std::sync::Mutex::new(Vec::new()), + } + } + + #[cfg(test)] + pub(crate) fn fail_with(&self, failure: Option) { + *self.failure.lock().unwrap() = failure; + } + + #[cfg(test)] + pub(crate) fn set_unavailable_for_handles_containing(&self, substring: &str) { + self.unavailable_handle_substrings + .lock() + .unwrap() + .push(substring.to_owned()); + } + + fn check(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> { + match &*self.failure.lock().unwrap() { + Some(SecureSecretStoreError::Locked) => return Err(SecureSecretStoreError::Locked), + Some(SecureSecretStoreError::Missing) => return Err(SecureSecretStoreError::Missing), + Some(SecureSecretStoreError::Corrupted) => { + return Err(SecureSecretStoreError::Corrupted) + } + Some(SecureSecretStoreError::Unavailable) => { + return Err(SecureSecretStoreError::Unavailable) + } + None => {} + } + if self + .unavailable_handle_substrings + .lock() + .unwrap() + .iter() + .any(|needle| handle.as_str().contains(needle)) + { + return Err(SecureSecretStoreError::Unavailable); + } + Ok(()) + } + + fn seal( + &self, + handle: &SecretHandle, + plaintext: &[u8], + ) -> Result, SecureSecretStoreError> { + let payload = encode_protected_payload(plaintext)?; + let key = dpapi_entropy(&self.context, handle); + Ok(xor_keystream(&key, &payload)) + } + + fn open( + &self, + handle: &SecretHandle, + ciphertext: &[u8], + ) -> Result, SecureSecretStoreError> { + let key = dpapi_entropy(&self.context, handle); + let payload = xor_keystream(&key, ciphertext); + decode_protected_payload(&payload).map(<[u8]>::to_vec) } } + +#[cfg(any(test, not(target_os = "windows")))] +impl WindowsDpapiApi for FakeWindowsDpapiApi { + fn protect( + &self, + handle: &SecretHandle, + plaintext: &[u8], + ) -> Result, SecureSecretStoreError> { + self.check(handle)?; + self.seal(handle, plaintext) + } + + fn unprotect( + &self, + handle: &SecretHandle, + ciphertext: &[u8], + ) -> Result, SecureSecretStoreError> { + self.check(handle)?; + self.open(handle, ciphertext) + } +} + +#[cfg(any(test, not(target_os = "windows")))] +fn xor_keystream(key: &[u8; 32], bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(bytes.len()); + let mut counter = 0u64; + let mut offset = 0usize; + let mut block = [0u8; 32]; + while offset < bytes.len() { + let mut hasher = blake3::Hasher::new(); + hasher.update(key); + hasher.update(&counter.to_le_bytes()); + block.copy_from_slice(hasher.finalize().as_bytes()); + let take = (bytes.len() - offset).min(block.len()); + for (index, byte) in bytes[offset..offset + take].iter().enumerate() { + out.push(byte ^ block[index]); + } + offset += take; + counter = counter.wrapping_add(1); + } + out +} diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 83e53a2..ffd08c3 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -22,6 +22,8 @@ mod limits_tests; mod network_config_tests; #[path = "tests/pairing_eligibility.rs"] mod pairing_eligibility_tests; +#[path = "tests/platform_contract_windows.rs"] +mod platform_contract_windows_tests; #[path = "tests/repository.rs"] mod repository_tests; #[path = "tests/runtime.rs"] diff --git a/crates/vnidrop/src/tests/platform_contract_windows.rs b/crates/vnidrop/src/tests/platform_contract_windows.rs new file mode 100644 index 0000000..512ace4 --- /dev/null +++ b/crates/vnidrop/src/tests/platform_contract_windows.rs @@ -0,0 +1,642 @@ +//! Windows saved-device core contract harness. +//! +//! Compiles on every host. Uses [`FakeWindowsDpapiApi`] as an injectable +//! current-user DPAPI stand-in; real DPAPI is exercised under `cfg(windows)`. + +use std::{ + collections::HashSet, + path::Path, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use crate::{ + secure_secret::{ + scope_store, + windows::{FakeWindowsDpapiApi, WindowsDpapiSecretStore}, + FaultInjectingSecretStore, ReferenceStoreFailure, SecretMaterial, SecureSecretStore, + SecureSecretStoreError, + }, + CoreEvent, CoreEventSink, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind, + TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError, +}; + +#[cfg(target_os = "windows")] +use crate::{CoreLimits, CoreNetworkConfig}; + +struct RecordingSink { + events: Mutex>, +} + +impl CoreEventSink for RecordingSink { + fn on_event(&self, event: CoreEvent) { + self.events.lock().unwrap().push(event); + } +} + +impl RecordingSink { + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +struct WindowsContractNode { + data_dir: tempfile::TempDir, + api: Arc, + sink: Arc, + core: Option>, +} + +impl WindowsContractNode { + fn new() -> Self { + let data_dir = tempfile::tempdir().unwrap(); + let api = Arc::new(FakeWindowsDpapiApi::new()); + let sink = Arc::new(RecordingSink { + events: Mutex::new(Vec::new()), + }); + let store = windows_scoped_store(data_dir.path(), api.clone()); + let core = VnidropCore::initialize_with_test_secret_store( + data_dir.path().to_string_lossy().into_owned(), + sink.clone(), + store, + ) + .expect("windows contract core"); + Self { + data_dir, + api, + sink, + core: Some(core), + } + } + + fn core(&self) -> Arc { + self.core.as_ref().expect("core alive").clone() + } + + fn restart(&mut self) -> Arc { + if let Some(core) = self.core.take() { + core.shutdown(); + drop(core); + } + self.sink = Arc::new(RecordingSink { + events: Mutex::new(Vec::new()), + }); + let store = windows_scoped_store(self.data_dir.path(), self.api.clone()); + let core = VnidropCore::initialize_with_test_secret_store( + self.data_dir.path().to_string_lossy().into_owned(), + self.sink.clone(), + store, + ) + .expect("restarted windows contract core"); + self.core = Some(core.clone()); + core + } +} + +impl Drop for WindowsContractNode { + fn drop(&mut self) { + if let Some(core) = self.core.take() { + core.shutdown(); + } + } +} + +fn windows_scoped_store( + app_data_dir: &Path, + api: Arc, +) -> Arc { + let store = WindowsDpapiSecretStore::with_api(app_data_dir.join("protected-secrets-v1"), api) + .expect("windows dpapi store"); + scope_store(app_data_dir, Arc::new(store)) +} + +fn share_path(core: &VnidropCore, source: &Path, transfer_id: u64) -> crate::ShareResult { + core.share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source.to_string_lossy().into_owned(), + display_name: Some("hello.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id, + transfer_name: Some("hello.txt".to_string()), + sender_name: Some("sender".to_string()), + access_mode: TransferAccessMode::ApprovalRequired, + }, + ) + .unwrap() +} + +fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> crate::ReceiverRequest { + let started = Instant::now(); + loop { + if let Some(request) = sender + .list_receiver_requests(transfer_id) + .unwrap() + .into_iter() + .find(|request| request.status == "requested") + { + return request; + } + assert!( + started.elapsed() < Duration::from_secs(15), + "timed out waiting for receiver request" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn complete_transfer( + sender: &WindowsContractNode, + receiver: &WindowsContractNode, + transfer_id: u64, +) { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("hello.txt"); + std::fs::write(&source_path, b"windows contract").unwrap(); + let share = share_path(&sender.core(), &source_path, transfer_id); + let output = output_dir.path().to_string_lossy().to_string(); + let receiver_core = receiver.core(); + let ticket = share.ticket.clone(); + let handle = std::thread::spawn(move || { + receiver_core.receive(ticket, output, Some("receiver".to_string())) + }); + let request = wait_for_receiver_request(&sender.core(), share.transfer_id); + sender + .core() + .respond_receiver_request(request.id, true, None) + .unwrap(); + handle.join().unwrap().unwrap(); + + let started = Instant::now(); + let peer = receiver.core().status().endpoint_id.clone(); + loop { + if sender + .core() + .list_pairing_eligibilities() + .unwrap() + .iter() + .any(|entry| entry.peer_endpoint_id == peer) + { + break; + } + assert!( + started.elapsed() < Duration::from_secs(10), + "eligibility never appeared" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn wait_for_relationship( + core: &VnidropCore, + peer: &str, + state: DeviceRelationshipState, +) -> crate::DeviceRelationship { + let started = Instant::now(); + loop { + if let Some(relationship) = core + .list_device_relationships() + .unwrap() + .into_iter() + .find(|entry| entry.remote_endpoint_id == peer && entry.state == state) + { + return relationship; + } + assert!( + started.elapsed() < Duration::from_secs(15), + "relationship {peer} never reached {state:?}" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn reach_saved(alice: &WindowsContractNode, bob: &WindowsContractNode, transfer_id: u64) { + let alice_id = alice.core().status().endpoint_id.clone(); + let bob_id = bob.core().status().endpoint_id.clone(); + complete_transfer(alice, bob, transfer_id); + assert!(alice + .core() + .request_saved_device_pairing(bob_id.clone()) + .unwrap()); + wait_for_relationship( + &bob.core(), + &alice_id, + DeviceRelationshipState::PendingIncoming, + ); + assert!(bob + .core() + .respond_to_device_pairing(alice_id, true) + .unwrap()); + wait_for_relationship(&alice.core(), &bob_id, DeviceRelationshipState::Saved); + wait_for_relationship( + &bob.core(), + &alice.core().status().endpoint_id, + DeviceRelationshipState::Saved, + ); +} + +fn wait_for_pending_offer(core: &VnidropCore) -> crate::PendingTargetedOffer { + let started = Instant::now(); + loop { + if let Some(offer) = core.list_pending_targeted_offers().into_iter().next() { + return offer; + } + assert!( + started.elapsed() < Duration::from_secs(20), + "timed out waiting for pending targeted offer" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn targeted_source(path: &Path) -> ShareSource { + ShareSource { + kind: SourceKind::Path, + value: path.to_string_lossy().into_owned(), + display_name: Some( + path.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + ), + is_directory: false, + } +} + +#[test] +fn windows_dpapi_identity_survives_core_restart() { + let mut node = WindowsContractNode::new(); + let first = node.core().status().endpoint_id.clone(); + assert!(!first.is_empty()); + let restarted = node.restart(); + assert_eq!(restarted.status().endpoint_id, first); +} + +#[cfg(target_os = "windows")] +#[test] +fn real_windows_dpapi_experimental_init_preserves_identity() { + let data_dir = tempfile::tempdir().unwrap(); + let path = data_dir.path().to_string_lossy().into_owned(); + let sink = Arc::new(RecordingSink { + events: Mutex::new(Vec::new()), + }); + let core = VnidropCore::initialize_with_experimental_saved_devices( + path.clone(), + sink, + CoreLimits::default(), + CoreNetworkConfig::default(), + ) + .expect("experimental windows core"); + let first = core.status().endpoint_id.clone(); + core.shutdown(); + drop(core); + + let sink = Arc::new(RecordingSink { + events: Mutex::new(Vec::new()), + }); + let restarted = VnidropCore::initialize_with_experimental_saved_devices( + path, + sink, + CoreLimits::default(), + CoreNetworkConfig::default(), + ) + .expect("restarted experimental windows core"); + assert_eq!(restarted.status().endpoint_id, first); + restarted.shutdown(); +} + +#[test] +fn public_api_exercises_complete_windows_saved_device_contract() { + let mut alice = WindowsContractNode::new(); + let mut bob = WindowsContractNode::new(); + let alice_id = alice.core().status().endpoint_id.clone(); + let bob_id = bob.core().status().endpoint_id.clone(); + + complete_transfer(&alice, &bob, 16_001); + assert!(alice + .core() + .list_pairing_eligibilities() + .unwrap() + .iter() + .any(|entry| entry.peer_endpoint_id == bob_id)); + + assert!(alice + .core() + .request_saved_device_pairing(bob_id.clone()) + .unwrap()); + wait_for_relationship( + &bob.core(), + &alice_id, + DeviceRelationshipState::PendingIncoming, + ); + assert!(bob + .core() + .respond_to_device_pairing(alice_id.clone(), true) + .unwrap()); + wait_for_relationship(&alice.core(), &bob_id, DeviceRelationshipState::Saved); + wait_for_relationship(&bob.core(), &alice_id, DeviceRelationshipState::Saved); + + alice + .core() + .set_saved_device_label(bob_id.clone(), Some("Bob PC".to_string())) + .unwrap(); + let listed = alice.core().list_saved_devices().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].endpoint_id, bob_id); + assert_eq!(listed[0].local_label.as_deref(), Some("Bob PC")); + + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("payload.txt"); + std::fs::write(&source_path, b"windows targeted payload").unwrap(); + + let bob_core = bob.core(); + let accept = std::thread::spawn(move || { + let offer = wait_for_pending_offer(&bob_core); + bob_core + .respond_to_targeted_offer(offer.transfer_id, true) + .unwrap() + }); + let transfer = alice + .core() + .create_targeted_transfer( + bob_id.clone(), + vec![targeted_source(&source_path)], + Some("payload.txt".to_string()), + ) + .unwrap(); + let _auth = accept.join().unwrap().expect("authorization"); + assert_eq!(transfer.state, TargetedTransferState::Approved); + + // Interrupt via receiver restart, then resume without re-approval. + let bob_core = bob.restart(); + let interrupted = bob_core + .get_targeted_transfer(transfer.id.clone()) + .unwrap() + .expect("durable transfer"); + assert!(matches!( + interrupted.state, + TargetedTransferState::Approved | TargetedTransferState::Interrupted + )); + let output = tempfile::tempdir().unwrap(); + bob_core + .resume_targeted_transfer( + transfer.id.clone(), + output.path().to_string_lossy().into_owned(), + ) + .unwrap(); + assert_eq!( + std::fs::read(output.path().join("payload.txt")).unwrap(), + b"windows targeted payload" + ); + + alice.core().forget_saved_device(bob_id.clone()).unwrap(); + assert!(alice.core().list_saved_devices().unwrap().is_empty()); + + bob_core.block_device(alice_id.clone()).unwrap(); + assert_eq!( + bob_core.list_blocked_devices().unwrap(), + vec![alice_id.clone()] + ); + bob_core.unblock_device(alice_id).unwrap(); + assert!(bob_core.list_blocked_devices().unwrap().is_empty()); + // Unblock does not restore forgotten relationships. + assert!(alice.core().list_saved_devices().unwrap().is_empty()); + + let _ = alice.restart(); +} + +#[test] +fn wrong_user_or_unavailable_identity_prevents_networking() { + let data_dir = tempfile::tempdir().unwrap(); + let first_api = Arc::new(FakeWindowsDpapiApi::with_context(b"windows-user-a")); + let sink = Arc::new(RecordingSink { + events: Mutex::new(Vec::new()), + }); + let store = windows_scoped_store(data_dir.path(), first_api); + let core = VnidropCore::initialize_with_test_secret_store( + data_dir.path().to_string_lossy().into_owned(), + sink, + store, + ) + .unwrap(); + let endpoint = core.status().endpoint_id.clone(); + core.shutdown(); + drop(core); + + let wrong_user = Arc::new(FakeWindowsDpapiApi::with_context(b"windows-user-b")); + let sink = Arc::new(RecordingSink { + events: Mutex::new(Vec::new()), + }); + let store = windows_scoped_store(data_dir.path(), wrong_user); + let err = match VnidropCore::initialize_with_test_secret_store( + data_dir.path().to_string_lossy().into_owned(), + sink, + store, + ) { + Ok(_) => panic!("wrong-user DPAPI context must not start networking"), + Err(error) => error, + }; + assert!( + matches!( + err, + VnidropError::SecureStorageCorrupted { .. } + | VnidropError::SecureStorageUnavailable { .. } + | VnidropError::SecureStorageLocked { .. } + | VnidropError::SecureStorageMissing { .. } + | VnidropError::Initialization { .. } + ), + "unexpected error for wrong-user identity: {err:?}" + ); + assert!(!endpoint.is_empty()); + + let unavailable = Arc::new(FaultInjectingSecretStore::default()); + unavailable.fail_with(Some(ReferenceStoreFailure::Unavailable)); + let sink = Arc::new(RecordingSink { + events: Mutex::new(Vec::new()), + }); + let err = match VnidropCore::initialize_with_test_secret_store( + tempfile::tempdir() + .unwrap() + .path() + .to_string_lossy() + .into_owned(), + sink, + unavailable, + ) { + Ok(_) => panic!("unavailable identity store must not start networking"), + Err(error) => error, + }; + assert!(matches!( + err, + VnidropError::SecureStorageUnavailable { .. } | VnidropError::Initialization { .. } + )); +} + +#[test] +fn unavailable_relationship_secrets_disable_only_saved_device_behavior() { + let alice = WindowsContractNode::new(); + let bob = WindowsContractNode::new(); + reach_saved(&alice, &bob, 16_010); + + alice + .api + .set_unavailable_for_handles_containing("relationship-grant"); + bob.api + .set_unavailable_for_handles_containing("relationship-grant"); + + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("invite.txt"); + std::fs::write(&source_path, b"invitation still works").unwrap(); + let share = share_path(&alice.core(), &source_path, 16_011); + let output_dir = tempfile::tempdir().unwrap(); + let output = output_dir.path().to_string_lossy().to_string(); + let receiver = bob.core(); + let ticket = share.ticket.clone(); + let handle = + std::thread::spawn(move || receiver.receive(ticket, output, Some("receiver".to_string()))); + let request = wait_for_receiver_request(&alice.core(), share.transfer_id); + alice + .core() + .respond_receiver_request(request.id, true, None) + .unwrap(); + handle.join().unwrap().unwrap(); + assert_eq!( + std::fs::read(output_dir.path().join("hello.txt")).unwrap(), + b"invitation still works" + ); + + let targeted = alice.core().create_targeted_transfer( + bob.core().status().endpoint_id, + vec![targeted_source(&source_path)], + Some("invite.txt".to_string()), + ); + assert!( + targeted.is_err(), + "saved-device targeted transfer must fail closed when relationship secrets are unavailable" + ); + let err = targeted.unwrap_err(); + assert!( + matches!( + err, + VnidropError::SecureStorageUnavailable { .. } + | VnidropError::SecureStorageCorrupted { .. } + | VnidropError::SecureStorageMissing { .. } + | VnidropError::SecureStorageLocked { .. } + | VnidropError::Permission { .. } + | VnidropError::Network { .. } + ), + "unexpected targeted failure: {err:?}" + ); +} + +#[test] +fn event_ids_and_revisions_recover_authoritative_state_after_listener_restart() { + let mut alice = WindowsContractNode::new(); + let bob = WindowsContractNode::new(); + reach_saved(&alice, &bob, 16_020); + + let live = alice.sink.events(); + assert!(!live.is_empty()); + let mut seen = HashSet::new(); + let mut revisions = Vec::new(); + for event in &live { + assert!(seen.insert(event.id.clone()), "duplicate live event id"); + assert!(event.revision >= 1); + revisions.push(event.revision); + } + revisions.sort_unstable(); + let unique = revisions.len(); + revisions.dedup(); + assert_eq!(revisions.len(), unique, "live revisions must be unique"); + + // Duplicate delivery: same events observed twice must still dedupe by id. + let mut merged = live.clone(); + merged.extend(live.iter().cloned()); + let mut deduped = HashSet::new(); + for event in &merged { + deduped.insert((event.id.clone(), event.revision)); + } + assert_eq!(deduped.len(), live.len()); + + let before_restart = alice.core().list_events(None).unwrap(); + alice.restart(); + let authoritative = alice.core().list_events(None).unwrap(); + assert!(!authoritative.is_empty()); + assert!( + authoritative.len() >= before_restart.len().saturating_sub(8), + "restart must retain durable events for recovery" + ); + let devices = alice.core().list_saved_devices().unwrap(); + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].endpoint_id, bob.core().status().endpoint_id); +} + +#[test] +fn public_bindings_omit_raw_secrets_and_generic_mutation() { + // UniFFI only exports the typed public surface from api.rs / VnidropCore. + // Secret custody types and test-only mutation helpers must stay crate-private. + let exported = include_str!("../lib.rs"); + assert!( + !exported.contains("SecretMaterial") && !exported.contains("SecretHandle"), + "raw secret types must not be re-exported from the crate root" + ); + assert!( + !exported.contains("SecureSecretStore"), + "secure secret store must not cross the public binding boundary" + ); + + let facade = include_str!("../runtime/facade.rs"); + for needle in [ + "fn execute_sql", + "fn mutate_state", + "fn put_secret", + "fn load_secret", + "SecretMaterial", + "SecretHandle", + ] { + assert!( + !facade.contains(needle), + "public facade must not expose generic mutation / raw secrets ({needle})" + ); + } + + // for_test helpers are cfg(test) only and never part of UniFFI export. + assert!(facade.contains("cfg(test)")); + assert!(facade.contains("for_test")); + + let api = include_str!("../api.rs"); + assert!(api.contains("struct SavedDevice")); + assert!(api.contains("struct PairingEligibilitySummary")); + assert!( + !api.contains("grant_bytes") + && !api.contains("private_key") + && !api.contains("secret_material"), + "public API records must not carry raw secret fields" + ); +} + +#[test] +fn fake_windows_dpapi_wrong_context_fails_closed() { + let directory = tempfile::tempdir().unwrap(); + let handle = WindowsDpapiSecretStore::relationship_handle_for_test(); + let material = SecretMaterial::new(vec![0xa1; 32]).unwrap(); + let original = WindowsDpapiSecretStore::with_api( + directory.path(), + Arc::new(FakeWindowsDpapiApi::with_context(b"user-a")), + ) + .unwrap(); + original.put(&handle, material).unwrap(); + + let wrong = WindowsDpapiSecretStore::with_api( + directory.path(), + Arc::new(FakeWindowsDpapiApi::with_context(b"user-b")), + ) + .unwrap(); + assert!(matches!( + wrong.get(&handle), + Err(SecureSecretStoreError::Corrupted) + )); +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/WindowsSavedDeviceCoreContractTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/WindowsSavedDeviceCoreContractTest.kt new file mode 100644 index 0000000..4201684 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/WindowsSavedDeviceCoreContractTest.kt @@ -0,0 +1,88 @@ +package com.vnidrop.app + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail +import uniffi.vnidrop.CoreEvent +import uniffi.vnidrop.CoreEventSink +import uniffi.vnidrop.VnidropCore +import uniffi.vnidrop.defaultCoreLimits +import uniffi.vnidrop.defaultCoreNetworkConfig + +/** + * Optional Windows host harness for the experimental saved-device core. + * + * Linux/macOS CI skips so shared jvmTest stays green; Windows desktop runs the + * DPAPI-backed initialize/restart identity check against public bindings. + */ +class WindowsSavedDeviceCoreContractTest { + @Test + fun windowsHostPreservesProtectedIdentityAndBindingHygiene() { + if (!isWindowsHost()) { + return + } + + val coreDir = Files.createTempDirectory("vnidrop-windows-contract") + val sink = object : CoreEventSink { + override fun onEvent(event: CoreEvent) { + assertTrue(event.id.isNotBlank()) + } + } + + val first = VnidropCore.initializeWithExperimentalSavedDevices( + appDataDir = coreDir.toString(), + eventSink = sink, + limits = defaultCoreLimits(), + networkConfig = defaultCoreNetworkConfig(), + ) + val endpointId = try { + val id = first.status().endpointId + assertTrue(id.isNotBlank()) + assertBindingHygiene(first) + id + } finally { + first.shutdown() + } + + val restarted = VnidropCore.initializeWithExperimentalSavedDevices( + appDataDir = coreDir.toString(), + eventSink = sink, + limits = defaultCoreLimits(), + networkConfig = defaultCoreNetworkConfig(), + ) + try { + assertTrue(restarted.status().endpointId == endpointId) + assertBindingHygiene(restarted) + } finally { + restarted.shutdown() + coreDir.toFile().deleteRecursively() + } + } + + private fun isWindowsHost(): Boolean = + System.getProperty("os.name").orEmpty().lowercase().contains("windows") + + private fun assertBindingHygiene(core: VnidropCore) { + val methods = core.javaClass.methods.map { it.name }.toSet() + for (forbidden in listOf( + "putSecret", + "loadSecret", + "executeSql", + "mutateState", + "setSecretMaterial", + "getSecretHandle", + )) { + if (methods.contains(forbidden)) { + fail("public bindings must not expose $forbidden") + } + } + // Typed saved-device operations (names may be mangled by UniFFI); require + // at least the experimental constructor surface used above. + assertTrue( + methods.any { it.contains("listSaved", ignoreCase = true) } + || methods.any { it.contains("SavedDevice", ignoreCase = true) } + || methods.contains("status"), + ) + } +}