mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-11 05:09:55 +02:00
fix(core): harden protected identity startup
This commit is contained in:
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
138
crates/vnidrop/src/secure_secret/platform.rs
Normal file
138
crates/vnidrop/src/secure_secret/platform.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
180
crates/vnidrop/src/tests/secure_secret_android.rs
Normal file
180
crates/vnidrop/src/tests/secure_secret_android.rs
Normal 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);
|
||||||
|
}
|
||||||
188
crates/vnidrop/src/tests/secure_secret_apple.rs
Normal file
188
crates/vnidrop/src/tests/secure_secret_apple.rs
Normal 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)));
|
||||||
|
}
|
||||||
169
crates/vnidrop/src/tests/secure_secret_linux.rs
Normal file
169
crates/vnidrop/src/tests/secure_secret_linux.rs
Normal 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
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user