mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
merge: ticket 16 Windows saved-device core contract
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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<Vec<u8>, SecureSecretStoreError>;
|
||||
|
||||
fn unprotect(
|
||||
&self,
|
||||
handle: &SecretHandle,
|
||||
ciphertext: &[u8],
|
||||
) -> Result<Vec<u8>, SecureSecretStoreError>;
|
||||
}
|
||||
|
||||
/// Current-user DPAPI storage backed by atomically published protected blobs.
|
||||
pub(crate) struct WindowsDpapiSecretStore {
|
||||
directory: PathBuf,
|
||||
protector: Arc<DpapiProtector>,
|
||||
api: Arc<dyn WindowsDpapiApi>,
|
||||
}
|
||||
|
||||
impl WindowsDpapiSecretStore {
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn new(directory: impl AsRef<Path>) -> Result<Self, SecureSecretStoreError> {
|
||||
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<Path>,
|
||||
protector: Arc<DpapiProtector>,
|
||||
api: Arc<dyn WindowsDpapiApi>,
|
||||
) -> Result<Self, SecureSecretStoreError> {
|
||||
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<Path>,
|
||||
context: &[u8],
|
||||
) -> Result<Self, SecureSecretStoreError> {
|
||||
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<Path>,
|
||||
context: &[u8],
|
||||
) -> Result<Self, SecureSecretStoreError> {
|
||||
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<SecretMaterial, SecureSecretStoreError> {
|
||||
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<u16> {
|
||||
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<Vec<u8>, 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<u8>,
|
||||
}
|
||||
|
||||
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<Vec<u8>, 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<CRYPT_INTEGER_BLOB, SecureSecretStoreError> {
|
||||
Ok(CRYPT_INTEGER_BLOB {
|
||||
cbData: u32::try_from(bytes.len()).map_err(|_| SecureSecretStoreError::Unavailable)?,
|
||||
@@ -435,6 +504,7 @@ fn blob(bytes: &[u8]) -> Result<CRYPT_INTEGER_BLOB, SecureSecretStoreError> {
|
||||
})
|
||||
}
|
||||
|
||||
#[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<u8>,
|
||||
failure: std::sync::Mutex<Option<SecureSecretStoreError>>,
|
||||
unavailable_handle_substrings: std::sync::Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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<SecureSecretStoreError>) {
|
||||
*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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, SecureSecretStoreError> {
|
||||
self.check(handle)?;
|
||||
self.seal(handle, plaintext)
|
||||
}
|
||||
|
||||
fn unprotect(
|
||||
&self,
|
||||
handle: &SecretHandle,
|
||||
ciphertext: &[u8],
|
||||
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||
self.check(handle)?;
|
||||
self.open(handle, ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(target_os = "windows")))]
|
||||
fn xor_keystream(key: &[u8; 32], bytes: &[u8]) -> Vec<u8> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ mod network_config_tests;
|
||||
mod pairing_eligibility_tests;
|
||||
#[path = "tests/platform_contract_android.rs"]
|
||||
mod platform_contract_android_tests;
|
||||
#[path = "tests/platform_contract_windows.rs"]
|
||||
mod platform_contract_windows_tests;
|
||||
#[path = "tests/repository.rs"]
|
||||
mod repository_tests;
|
||||
#[path = "tests/runtime.rs"]
|
||||
|
||||
642
crates/vnidrop/src/tests/platform_contract_windows.rs
Normal file
642
crates/vnidrop/src/tests/platform_contract_windows.rs
Normal file
@@ -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<Vec<CoreEvent>>,
|
||||
}
|
||||
|
||||
impl CoreEventSink for RecordingSink {
|
||||
fn on_event(&self, event: CoreEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordingSink {
|
||||
fn events(&self) -> Vec<CoreEvent> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct WindowsContractNode {
|
||||
data_dir: tempfile::TempDir,
|
||||
api: Arc<FakeWindowsDpapiApi>,
|
||||
sink: Arc<RecordingSink>,
|
||||
core: Option<Arc<VnidropCore>>,
|
||||
}
|
||||
|
||||
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<VnidropCore> {
|
||||
self.core.as_ref().expect("core alive").clone()
|
||||
}
|
||||
|
||||
fn restart(&mut self) -> Arc<VnidropCore> {
|
||||
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<FakeWindowsDpapiApi>,
|
||||
) -> Arc<dyn SecureSecretStore> {
|
||||
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)
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user