feat(core): promote protected saved device APIs

This commit is contained in:
2026-08-12 16:38:21 +02:00
parent 67a557af7c
commit 5ffd86afbc
23 changed files with 492 additions and 103 deletions

View File

@@ -8,6 +8,9 @@ license = "Apache-2.0"
name = "vnidrop"
crate-type = ["cdylib", "staticlib", "rlib"]
[features]
integration-test-store = []
[dependencies]
anyhow = "1.0.102"
async-channel = "2.5.0"

View File

@@ -1,3 +1,5 @@
#![allow(deprecated)]
use anyhow::Context;
use iroh::RelayUrl;
use iroh_blobs::Hash;
@@ -10,7 +12,24 @@ use crate::util::{non_empty, now_ms};
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048;
/// Versions the additive public domain seam and its two experimental wire protocols.
/// Versions the saved-device domain seam and its wire protocols.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct SavedDeviceCapabilities {
pub domain_contract_version: u16,
pub relationship_protocol_version: u16,
pub targeted_transfer_protocol_version: u16,
}
#[uniffi::export]
pub fn saved_device_capabilities() -> SavedDeviceCapabilities {
SavedDeviceCapabilities {
domain_contract_version: 1,
relationship_protocol_version: 1,
targeted_transfer_protocol_version: 3,
}
}
#[deprecated(note = "use SavedDeviceCapabilities")]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct ExperimentalSavedDeviceCapabilities {
pub domain_contract_version: u16,
@@ -18,12 +37,14 @@ pub struct ExperimentalSavedDeviceCapabilities {
pub targeted_transfer_protocol_version: u16,
}
#[deprecated(note = "use saved_device_capabilities")]
#[uniffi::export]
pub fn experimental_saved_device_capabilities() -> ExperimentalSavedDeviceCapabilities {
let capabilities = saved_device_capabilities();
ExperimentalSavedDeviceCapabilities {
domain_contract_version: 1,
relationship_protocol_version: 1,
targeted_transfer_protocol_version: 3,
domain_contract_version: capabilities.domain_contract_version,
relationship_protocol_version: capabilities.relationship_protocol_version,
targeted_transfer_protocol_version: capabilities.targeted_transfer_protocol_version,
}
}

View File

@@ -93,7 +93,7 @@ fn relationship_mac(
#[cfg(test)]
mod grant_vectors {
use super::*;
use crate::api::experimental_saved_device_capabilities;
use crate::api::saved_device_capabilities;
use data_encoding::HEXLOWER;
#[test]
@@ -103,7 +103,7 @@ mod grant_vectors {
.unwrap();
let grant_id = GrantId::decode("0123456789abcdef0123456789abcdef").unwrap();
let challenge = Challenge::from_bytes([9u8; 32]);
let protocol = experimental_saved_device_capabilities().relationship_protocol_version;
let protocol = saved_device_capabilities().relationship_protocol_version;
let proof = prove_relationship_grant(
grant_id, &secret, &challenge, "issuer", "holder", 1, protocol,
);

View File

@@ -12,8 +12,8 @@ use tokio::sync::Mutex as TokioMutex;
use crate::{
api::{
experimental_saved_device_capabilities, CoreRelayMode, DeviceRelationship,
DeviceRelationshipState, SavedDevice,
saved_device_capabilities, CoreRelayMode, DeviceRelationship, DeviceRelationshipState,
SavedDevice,
},
blocked_devices::BlockStore,
error::VnidropError,
@@ -499,7 +499,7 @@ impl DeviceRelationshipService {
Ok(capability) => capability,
Err(_) => return PairingRequestResponse::Rejected,
};
let local_protocol = experimental_saved_device_capabilities().relationship_protocol_version;
let local_protocol = saved_device_capabilities().relationship_protocol_version;
// Peers without a compatible saved-device protocol cannot pair; they
// retain ordinary invitation flow outside this ALPN.
if request.protocol_version != local_protocol {

View File

@@ -14,6 +14,7 @@ mod logging;
mod pairing_eligibility;
mod persistence;
mod runtime;
#[cfg(test)]
mod secret;
#[allow(
dead_code,
@@ -25,16 +26,17 @@ mod ticket;
mod transfer_state;
mod util;
#[allow(deprecated)]
pub use api::{
clear_inactive_transfer_cache, default_core_limits, default_core_network_config,
experimental_saved_device_capabilities, CoreEvent, CoreEventSink, CoreLimits,
CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, DeviceRelationship,
experimental_saved_device_capabilities, saved_device_capabilities, CoreEvent, CoreEventSink,
CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, DeviceRelationship,
DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, PairingEligibilitySummary,
PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice,
ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
TargetedOfferResponse, TargetedTransfer, TargetedTransferState, TicketInspection,
TransferAccessMode, TransferMetadata,
SavedDeviceCapabilities, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
StoredTransfer, TargetedOfferResponse, TargetedTransfer, TargetedTransferState,
TicketInspection, TransferAccessMode, TransferMetadata,
};
pub use error::VnidropError;
pub use runtime::VnidropCore;

View File

@@ -14,7 +14,7 @@ mod store;
pub(crate) use store::PairingEligibilityStore;
use crate::{
api::{experimental_saved_device_capabilities, PairingEligibilitySummary},
api::{saved_device_capabilities, PairingEligibilitySummary},
device_relationship::DeviceRelationshipStore,
error::VnidropError,
event_hub::EventHub,
@@ -113,8 +113,7 @@ impl PairingEligibilityService {
return Ok(());
}
let protocol_version =
experimental_saved_device_capabilities().relationship_protocol_version;
let protocol_version = saved_device_capabilities().relationship_protocol_version;
let capability = derive_capability(
approval_token,
&self.local_endpoint_id,

View File

@@ -1,3 +1,5 @@
#![allow(deprecated)]
use std::{future::Future, path::PathBuf, sync::Arc};
use anyhow::Context;
@@ -28,6 +30,30 @@ pub struct VnidropCore {
}
impl VnidropCore {
fn initialize_protected(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, 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,
},
)
}
/// Drive work on this core's multi-thread runtime from a sync API boundary.
///
/// Uses [`tokio::runtime::Handle::block_on`] rather than exclusive
@@ -63,11 +89,43 @@ impl VnidropCore {
relay_urls,
identity_mode,
))
.map_err(VnidropError::initialization)?;
.map_err(|error| match error.downcast::<VnidropError>() {
Ok(error) => error,
Err(error) => VnidropError::initialization(error),
})?;
Ok(Arc::new(Self { runtime, inner }))
}
}
#[cfg(all(feature = "integration-test-store", debug_assertions))]
impl VnidropCore {
/// Non-production Rust test harness entry that selects protected in-memory custody.
#[doc(hidden)]
pub fn initialize_for_integration_test(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
let path = std::fs::canonicalize(&app_data_dir)
.or_else(|_| {
std::fs::create_dir_all(&app_data_dir)?;
std::fs::canonicalize(&app_data_dir)
})
.map_err(VnidropError::filesystem)?;
crate::secure_secret::install_platform_secret_store_for_test(
&path,
Arc::new(crate::secure_secret::FaultInjectingSecretStore::default()),
);
Self::initialize_with_limits_and_network_config(
path.to_string_lossy().into_owned(),
event_sink,
limits,
network_config,
)
}
}
#[cfg(test)]
impl VnidropCore {
/// Test-only protected identity with an injected secret store.
@@ -259,6 +317,7 @@ impl VnidropCore {
}
}
#[allow(deprecated)]
#[uniffi::export]
impl VnidropCore {
#[uniffi::constructor]
@@ -309,16 +368,11 @@ impl VnidropCore {
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
Self::initialize_with_identity_mode(
app_data_dir,
event_sink,
limits,
network_config,
IdentityMode::Legacy,
)
Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
}
/// Starts the experimental saved-device core with a platform-protected identity.
/// Compatibility constructor retained during the protected-initialization expand phase.
#[deprecated(note = "use initialize_with_limits_and_network_config")]
#[uniffi::constructor]
pub fn initialize_with_experimental_saved_devices(
app_data_dir: String,
@@ -326,22 +380,7 @@ impl VnidropCore {
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, 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,
},
)
Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
}
pub fn status(&self) -> RuntimeStatus {

View File

@@ -62,7 +62,6 @@ use crate::{
invitation::Repository,
logging::init_logging,
pairing_eligibility::PairingEligibilityService,
secret::load_or_create_secret,
secure_secret::{start_endpoint_identity, ProfileLock, SecureSecretStore},
targeted_transfer::{TargetedOfferInbox, TargetedTransferProtocol},
ticket::ticket_matches_relay_profile,
@@ -138,7 +137,6 @@ pub(super) struct ActiveTransfer {
}
pub(super) enum IdentityMode {
Legacy,
Protected {
store: Arc<dyn SecureSecretStore>,
profile_lock: ProfileLock,
@@ -161,7 +159,6 @@ impl CoreInner {
let targeted_transfers = stores.targeted.clone();
let blocked_devices = stores.blocked.clone();
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,

View File

@@ -9,8 +9,8 @@ use uuid::Uuid;
use super::{receive::ReceiveTarget, targeted_tag_name, CoreInner};
use crate::{
api::{
experimental_saved_device_capabilities, PendingTargetedOffer, ShareSource,
TargetedOfferResponse, TargetedTransfer, TargetedTransferState, TransferAccessMode,
saved_device_capabilities, PendingTargetedOffer, ShareSource, TargetedOfferResponse,
TargetedTransfer, TargetedTransferState, TransferAccessMode,
},
error::VnidropError,
secure_secret::{SecretHandle, SecretKind},
@@ -610,8 +610,7 @@ impl CoreInner {
}
};
let protocol_version =
experimental_saved_device_capabilities().targeted_transfer_protocol_version;
let protocol_version = saved_device_capabilities().targeted_transfer_protocol_version;
if let Err(error) = store
.set_state(
&transfer_uuid,
@@ -1214,8 +1213,7 @@ impl CoreInner {
content_hash: row.content_hash.clone(),
file_count: row.file_count,
total_size: row.total_size,
protocol_version: experimental_saved_device_capabilities()
.targeted_transfer_protocol_version,
protocol_version: saved_device_capabilities().targeted_transfer_protocol_version,
transfer_name: row.transfer_name.clone(),
blob_ticket: blob_ticket.clone(),
},

View File

@@ -1,6 +1,6 @@
use std::{collections::HashSet, fmt, io, path::Path, sync::Arc, time::Duration};
#[cfg(test)]
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
use std::{collections::HashMap, sync::Mutex};
use data_encoding::HEXLOWER;
@@ -20,6 +20,8 @@ mod platform;
#[cfg(any(test, target_os = "windows"))]
pub(crate) mod windows;
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
pub(crate) use platform::install_platform_secret_store_for_test;
#[cfg(test)]
pub(crate) use platform::scope_store;
#[cfg(test)]
@@ -477,9 +479,7 @@ impl SecretCustody {
.to_string(),
});
}
tokio::fs::remove_file(legacy_path)
.await
.map_err(VnidropError::filesystem)?;
remove_legacy_identity_durably(legacy_path).await?;
}
Err(VnidropError::SecureStorageMissing { .. }) => {}
Err(error) => return Err(error),
@@ -496,9 +496,7 @@ impl SecretCustody {
Some(endpoint_id.as_str()),
)
.await?;
tokio::fs::remove_file(legacy_path)
.await
.map_err(VnidropError::filesystem)?;
remove_legacy_identity_durably(legacy_path).await?;
Ok(handle)
}
@@ -595,6 +593,11 @@ impl SecretCustody {
if validate_material(entry.kind, &material, entry.expected_identity.as_deref())
.is_err()
{
if entry.kind == SecretKind::EndpointIdentity {
return Err(VnidropError::SecureStorageCorrupted {
reason: "protected endpoint identity is corrupted".to_string(),
});
}
self.metadata.disable(&entry.handle).await?;
self.delete_if_present(&entry.handle).await?;
summary.disabled += 1;
@@ -603,7 +606,12 @@ impl SecretCustody {
summary.staged_activated += 1;
}
}
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {
Err(
error @ (SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted),
) => {
if entry.kind == SecretKind::EndpointIdentity {
return Err(map_store_error(error));
}
self.metadata.disable(&entry.handle).await?;
self.delete_if_present(&entry.handle).await?;
summary.disabled += 1;
@@ -700,6 +708,32 @@ impl SecretCustody {
}
}
async fn remove_legacy_identity_durably(path: &Path) -> Result<(), VnidropError> {
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || {
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(VnidropError::filesystem(error)),
}
#[cfg(unix)]
{
let parent = path.parent().ok_or_else(|| {
VnidropError::filesystem(io::Error::new(
io::ErrorKind::InvalidInput,
"legacy identity path has no parent",
))
})?;
std::fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(VnidropError::filesystem)?;
}
Ok(())
})
.await
.map_err(VnidropError::internal)?
}
async fn read_legacy_endpoint_identity(path: &Path) -> Result<SecretMaterial, VnidropError> {
let encoded = match tokio::fs::read_to_string(path).await {
Ok(encoded) => encoded,
@@ -725,7 +759,7 @@ pub(crate) struct ReconciliationSummary {
pub(crate) disabled: u64,
}
#[cfg(test)]
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CustodyCrashPoint {
StoreWrite,
@@ -762,14 +796,14 @@ fn map_store_error(error: SecureSecretStoreError) -> VnidropError {
}
}
#[cfg(test)]
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReferenceStoreFailure {
Locked,
Unavailable,
}
#[cfg(test)]
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
#[derive(Default)]
pub(crate) struct FaultInjectingSecretStore {
values: Mutex<HashMap<SecretHandle, SecretMaterial>>,
@@ -777,7 +811,7 @@ pub(crate) struct FaultInjectingSecretStore {
corrupted: Mutex<Vec<SecretHandle>>,
}
#[cfg(test)]
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
impl FaultInjectingSecretStore {
pub(crate) fn fail_with(&self, failure: Option<ReferenceStoreFailure>) {
*self.failure.lock().unwrap() = failure;
@@ -812,7 +846,7 @@ impl FaultInjectingSecretStore {
}
}
#[cfg(test)]
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
impl SecureSecretStore for FaultInjectingSecretStore {
fn put(
&self,

View File

@@ -13,6 +13,34 @@ use super::{
};
use crate::error::VnidropError;
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
static TEST_STORES: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<std::path::PathBuf, Arc<dyn SecureSecretStore>>>,
> = std::sync::OnceLock::new();
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
pub(crate) fn install_platform_secret_store_for_test(
app_data_dir: &Path,
store: Arc<dyn SecureSecretStore>,
) {
TEST_STORES
.get_or_init(Default::default)
.lock()
.expect("test stores")
.entry(app_data_dir.to_path_buf())
.or_insert_with(|| scope_store(app_data_dir, store));
}
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
fn platform_secret_store_for_test(app_data_dir: &Path) -> Option<Arc<dyn SecureSecretStore>> {
TEST_STORES
.get_or_init(Default::default)
.lock()
.expect("test stores")
.get(app_data_dir)
.cloned()
}
struct ScopedSecretStore {
inner: Arc<dyn SecureSecretStore>,
physical_prefix: String,
@@ -157,6 +185,10 @@ pub(crate) fn scope_store(
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
if let Some(store) = platform_secret_store_for_test(app_data_dir) {
return Ok(store);
}
Ok(scope_store(
app_data_dir,
Arc::new(super::apple::AppleKeychainSecretStore::new()),
@@ -167,6 +199,10 @@ pub(crate) fn platform_secret_store(
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
if let Some(store) = platform_secret_store_for_test(app_data_dir) {
return Ok(store);
}
super::android::native::create_store_from_android_runtime()
.map(|store| scope_store(app_data_dir, store))
.map_err(map_store_error)
@@ -176,6 +212,10 @@ pub(crate) fn platform_secret_store(
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
if let Some(store) = platform_secret_store_for_test(app_data_dir) {
return Ok(store);
}
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)
@@ -185,6 +225,10 @@ pub(crate) fn platform_secret_store(
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
#[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
if let Some(store) = platform_secret_store_for_test(app_data_dir) {
return Ok(store);
}
super::linux::LinuxSecretServiceStore::connect()
.map(|store| scope_store(app_data_dir, Arc::new(store)))
.map_err(map_store_error)

View File

@@ -22,10 +22,7 @@ use super::{
state_as_str, TargetedTransferRole, TargetedTransferStore,
};
use crate::{
api::{
experimental_saved_device_capabilities, CoreRelayMode, PendingTargetedOffer,
TargetedTransferState,
},
api::{saved_device_capabilities, CoreRelayMode, PendingTargetedOffer, TargetedTransferState},
device_relationship::{DeviceRelationshipService, WireProof},
error::VnidropError,
grant::Challenge,
@@ -105,7 +102,7 @@ impl TargetedTransferProtocol {
challenge: &Challenge,
offer: SubmitTargetedOffer,
) -> WireOfferResponse {
let expected = experimental_saved_device_capabilities().targeted_transfer_protocol_version;
let expected = saved_device_capabilities().targeted_transfer_protocol_version;
if self.inbox.cooldown().is_cooling(remote_endpoint_id) {
return WireOfferResponse::Refused {
reason: "identity-cooldown".to_string(),
@@ -275,7 +272,7 @@ impl TargetedTransferProtocol {
&& row.total_size == auth.total_size
&& row.blob_ticket.as_deref() == Some(auth.blob_ticket.as_str())
&& auth.protocol_version
== experimental_saved_device_capabilities().targeted_transfer_protocol_version
== saved_device_capabilities().targeted_transfer_protocol_version
{
return DeliverAuthorizationResponse::Stored;
}
@@ -421,7 +418,7 @@ impl TargetedTransferProtocol {
|| auth.total_size != completion.verified_bytes
|| row.blob_ticket.as_deref() != Some(auth.blob_ticket.as_str())
|| auth.protocol_version
!= experimental_saved_device_capabilities().targeted_transfer_protocol_version
!= saved_device_capabilities().targeted_transfer_protocol_version
{
return CompletionResponse::Rejected;
}

View File

@@ -59,6 +59,7 @@ fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_point
"fn share_files(",
"fn receive(",
"experimental_saved_device_capabilities",
"saved_device_capabilities",
] {
assert!(
facade.contains(required) || api.contains(required) || lib.contains(required),
@@ -66,7 +67,7 @@ fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_point
);
}
let caps = crate::experimental_saved_device_capabilities();
let caps = crate::saved_device_capabilities();
assert_eq!(caps.domain_contract_version, 1);
assert_eq!(caps.relationship_protocol_version, 1);
assert_eq!(caps.targeted_transfer_protocol_version, 3);

View File

@@ -5,9 +5,8 @@ use std::{
};
use crate::{
experimental_saved_device_capabilities, secure_secret::FaultInjectingSecretStore, CoreEvent,
CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode, VnidropCore,
VnidropError,
saved_device_capabilities, secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink,
ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError,
};
struct RecordingSink {
@@ -164,7 +163,7 @@ fn completed_authenticated_transfer_creates_pairing_eligibility_on_both_sides()
wait_for_eligibility(&sender.core, &receiver_id);
wait_for_eligibility(&receiver.core, &sender_id);
let protocol = experimental_saved_device_capabilities().relationship_protocol_version;
let protocol = saved_device_capabilities().relationship_protocol_version;
let sender_entry = sender
.core
.list_pairing_eligibilities()

View File

@@ -14,6 +14,8 @@ use std::{
time::{Duration, Instant},
};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
use tempfile::TempDir;
use crate::{
@@ -157,6 +159,36 @@ struct AndroidContractNode {
}
impl AndroidContractNode {
fn new_with_legacy(identity: &SecretKey) -> Self {
let no_backup = TempDir::new().unwrap();
let data_dir = TempDir::new().unwrap();
std::fs::write(
data_dir.path().join("iroh.secret"),
HEXLOWER.encode(&identity.to_bytes()),
)
.unwrap();
let keystore = Arc::new(FakeAndroidKeystore::default());
let android_store =
AndroidSecureSecretStore::new(no_backup.path(), keystore.clone()).unwrap();
let store = Arc::new(RelationshipGatedStore::new(Arc::new(android_store)));
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store.clone(),
)
.expect("Android legacy migration");
Self {
_no_backup: no_backup,
data_dir,
keystore,
store,
sink,
core: Some(core),
}
}
fn new() -> Self {
let no_backup = TempDir::new().unwrap();
let data_dir = TempDir::new().unwrap();
@@ -230,6 +262,17 @@ impl AndroidContractNode {
}
}
#[test]
fn android_adapter_protects_identity_before_plaintext_removal() {
let identity = SecretKey::generate();
let mut node = AndroidContractNode::new_with_legacy(&identity);
assert!(!node.data_dir.path().join("iroh.secret").exists());
let endpoint = node.core().status().endpoint_id;
assert_eq!(endpoint, identity.public().to_string());
node.restart();
assert_eq!(node.core().status().endpoint_id, endpoint);
}
impl Drop for AndroidContractNode {
fn drop(&mut self) {
if let Some(core) = self.core.take() {

View File

@@ -21,6 +21,8 @@ use crate::{
ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState, TransferAccessMode,
VnidropCore, VnidropError,
};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308;
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25_300;
@@ -47,7 +49,7 @@ impl RecordingSink {
/// Node backed by the Apple Keychain adapter (injectable API for headless cargo).
///
/// Production `initialize_with_experimental_saved_devices` uses the same
/// Production standard constructors use the same
/// `AppleKeychainSecretStore` + profile scoping. CLI unit tests lack the app
/// Keychain entitlement, so the system Keychain returns Unavailable; the
/// injectable API exercises the identical adapter path. Swift XCTest covers
@@ -118,6 +120,34 @@ impl AppleKeychainApi for RecordingKeychain {
}
impl KeychainNode {
fn new_with_legacy(identity: &SecretKey) -> Self {
let data_dir = tempfile::tempdir().unwrap();
std::fs::write(
data_dir.path().join("iroh.secret"),
HEXLOWER.encode(&identity.to_bytes()),
)
.unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let api = RecordingKeychain::default();
let store = crate::secure_secret::scope_store(
data_dir.path(),
Arc::new(AppleKeychainSecretStore::with_api(api.clone())),
);
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store,
)
.expect("Apple legacy migration");
Self {
data_dir,
api,
sink,
core: Some(core),
}
}
fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink {
@@ -179,7 +209,7 @@ impl Drop for KeychainNode {
}
fn try_experimental_keychain_init(app_data_dir: &Path) -> Result<Arc<VnidropCore>, VnidropError> {
VnidropCore::initialize_with_experimental_saved_devices(
VnidropCore::initialize_with_limits_and_network_config(
app_data_dir.to_string_lossy().into_owned(),
Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
@@ -264,6 +294,17 @@ impl FaultNode {
}
}
#[test]
fn apple_adapter_protects_identity_before_plaintext_removal() {
let identity = SecretKey::generate();
let node = KeychainNode::new_with_legacy(&identity);
assert!(!node.data_dir.path().join("iroh.secret").exists());
let endpoint = node.core().status().endpoint_id;
assert_eq!(endpoint, identity.public().to_string());
let node = node.restart();
assert_eq!(node.core().status().endpoint_id, endpoint);
}
impl Drop for FaultNode {
fn drop(&mut self) {
if let Some(core) = self.core.take() {

View File

@@ -21,6 +21,8 @@ use crate::{
CoreEvent, CoreEventSink, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind,
TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError,
};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
#[cfg(target_os = "linux")]
use crate::{CoreLimits, CoreNetworkConfig};
@@ -54,6 +56,31 @@ struct SecretServiceNode {
}
impl SecretServiceNode {
fn new_with_legacy(identity: &SecretKey) -> Self {
let data_dir = tempfile::tempdir().unwrap();
std::fs::write(
data_dir.path().join("iroh.secret"),
HEXLOWER.encode(&identity.to_bytes()),
)
.unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let api = Arc::new(ControllableSecretService::default());
let store = Arc::new(LinuxSecretServiceStore::with_api(api.clone()));
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store,
)
.expect("Linux legacy migration");
Self {
data_dir,
api,
sink,
core: Some(core),
}
}
fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink {
@@ -207,6 +234,17 @@ impl FaultNode {
}
}
#[test]
fn linux_adapter_protects_identity_before_plaintext_removal() {
let identity = SecretKey::generate();
let node = SecretServiceNode::new_with_legacy(&identity);
assert!(!node.data_dir.path().join("iroh.secret").exists());
let endpoint = node.core().status().endpoint_id;
assert_eq!(endpoint, identity.public().to_string());
let node = node.restart();
assert_eq!(node.core().status().endpoint_id, endpoint);
}
impl Drop for FaultNode {
fn drop(&mut self) {
if let Some(core) = self.core.take() {
@@ -516,7 +554,7 @@ fn experimental_secret_service_identity_survives_core_restart_on_linux() {
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let core = VnidropCore::initialize_with_experimental_saved_devices(
let core = VnidropCore::initialize_with_limits_and_network_config(
data_dir.path().to_string_lossy().into_owned(),
sink,
CoreLimits::default(),
@@ -535,7 +573,7 @@ fn experimental_secret_service_identity_survives_core_restart_on_linux() {
});
let started = Instant::now();
let restarted = loop {
match VnidropCore::initialize_with_experimental_saved_devices(
match VnidropCore::initialize_with_limits_and_network_config(
path.clone(),
sink.clone(),
CoreLimits::default(),
@@ -803,7 +841,7 @@ fn linux_public_bindings_omit_raw_secrets_and_generic_mutation() {
}
}
assert!(
public_facade.contains("initialize_with_experimental_saved_devices"),
public_facade.contains("initialize_with_limits_and_network_config"),
"facade must expose experimental saved-device init"
);
assert!(

View File

@@ -20,6 +20,8 @@ use crate::{
CoreEvent, CoreEventSink, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind,
TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError,
};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
#[cfg(target_os = "windows")]
use crate::{CoreLimits, CoreNetworkConfig};
@@ -48,6 +50,31 @@ struct WindowsContractNode {
}
impl WindowsContractNode {
fn new_with_legacy(identity: &SecretKey) -> Self {
let data_dir = tempfile::tempdir().unwrap();
std::fs::write(
data_dir.path().join("iroh.secret"),
HEXLOWER.encode(&identity.to_bytes()),
)
.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 legacy migration");
Self {
data_dir,
api,
sink,
core: Some(core),
}
}
fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let api = Arc::new(FakeWindowsDpapiApi::new());
@@ -93,6 +120,17 @@ impl WindowsContractNode {
}
}
#[test]
fn windows_adapter_protects_identity_before_plaintext_removal() {
let identity = SecretKey::generate();
let mut node = WindowsContractNode::new_with_legacy(&identity);
assert!(!node.data_dir.path().join("iroh.secret").exists());
let endpoint = node.core().status().endpoint_id;
assert_eq!(endpoint, identity.public().to_string());
let restarted = node.restart();
assert_eq!(restarted.status().endpoint_id, endpoint);
}
impl Drop for WindowsContractNode {
fn drop(&mut self) {
if let Some(core) = self.core.take() {
@@ -283,7 +321,7 @@ fn real_windows_dpapi_experimental_init_preserves_identity() {
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let core = VnidropCore::initialize_with_experimental_saved_devices(
let core = VnidropCore::initialize_with_limits_and_network_config(
path.clone(),
sink,
CoreLimits::default(),
@@ -297,7 +335,7 @@ fn real_windows_dpapi_experimental_init_preserves_identity() {
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let restarted = VnidropCore::initialize_with_experimental_saved_devices(
let restarted = VnidropCore::initialize_with_limits_and_network_config(
path,
sink,
CoreLimits::default(),

View File

@@ -1,5 +1,7 @@
use std::{sync::Arc, time::Duration};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
use iroh_blobs::{
provider::{
events::{RequestUpdate, TransferCompleted},
@@ -11,7 +13,9 @@ use iroh_blobs::{
use crate::{
invitation::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
runtime::{consume_request_updates, CoreInner, IdentityMode, RequestStreamOutcome},
secure_secret::{lock_profile, FaultInjectingSecretStore},
secure_secret::{
install_platform_secret_store_for_test, lock_profile, FaultInjectingSecretStore,
},
transfer_state::{TransferDirection, TransferStatus},
CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode, VnidropCore, VnidropError,
};
@@ -22,6 +26,14 @@ impl CoreEventSink for TestSink {
fn on_event(&self, _event: CoreEvent) {}
}
fn install_protected_test_store(path: &std::path::Path) {
let canonical = std::fs::canonicalize(path).unwrap();
install_platform_secret_store_for_test(
&canonical,
Arc::new(FaultInjectingSecretStore::default()),
);
}
#[test]
fn provider_request_stream_distinguishes_success_from_silent_abort() {
let runtime = tokio::runtime::Runtime::new().unwrap();
@@ -56,6 +68,7 @@ fn provider_request_stream_distinguishes_success_from_silent_abort() {
#[test]
fn initializes_and_reports_endpoint() {
let temp = tempfile::tempdir().unwrap();
install_protected_test_store(temp.path());
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
@@ -66,6 +79,55 @@ fn initializes_and_reports_endpoint() {
core.shutdown();
}
#[test]
fn all_standard_constructors_migrate_and_restart_one_protected_identity() {
let temp = tempfile::tempdir().unwrap();
let canonical = std::fs::canonicalize(temp.path()).unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
install_platform_secret_store_for_test(&canonical, store);
let original = SecretKey::generate();
let legacy = temp.path().join("iroh.secret");
std::fs::write(&legacy, HEXLOWER.encode(&original.to_bytes())).unwrap();
let path = temp.path().to_string_lossy().into_owned();
let network = || crate::CoreNetworkConfig {
mode: CoreRelayMode::LocalOnly,
relay_urls: Vec::new(),
};
type Constructor = Box<dyn FnOnce() -> Result<Arc<VnidropCore>, VnidropError>>;
let constructors: Vec<Constructor> = vec![
Box::new({
let path = path.clone();
move || VnidropCore::initialize(path, Arc::new(TestSink))
}),
Box::new({
let path = path.clone();
move || {
VnidropCore::initialize_with_limits(path, Arc::new(TestSink), CoreLimits::default())
}
}),
Box::new({
let path = path.clone();
move || VnidropCore::initialize_with_network_config(path, Arc::new(TestSink), network())
}),
Box::new(move || {
VnidropCore::initialize_with_limits_and_network_config(
path.clone(),
Arc::new(TestSink),
CoreLimits::default(),
network(),
)
}),
];
for constructor in constructors {
let core = constructor().unwrap();
assert_eq!(core.status().endpoint_id, original.public().to_string());
assert!(!legacy.exists());
core.shutdown();
drop(core);
}
}
#[tokio::test]
async fn protected_runtime_restart_preserves_identity_without_plaintext_fallback() {
let temp = tempfile::tempdir().unwrap();
@@ -109,6 +171,7 @@ async fn protected_runtime_restart_preserves_identity_without_plaintext_fallback
#[test]
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
let temp = tempfile::tempdir().unwrap();
install_protected_test_store(temp.path());
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
@@ -134,6 +197,7 @@ fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
#[test]
fn startup_recovers_interrupted_transfer_and_persists_event() {
let temp = tempfile::tempdir().unwrap();
install_protected_test_store(temp.path());
let preparation_runtime = tokio::runtime::Runtime::new().unwrap();
preparation_runtime.block_on(async {
let repository = Repository::open(temp.path()).await.unwrap();
@@ -181,6 +245,7 @@ fn startup_recovers_interrupted_transfer_and_persists_event() {
#[test]
fn startup_processes_persisted_delivery_receipts() {
let temp = tempfile::tempdir().unwrap();
install_protected_test_store(temp.path());
let preparation_runtime = tokio::runtime::Runtime::new().unwrap();
preparation_runtime.block_on(async {
let repository = Repository::open(temp.path()).await.unwrap();
@@ -240,6 +305,7 @@ fn startup_processes_persisted_delivery_receipts() {
#[test]
fn startup_fails_persisted_share_when_root_blob_is_missing() {
let temp = tempfile::tempdir().unwrap();
install_protected_test_store(temp.path());
let preparation_runtime = tokio::runtime::Runtime::new().unwrap();
preparation_runtime.block_on(async {
let repository = Repository::open(temp.path()).await.unwrap();

View File

@@ -332,13 +332,9 @@ async fn first_install_identity_is_protected_once_and_never_silently_replaced()
store.remove_for_test(&handle);
drop(custody);
let stores = persistence::open_all(temp.path()).await.unwrap();
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
.await
.unwrap();
assert_eq!(summary.disabled, 1);
assert!(matches!(
custody.initialize_endpoint_identity(&legacy_path).await,
Err(VnidropError::SecureStorageUnavailable { .. })
SecretCustody::start(stores.secrets.clone(), store.clone()).await,
Err(VnidropError::SecureStorageMissing { .. })
));
assert!(store.list_handles().unwrap().is_empty());
}

View File

@@ -2,16 +2,16 @@ mod support;
use support::TestNode;
use vnidrop::{
experimental_saved_device_capabilities, DeviceRelationship, DeviceRelationshipState,
ExperimentalSavedDeviceCapabilities, SavedDevice, ShareMetadataInput, ShareSource, SourceKind,
TargetedTransfer, TargetedTransferState, TransferAccessMode, VnidropError,
saved_device_capabilities, DeviceRelationship, DeviceRelationshipState, SavedDevice,
SavedDeviceCapabilities, ShareMetadataInput, ShareSource, SourceKind, TargetedTransfer,
TargetedTransferState, TransferAccessMode, VnidropError,
};
#[test]
fn saved_device_protocols_are_explicitly_experimental_and_versioned() {
assert_eq!(
experimental_saved_device_capabilities(),
ExperimentalSavedDeviceCapabilities {
saved_device_capabilities(),
SavedDeviceCapabilities {
domain_contract_version: 1,
relationship_protocol_version: 1,
targeted_transfer_protocol_version: 3,

View File

@@ -41,6 +41,17 @@ pub struct CoreGuard(Arc<VnidropCore>);
impl CoreGuard {
pub fn start(path: &Path, sink: Arc<dyn CoreEventSink>) -> Self {
#[cfg(feature = "integration-test-store")]
return Self(
VnidropCore::initialize_for_integration_test(
path.to_string_lossy().to_string(),
sink,
CoreLimits::default(),
CoreNetworkConfig::default(),
)
.expect("test core should initialize"),
);
#[cfg(not(feature = "integration-test-store"))]
Self(
VnidropCore::initialize(path.to_string_lossy().to_string(), sink)
.expect("test core should initialize"),
@@ -52,6 +63,17 @@ impl CoreGuard {
sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
) -> Self {
#[cfg(feature = "integration-test-store")]
return Self(
VnidropCore::initialize_for_integration_test(
path.to_string_lossy().to_string(),
sink,
limits,
CoreNetworkConfig::default(),
)
.expect("test core should initialize with limits"),
);
#[cfg(not(feature = "integration-test-store"))]
Self(
VnidropCore::initialize_with_limits(path.to_string_lossy().to_string(), sink, limits)
.expect("test core should initialize with limits"),
@@ -63,6 +85,17 @@ impl CoreGuard {
sink: Arc<dyn CoreEventSink>,
network_config: CoreNetworkConfig,
) -> Self {
#[cfg(feature = "integration-test-store")]
return Self(
VnidropCore::initialize_for_integration_test(
path.to_string_lossy().to_string(),
sink,
CoreLimits::default(),
network_config,
)
.expect("test core should initialize with network config"),
);
#[cfg(not(feature = "integration-test-store"))]
Self(
VnidropCore::initialize_with_network_config(
path.to_string_lossy().to_string(),