fix(core): harden protected identity startup

This commit is contained in:
2026-08-09 20:25:16 +02:00
parent 429987785e
commit eefedd0cb0
25 changed files with 1351 additions and 616 deletions

View File

@@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.vnidrop.app.core.initializeAndroidCoreRuntime
import com.vnidrop.app.feature.receive.ExternalInvitationController import com.vnidrop.app.feature.receive.ExternalInvitationController
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
import com.vnidrop.app.feature.receive.VniDropInvitationExtension import com.vnidrop.app.feature.receive.VniDropInvitationExtension
@@ -22,6 +23,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
initializeAndroidCoreRuntime(applicationContext)
setContent { setContent {
App(rememberAndroidAppDependencies(this, externalInvitations)) App(rememberAndroidAppDependencies(this, externalInvitations))
} }

View File

@@ -53,9 +53,10 @@ struct NativeCoreBindingFactory: CoreBindingFactory {
case .localOnly: case .localOnly:
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: []) nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
} }
return try VnidropCore.initializeWithNetworkConfig( return try VnidropCore.initializeWithExperimentalSavedDevices(
appDataDir: appDataDir, appDataDir: appDataDir,
eventSink: eventSink, eventSink: eventSink,
limits: defaultCoreLimits(),
networkConfig: nativeConfiguration networkConfig: nativeConfiguration
) )
} }

View File

@@ -38,6 +38,12 @@ extension Error {
return .resource(L10n.Error.generic) return .resource(L10n.Error.generic)
case .InvalidInput: case .InvalidInput:
return .resource(L10n.Error.invalidInput) return .resource(L10n.Error.invalidInput)
case .InvalidTransition:
return .resource(L10n.Error.invalidInput)
case .SecureStorageLocked, .SecureStorageUnavailable:
return .resource(L10n.Error.startingUp)
case .SecureStorageMissing, .SecureStorageCorrupted:
return .resource(L10n.Error.generic)
case .Initialization(let reason): case .Initialization(let reason):
return initializationUiText(reason) return initializationUiText(reason)
case .Internal(let reason): case .Internal(let reason):
@@ -83,7 +89,9 @@ extension Error {
case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r), case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r),
.DestinationExists(let r), .StorageFull(let r), .Network(let r), .DestinationExists(let r), .StorageFull(let r), .Network(let r),
.Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r), .Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r),
.InvalidInput(let r), .Internal(let r): .InvalidInput(let r), .InvalidTransition(let r), .SecureStorageLocked(let r),
.SecureStorageMissing(let r), .SecureStorageCorrupted(let r),
.SecureStorageUnavailable(let r), .Internal(let r):
return r return r
} }
} }

View File

@@ -42,9 +42,7 @@ pub enum VnidropError {
impl VnidropError { impl VnidropError {
pub(crate) fn initialization(error: impl Into<anyhow::Error>) -> Self { pub(crate) fn initialization(error: impl Into<anyhow::Error>) -> Self {
Self::Initialization { Self::from_error(error.into(), |reason| Self::Initialization { reason })
reason: error.into().to_string(),
}
} }
pub(crate) fn ticket(error: impl Into<anyhow::Error>) -> Self { pub(crate) fn ticket(error: impl Into<anyhow::Error>) -> Self {

View File

@@ -3,7 +3,7 @@ use std::{future::Future, path::PathBuf, sync::Arc};
use anyhow::Context; use anyhow::Context;
use serde_json::json; use serde_json::json;
use super::CoreInner; use super::{CoreInner, IdentityMode};
use crate::{ use crate::{
api::{ api::{
ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig,
@@ -14,6 +14,7 @@ use crate::{
}, },
error::VnidropError, error::VnidropError,
filesystem::platform_path, filesystem::platform_path,
secure_secret::{lock_profile, platform_secret_store},
ticket::parse_transfer_ticket_with_limits, ticket::parse_transfer_ticket_with_limits,
transfer_state::{TransferDirection, TransferStatus}, transfer_state::{TransferDirection, TransferStatus},
}; };
@@ -35,6 +36,34 @@ impl VnidropCore {
fn block_on<F: Future>(&self, future: F) -> F::Output { fn block_on<F: Future>(&self, future: F) -> F::Output {
self.runtime.handle().block_on(future) self.runtime.handle().block_on(future)
} }
fn initialize_with_identity_mode(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
identity_mode: IdentityMode,
) -> Result<Arc<Self>, VnidropError> {
limits.validate().map_err(VnidropError::initialization)?;
let relay_urls = network_config
.validated_relay_urls()
.map_err(VnidropError::initialization)?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name("vnidrop")
.build()?;
let inner = runtime
.block_on(CoreInner::start(
PathBuf::from(app_data_dir),
event_sink,
limits,
network_config.mode,
relay_urls,
identity_mode,
))
.map_err(VnidropError::initialization)?;
Ok(Arc::new(Self { runtime, inner }))
}
} }
#[uniffi::export] #[uniffi::export]
@@ -87,25 +116,39 @@ impl VnidropCore {
limits: CoreLimits, limits: CoreLimits,
network_config: CoreNetworkConfig, network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> { ) -> Result<Arc<Self>, VnidropError> {
limits.validate().map_err(VnidropError::initialization)?; Self::initialize_with_identity_mode(
let relay_urls = network_config app_data_dir,
.validated_relay_urls() event_sink,
.map_err(VnidropError::initialization)?; limits,
let runtime = tokio::runtime::Builder::new_multi_thread() network_config,
.enable_all() IdentityMode::Legacy,
.thread_name("vnidrop") )
.build()?; }
let app_data_dir = PathBuf::from(app_data_dir);
let inner = runtime /// Starts the experimental saved-device core with a platform-protected identity.
.block_on(CoreInner::start( #[uniffi::constructor]
app_data_dir, pub fn initialize_with_experimental_saved_devices(
event_sink, app_data_dir: String,
limits, event_sink: Arc<dyn CoreEventSink>,
network_config.mode, limits: CoreLimits,
relay_urls, network_config: CoreNetworkConfig,
)) ) -> Result<Arc<Self>, VnidropError> {
.map_err(VnidropError::initialization)?; let app_data_path = PathBuf::from(app_data_dir);
Ok(Arc::new(Self { runtime, inner })) 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

@@ -186,7 +186,7 @@ impl CoreInner {
Ok(()) Ok(())
} }
pub(super) async fn shutdown(&self) { pub(crate) async fn shutdown(&self) {
if self.shutdown_started.swap(true, Ordering::SeqCst) { if self.shutdown_started.swap(true, Ordering::SeqCst) {
return; return;
} }

View File

@@ -64,6 +64,7 @@ use crate::{
pairing::PairingService, pairing::PairingService,
repository::Repository, repository::Repository,
secret::load_or_create_secret, secret::load_or_create_secret,
secure_secret::{start_endpoint_identity, ProfileLock, SecretCustody, SecureSecretStore},
ticket::ticket_matches_relay_profile, ticket::ticket_matches_relay_profile,
transfer_state::{TransferDirection, TransferStatus}, transfer_state::{TransferDirection, TransferStatus},
}; };
@@ -99,6 +100,8 @@ pub(super) struct CoreInner {
pub(super) router: Router, pub(super) router: Router,
pub(super) store: FsStore, pub(super) store: FsStore,
pub(super) repository: Repository, pub(super) repository: Repository,
_secret_custody: Option<SecretCustody>,
_profile_lock: Option<ProfileLock>,
pub(super) event_hub: Arc<EventHub>, pub(super) event_hub: Arc<EventHub>,
pub(super) approval: ApprovalService, pub(super) approval: ApprovalService,
pub(super) pairing: PairingService, pub(super) pairing: PairingService,
@@ -130,6 +133,14 @@ pub(super) struct ActiveTransfer {
pub(super) cancel: oneshot::Sender<()>, pub(super) cancel: oneshot::Sender<()>,
} }
pub(super) enum IdentityMode {
Legacy,
Protected {
store: Arc<dyn SecureSecretStore>,
profile_lock: ProfileLock,
},
}
impl CoreInner { impl CoreInner {
pub(super) async fn start( pub(super) async fn start(
app_data_dir: PathBuf, app_data_dir: PathBuf,
@@ -137,11 +148,26 @@ impl CoreInner {
limits: CoreLimits, limits: CoreLimits,
relay_mode: CoreRelayMode, relay_mode: CoreRelayMode,
relay_urls: Vec<RelayUrl>, relay_urls: Vec<RelayUrl>,
identity_mode: IdentityMode,
) -> Result<Arc<Self>> { ) -> Result<Arc<Self>> {
tokio::fs::create_dir_all(&app_data_dir).await?; tokio::fs::create_dir_all(&app_data_dir).await?;
init_logging(&app_data_dir)?; init_logging(&app_data_dir)?;
let secret_key = load_or_create_secret(&app_data_dir).await?;
let repository = Repository::open(&app_data_dir).await?; let repository = Repository::open(&app_data_dir).await?;
let (secret_key, secret_custody, profile_lock) = match identity_mode {
IdentityMode::Legacy => (load_or_create_secret(&app_data_dir).await?, None, None),
IdentityMode::Protected {
store,
profile_lock,
} => {
let (secret_key, custody) = start_endpoint_identity(
repository.protected_secrets(),
store,
&app_data_dir.join("iroh.secret"),
)
.await?;
(secret_key, Some(custody), Some(profile_lock))
}
};
let store_root = app_data_dir.join("blobs"); let store_root = app_data_dir.join("blobs");
let mut store_options = FsStoreOptions::new(&store_root); let mut store_options = FsStoreOptions::new(&store_root);
store_options.gc = Some(GcConfig { store_options.gc = Some(GcConfig {
@@ -366,6 +392,8 @@ impl CoreInner {
router, router,
store, store,
repository, repository,
_secret_custody: secret_custody,
_profile_lock: profile_lock,
event_hub, event_hub,
approval, approval,
pairing, pairing,

View File

@@ -1,4 +1,4 @@
use std::{collections::HashSet, fmt, io, path::Path, sync::Arc}; use std::{collections::HashSet, fmt, io, path::Path, sync::Arc, time::Duration};
#[cfg(test)] #[cfg(test)]
use std::{collections::HashMap, sync::Mutex}; use std::{collections::HashMap, sync::Mutex};
@@ -11,13 +11,18 @@ use uuid::Uuid;
use crate::{error::VnidropError, util::now_ms}; use crate::{error::VnidropError, util::now_ms};
#[cfg(any(test, target_os = "android"))] #[cfg(any(test, target_os = "android"))]
mod android; pub(crate) mod android;
#[cfg(any(target_os = "macos", target_os = "ios"))] #[cfg(any(target_os = "macos", target_os = "ios"))]
mod apple; pub(crate) mod apple;
#[cfg(any(test, target_os = "linux"))] #[cfg(any(test, target_os = "linux"))]
mod linux; pub(crate) mod linux;
mod platform;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod windows; pub(crate) mod windows;
#[cfg(test)]
pub(crate) use platform::scope_store;
pub(crate) use platform::{lock_profile, platform_secret_store, ProfileLock};
const SECRET_BYTES: usize = 32; const SECRET_BYTES: usize = 32;
const HANDLE_NAMESPACE: &str = "vnidrop"; const HANDLE_NAMESPACE: &str = "vnidrop";
@@ -40,6 +45,11 @@ impl SecretMaterial {
let bytes: [u8; SECRET_BYTES] = self.0.as_slice().try_into().expect("validated length"); let bytes: [u8; SECRET_BYTES] = self.0.as_slice().try_into().expect("validated length");
SecretKey::from_bytes(&bytes).public().to_string() SecretKey::from_bytes(&bytes).public().to_string()
} }
fn into_secret_key(self) -> SecretKey {
let bytes: [u8; SECRET_BYTES] = self.0.try_into().expect("validated length");
SecretKey::from_bytes(&bytes)
}
} }
impl fmt::Debug for SecretMaterial { impl fmt::Debug for SecretMaterial {
@@ -74,6 +84,11 @@ impl fmt::Debug for SecretHandle {
} }
} }
#[cfg(test)]
pub(crate) fn secret_handle_for_test(value: String) -> SecretHandle {
SecretHandle(value)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SecretKind { pub(crate) enum SecretKind {
EndpointIdentity, EndpointIdentity,
@@ -129,38 +144,6 @@ pub(crate) trait SecureSecretStore: Send + Sync {
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError>; fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError>;
} }
#[cfg(any(target_os = "macos", target_os = "ios"))]
pub(crate) fn platform_secret_store(
_app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
Ok(Arc::new(apple::AppleKeychainSecretStore::new()))
}
#[cfg(target_os = "android")]
pub(crate) fn platform_secret_store(
_app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
android::native::create_store_from_android_runtime().map_err(map_store_error)
}
#[cfg(target_os = "windows")]
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
windows::WindowsDpapiSecretStore::new(app_data_dir.join("protected-secrets-v1"))
.map(|store| Arc::new(store) as Arc<dyn SecureSecretStore>)
.map_err(map_store_error)
}
#[cfg(target_os = "linux")]
pub(crate) fn platform_secret_store(
_app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
linux::LinuxSecretServiceStore::connect()
.map(|store| Arc::new(store) as Arc<dyn SecureSecretStore>)
.map_err(map_store_error)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SecretMetadataState { enum SecretMetadataState {
Staged, Staged,
@@ -212,6 +195,15 @@ pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> {
) )
.execute(pool) .execute(pool)
.await?; .await?;
sqlx::query(
r#"
CREATE UNIQUE INDEX IF NOT EXISTS protected_secret_one_endpoint_identity
ON protected_secret_refs(kind)
WHERE kind = 'endpoint-identity' AND state != 'disabled'
"#,
)
.execute(pool)
.await?;
Ok(()) Ok(())
} }
@@ -314,6 +306,15 @@ impl SecretMetadataStore {
.map_err(VnidropError::repository)?; .map_err(VnidropError::repository)?;
row.map(row_to_metadata).transpose() row.map(row_to_metadata).transpose()
} }
async fn contains_kind(&self, kind: SecretKind) -> Result<bool, VnidropError> {
let row = sqlx::query("SELECT 1 FROM protected_secret_refs WHERE kind = ?1 LIMIT 1")
.bind(kind.as_str())
.fetch_optional(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(row.is_some())
}
} }
fn row_to_metadata(row: sqlx::sqlite::SqliteRow) -> Result<SecretMetadata, VnidropError> { fn row_to_metadata(row: sqlx::sqlite::SqliteRow) -> Result<SecretMetadata, VnidropError> {
@@ -332,6 +333,19 @@ pub(crate) struct SecretCustody {
crash_point: Mutex<Option<CustodyCrashPoint>>, crash_point: Mutex<Option<CustodyCrashPoint>>,
} }
pub(crate) async fn start_endpoint_identity(
metadata: SecretMetadataStore,
store: Arc<dyn SecureSecretStore>,
legacy_path: &Path,
) -> Result<(SecretKey, SecretCustody), VnidropError> {
let (custody, _) = SecretCustody::start(metadata, store).await?;
let secret_key = custody
.initialize_endpoint_identity(legacy_path)
.await?
.into_secret_key();
Ok((secret_key, custody))
}
impl SecretCustody { impl SecretCustody {
pub(crate) async fn start( pub(crate) async fn start(
metadata: SecretMetadataStore, metadata: SecretMetadataStore,
@@ -376,9 +390,10 @@ impl SecretCustody {
}); });
} }
validate_material(kind, &stored, expected_identity)?; validate_material(kind, &stored, expected_identity)?;
self.metadata if let Err(error) = self.metadata.stage(&handle, kind, expected_identity).await {
.stage(&handle, kind, expected_identity) self.delete_if_present(&handle)?;
.await?; return Err(error);
}
#[cfg(test)] #[cfg(test)]
self.maybe_crash(CustodyCrashPoint::MetadataStage)?; self.maybe_crash(CustodyCrashPoint::MetadataStage)?;
self.metadata.activate(&handle).await?; self.metadata.activate(&handle).await?;
@@ -450,6 +465,71 @@ impl SecretCustody {
Ok(handle) Ok(handle)
} }
pub(crate) async fn initialize_endpoint_identity(
&self,
legacy_path: &Path,
) -> Result<SecretMaterial, VnidropError> {
if self
.metadata
.find_active_kind(SecretKind::EndpointIdentity)
.await?
.is_some()
{
let handle = self.migrate_legacy_endpoint_identity(legacy_path).await?;
return self.load(&handle).await;
}
if self
.metadata
.contains_kind(SecretKind::EndpointIdentity)
.await?
{
return Err(VnidropError::SecureStorageUnavailable {
reason: "protected endpoint identity is disabled".to_string(),
});
}
match tokio::fs::try_exists(legacy_path).await {
Ok(true) => {
let handle = self.migrate_legacy_endpoint_identity(legacy_path).await?;
self.load(&handle).await
}
Ok(false) => {
let secret = SecretKey::generate();
let material = SecretMaterial::new(secret.to_bytes().to_vec())?;
let endpoint_id = material.endpoint_id();
match self
.protect(
SecretKind::EndpointIdentity,
material,
Some(endpoint_id.as_str()),
)
.await
{
Ok(handle) => self.load(&handle).await,
Err(error) => {
let winner = tokio::time::timeout(Duration::from_secs(1), async {
loop {
if let Some(active) = self
.metadata
.find_active_kind(SecretKind::EndpointIdentity)
.await?
{
return self.load(&active.handle).await;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await;
match winner {
Ok(result) => result,
Err(_) => Err(error),
}
}
}
}
Err(error) => Err(VnidropError::filesystem(error)),
}
}
pub(crate) async fn reconcile(&self) -> Result<ReconciliationSummary, VnidropError> { pub(crate) async fn reconcile(&self) -> Result<ReconciliationSummary, VnidropError> {
let metadata = self.metadata.list().await?; let metadata = self.metadata.list().await?;
let stored_handles = self.store.list_handles().map_err(map_store_error)?; let stored_handles = self.store.list_handles().map_err(map_store_error)?;

View File

@@ -2,7 +2,7 @@ use std::{
fs::{self, File, OpenOptions}, fs::{self, File, OpenOptions},
io::{Read, Write}, io::{Read, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::{Arc, Mutex},
}; };
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
@@ -48,6 +48,7 @@ pub(crate) trait AndroidKeystore: Send + Sync {
pub(crate) struct AndroidSecureSecretStore { pub(crate) struct AndroidSecureSecretStore {
records_dir: PathBuf, records_dir: PathBuf,
keystore: Arc<dyn AndroidKeystore>, keystore: Arc<dyn AndroidKeystore>,
mutation_lock: Mutex<()>,
} }
impl AndroidSecureSecretStore { impl AndroidSecureSecretStore {
@@ -64,6 +65,7 @@ impl AndroidSecureSecretStore {
Ok(Self { Ok(Self {
records_dir, records_dir,
keystore, keystore,
mutation_lock: Mutex::new(()),
}) })
} }
@@ -112,6 +114,24 @@ impl AndroidSecureSecretStore {
.map_err(map_io_error)?; .map_err(map_io_error)?;
decode_record(&bytes, handle) decode_record(&bytes, handle)
} }
#[cfg(test)]
pub(crate) fn record_path_for_test(&self, handle: &SecretHandle) -> PathBuf {
self.record_path(handle)
}
#[cfg(test)]
pub(crate) fn stage_for_test(
&self,
handle: &SecretHandle,
) -> Result<(), SecureSecretStoreError> {
self.write_record(handle, RECORD_STAGED, None)
}
}
#[cfg(test)]
pub(crate) fn secret_handle_for_test(value: &str) -> SecretHandle {
SecretHandle(value.to_string())
} }
impl SecureSecretStore for AndroidSecureSecretStore { impl SecureSecretStore for AndroidSecureSecretStore {
@@ -120,7 +140,18 @@ impl SecureSecretStore for AndroidSecureSecretStore {
handle: &SecretHandle, handle: &SecretHandle,
material: SecretMaterial, material: SecretMaterial,
) -> Result<(), SecureSecretStoreError> { ) -> Result<(), SecureSecretStoreError> {
self.write_record(handle, RECORD_STAGED, None)?; let _mutation = self
.mutation_lock
.lock()
.map_err(|_| SecureSecretStoreError::Unavailable)?;
let record_exists = match fs::metadata(self.record_path(handle)) {
Ok(_) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => return Err(map_io_error(error)),
};
if !record_exists {
self.write_record(handle, RECORD_STAGED, None)?;
}
let alias = Self::alias(handle); let alias = Self::alias(handle);
let sealed = self.keystore.seal(&alias, &material.0)?; let sealed = self.keystore.seal(&alias, &material.0)?;
if sealed.nonce.is_empty() || sealed.ciphertext.is_empty() { if sealed.nonce.is_empty() || sealed.ciphertext.is_empty() {
@@ -136,6 +167,10 @@ impl SecureSecretStore for AndroidSecureSecretStore {
} }
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> { fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
let _mutation = self
.mutation_lock
.lock()
.map_err(|_| SecureSecretStoreError::Unavailable)?;
match self.keystore.delete(&Self::alias(handle)) { match self.keystore.delete(&Self::alias(handle)) {
Ok(()) | Err(SecureSecretStoreError::Missing) => {} Ok(()) | Err(SecureSecretStoreError::Missing) => {}
Err(error) => return Err(error), Err(error) => return Err(error),
@@ -281,150 +316,3 @@ fn set_private_file_permissions(_path: &Path) -> Result<(), SecureSecretStoreErr
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[path = "android_native.rs"] #[path = "android_native.rs"]
pub(crate) mod native; pub(crate) mod native;
#[cfg(test)]
mod tests {
use std::{collections::HashMap, sync::Mutex};
use tempfile::TempDir;
use super::*;
use crate::secure_secret::SECRET_BYTES;
#[derive(Default)]
struct FakeKeystore {
keys: Mutex<HashMap<String, u8>>,
delete_failure: Mutex<Option<SecureSecretStoreError>>,
}
impl AndroidKeystore for FakeKeystore {
fn seal(
&self,
alias: &str,
plaintext: &[u8],
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
let mask = 0xa7;
self.keys.lock().unwrap().insert(alias.to_string(), mask);
Ok(AndroidSealedValue {
nonce: vec![4; 12],
ciphertext: plaintext.iter().map(|byte| byte ^ mask).collect(),
})
}
fn open(
&self,
alias: &str,
sealed: &AndroidSealedValue,
) -> Result<Vec<u8>, SecureSecretStoreError> {
let mask = *self
.keys
.lock()
.unwrap()
.get(alias)
.ok_or(SecureSecretStoreError::Missing)?;
Ok(sealed.ciphertext.iter().map(|byte| byte ^ mask).collect())
}
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> {
if let Some(error) = self.delete_failure.lock().unwrap().take() {
return Err(error);
}
self.keys.lock().unwrap().remove(alias);
Ok(())
}
}
fn fixture() -> (TempDir, AndroidSecureSecretStore, Arc<FakeKeystore>) {
let directory = TempDir::new().unwrap();
let keystore = Arc::new(FakeKeystore::default());
let store = AndroidSecureSecretStore::new(directory.path(), keystore.clone()).unwrap();
(directory, store, keystore)
}
fn handle() -> SecretHandle {
SecretHandle("vnidrop/v1/endpoint-identity/test".to_string())
}
#[test]
fn adapter_round_trips_lists_and_deletes_without_plaintext_persistence() {
let (directory, store, keystore) = fixture();
let handle = handle();
let plaintext = vec![0x5a; SECRET_BYTES];
store
.put(&handle, SecretMaterial::new(plaintext.clone()).unwrap())
.unwrap();
let persisted = fs::read(store.record_path(&handle)).unwrap();
assert!(!persisted
.windows(plaintext.len())
.any(|window| window == plaintext));
drop(store);
let restarted = AndroidSecureSecretStore::new(directory.path(), keystore).unwrap();
assert_eq!(restarted.list_handles().unwrap(), vec![handle.clone()]);
assert_eq!(restarted.get(&handle).unwrap().0, plaintext);
restarted.delete(&handle).unwrap();
assert!(restarted.list_handles().unwrap().is_empty());
assert!(matches!(
restarted.get(&handle),
Err(SecureSecretStoreError::Missing)
));
}
#[test]
fn staged_crash_record_remains_discoverable_and_fails_closed() {
let (_directory, store, _keystore) = fixture();
let handle = handle();
store.write_record(&handle, RECORD_STAGED, None).unwrap();
assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]);
assert!(matches!(
store.get(&handle),
Err(SecureSecretStoreError::Corrupted)
));
store.delete(&handle).unwrap();
assert!(store.list_handles().unwrap().is_empty());
}
#[test]
fn tampering_and_missing_keystore_keys_are_distinct_failures() {
let (_directory, store, keystore) = fixture();
let handle = handle();
store
.put(&handle, SecretMaterial::new(vec![9; SECRET_BYTES]).unwrap())
.unwrap();
keystore.keys.lock().unwrap().clear();
assert!(matches!(
store.get(&handle),
Err(SecureSecretStoreError::Missing)
));
fs::write(store.record_path(&handle), b"tampered").unwrap();
assert!(matches!(
store.get(&handle),
Err(SecureSecretStoreError::Corrupted)
));
}
#[test]
fn failed_key_deletion_retains_the_record_for_safe_retry() {
let (_directory, store, keystore) = fixture();
let handle = handle();
store
.put(&handle, SecretMaterial::new(vec![7; SECRET_BYTES]).unwrap())
.unwrap();
*keystore.delete_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked);
assert!(matches!(
store.delete(&handle),
Err(SecureSecretStoreError::Locked)
));
assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]);
store.delete(&handle).unwrap();
assert!(store.list_handles().unwrap().is_empty());
}
}

View File

@@ -1,8 +1,10 @@
use jni::{ use jni::{
errors::Error as JniError, errors::Error as JniError,
objects::{JByteArray, JObject, JString, JValue}, objects::{GlobalRef, JByteArray, JObject, JString, JValue},
sys::jboolean,
JNIEnv, JavaVM, JNIEnv, JavaVM,
}; };
use std::{panic::AssertUnwindSafe, sync::Mutex};
use super::*; use super::*;
@@ -10,6 +12,48 @@ const ANDROID_KEYSTORE: &str = "AndroidKeyStore";
const AES: &str = "AES"; const AES: &str = "AES";
const TRANSFORMATION: &str = "AES/GCM/NoPadding"; const TRANSFORMATION: &str = "AES/GCM/NoPadding";
static ANDROID_APPLICATION_CONTEXT: Mutex<Option<GlobalRef>> = Mutex::new(None);
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnidrop_app_core_AndroidCoreRuntime_initialize(
mut env: JNIEnv<'_>,
_receiver: JObject<'_>,
context: JObject<'_>,
) -> jboolean {
std::panic::catch_unwind(AssertUnwindSafe(|| {
initialize_android_context(&mut env, context)
}))
.ok()
.and_then(Result::ok)
.map_or(0, |()| 1)
}
fn initialize_android_context(
env: &mut JNIEnv<'_>,
context: JObject<'_>,
) -> Result<(), SecureSecretStoreError> {
let mut stored = ANDROID_APPLICATION_CONTEXT
.lock()
.map_err(|_| SecureSecretStoreError::Unavailable)?;
if stored.is_some() {
return Ok(());
}
let vm = env
.get_java_vm()
.map_err(|_| SecureSecretStoreError::Unavailable)?;
let context = env
.new_global_ref(context)
.map_err(|_| SecureSecretStoreError::Unavailable)?;
unsafe {
ndk_context::initialize_android_context(
vm.get_java_vm_pointer().cast(),
context.as_obj().as_raw().cast(),
);
}
*stored = Some(context);
Ok(())
}
/// JNI-backed Android Keystore engine. The VM pointer comes from the Android /// JNI-backed Android Keystore engine. The VM pointer comes from the Android
/// runtime; no Context or secret bytes are exposed through UniFFI. /// runtime; no Context or secret bytes are exposed through UniFFI.
pub(crate) struct AndroidJniKeystore { pub(crate) struct AndroidJniKeystore {
@@ -51,8 +95,7 @@ impl AndroidJniKeystore {
if context.is_null() { if context.is_null() {
return Err(SecureSecretStoreError::Unavailable); return Err(SecureSecretStoreError::Unavailable);
} }
// ndk-context retains this process Context for the Android runtime lifetime. let context = local_ref_from_process_context(env, context.cast())?;
let context = unsafe { JObject::from_raw(context.cast()) };
let directory = env let directory = env
.call_method(&context, "getNoBackupFilesDir", "()Ljava/io/File;", &[]) .call_method(&context, "getNoBackupFilesDir", "()Ljava/io/File;", &[])
.map_err(|error| map_jni_error(env, error))? .map_err(|error| map_jni_error(env, error))?
@@ -429,3 +472,23 @@ fn map_jni_error(env: &mut JNIEnv<'_>, _error: JniError) -> SecureSecretStoreErr
fn is_instance_of(env: &mut JNIEnv<'_>, object: &JObject<'_>, class: &str) -> bool { fn is_instance_of(env: &mut JNIEnv<'_>, object: &JObject<'_>, class: &str) -> bool {
env.is_instance_of(object, class).unwrap_or(false) env.is_instance_of(object, class).unwrap_or(false)
} }
fn local_ref_from_process_context<'local>(
env: &mut JNIEnv<'local>,
context: jni::sys::jobject,
) -> Result<JObject<'local>, SecureSecretStoreError> {
let interface = env.get_native_interface();
// ndk-context retains a process-wide global Context reference. JNI NewLocalRef
// is required before representing it as a frame-bound JObject.
let context = unsafe {
let new_local_ref = (**interface)
.NewLocalRef
.ok_or(SecureSecretStoreError::Unavailable)?;
new_local_ref(interface, context)
};
if context.is_null() {
return Err(SecureSecretStoreError::Unavailable);
}
// NewLocalRef created this reference in the currently attached JNI frame.
Ok(unsafe { JObject::from_raw(context) })
}

View File

@@ -25,7 +25,7 @@ enum AppleAccessibility {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AppleKeychainPolicy { pub(crate) struct AppleKeychainPolicy {
accessibility: AppleAccessibility, accessibility: AppleAccessibility,
synchronizable: bool, synchronizable: bool,
data_protection_keychain: bool, data_protection_keychain: bool,
@@ -41,7 +41,7 @@ impl Default for AppleKeychainPolicy {
} }
} }
trait AppleKeychainApi: Send + Sync { pub(crate) trait AppleKeychainApi: Send + Sync {
fn put( fn put(
&self, &self,
service: &str, service: &str,
@@ -136,7 +136,7 @@ impl AppleKeychainSecretStore {
} }
#[cfg(test)] #[cfg(test)]
fn with_api(api: impl AppleKeychainApi + 'static) -> Self { pub(crate) fn with_api(api: impl AppleKeychainApi + 'static) -> Self {
Self { api: Arc::new(api) } Self { api: Arc::new(api) }
} }
} }
@@ -187,186 +187,21 @@ fn map_status(status: i32) -> SecureSecretStoreError {
} }
#[cfg(test)] #[cfg(test)]
mod tests { pub(crate) fn expected_policy_for_test() -> AppleKeychainPolicy {
use std::{collections::HashMap, sync::Mutex}; AppleKeychainPolicy::default()
}
use super::*;
#[cfg(test)]
#[derive(Clone, Default)] pub(crate) fn service_for_test() -> &'static str {
struct RecordingKeychain { SERVICE
state: Arc<Mutex<RecordingState>>, }
}
#[cfg(test)]
#[derive(Default)] pub(crate) fn handle_for_test(value: &str) -> SecretHandle {
struct RecordingState { SecretHandle(value.to_string())
entries: HashMap<(String, String), Vec<u8>>, }
last_policy: Option<AppleKeychainPolicy>,
} #[cfg(test)]
pub(crate) fn map_status_for_test(status: i32) -> SecureSecretStoreError {
impl AppleKeychainApi for RecordingKeychain { map_status(status)
fn put(
&self,
service: &str,
account: &str,
material: &[u8],
policy: AppleKeychainPolicy,
) -> Result<(), i32> {
let mut state = self.state.lock().unwrap();
state.last_policy = Some(policy);
state.entries.insert(
(service.to_string(), account.to_string()),
material.to_vec(),
);
Ok(())
}
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32> {
self.state
.lock()
.unwrap()
.entries
.get(&(service.to_string(), account.to_string()))
.cloned()
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
}
fn delete(&self, service: &str, account: &str) -> Result<(), i32> {
self.state
.lock()
.unwrap()
.entries
.remove(&(service.to_string(), account.to_string()))
.map(|_| ())
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
}
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32> {
Ok(self
.state
.lock()
.unwrap()
.entries
.keys()
.filter(|(entry_service, _)| entry_service == service)
.map(|(_, account)| account.clone())
.collect())
}
}
fn handle(value: &str) -> SecretHandle {
SecretHandle(value.to_string())
}
#[test]
fn adapter_creates_replaces_reads_lists_and_deletes_only_its_service() {
let api = RecordingKeychain::default();
api.state.lock().unwrap().entries.insert(
("com.example.unrelated".to_string(), "leave-me".to_string()),
vec![0x77; 32],
);
let store = AppleKeychainSecretStore::with_api(api.clone());
let owned = handle("vnidrop/v1/endpoint-identity/apple-test");
store
.put(&owned, SecretMaterial::new(vec![0x31; 32]).unwrap())
.unwrap();
drop(store);
let reopened_store = AppleKeychainSecretStore::with_api(api.clone());
assert_eq!(
reopened_store.get(&owned).unwrap(),
SecretMaterial::new(vec![0x31; 32]).unwrap()
);
reopened_store
.put(&owned, SecretMaterial::new(vec![0x42; 32]).unwrap())
.unwrap();
assert_eq!(
reopened_store.get(&owned).unwrap(),
SecretMaterial::new(vec![0x42; 32]).unwrap()
);
assert_eq!(reopened_store.list_handles().unwrap(), vec![owned.clone()]);
reopened_store.delete(&owned).unwrap();
assert!(matches!(
reopened_store.get(&owned),
Err(SecureSecretStoreError::Missing)
));
assert!(api
.state
.lock()
.unwrap()
.entries
.contains_key(&("com.example.unrelated".to_string(), "leave-me".to_string())));
}
#[test]
fn adapter_always_requests_device_local_non_synchronizing_protection() {
let api = RecordingKeychain::default();
let store = AppleKeychainSecretStore::with_api(api.clone());
store
.put(
&handle("vnidrop/v1/relationship-grant/apple-policy"),
SecretMaterial::new(vec![0x51; 32]).unwrap(),
)
.unwrap();
assert_eq!(
api.state.lock().unwrap().last_policy,
Some(AppleKeychainPolicy {
accessibility: AppleAccessibility::AfterFirstUnlockThisDeviceOnly,
synchronizable: false,
data_protection_keychain: true,
})
);
}
#[test]
fn apple_statuses_map_to_fail_closed_contract_outcomes() {
assert!(matches!(
map_status(ERR_SEC_INTERACTION_NOT_ALLOWED),
SecureSecretStoreError::Locked
));
assert!(matches!(
map_status(ERR_SEC_AUTH_FAILED),
SecureSecretStoreError::Locked
));
assert!(matches!(
map_status(ERR_SEC_ITEM_NOT_FOUND),
SecureSecretStoreError::Missing
));
assert!(matches!(
map_status(ERR_SEC_DECODE),
SecureSecretStoreError::Corrupted
));
assert!(matches!(
map_status(ERR_SEC_NOT_AVAILABLE),
SecureSecretStoreError::Unavailable
));
assert!(matches!(
map_status(-1),
SecureSecretStoreError::Unavailable
));
}
#[test]
fn malformed_keychain_values_are_corrupted_without_diagnostic_disclosure() {
let api = RecordingKeychain::default();
let secret = vec![0x6d; 31];
api.state.lock().unwrap().entries.insert(
(
SERVICE.to_string(),
"vnidrop/v1/pairing-eligibility/corrupt".to_string(),
),
secret.clone(),
);
let store = AppleKeychainSecretStore::with_api(api);
let error = store
.get(&handle("vnidrop/v1/pairing-eligibility/corrupt"))
.unwrap_err();
assert!(matches!(&error, SecureSecretStoreError::Corrupted));
assert!(!format!("{error:?}").contains(&data_encoding::HEXLOWER.encode(&secret)));
}
} }

View File

@@ -12,7 +12,7 @@ const ATTRIBUTE_HANDLE: &str = "vnidrop-handle";
const APPLICATION_ID: &str = "com.vnidrop.VniDrop"; const APPLICATION_ID: &str = "com.vnidrop.VniDrop";
const ITEM_LABEL: &str = "VniDrop protected secret"; const ITEM_LABEL: &str = "VniDrop protected secret";
trait LinuxSecretServiceApi: Send + Sync { pub(crate) trait LinuxSecretServiceApi: Send + Sync {
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError>; fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError>;
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError>; fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError>;
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError>; fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError>;
@@ -114,7 +114,7 @@ impl LinuxSecretServiceApi for SystemLinuxSecretService {
} }
} }
pub(super) struct LinuxSecretServiceStore { pub(crate) struct LinuxSecretServiceStore {
api: Arc<dyn LinuxSecretServiceApi>, api: Arc<dyn LinuxSecretServiceApi>,
} }
@@ -126,7 +126,7 @@ impl LinuxSecretServiceStore {
} }
#[cfg(test)] #[cfg(test)]
fn with_api(api: Arc<dyn LinuxSecretServiceApi>) -> Self { pub(crate) fn with_api(api: Arc<dyn LinuxSecretServiceApi>) -> Self {
Self { api } Self { api }
} }
} }
@@ -168,143 +168,14 @@ impl SecureSecretStore for LinuxSecretServiceStore {
} }
} }
fn map_error(error: Error) -> SecureSecretStoreError { pub(crate) fn map_error(error: Error) -> SecureSecretStoreError {
match error { match error {
Error::Locked | Error::Prompt => SecureSecretStoreError::Locked, Error::Locked | Error::Prompt => SecureSecretStoreError::Locked,
Error::NoResult => SecureSecretStoreError::Missing, Error::NoResult => SecureSecretStoreError::Missing,
Error::Crypto(_) | Error::Zvariant(_) => SecureSecretStoreError::Corrupted, Error::Crypto(_) => SecureSecretStoreError::Corrupted,
Error::Unavailable | Error::Zbus(_) | Error::ZbusFdo(_) => { Error::Unavailable | Error::Zvariant(_) | Error::Zbus(_) | Error::ZbusFdo(_) => {
SecureSecretStoreError::Unavailable SecureSecretStoreError::Unavailable
} }
_ => SecureSecretStoreError::Unavailable, _ => SecureSecretStoreError::Unavailable,
} }
} }
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
#[derive(Default)]
struct RecordingSecretService {
values: Mutex<HashMap<String, Vec<u8>>>,
failure: Mutex<Option<SecureSecretStoreError>>,
}
impl RecordingSecretService {
fn failure(&self) -> Result<(), SecureSecretStoreError> {
match &*self.failure.lock().unwrap() {
Some(SecureSecretStoreError::Locked) => Err(SecureSecretStoreError::Locked),
Some(SecureSecretStoreError::Missing) => Err(SecureSecretStoreError::Missing),
Some(SecureSecretStoreError::Corrupted) => Err(SecureSecretStoreError::Corrupted),
Some(SecureSecretStoreError::Unavailable) => {
Err(SecureSecretStoreError::Unavailable)
}
None => Ok(()),
}
}
}
impl LinuxSecretServiceApi for RecordingSecretService {
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> {
self.failure()?;
self.values
.lock()
.unwrap()
.insert(handle.to_string(), material.to_vec());
Ok(())
}
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError> {
self.failure()?;
self.values
.lock()
.unwrap()
.get(handle)
.cloned()
.ok_or(SecureSecretStoreError::Missing)
}
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> {
self.failure()?;
self.values
.lock()
.unwrap()
.remove(handle)
.map(|_| ())
.ok_or(SecureSecretStoreError::Missing)
}
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError> {
self.failure()?;
Ok(self.values.lock().unwrap().keys().cloned().collect())
}
}
fn handle(suffix: &str) -> SecretHandle {
SecretHandle(format!("vnidrop/v1/relationship-grant/{suffix}"))
}
#[test]
fn adapter_survives_restart_and_deletes_only_the_selected_item() {
let api = Arc::new(RecordingSecretService::default());
let first = handle("first");
let second = handle("second");
let material = SecretMaterial::new(vec![0x5a; 32]).unwrap();
let store = LinuxSecretServiceStore::with_api(api.clone());
store.put(&first, material.clone()).unwrap();
store
.put(&second, SecretMaterial::new(vec![0x6b; 32]).unwrap())
.unwrap();
let restarted = LinuxSecretServiceStore::with_api(api);
assert_eq!(restarted.get(&first).unwrap(), material);
assert_eq!(
restarted.list_handles().unwrap(),
vec![first.clone(), second]
);
restarted.delete(&first).unwrap();
assert!(matches!(
restarted.get(&first),
Err(SecureSecretStoreError::Missing)
));
}
#[test]
fn failures_are_typed_and_secret_material_is_redacted() {
let api = Arc::new(RecordingSecretService::default());
let store = LinuxSecretServiceStore::with_api(api.clone());
let secret = SecretMaterial::new(vec![0x7c; 32]).unwrap();
assert_eq!(format!("{secret:?}"), "SecretMaterial(redacted)");
for failure in [
SecureSecretStoreError::Locked,
SecureSecretStoreError::Unavailable,
SecureSecretStoreError::Corrupted,
] {
*api.failure.lock().unwrap() = Some(failure);
assert!(store.get(&handle("failure")).is_err());
}
}
#[test]
fn secret_service_errors_map_without_exposing_details() {
assert!(matches!(
map_error(Error::Locked),
SecureSecretStoreError::Locked
));
assert!(matches!(
map_error(Error::NoResult),
SecureSecretStoreError::Missing
));
assert!(matches!(
map_error(Error::Crypto("distinctive-secret")),
SecureSecretStoreError::Corrupted
));
assert!(matches!(
map_error(Error::Unavailable),
SecureSecretStoreError::Unavailable
));
}
}

View File

@@ -0,0 +1,138 @@
use std::{
fs::{File, OpenOptions},
path::Path,
sync::Arc,
};
#[cfg(any(target_os = "android", target_os = "windows", target_os = "linux"))]
use super::map_store_error;
use super::{
SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError, HANDLE_NAMESPACE,
HANDLE_VERSION,
};
use crate::error::VnidropError;
struct ScopedSecretStore {
inner: Arc<dyn SecureSecretStore>,
physical_prefix: String,
}
pub(crate) struct ProfileLock {
_file: File,
}
pub(crate) fn lock_profile(app_data_dir: &Path) -> Result<ProfileLock, VnidropError> {
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(app_data_dir.join("protected-secrets.lock"))
.map_err(VnidropError::filesystem)?;
file.try_lock()
.map_err(|_| VnidropError::SecureStorageUnavailable {
reason: "another protected core is already using this profile".to_string(),
})?;
Ok(ProfileLock { _file: file })
}
impl ScopedSecretStore {
fn new(app_data_dir: &Path, inner: Arc<dyn SecureSecretStore>) -> Self {
let profile = blake3::hash(app_data_dir.to_string_lossy().as_bytes()).to_hex();
Self {
inner,
physical_prefix: format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/scope-{profile}/"),
}
}
fn physical_handle(
&self,
handle: &SecretHandle,
) -> Result<SecretHandle, SecureSecretStoreError> {
let logical_prefix = format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/");
let suffix = handle
.as_str()
.strip_prefix(&logical_prefix)
.ok_or(SecureSecretStoreError::Corrupted)?;
Ok(SecretHandle(format!("{}{suffix}", self.physical_prefix)))
}
}
impl SecureSecretStore for ScopedSecretStore {
fn put(
&self,
handle: &SecretHandle,
material: SecretMaterial,
) -> Result<(), SecureSecretStoreError> {
self.inner.put(&self.physical_handle(handle)?, material)
}
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
self.inner.get(&self.physical_handle(handle)?)
}
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
self.inner.delete(&self.physical_handle(handle)?)
}
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
let handles = self
.inner
.list_handles()?
.into_iter()
.filter_map(|handle| {
handle
.as_str()
.strip_prefix(&self.physical_prefix)
.map(|suffix| {
SecretHandle(format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/{suffix}"))
})
})
.collect();
Ok(handles)
}
}
pub(crate) fn scope_store(
app_data_dir: &Path,
store: Arc<dyn SecureSecretStore>,
) -> Arc<dyn SecureSecretStore> {
Arc::new(ScopedSecretStore::new(app_data_dir, store))
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
Ok(scope_store(
app_data_dir,
Arc::new(super::apple::AppleKeychainSecretStore::new()),
))
}
#[cfg(target_os = "android")]
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
super::android::native::create_store_from_android_runtime()
.map(|store| scope_store(app_data_dir, store))
.map_err(map_store_error)
}
#[cfg(target_os = "windows")]
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
super::windows::WindowsDpapiSecretStore::new(app_data_dir.join("protected-secrets-v1"))
.map(|store| scope_store(app_data_dir, Arc::new(store)))
.map_err(map_store_error)
}
#[cfg(target_os = "linux")]
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
super::linux::LinuxSecretServiceStore::connect()
.map(|store| scope_store(app_data_dir, Arc::new(store)))
.map_err(map_store_error)
}

View File

@@ -19,11 +19,14 @@ use windows_sys::Win32::{
Security::Cryptography::{ Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
}, },
Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH}, Storage::FileSystem::{MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH},
}; };
use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError}; use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError};
#[cfg(test)]
use super::SecretKind;
const ENVELOPE_MAGIC: &[u8; 8] = b"VNIDPAPI"; const ENVELOPE_MAGIC: &[u8; 8] = b"VNIDPAPI";
const ENVELOPE_VERSION: u8 = 1; const ENVELOPE_VERSION: u8 = 1;
const FILE_EXTENSION: &str = "dpapi"; const FILE_EXTENSION: &str = "dpapi";
@@ -65,17 +68,25 @@ impl WindowsDpapiSecretStore {
} }
#[cfg(test)] #[cfg(test)]
fn with_protector_for_test( pub(crate) fn with_context_for_test(
directory: impl AsRef<Path>, directory: impl AsRef<Path>,
protector: Arc<DpapiProtector>, context: &[u8],
) -> Result<Self, SecureSecretStoreError> { ) -> Result<Self, SecureSecretStoreError> {
Self::with_protector(directory, protector) Self::with_protector(
directory,
Arc::new(DpapiProtector::with_context_for_test(context)),
)
} }
#[cfg(test)] #[cfg(test)]
fn path_for_test(&self, handle: &SecretHandle) -> PathBuf { pub(crate) fn path_for_test(&self, handle: &SecretHandle) -> PathBuf {
self.path_for(handle) self.path_for(handle)
} }
#[cfg(test)]
pub(crate) fn relationship_handle_for_test() -> SecretHandle {
SecretHandle::generate(SecretKind::RelationshipGrant)
}
} }
impl SecureSecretStore for WindowsDpapiSecretStore { impl SecureSecretStore for WindowsDpapiSecretStore {
@@ -85,9 +96,12 @@ impl SecureSecretStore for WindowsDpapiSecretStore {
material: SecretMaterial, material: SecretMaterial,
) -> Result<(), SecureSecretStoreError> { ) -> Result<(), SecureSecretStoreError> {
let destination = self.path_for(handle); let destination = self.path_for(handle);
if destination.exists() { let replace_existing = match self.get(handle) {
return ensure_same_value(self, handle, &material); Ok(existing) if existing == material => return Ok(()),
} Ok(_) => true,
Err(SecureSecretStoreError::Missing) => false,
Err(error) => return Err(error),
};
let ciphertext = self.protector.protect(handle, &material.0)?; let ciphertext = self.protector.protect(handle, &material.0)?;
let envelope = encode_envelope(handle, &ciphertext)?; let envelope = encode_envelope(handle, &ciphertext)?;
@@ -109,7 +123,7 @@ impl SecureSecretStore for WindowsDpapiSecretStore {
file.sync_all().map_err(map_io_error)?; file.sync_all().map_err(map_io_error)?;
drop(file); drop(file);
match move_write_through(temporary_guard.path(), &destination) { match move_write_through(temporary_guard.path(), &destination, replace_existing) {
Ok(()) => { Ok(()) => {
temporary_guard.disarm(); temporary_guard.disarm();
Ok(()) Ok(())
@@ -120,7 +134,16 @@ impl SecureSecretStore for WindowsDpapiSecretStore {
Some(ERROR_ALREADY_EXISTS) | Some(ERROR_FILE_EXISTS) Some(ERROR_ALREADY_EXISTS) | Some(ERROR_FILE_EXISTS)
) => ) =>
{ {
ensure_same_value(self, handle, &material) match self.get(handle) {
Ok(existing) if existing == material => Ok(()),
Ok(_) => {
move_write_through(temporary_guard.path(), &destination, true)
.map_err(map_io_error)?;
temporary_guard.disarm();
Ok(())
}
Err(error) => Err(error),
}
} }
Err(error) => Err(map_io_error(error)), Err(error) => Err(map_io_error(error)),
} }
@@ -147,7 +170,7 @@ impl SecureSecretStore for WindowsDpapiSecretStore {
continue; continue;
} }
let envelope = fs::read(&path).map_err(map_io_error)?; let envelope = fs::read(&path).map_err(map_io_error)?;
let handle = decode_handle(&envelope)?; let (handle, _) = decode_envelope_parts(&envelope)?;
if self.path_for(&handle) != path || !unique.insert(handle.clone()) { if self.path_for(&handle) != path || !unique.insert(handle.clone()) {
return Err(SecureSecretStoreError::Corrupted); return Err(SecureSecretStoreError::Corrupted);
} }
@@ -158,18 +181,6 @@ impl SecureSecretStore for WindowsDpapiSecretStore {
} }
} }
fn ensure_same_value(
store: &WindowsDpapiSecretStore,
handle: &SecretHandle,
expected: &SecretMaterial,
) -> Result<(), SecureSecretStoreError> {
if store.get(handle)? == *expected {
Ok(())
} else {
Err(SecureSecretStoreError::Corrupted)
}
}
fn encode_envelope( fn encode_envelope(
handle: &SecretHandle, handle: &SecretHandle,
ciphertext: &[u8], ciphertext: &[u8],
@@ -193,11 +204,6 @@ fn encode_envelope(
Ok(envelope) Ok(envelope)
} }
fn decode_handle(envelope: &[u8]) -> Result<SecretHandle, SecureSecretStoreError> {
let (handle, _) = decode_envelope_parts(envelope)?;
Ok(handle)
}
fn decode_envelope<'a>( fn decode_envelope<'a>(
envelope: &'a [u8], envelope: &'a [u8],
expected_handle: &SecretHandle, expected_handle: &SecretHandle,
@@ -276,17 +282,16 @@ impl Drop for TemporaryFile {
} }
} }
fn move_write_through(source: &Path, destination: &Path) -> io::Result<()> { fn move_write_through(source: &Path, destination: &Path, replace_existing: bool) -> io::Result<()> {
let source = wide_path(source); let source = wide_path(source);
let destination = wide_path(destination); let destination = wide_path(destination);
// The files share a directory, so MoveFileEx publishes the fully flushed blob as one rename. // The files share a directory, so MoveFileEx publishes the fully flushed blob as one rename.
let moved = unsafe { let flags = if replace_existing {
MoveFileExW( MOVEFILE_WRITE_THROUGH | MOVEFILE_REPLACE_EXISTING
source.as_ptr(), } else {
destination.as_ptr(), MOVEFILE_WRITE_THROUGH
MOVEFILE_WRITE_THROUGH,
)
}; };
let moved = unsafe { MoveFileExW(source.as_ptr(), destination.as_ptr(), flags) };
if moved == 0 { if moved == 0 {
Err(io::Error::last_os_error()) Err(io::Error::last_os_error())
} else { } else {
@@ -466,7 +471,3 @@ fn map_io_error(error: io::Error) -> SecureSecretStoreError {
_ => SecureSecretStoreError::Unavailable, _ => SecureSecretStoreError::Unavailable,
} }
} }
#[cfg(test)]
#[path = "windows_tests.rs"]
mod tests;

View File

@@ -22,8 +22,18 @@ mod repository_tests;
mod runtime_tests; mod runtime_tests;
#[path = "tests/secret.rs"] #[path = "tests/secret.rs"]
mod secret_tests; mod secret_tests;
#[path = "tests/secure_secret_android.rs"]
mod secure_secret_android_tests;
#[cfg(any(target_os = "macos", target_os = "ios"))]
#[path = "tests/secure_secret_apple.rs"]
mod secure_secret_apple_tests;
#[path = "tests/secure_secret_linux.rs"]
mod secure_secret_linux_tests;
#[path = "tests/secure_secret.rs"] #[path = "tests/secure_secret.rs"]
mod secure_secret_tests; mod secure_secret_tests;
#[cfg(target_os = "windows")]
#[path = "tests/secure_secret_windows.rs"]
mod secure_secret_windows_tests;
#[path = "tests/ticket.rs"] #[path = "tests/ticket.rs"]
mod ticket_tests; mod ticket_tests;
#[path = "tests/transfer_state.rs"] #[path = "tests/transfer_state.rs"]

View File

@@ -44,6 +44,22 @@ fn transfer_boundary_preserves_typed_errors_through_context() {
assert_eq!(classified.code(), "network"); assert_eq!(classified.code(), "network");
} }
#[test]
fn initialization_boundary_preserves_secure_storage_failures() {
let error = anyhow::Error::new(VnidropError::SecureStorageLocked {
reason: "credential store is locked".to_string(),
})
.context("endpoint identity could not be loaded");
let classified = VnidropError::initialization(error);
assert!(matches!(
classified,
VnidropError::SecureStorageLocked { ref reason }
if reason == "endpoint identity could not be loaded"
));
}
#[test] #[test]
fn transfer_boundary_classifies_database_failures() { fn transfer_boundary_classifies_database_failures() {
let transfer = VnidropError::transfer(sqlx::Error::RowNotFound); let transfer = VnidropError::transfer(sqlx::Error::RowNotFound);

View File

@@ -10,9 +10,10 @@ use iroh_blobs::{
use crate::{ use crate::{
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert}, repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
runtime::{consume_request_updates, RequestStreamOutcome}, runtime::{consume_request_updates, CoreInner, IdentityMode, RequestStreamOutcome},
secure_secret::{lock_profile, FaultInjectingSecretStore},
transfer_state::{TransferDirection, TransferStatus}, transfer_state::{TransferDirection, TransferStatus},
CoreEvent, CoreEventSink, VnidropCore, VnidropError, CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode, VnidropCore, VnidropError,
}; };
struct TestSink; struct TestSink;
@@ -65,6 +66,46 @@ fn initializes_and_reports_endpoint() {
core.shutdown(); core.shutdown();
} }
#[tokio::test]
async fn protected_runtime_restart_preserves_identity_without_plaintext_fallback() {
let temp = tempfile::tempdir().unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let first = CoreInner::start(
temp.path().to_path_buf(),
Arc::new(TestSink),
CoreLimits::default(),
CoreRelayMode::LocalOnly,
Vec::new(),
IdentityMode::Protected {
store: store.clone(),
profile_lock: lock_profile(temp.path()).unwrap(),
},
)
.await
.unwrap();
let endpoint_id = first.endpoint.id();
first.shutdown().await;
drop(first);
let restarted = CoreInner::start(
temp.path().to_path_buf(),
Arc::new(TestSink),
CoreLimits::default(),
CoreRelayMode::LocalOnly,
Vec::new(),
IdentityMode::Protected {
store,
profile_lock: lock_profile(temp.path()).unwrap(),
},
)
.await
.unwrap();
assert_eq!(restarted.endpoint.id(), endpoint_id);
assert!(!temp.path().join("iroh.secret").exists());
restarted.shutdown().await;
}
#[test] #[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();

View File

@@ -9,8 +9,8 @@ use iroh::SecretKey;
use crate::{ use crate::{
repository::Repository, repository::Repository,
secure_secret::{ secure_secret::{
CustodyCrashPoint, FaultInjectingSecretStore, ReferenceStoreFailure, SecretCustody, lock_profile, scope_store, CustodyCrashPoint, FaultInjectingSecretStore,
SecretKind, SecretMaterial, ReferenceStoreFailure, SecretCustody, SecretKind, SecretMaterial, SecureSecretStore,
}, },
VnidropError, VnidropError,
}; };
@@ -31,6 +31,68 @@ impl Write for CapturedWriter {
} }
} }
#[test]
fn a_profile_allows_only_one_protected_core_mutator() {
let temp = tempfile::tempdir().unwrap();
let first = lock_profile(temp.path()).unwrap();
assert!(matches!(
lock_profile(temp.path()),
Err(VnidropError::SecureStorageUnavailable { .. })
));
drop(first);
assert!(lock_profile(temp.path()).is_ok());
}
#[tokio::test]
async fn reconciliation_is_scoped_to_one_application_profile() {
let root = tempfile::tempdir().unwrap();
let first_dir = root.path().join("first");
let second_dir = root.path().join("second");
std::fs::create_dir_all(&first_dir).unwrap();
std::fs::create_dir_all(&second_dir).unwrap();
let shared_platform_store = Arc::new(FaultInjectingSecretStore::default());
let first_store = scope_store(&first_dir, shared_platform_store.clone());
let second_store = scope_store(&second_dir, shared_platform_store);
let first_repository = Repository::open(&first_dir).await.unwrap();
let second_repository = Repository::open(&second_dir).await.unwrap();
let first = SecretCustody::new(first_repository.protected_secrets(), first_store.clone());
let second = SecretCustody::new(second_repository.protected_secrets(), second_store.clone());
let first_handle = first
.protect(
SecretKind::RelationshipGrant,
SecretMaterial::new(vec![0x31; 32]).unwrap(),
None,
)
.await
.unwrap();
let second_handle = second
.protect(
SecretKind::RelationshipGrant,
SecretMaterial::new(vec![0x42; 32]).unwrap(),
None,
)
.await
.unwrap();
drop(first);
let (restarted, summary) =
SecretCustody::start(first_repository.protected_secrets(), first_store)
.await
.unwrap();
assert_eq!(summary.orphans_deleted, 0);
assert_eq!(
restarted.load(&first_handle).await.unwrap(),
SecretMaterial::new(vec![0x31; 32]).unwrap()
);
assert_eq!(
second.load(&second_handle).await.unwrap(),
SecretMaterial::new(vec![0x42; 32]).unwrap()
);
}
#[tokio::test] #[tokio::test]
async fn custody_maps_reference_store_failures_to_typed_core_errors() { async fn custody_maps_reference_store_failures_to_typed_core_errors() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -220,6 +282,70 @@ async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacem
)); ));
} }
#[tokio::test]
async fn first_install_identity_is_protected_once_and_never_silently_replaced() {
let temp = tempfile::tempdir().unwrap();
let legacy_path = temp.path().join("iroh.secret");
let repository = Repository::open(temp.path()).await.unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let (custody, _) = SecretCustody::start(repository.protected_secrets(), store.clone())
.await
.unwrap();
let original = custody
.initialize_endpoint_identity(&legacy_path)
.await
.unwrap();
assert!(!legacy_path.exists());
let handle = store.only_handle_for_test();
drop(custody);
drop(repository);
let repository = Repository::open(temp.path()).await.unwrap();
let (custody, _) = SecretCustody::start(repository.protected_secrets(), store.clone())
.await
.unwrap();
assert_eq!(
custody
.initialize_endpoint_identity(&legacy_path)
.await
.unwrap(),
original
);
store.remove_for_test(&handle);
drop(custody);
drop(repository);
let repository = Repository::open(temp.path()).await.unwrap();
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
.await
.unwrap();
assert_eq!(summary.disabled, 1);
assert!(matches!(
custody.initialize_endpoint_identity(&legacy_path).await,
Err(VnidropError::SecureStorageUnavailable { .. })
));
assert!(store.list_handles().unwrap().is_empty());
}
#[tokio::test]
async fn concurrent_first_starts_converge_on_one_protected_endpoint_identity() {
let temp = tempfile::tempdir().unwrap();
let legacy_path = temp.path().join("iroh.secret");
let repository = Repository::open(temp.path()).await.unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let first = SecretCustody::new(repository.protected_secrets(), store.clone());
let second = SecretCustody::new(repository.protected_secrets(), store.clone());
let (first_identity, second_identity) = tokio::join!(
first.initialize_endpoint_identity(&legacy_path),
second.initialize_endpoint_identity(&legacy_path),
);
assert_eq!(first_identity.unwrap(), second_identity.unwrap());
assert_eq!(store.list_handles().unwrap().len(), 1);
}
#[tokio::test] #[tokio::test]
async fn protected_material_is_absent_from_database_and_diagnostics() { async fn protected_material_is_absent_from_database_and_diagnostics() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();

View File

@@ -0,0 +1,180 @@
use std::{
collections::HashMap,
fs,
sync::{Arc, Mutex},
};
use tempfile::TempDir;
use crate::secure_secret::{
android::{
secret_handle_for_test, AndroidKeystore, AndroidSealedValue, AndroidSecureSecretStore,
},
SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError,
};
const TEST_SECRET_BYTES: usize = 32;
#[derive(Default)]
struct FakeKeystore {
keys: Mutex<HashMap<String, u8>>,
seal_failure: Mutex<Option<SecureSecretStoreError>>,
delete_failure: Mutex<Option<SecureSecretStoreError>>,
}
impl AndroidKeystore for FakeKeystore {
fn seal(
&self,
alias: &str,
plaintext: &[u8],
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
if let Some(error) = self.seal_failure.lock().unwrap().take() {
return Err(error);
}
let mask = 0xa7;
self.keys.lock().unwrap().insert(alias.to_string(), mask);
Ok(AndroidSealedValue {
nonce: vec![4; 12],
ciphertext: plaintext.iter().map(|byte| byte ^ mask).collect(),
})
}
fn open(
&self,
alias: &str,
sealed: &AndroidSealedValue,
) -> Result<Vec<u8>, SecureSecretStoreError> {
let mask = *self
.keys
.lock()
.unwrap()
.get(alias)
.ok_or(SecureSecretStoreError::Missing)?;
Ok(sealed.ciphertext.iter().map(|byte| byte ^ mask).collect())
}
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> {
if let Some(error) = self.delete_failure.lock().unwrap().take() {
return Err(error);
}
self.keys.lock().unwrap().remove(alias);
Ok(())
}
}
fn fixture() -> (TempDir, AndroidSecureSecretStore, Arc<FakeKeystore>) {
let directory = TempDir::new().unwrap();
let keystore = Arc::new(FakeKeystore::default());
let store = AndroidSecureSecretStore::new(directory.path(), keystore.clone()).unwrap();
(directory, store, keystore)
}
fn handle() -> SecretHandle {
secret_handle_for_test("vnidrop/v1/endpoint-identity/test")
}
#[test]
fn adapter_round_trips_lists_and_deletes_without_plaintext_persistence() {
let (directory, store, keystore) = fixture();
let handle = handle();
let plaintext = vec![0x5a; TEST_SECRET_BYTES];
let material = SecretMaterial::new(plaintext.clone()).unwrap();
store.put(&handle, material.clone()).unwrap();
let persisted = fs::read(store.record_path_for_test(&handle)).unwrap();
assert!(!persisted
.windows(plaintext.len())
.any(|window| window == plaintext));
drop(store);
let restarted = AndroidSecureSecretStore::new(directory.path(), keystore).unwrap();
assert_eq!(restarted.list_handles().unwrap(), vec![handle.clone()]);
assert_eq!(restarted.get(&handle).unwrap(), material);
restarted.delete(&handle).unwrap();
assert!(restarted.list_handles().unwrap().is_empty());
assert!(matches!(
restarted.get(&handle),
Err(SecureSecretStoreError::Missing)
));
}
#[test]
fn staged_crash_record_remains_discoverable_and_fails_closed() {
let (_directory, store, _keystore) = fixture();
let handle = handle();
store.stage_for_test(&handle).unwrap();
assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]);
assert!(matches!(
store.get(&handle),
Err(SecureSecretStoreError::Corrupted)
));
store.delete(&handle).unwrap();
assert!(store.list_handles().unwrap().is_empty());
}
#[test]
fn tampering_and_missing_keystore_keys_are_distinct_failures() {
let (_directory, store, keystore) = fixture();
let handle = handle();
store
.put(
&handle,
SecretMaterial::new(vec![9; TEST_SECRET_BYTES]).unwrap(),
)
.unwrap();
keystore.keys.lock().unwrap().clear();
assert!(matches!(
store.get(&handle),
Err(SecureSecretStoreError::Missing)
));
fs::write(store.record_path_for_test(&handle), b"tampered").unwrap();
assert!(matches!(
store.get(&handle),
Err(SecureSecretStoreError::Corrupted)
));
}
#[test]
fn failed_key_deletion_retains_the_record_for_safe_retry() {
let (_directory, store, keystore) = fixture();
let handle = handle();
store
.put(
&handle,
SecretMaterial::new(vec![7; TEST_SECRET_BYTES]).unwrap(),
)
.unwrap();
*keystore.delete_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked);
assert!(matches!(
store.delete(&handle),
Err(SecureSecretStoreError::Locked)
));
assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]);
store.delete(&handle).unwrap();
assert!(store.list_handles().unwrap().is_empty());
}
#[test]
fn failed_replacement_keeps_the_previous_secret_readable() {
let (_directory, store, keystore) = fixture();
let handle = handle();
let original = SecretMaterial::new(vec![3; TEST_SECRET_BYTES]).unwrap();
store.put(&handle, original.clone()).unwrap();
*keystore.seal_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked);
assert!(matches!(
store.put(
&handle,
SecretMaterial::new(vec![8; TEST_SECRET_BYTES]).unwrap()
),
Err(SecureSecretStoreError::Locked)
));
assert_eq!(store.get(&handle).unwrap(), original);
}

View File

@@ -0,0 +1,188 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use crate::secure_secret::{
apple::{
expected_policy_for_test, handle_for_test, map_status_for_test, service_for_test,
AppleKeychainApi, AppleKeychainPolicy, AppleKeychainSecretStore,
},
SecretMaterial, SecureSecretStore, SecureSecretStoreError,
};
const ERR_SEC_AUTH_FAILED: i32 = -25_293;
const ERR_SEC_NOT_AVAILABLE: i32 = -25_291;
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25_300;
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308;
const ERR_SEC_DECODE: i32 = -26_275;
#[derive(Clone, Default)]
struct RecordingKeychain {
state: Arc<Mutex<RecordingState>>,
}
#[derive(Default)]
struct RecordingState {
entries: HashMap<(String, String), Vec<u8>>,
last_policy: Option<AppleKeychainPolicy>,
}
impl AppleKeychainApi for RecordingKeychain {
fn put(
&self,
service: &str,
account: &str,
material: &[u8],
policy: AppleKeychainPolicy,
) -> Result<(), i32> {
let mut state = self.state.lock().unwrap();
state.last_policy = Some(policy);
state.entries.insert(
(service.to_string(), account.to_string()),
material.to_vec(),
);
Ok(())
}
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32> {
self.state
.lock()
.unwrap()
.entries
.get(&(service.to_string(), account.to_string()))
.cloned()
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
}
fn delete(&self, service: &str, account: &str) -> Result<(), i32> {
self.state
.lock()
.unwrap()
.entries
.remove(&(service.to_string(), account.to_string()))
.map(|_| ())
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
}
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32> {
Ok(self
.state
.lock()
.unwrap()
.entries
.keys()
.filter(|(entry_service, _)| entry_service == service)
.map(|(_, account)| account.clone())
.collect())
}
}
#[test]
fn adapter_creates_replaces_reads_lists_and_deletes_only_its_service() {
let api = RecordingKeychain::default();
api.state.lock().unwrap().entries.insert(
("com.example.unrelated".to_string(), "leave-me".to_string()),
vec![0x77; 32],
);
let store = AppleKeychainSecretStore::with_api(api.clone());
let owned = handle_for_test("vnidrop/v1/endpoint-identity/apple-test");
store
.put(&owned, SecretMaterial::new(vec![0x31; 32]).unwrap())
.unwrap();
drop(store);
let reopened_store = AppleKeychainSecretStore::with_api(api.clone());
assert_eq!(
reopened_store.get(&owned).unwrap(),
SecretMaterial::new(vec![0x31; 32]).unwrap()
);
reopened_store
.put(&owned, SecretMaterial::new(vec![0x42; 32]).unwrap())
.unwrap();
assert_eq!(
reopened_store.get(&owned).unwrap(),
SecretMaterial::new(vec![0x42; 32]).unwrap()
);
assert_eq!(reopened_store.list_handles().unwrap(), vec![owned.clone()]);
reopened_store.delete(&owned).unwrap();
assert!(matches!(
reopened_store.get(&owned),
Err(SecureSecretStoreError::Missing)
));
assert!(api
.state
.lock()
.unwrap()
.entries
.contains_key(&("com.example.unrelated".to_string(), "leave-me".to_string())));
}
#[test]
fn adapter_always_requests_device_local_non_synchronizing_protection() {
let api = RecordingKeychain::default();
let store = AppleKeychainSecretStore::with_api(api.clone());
store
.put(
&handle_for_test("vnidrop/v1/relationship-grant/apple-policy"),
SecretMaterial::new(vec![0x51; 32]).unwrap(),
)
.unwrap();
assert_eq!(
api.state.lock().unwrap().last_policy,
Some(expected_policy_for_test())
);
}
#[test]
fn apple_statuses_map_to_fail_closed_contract_outcomes() {
assert!(matches!(
map_status_for_test(ERR_SEC_INTERACTION_NOT_ALLOWED),
SecureSecretStoreError::Locked
));
assert!(matches!(
map_status_for_test(ERR_SEC_AUTH_FAILED),
SecureSecretStoreError::Locked
));
assert!(matches!(
map_status_for_test(ERR_SEC_ITEM_NOT_FOUND),
SecureSecretStoreError::Missing
));
assert!(matches!(
map_status_for_test(ERR_SEC_DECODE),
SecureSecretStoreError::Corrupted
));
assert!(matches!(
map_status_for_test(ERR_SEC_NOT_AVAILABLE),
SecureSecretStoreError::Unavailable
));
assert!(matches!(
map_status_for_test(-1),
SecureSecretStoreError::Unavailable
));
}
#[test]
fn malformed_keychain_values_are_corrupted_without_diagnostic_disclosure() {
let api = RecordingKeychain::default();
let secret = vec![0x6d; 31];
api.state.lock().unwrap().entries.insert(
(
service_for_test().to_string(),
"vnidrop/v1/pairing-eligibility/corrupt".to_string(),
),
secret.clone(),
);
let store = AppleKeychainSecretStore::with_api(api);
let error = store
.get(&handle_for_test("vnidrop/v1/pairing-eligibility/corrupt"))
.unwrap_err();
assert!(matches!(&error, SecureSecretStoreError::Corrupted));
assert!(!format!("{error:?}").contains(&data_encoding::HEXLOWER.encode(&secret)));
}

View File

@@ -0,0 +1,169 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use secret_service::Error;
use crate::{
repository::Repository,
secure_secret::{
linux::{map_error, LinuxSecretServiceApi, LinuxSecretServiceStore},
SecretCustody, SecretHandle, SecretKind, SecretMaterial, SecureSecretStore,
SecureSecretStoreError,
},
VnidropError,
};
#[derive(Default)]
struct RecordingSecretService {
values: Mutex<HashMap<String, Vec<u8>>>,
failure: Mutex<Option<SecureSecretStoreError>>,
}
impl RecordingSecretService {
fn failure(&self) -> Result<(), SecureSecretStoreError> {
match &*self.failure.lock().unwrap() {
Some(SecureSecretStoreError::Locked) => Err(SecureSecretStoreError::Locked),
Some(SecureSecretStoreError::Missing) => Err(SecureSecretStoreError::Missing),
Some(SecureSecretStoreError::Corrupted) => Err(SecureSecretStoreError::Corrupted),
Some(SecureSecretStoreError::Unavailable) => Err(SecureSecretStoreError::Unavailable),
None => Ok(()),
}
}
}
impl LinuxSecretServiceApi for RecordingSecretService {
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> {
self.failure()?;
self.values
.lock()
.unwrap()
.insert(handle.to_string(), material.to_vec());
Ok(())
}
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError> {
self.failure()?;
self.values
.lock()
.unwrap()
.get(handle)
.cloned()
.ok_or(SecureSecretStoreError::Missing)
}
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> {
self.failure()?;
self.values
.lock()
.unwrap()
.remove(handle)
.map(|_| ())
.ok_or(SecureSecretStoreError::Missing)
}
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError> {
self.failure()?;
Ok(self.values.lock().unwrap().keys().cloned().collect())
}
}
fn handle(suffix: &str) -> SecretHandle {
crate::secure_secret::secret_handle_for_test(format!("vnidrop/v1/relationship-grant/{suffix}"))
}
#[test]
fn adapter_survives_restart_and_deletes_only_the_selected_item() {
let api = Arc::new(RecordingSecretService::default());
let first = handle("first");
let second = handle("second");
let material = SecretMaterial::new(vec![0x5a; 32]).unwrap();
let store = LinuxSecretServiceStore::with_api(api.clone());
store.put(&first, material.clone()).unwrap();
store
.put(&second, SecretMaterial::new(vec![0x6b; 32]).unwrap())
.unwrap();
let restarted = LinuxSecretServiceStore::with_api(api);
assert_eq!(restarted.get(&first).unwrap(), material);
assert_eq!(
restarted.list_handles().unwrap(),
vec![first.clone(), second]
);
restarted.delete(&first).unwrap();
assert!(matches!(
restarted.get(&first),
Err(SecureSecretStoreError::Missing)
));
}
#[tokio::test]
async fn transient_backend_failures_do_not_delete_protected_metadata_or_material() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
let api = Arc::new(RecordingSecretService::default());
let store = Arc::new(LinuxSecretServiceStore::with_api(api.clone()));
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
let protected = custody
.protect(
SecretKind::RelationshipGrant,
SecretMaterial::new(vec![0x7c; 32]).unwrap(),
None,
)
.await
.unwrap();
*api.failure.lock().unwrap() = Some(SecureSecretStoreError::Unavailable);
drop(custody);
assert!(matches!(
SecretCustody::start(repository.protected_secrets(), store.clone()).await,
Err(VnidropError::SecureStorageUnavailable { .. })
));
*api.failure.lock().unwrap() = None;
let (restarted, _) = SecretCustody::start(repository.protected_secrets(), store)
.await
.unwrap();
assert_eq!(
restarted.load(&protected).await.unwrap(),
SecretMaterial::new(vec![0x7c; 32]).unwrap()
);
}
#[test]
fn failures_are_typed_and_secret_material_is_redacted() {
let api = Arc::new(RecordingSecretService::default());
let store = LinuxSecretServiceStore::with_api(api.clone());
let secret = SecretMaterial::new(vec![0x7c; 32]).unwrap();
assert_eq!(format!("{secret:?}"), "SecretMaterial(redacted)");
for failure in [
SecureSecretStoreError::Locked,
SecureSecretStoreError::Unavailable,
SecureSecretStoreError::Corrupted,
] {
*api.failure.lock().unwrap() = Some(failure);
assert!(store.get(&handle("failure")).is_err());
}
}
#[test]
fn secret_service_errors_map_without_exposing_details() {
assert!(matches!(
map_error(Error::Locked),
SecureSecretStoreError::Locked
));
assert!(matches!(
map_error(Error::NoResult),
SecureSecretStoreError::Missing
));
assert!(matches!(
map_error(Error::Crypto("distinctive-secret")),
SecureSecretStoreError::Corrupted
));
assert!(matches!(
map_error(Error::Unavailable),
SecureSecretStoreError::Unavailable
));
}

View File

@@ -3,18 +3,17 @@ use std::{fs, sync::Arc};
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
use iroh::SecretKey; use iroh::SecretKey;
use super::{DpapiProtector, WindowsDpapiSecretStore};
use crate::{ use crate::{
repository::Repository, repository::Repository,
secure_secret::{ secure_secret::{
CustodyCrashPoint, SecretCustody, SecretHandle, SecretKind, SecretMaterial, windows::WindowsDpapiSecretStore, CustodyCrashPoint, SecretCustody, SecretMaterial,
SecureSecretStore, SecureSecretStoreError, SecureSecretStore, SecureSecretStoreError,
}, },
VnidropError, VnidropError,
}; };
fn handle(suffix: &str) -> SecretHandle { fn handle() -> crate::secure_secret::SecretHandle {
SecretHandle(format!("vnidrop/v1/relationship-grant/{suffix}")) WindowsDpapiSecretStore::relationship_handle_for_test()
} }
fn material(seed: u8) -> SecretMaterial { fn material(seed: u8) -> SecretMaterial {
@@ -24,7 +23,7 @@ fn material(seed: u8) -> SecretMaterial {
#[test] #[test]
fn round_trip_survives_adapter_restart_and_never_persists_plaintext() { fn round_trip_survives_adapter_restart_and_never_persists_plaintext() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let handle = handle("restart"); let handle = handle();
let secret = material(0xa7); let secret = material(0xa7);
WindowsDpapiSecretStore::new(directory.path()) WindowsDpapiSecretStore::new(directory.path())
@@ -46,8 +45,8 @@ fn round_trip_survives_adapter_restart_and_never_persists_plaintext() {
fn delete_removes_only_the_selected_protected_value() { fn delete_removes_only_the_selected_protected_value() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
let retained = handle("retained"); let retained = handle();
let removed = handle("removed"); let removed = handle();
store.put(&retained, material(1)).unwrap(); store.put(&retained, material(1)).unwrap();
store.put(&removed, material(2)).unwrap(); store.put(&removed, material(2)).unwrap();
@@ -62,25 +61,28 @@ fn delete_removes_only_the_selected_protected_value() {
} }
#[test] #[test]
fn repeated_put_is_idempotent_but_cannot_replace_secret_material() { fn repeated_put_is_idempotent_and_atomically_updates_changed_material() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
let handle = handle("immutable"); let handle = handle();
store.put(&handle, material(6)).unwrap(); store.put(&handle, material(6)).unwrap();
let first_blob = fs::read(store.path_for_test(&handle)).unwrap();
store.put(&handle, material(6)).unwrap(); store.put(&handle, material(6)).unwrap();
assert_eq!(fs::read(store.path_for_test(&handle)).unwrap(), first_blob);
assert!(matches!( store.put(&handle, material(7)).unwrap();
store.put(&handle, material(7)), assert_eq!(store.get(&handle).unwrap(), material(7));
Err(SecureSecretStoreError::Corrupted) assert!(!fs::read(store.path_for_test(&handle))
)); .unwrap()
assert_eq!(store.get(&handle).unwrap(), material(6)); .windows(32)
.any(|window| window == [7; 32]));
} }
#[test] #[test]
fn missing_corrupt_and_wrong_context_values_fail_closed() { fn missing_corrupt_and_wrong_context_values_fail_closed() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let handle = handle("failure-mapping"); let handle = handle();
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
assert!(matches!( assert!(matches!(
store.get(&handle), store.get(&handle),
@@ -95,17 +97,11 @@ fn missing_corrupt_and_wrong_context_values_fail_closed() {
)); ));
let isolated = tempfile::tempdir().unwrap(); let isolated = tempfile::tempdir().unwrap();
let original = WindowsDpapiSecretStore::with_protector_for_test( let original =
isolated.path(), WindowsDpapiSecretStore::with_context_for_test(isolated.path(), b"first-context").unwrap();
Arc::new(DpapiProtector::with_context_for_test(b"first-context")),
)
.unwrap();
original.put(&handle, material(4)).unwrap(); original.put(&handle, material(4)).unwrap();
let wrong_context = WindowsDpapiSecretStore::with_protector_for_test( let wrong_context =
isolated.path(), WindowsDpapiSecretStore::with_context_for_test(isolated.path(), b"second-context").unwrap();
Arc::new(DpapiProtector::with_context_for_test(b"second-context")),
)
.unwrap();
assert!(matches!( assert!(matches!(
wrong_context.get(&handle), wrong_context.get(&handle),
Err(SecureSecretStoreError::Corrupted) Err(SecureSecretStoreError::Corrupted)
@@ -113,15 +109,18 @@ fn missing_corrupt_and_wrong_context_values_fail_closed() {
} }
#[test] #[test]
fn incomplete_temporary_writes_are_removed_on_restart() { fn interrupted_replacement_preserves_the_previous_value() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let temporary = directory.path().join("interrupted.tmp-123"); let handle = handle();
fs::write(&temporary, material(5).0).unwrap();
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
store.put(&handle, material(5)).unwrap();
let temporary = directory.path().join("interrupted.tmp-123");
fs::write(&temporary, b"incomplete protected replacement").unwrap();
let restarted = WindowsDpapiSecretStore::new(directory.path()).unwrap();
assert!(!temporary.exists()); assert!(!temporary.exists());
assert!(store.list_handles().unwrap().is_empty()); assert_eq!(restarted.get(&handle).unwrap(), material(5));
} }
#[test] #[test]

View File

@@ -0,0 +1,17 @@
package com.vnidrop.app.core
import android.content.Context
internal object AndroidCoreRuntime {
init {
System.loadLibrary("vnidrop")
}
external fun initialize(context: Context): Boolean
}
fun initializeAndroidCoreRuntime(context: Context) {
check(AndroidCoreRuntime.initialize(context.applicationContext)) {
"The protected VniDrop runtime could not initialize"
}
}

View File

@@ -31,10 +31,12 @@ import uniffi.vnidrop.TransferMetadata
import uniffi.vnidrop.TransferAccessMode import uniffi.vnidrop.TransferAccessMode
import uniffi.vnidrop.VnidropCore import uniffi.vnidrop.VnidropCore
import uniffi.vnidrop.clearInactiveTransferCache import uniffi.vnidrop.clearInactiveTransferCache
import uniffi.vnidrop.defaultCoreLimits
import uniffi.vnidrop.defaultCoreNetworkConfig import uniffi.vnidrop.defaultCoreNetworkConfig
class CoreRepository( class CoreRepository internal constructor(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
private val coreFactory: CoreFactory = ProtectedCoreFactory,
) : CoreGateway { ) : CoreGateway {
private val _state = MutableStateFlow(CoreState()) private val _state = MutableStateFlow(CoreState())
override val state: StateFlow<CoreState> = _state.asStateFlow() override val state: StateFlow<CoreState> = _state.asStateFlow()
@@ -79,7 +81,7 @@ class CoreRepository(
_state.update { it.copy(isInitialized = false, status = null) } _state.update { it.copy(isInitialized = false, status = null) }
core = null core = null
disposeCore(previousCore) disposeCore(previousCore)
core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative()) core = createCore(appDataDir, relaySettings)
currentAppDataDir = appDataDir currentAppDataDir = appDataDir
currentRelaySettings = relaySettings currentRelaySettings = relaySettings
refreshSnapshot(requireCore()) refreshSnapshot(requireCore())
@@ -190,7 +192,7 @@ class CoreRepository(
try { try {
reclaimed = clearInactiveTransferCache(appDataDir) reclaimed = clearInactiveTransferCache(appDataDir)
} finally { } finally {
core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative()) core = createCore(appDataDir, relaySettings)
refreshSnapshot(requireCore()) refreshSnapshot(requireCore())
_state.update { it.copy(isInitialized = true) } _state.update { it.copy(isInitialized = true) }
} }
@@ -207,6 +209,9 @@ class CoreRepository(
} }
} }
private fun createCore(appDataDir: String, relaySettings: RelaySettings): VnidropCore =
coreFactory.create(appDataDir, sink, relaySettings)
override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = runCore { activeCore -> override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = runCore { activeCore ->
activeCore.listReceivedArtifacts().map { artifact -> activeCore.listReceivedArtifacts().map { artifact ->
ReceivedArtifactModel( ReceivedArtifactModel(
@@ -330,6 +335,23 @@ class CoreRepository(
} }
} }
internal fun interface CoreFactory {
fun create(appDataDir: String, eventSink: CoreEventSink, relaySettings: RelaySettings): VnidropCore
}
private object ProtectedCoreFactory : CoreFactory {
override fun create(
appDataDir: String,
eventSink: CoreEventSink,
relaySettings: RelaySettings,
): VnidropCore = VnidropCore.initializeWithExperimentalSavedDevices(
appDataDir,
eventSink,
defaultCoreLimits(),
relaySettings.toNative(),
)
}
private fun RelaySettings.toNative(): CoreNetworkConfig = when (mode) { private fun RelaySettings.toNative(): CoreNetworkConfig = when (mode) {
RelayMode.Automatic -> defaultCoreNetworkConfig() RelayMode.Automatic -> defaultCoreNetworkConfig()
RelayMode.StrictCustom -> CoreNetworkConfig( RelayMode.StrictCustom -> CoreNetworkConfig(

View File

@@ -6,13 +6,24 @@ import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import uniffi.vnidrop.CoreNetworkConfig
import uniffi.vnidrop.CoreRelayMode
import uniffi.vnidrop.VnidropCore
class CoreRepositoryStorageTest { class CoreRepositoryStorageTest {
@Test @Test
fun cacheClearWaitsForActiveSharesThenRestartsWithTheSameIdentity() = runTest { fun cacheClearWaitsForActiveSharesThenRestartsWithTheSameIdentity() = runTest {
val appData = createTempDirectory("vnidrop-cache-clear") val appData = createTempDirectory("vnidrop-cache-clear")
val source = Files.write(appData.resolve("source.bin"), ByteArray(64 * 1024) { 5 }) val source = Files.write(appData.resolve("source.bin"), ByteArray(64 * 1024) { 5 })
val repository = CoreRepository() val repository = CoreRepository(
coreFactory = CoreFactory { appDataDir, eventSink, _ ->
VnidropCore.initializeWithNetworkConfig(
appDataDir,
eventSink,
CoreNetworkConfig(CoreRelayMode.LOCAL_ONLY, emptyList()),
)
},
)
try { try {
assertTrue( assertTrue(
repository.initialize( repository.initialize(