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

@@ -83,30 +83,30 @@ check-release: ## Validate coordinated release scripts and workflow YAML.
check-rust: ## Run Rust formatting, lint, tests, and documentation checks. check-rust: ## Run Rust formatting, lint, tests, and documentation checks.
cd $(ROOT) && $(CARGO) fmt --all -- --check cd $(ROOT) && $(CARGO) fmt --all -- --check
cd $(ROOT) && $(CARGO) clippy --workspace --all-targets -- -D warnings cd $(ROOT) && $(CARGO) clippy --workspace --all-targets --features integration-test-store -- -D warnings
cd $(ROOT) && $(CARGO) test --workspace --all-targets cd $(ROOT) && $(CARGO) test --workspace --all-targets --features integration-test-store
cd $(ROOT) && RUSTDOCFLAGS='-D warnings' $(CARGO) doc --workspace --no-deps cd $(ROOT) && RUSTDOCFLAGS='-D warnings' $(CARGO) doc --workspace --no-deps
audit-rust: ## Audit Rust dependencies (requires cargo-audit). audit-rust: ## Audit Rust dependencies (requires cargo-audit).
cd $(ROOT) && $(CARGO) audit cd $(ROOT) && $(CARGO) audit
test-rust: ## Run the focused Rust core suite. test-rust: ## Run the focused Rust core suite.
cd $(ROOT) && $(CARGO) test -p vnidrop cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store
test-rust-all: ## Run every Rust workspace test target. test-rust-all: ## Run every Rust workspace test target.
cd $(ROOT) && $(CARGO) test --workspace --all-targets cd $(ROOT) && $(CARGO) test --workspace --all-targets --features integration-test-store
test-rust-transfer: ## Run Rust transfer integration tests. test-rust-transfer: ## Run Rust transfer integration tests.
cd $(ROOT) && $(CARGO) test -p vnidrop --test transfer cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test transfer
test-rust-approval: ## Run Rust approval integration tests. test-rust-approval: ## Run Rust approval integration tests.
cd $(ROOT) && $(CARGO) test -p vnidrop --test approval cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test approval
test-rust-lifecycle: ## Run Rust lifecycle integration tests. test-rust-lifecycle: ## Run Rust lifecycle integration tests.
cd $(ROOT) && $(CARGO) test -p vnidrop --test lifecycle cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test lifecycle
test-rust-output-sink: ## Run Rust output-sink integration tests. test-rust-output-sink: ## Run Rust output-sink integration tests.
cd $(ROOT) && $(CARGO) test -p vnidrop --test output_sink cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test output_sink
check-shared: ## Test and compile the shared Android/JVM module. check-shared: ## Test and compile the shared Android/JVM module.
cd $(ROOT) && $(GRADLE) :shared:jvmTest :shared:compileKotlinJvm $(GRADLE_FLAGS) cd $(ROOT) && $(GRADLE) :shared:jvmTest :shared:compileKotlinJvm $(GRADLE_FLAGS)

View File

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

View File

@@ -1,3 +1,5 @@
#![allow(deprecated)]
use anyhow::Context; use anyhow::Context;
use iroh::RelayUrl; use iroh::RelayUrl;
use iroh_blobs::Hash; 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_CUSTOM_RELAYS: usize = 8;
pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048; 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct ExperimentalSavedDeviceCapabilities { pub struct ExperimentalSavedDeviceCapabilities {
pub domain_contract_version: u16, pub domain_contract_version: u16,
@@ -18,12 +37,14 @@ pub struct ExperimentalSavedDeviceCapabilities {
pub targeted_transfer_protocol_version: u16, pub targeted_transfer_protocol_version: u16,
} }
#[deprecated(note = "use saved_device_capabilities")]
#[uniffi::export] #[uniffi::export]
pub fn experimental_saved_device_capabilities() -> ExperimentalSavedDeviceCapabilities { pub fn experimental_saved_device_capabilities() -> ExperimentalSavedDeviceCapabilities {
let capabilities = saved_device_capabilities();
ExperimentalSavedDeviceCapabilities { ExperimentalSavedDeviceCapabilities {
domain_contract_version: 1, domain_contract_version: capabilities.domain_contract_version,
relationship_protocol_version: 1, relationship_protocol_version: capabilities.relationship_protocol_version,
targeted_transfer_protocol_version: 3, targeted_transfer_protocol_version: capabilities.targeted_transfer_protocol_version,
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
#![allow(deprecated)]
use std::{future::Future, path::PathBuf, sync::Arc}; use std::{future::Future, path::PathBuf, sync::Arc};
use anyhow::Context; use anyhow::Context;
@@ -28,6 +30,30 @@ pub struct VnidropCore {
} }
impl 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. /// Drive work on this core's multi-thread runtime from a sync API boundary.
/// ///
/// Uses [`tokio::runtime::Handle::block_on`] rather than exclusive /// Uses [`tokio::runtime::Handle::block_on`] rather than exclusive
@@ -63,11 +89,43 @@ impl VnidropCore {
relay_urls, relay_urls,
identity_mode, 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 })) 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)] #[cfg(test)]
impl VnidropCore { impl VnidropCore {
/// Test-only protected identity with an injected secret store. /// Test-only protected identity with an injected secret store.
@@ -259,6 +317,7 @@ impl VnidropCore {
} }
} }
#[allow(deprecated)]
#[uniffi::export] #[uniffi::export]
impl VnidropCore { impl VnidropCore {
#[uniffi::constructor] #[uniffi::constructor]
@@ -309,16 +368,11 @@ impl VnidropCore {
limits: CoreLimits, limits: CoreLimits,
network_config: CoreNetworkConfig, network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> { ) -> Result<Arc<Self>, VnidropError> {
Self::initialize_with_identity_mode( Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
app_data_dir,
event_sink,
limits,
network_config,
IdentityMode::Legacy,
)
} }
/// 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] #[uniffi::constructor]
pub fn initialize_with_experimental_saved_devices( pub fn initialize_with_experimental_saved_devices(
app_data_dir: String, app_data_dir: String,
@@ -326,22 +380,7 @@ impl VnidropCore {
limits: CoreLimits, limits: CoreLimits,
network_config: CoreNetworkConfig, network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> { ) -> Result<Arc<Self>, VnidropError> {
let app_data_path = PathBuf::from(app_data_dir); Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
let app_data_path =
std::fs::canonicalize(app_data_path).map_err(VnidropError::filesystem)?;
let profile_lock = lock_profile(&app_data_path)?;
let store = platform_secret_store(&app_data_path)?;
Self::initialize_with_identity_mode(
app_data_path.to_string_lossy().into_owned(),
event_sink,
limits,
network_config,
IdentityMode::Protected {
store,
profile_lock,
},
)
} }
pub fn status(&self) -> RuntimeStatus { pub fn status(&self) -> RuntimeStatus {

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
use std::{collections::HashSet, fmt, io, path::Path, sync::Arc, time::Duration}; 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 std::{collections::HashMap, sync::Mutex};
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
@@ -20,6 +20,8 @@ mod platform;
#[cfg(any(test, target_os = "windows"))] #[cfg(any(test, target_os = "windows"))]
pub(crate) mod 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)] #[cfg(test)]
pub(crate) use platform::scope_store; pub(crate) use platform::scope_store;
#[cfg(test)] #[cfg(test)]
@@ -477,9 +479,7 @@ impl SecretCustody {
.to_string(), .to_string(),
}); });
} }
tokio::fs::remove_file(legacy_path) remove_legacy_identity_durably(legacy_path).await?;
.await
.map_err(VnidropError::filesystem)?;
} }
Err(VnidropError::SecureStorageMissing { .. }) => {} Err(VnidropError::SecureStorageMissing { .. }) => {}
Err(error) => return Err(error), Err(error) => return Err(error),
@@ -496,9 +496,7 @@ impl SecretCustody {
Some(endpoint_id.as_str()), Some(endpoint_id.as_str()),
) )
.await?; .await?;
tokio::fs::remove_file(legacy_path) remove_legacy_identity_durably(legacy_path).await?;
.await
.map_err(VnidropError::filesystem)?;
Ok(handle) Ok(handle)
} }
@@ -595,6 +593,11 @@ impl SecretCustody {
if validate_material(entry.kind, &material, entry.expected_identity.as_deref()) if validate_material(entry.kind, &material, entry.expected_identity.as_deref())
.is_err() .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.metadata.disable(&entry.handle).await?;
self.delete_if_present(&entry.handle).await?; self.delete_if_present(&entry.handle).await?;
summary.disabled += 1; summary.disabled += 1;
@@ -603,7 +606,12 @@ impl SecretCustody {
summary.staged_activated += 1; 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.metadata.disable(&entry.handle).await?;
self.delete_if_present(&entry.handle).await?; self.delete_if_present(&entry.handle).await?;
summary.disabled += 1; 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> { async fn read_legacy_endpoint_identity(path: &Path) -> Result<SecretMaterial, VnidropError> {
let encoded = match tokio::fs::read_to_string(path).await { let encoded = match tokio::fs::read_to_string(path).await {
Ok(encoded) => encoded, Ok(encoded) => encoded,
@@ -725,7 +759,7 @@ pub(crate) struct ReconciliationSummary {
pub(crate) disabled: u64, pub(crate) disabled: u64,
} }
#[cfg(test)] #[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CustodyCrashPoint { pub(crate) enum CustodyCrashPoint {
StoreWrite, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReferenceStoreFailure { pub(crate) enum ReferenceStoreFailure {
Locked, Locked,
Unavailable, Unavailable,
} }
#[cfg(test)] #[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
#[derive(Default)] #[derive(Default)]
pub(crate) struct FaultInjectingSecretStore { pub(crate) struct FaultInjectingSecretStore {
values: Mutex<HashMap<SecretHandle, SecretMaterial>>, values: Mutex<HashMap<SecretHandle, SecretMaterial>>,
@@ -777,7 +811,7 @@ pub(crate) struct FaultInjectingSecretStore {
corrupted: Mutex<Vec<SecretHandle>>, corrupted: Mutex<Vec<SecretHandle>>,
} }
#[cfg(test)] #[cfg(any(test, all(feature = "integration-test-store", debug_assertions)))]
impl FaultInjectingSecretStore { impl FaultInjectingSecretStore {
pub(crate) fn fail_with(&self, failure: Option<ReferenceStoreFailure>) { pub(crate) fn fail_with(&self, failure: Option<ReferenceStoreFailure>) {
*self.failure.lock().unwrap() = failure; *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 { impl SecureSecretStore for FaultInjectingSecretStore {
fn put( fn put(
&self, &self,

View File

@@ -13,6 +13,34 @@ use super::{
}; };
use crate::error::VnidropError; 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 { struct ScopedSecretStore {
inner: Arc<dyn SecureSecretStore>, inner: Arc<dyn SecureSecretStore>,
physical_prefix: String, physical_prefix: String,
@@ -157,6 +185,10 @@ pub(crate) fn scope_store(
pub(crate) fn platform_secret_store( pub(crate) fn platform_secret_store(
app_data_dir: &Path, app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> { ) -> 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( Ok(scope_store(
app_data_dir, app_data_dir,
Arc::new(super::apple::AppleKeychainSecretStore::new()), Arc::new(super::apple::AppleKeychainSecretStore::new()),
@@ -167,6 +199,10 @@ pub(crate) fn platform_secret_store(
pub(crate) fn platform_secret_store( pub(crate) fn platform_secret_store(
app_data_dir: &Path, app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> { ) -> 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() super::android::native::create_store_from_android_runtime()
.map(|store| scope_store(app_data_dir, store)) .map(|store| scope_store(app_data_dir, store))
.map_err(map_store_error) .map_err(map_store_error)
@@ -176,6 +212,10 @@ pub(crate) fn platform_secret_store(
pub(crate) fn platform_secret_store( pub(crate) fn platform_secret_store(
app_data_dir: &Path, app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> { ) -> 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")) super::windows::WindowsDpapiSecretStore::new(app_data_dir.join("protected-secrets-v1"))
.map(|store| scope_store(app_data_dir, Arc::new(store))) .map(|store| scope_store(app_data_dir, Arc::new(store)))
.map_err(map_store_error) .map_err(map_store_error)
@@ -185,6 +225,10 @@ pub(crate) fn platform_secret_store(
pub(crate) fn platform_secret_store( pub(crate) fn platform_secret_store(
app_data_dir: &Path, app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> { ) -> 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() super::linux::LinuxSecretServiceStore::connect()
.map(|store| scope_store(app_data_dir, Arc::new(store))) .map(|store| scope_store(app_data_dir, Arc::new(store)))
.map_err(map_store_error) .map_err(map_store_error)

View File

@@ -22,10 +22,7 @@ use super::{
state_as_str, TargetedTransferRole, TargetedTransferStore, state_as_str, TargetedTransferRole, TargetedTransferStore,
}; };
use crate::{ use crate::{
api::{ api::{saved_device_capabilities, CoreRelayMode, PendingTargetedOffer, TargetedTransferState},
experimental_saved_device_capabilities, CoreRelayMode, PendingTargetedOffer,
TargetedTransferState,
},
device_relationship::{DeviceRelationshipService, WireProof}, device_relationship::{DeviceRelationshipService, WireProof},
error::VnidropError, error::VnidropError,
grant::Challenge, grant::Challenge,
@@ -105,7 +102,7 @@ impl TargetedTransferProtocol {
challenge: &Challenge, challenge: &Challenge,
offer: SubmitTargetedOffer, offer: SubmitTargetedOffer,
) -> WireOfferResponse { ) -> 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) { if self.inbox.cooldown().is_cooling(remote_endpoint_id) {
return WireOfferResponse::Refused { return WireOfferResponse::Refused {
reason: "identity-cooldown".to_string(), reason: "identity-cooldown".to_string(),
@@ -275,7 +272,7 @@ impl TargetedTransferProtocol {
&& row.total_size == auth.total_size && row.total_size == auth.total_size
&& row.blob_ticket.as_deref() == Some(auth.blob_ticket.as_str()) && row.blob_ticket.as_deref() == Some(auth.blob_ticket.as_str())
&& auth.protocol_version && auth.protocol_version
== experimental_saved_device_capabilities().targeted_transfer_protocol_version == saved_device_capabilities().targeted_transfer_protocol_version
{ {
return DeliverAuthorizationResponse::Stored; return DeliverAuthorizationResponse::Stored;
} }
@@ -421,7 +418,7 @@ impl TargetedTransferProtocol {
|| auth.total_size != completion.verified_bytes || auth.total_size != completion.verified_bytes
|| row.blob_ticket.as_deref() != Some(auth.blob_ticket.as_str()) || row.blob_ticket.as_deref() != Some(auth.blob_ticket.as_str())
|| auth.protocol_version || auth.protocol_version
!= experimental_saved_device_capabilities().targeted_transfer_protocol_version != saved_device_capabilities().targeted_transfer_protocol_version
{ {
return CompletionResponse::Rejected; 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 share_files(",
"fn receive(", "fn receive(",
"experimental_saved_device_capabilities", "experimental_saved_device_capabilities",
"saved_device_capabilities",
] { ] {
assert!( assert!(
facade.contains(required) || api.contains(required) || lib.contains(required), 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.domain_contract_version, 1);
assert_eq!(caps.relationship_protocol_version, 1); assert_eq!(caps.relationship_protocol_version, 1);
assert_eq!(caps.targeted_transfer_protocol_version, 3); assert_eq!(caps.targeted_transfer_protocol_version, 3);

View File

@@ -5,9 +5,8 @@ use std::{
}; };
use crate::{ use crate::{
experimental_saved_device_capabilities, secure_secret::FaultInjectingSecretStore, CoreEvent, saved_device_capabilities, secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink,
CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode, VnidropCore, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError,
VnidropError,
}; };
struct RecordingSink { 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(&sender.core, &receiver_id);
wait_for_eligibility(&receiver.core, &sender_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 let sender_entry = sender
.core .core
.list_pairing_eligibilities() .list_pairing_eligibilities()

View File

@@ -14,6 +14,8 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use data_encoding::HEXLOWER;
use iroh::SecretKey;
use tempfile::TempDir; use tempfile::TempDir;
use crate::{ use crate::{
@@ -157,6 +159,36 @@ struct AndroidContractNode {
} }
impl 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 { fn new() -> Self {
let no_backup = TempDir::new().unwrap(); let no_backup = TempDir::new().unwrap();
let data_dir = 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 { impl Drop for AndroidContractNode {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(core) = self.core.take() { if let Some(core) = self.core.take() {

View File

@@ -21,6 +21,8 @@ use crate::{
ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState, TransferAccessMode, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState, TransferAccessMode,
VnidropCore, VnidropError, VnidropCore, VnidropError,
}; };
use data_encoding::HEXLOWER;
use iroh::SecretKey;
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308; const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308;
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25_300; 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). /// 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 /// `AppleKeychainSecretStore` + profile scoping. CLI unit tests lack the app
/// Keychain entitlement, so the system Keychain returns Unavailable; the /// Keychain entitlement, so the system Keychain returns Unavailable; the
/// injectable API exercises the identical adapter path. Swift XCTest covers /// injectable API exercises the identical adapter path. Swift XCTest covers
@@ -118,6 +120,34 @@ impl AppleKeychainApi for RecordingKeychain {
} }
impl KeychainNode { 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 { fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap(); let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink { 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> { 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(), app_data_dir.to_string_lossy().into_owned(),
Arc::new(RecordingSink { Arc::new(RecordingSink {
events: Mutex::new(Vec::new()), 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 { impl Drop for FaultNode {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(core) = self.core.take() { if let Some(core) = self.core.take() {

View File

@@ -21,6 +21,8 @@ use crate::{
CoreEvent, CoreEventSink, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind, CoreEvent, CoreEventSink, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind,
TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError, TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError,
}; };
use data_encoding::HEXLOWER;
use iroh::SecretKey;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use crate::{CoreLimits, CoreNetworkConfig}; use crate::{CoreLimits, CoreNetworkConfig};
@@ -54,6 +56,31 @@ struct SecretServiceNode {
} }
impl 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 { fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap(); let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink { 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 { impl Drop for FaultNode {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(core) = self.core.take() { 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 { let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()), 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(), data_dir.path().to_string_lossy().into_owned(),
sink, sink,
CoreLimits::default(), CoreLimits::default(),
@@ -535,7 +573,7 @@ fn experimental_secret_service_identity_survives_core_restart_on_linux() {
}); });
let started = Instant::now(); let started = Instant::now();
let restarted = loop { let restarted = loop {
match VnidropCore::initialize_with_experimental_saved_devices( match VnidropCore::initialize_with_limits_and_network_config(
path.clone(), path.clone(),
sink.clone(), sink.clone(),
CoreLimits::default(), CoreLimits::default(),
@@ -803,7 +841,7 @@ fn linux_public_bindings_omit_raw_secrets_and_generic_mutation() {
} }
} }
assert!( 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" "facade must expose experimental saved-device init"
); );
assert!( assert!(

View File

@@ -20,6 +20,8 @@ use crate::{
CoreEvent, CoreEventSink, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind, CoreEvent, CoreEventSink, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind,
TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError, TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError,
}; };
use data_encoding::HEXLOWER;
use iroh::SecretKey;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use crate::{CoreLimits, CoreNetworkConfig}; use crate::{CoreLimits, CoreNetworkConfig};
@@ -48,6 +50,31 @@ struct WindowsContractNode {
} }
impl 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 { fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap(); let data_dir = tempfile::tempdir().unwrap();
let api = Arc::new(FakeWindowsDpapiApi::new()); 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 { impl Drop for WindowsContractNode {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(core) = self.core.take() { if let Some(core) = self.core.take() {
@@ -283,7 +321,7 @@ fn real_windows_dpapi_experimental_init_preserves_identity() {
let sink = Arc::new(RecordingSink { let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()), events: Mutex::new(Vec::new()),
}); });
let core = VnidropCore::initialize_with_experimental_saved_devices( let core = VnidropCore::initialize_with_limits_and_network_config(
path.clone(), path.clone(),
sink, sink,
CoreLimits::default(), CoreLimits::default(),
@@ -297,7 +335,7 @@ fn real_windows_dpapi_experimental_init_preserves_identity() {
let sink = Arc::new(RecordingSink { let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()), events: Mutex::new(Vec::new()),
}); });
let restarted = VnidropCore::initialize_with_experimental_saved_devices( let restarted = VnidropCore::initialize_with_limits_and_network_config(
path, path,
sink, sink,
CoreLimits::default(), CoreLimits::default(),

View File

@@ -1,5 +1,7 @@
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
use iroh_blobs::{ use iroh_blobs::{
provider::{ provider::{
events::{RequestUpdate, TransferCompleted}, events::{RequestUpdate, TransferCompleted},
@@ -11,7 +13,9 @@ use iroh_blobs::{
use crate::{ use crate::{
invitation::{PendingDeliveryReceiptInsert, Repository, TransferUpsert}, invitation::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
runtime::{consume_request_updates, CoreInner, IdentityMode, RequestStreamOutcome}, 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}, transfer_state::{TransferDirection, TransferStatus},
CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode, VnidropCore, VnidropError, CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode, VnidropCore, VnidropError,
}; };
@@ -22,6 +26,14 @@ impl CoreEventSink for TestSink {
fn on_event(&self, _event: CoreEvent) {} 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] #[test]
fn provider_request_stream_distinguishes_success_from_silent_abort() { fn provider_request_stream_distinguishes_success_from_silent_abort() {
let runtime = tokio::runtime::Runtime::new().unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap();
@@ -56,6 +68,7 @@ fn provider_request_stream_distinguishes_success_from_silent_abort() {
#[test] #[test]
fn initializes_and_reports_endpoint() { fn initializes_and_reports_endpoint() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
install_protected_test_store(temp.path());
let core = VnidropCore::initialize( let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(), temp.path().to_string_lossy().to_string(),
Arc::new(TestSink), Arc::new(TestSink),
@@ -66,6 +79,55 @@ fn initializes_and_reports_endpoint() {
core.shutdown(); 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] #[tokio::test]
async fn protected_runtime_restart_preserves_identity_without_plaintext_fallback() { async fn protected_runtime_restart_preserves_identity_without_plaintext_fallback() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -109,6 +171,7 @@ async fn protected_runtime_restart_preserves_identity_without_plaintext_fallback
#[test] #[test]
fn invalid_receive_ticket_is_typed_and_persisted_as_event() { fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
install_protected_test_store(temp.path());
let core = VnidropCore::initialize( let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(), temp.path().to_string_lossy().to_string(),
Arc::new(TestSink), Arc::new(TestSink),
@@ -134,6 +197,7 @@ fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
#[test] #[test]
fn startup_recovers_interrupted_transfer_and_persists_event() { fn startup_recovers_interrupted_transfer_and_persists_event() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
install_protected_test_store(temp.path());
let preparation_runtime = tokio::runtime::Runtime::new().unwrap(); let preparation_runtime = tokio::runtime::Runtime::new().unwrap();
preparation_runtime.block_on(async { preparation_runtime.block_on(async {
let repository = Repository::open(temp.path()).await.unwrap(); let repository = Repository::open(temp.path()).await.unwrap();
@@ -181,6 +245,7 @@ fn startup_recovers_interrupted_transfer_and_persists_event() {
#[test] #[test]
fn startup_processes_persisted_delivery_receipts() { fn startup_processes_persisted_delivery_receipts() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
install_protected_test_store(temp.path());
let preparation_runtime = tokio::runtime::Runtime::new().unwrap(); let preparation_runtime = tokio::runtime::Runtime::new().unwrap();
preparation_runtime.block_on(async { preparation_runtime.block_on(async {
let repository = Repository::open(temp.path()).await.unwrap(); let repository = Repository::open(temp.path()).await.unwrap();
@@ -240,6 +305,7 @@ fn startup_processes_persisted_delivery_receipts() {
#[test] #[test]
fn startup_fails_persisted_share_when_root_blob_is_missing() { fn startup_fails_persisted_share_when_root_blob_is_missing() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
install_protected_test_store(temp.path());
let preparation_runtime = tokio::runtime::Runtime::new().unwrap(); let preparation_runtime = tokio::runtime::Runtime::new().unwrap();
preparation_runtime.block_on(async { preparation_runtime.block_on(async {
let repository = Repository::open(temp.path()).await.unwrap(); 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); store.remove_for_test(&handle);
drop(custody); drop(custody);
let stores = persistence::open_all(temp.path()).await.unwrap(); 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!( assert!(matches!(
custody.initialize_endpoint_identity(&legacy_path).await, SecretCustody::start(stores.secrets.clone(), store.clone()).await,
Err(VnidropError::SecureStorageUnavailable { .. }) Err(VnidropError::SecureStorageMissing { .. })
)); ));
assert!(store.list_handles().unwrap().is_empty()); assert!(store.list_handles().unwrap().is_empty());
} }

View File

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

View File

@@ -41,6 +41,17 @@ pub struct CoreGuard(Arc<VnidropCore>);
impl CoreGuard { impl CoreGuard {
pub fn start(path: &Path, sink: Arc<dyn CoreEventSink>) -> Self { 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( Self(
VnidropCore::initialize(path.to_string_lossy().to_string(), sink) VnidropCore::initialize(path.to_string_lossy().to_string(), sink)
.expect("test core should initialize"), .expect("test core should initialize"),
@@ -52,6 +63,17 @@ impl CoreGuard {
sink: Arc<dyn CoreEventSink>, sink: Arc<dyn CoreEventSink>,
limits: CoreLimits, limits: CoreLimits,
) -> Self { ) -> 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( Self(
VnidropCore::initialize_with_limits(path.to_string_lossy().to_string(), sink, limits) VnidropCore::initialize_with_limits(path.to_string_lossy().to_string(), sink, limits)
.expect("test core should initialize with limits"), .expect("test core should initialize with limits"),
@@ -63,6 +85,17 @@ impl CoreGuard {
sink: Arc<dyn CoreEventSink>, sink: Arc<dyn CoreEventSink>,
network_config: CoreNetworkConfig, network_config: CoreNetworkConfig,
) -> Self { ) -> 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( Self(
VnidropCore::initialize_with_network_config( VnidropCore::initialize_with_network_config(
path.to_string_lossy().to_string(), path.to_string_lossy().to_string(),