From 1cd09a2ec325595b4f5adfa992f295dc3123312f Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sun, 9 Aug 2026 18:37:54 +0200 Subject: [PATCH] feat(core): add recoverable secret custody --- crates/vnidrop/src/error.rs | 20 + crates/vnidrop/src/lib.rs | 5 + crates/vnidrop/src/repository.rs | 11 +- crates/vnidrop/src/secure_secret.rs | 630 ++++++++++++++++++++++ crates/vnidrop/src/tests.rs | 2 + crates/vnidrop/src/tests/repository.rs | 4 +- crates/vnidrop/src/tests/secure_secret.rs | 215 ++++++++ 7 files changed, 884 insertions(+), 3 deletions(-) create mode 100644 crates/vnidrop/src/secure_secret.rs create mode 100644 crates/vnidrop/src/tests/secure_secret.rs diff --git a/crates/vnidrop/src/error.rs b/crates/vnidrop/src/error.rs index a7669fd..05d4181 100644 --- a/crates/vnidrop/src/error.rs +++ b/crates/vnidrop/src/error.rs @@ -28,6 +28,14 @@ pub enum VnidropError { InvalidInput { reason: String }, #[error("invalid targeted transfer transition: {reason}")] InvalidTransition { reason: String }, + #[error("secure storage is locked: {reason}")] + SecureStorageLocked { reason: String }, + #[error("secure storage item is missing: {reason}")] + SecureStorageMissing { reason: String }, + #[error("secure storage item is corrupted: {reason}")] + SecureStorageCorrupted { reason: String }, + #[error("secure storage is unavailable: {reason}")] + SecureStorageUnavailable { reason: String }, #[error("internal error: {reason}")] Internal { reason: String }, } @@ -96,6 +104,10 @@ impl VnidropError { Self::Cancelled { .. } => "cancelled", Self::InvalidInput { .. } => "invalid_input", Self::InvalidTransition { .. } => "invalid_transition", + Self::SecureStorageLocked { .. } => "secure_storage_locked", + Self::SecureStorageMissing { .. } => "secure_storage_missing", + Self::SecureStorageCorrupted { .. } => "secure_storage_corrupted", + Self::SecureStorageUnavailable { .. } => "secure_storage_unavailable", Self::Internal { .. } => "internal", } } @@ -115,6 +127,10 @@ impl VnidropError { | Self::Cancelled { reason } | Self::InvalidInput { reason } | Self::InvalidTransition { reason } + | Self::SecureStorageLocked { reason } + | Self::SecureStorageMissing { reason } + | Self::SecureStorageCorrupted { reason } + | Self::SecureStorageUnavailable { reason } | Self::Internal { reason } => reason, } } @@ -168,6 +184,10 @@ impl VnidropError { Self::Cancelled { .. } => Self::Cancelled { reason }, Self::InvalidInput { .. } => Self::InvalidInput { reason }, Self::InvalidTransition { .. } => Self::InvalidTransition { reason }, + Self::SecureStorageLocked { .. } => Self::SecureStorageLocked { reason }, + Self::SecureStorageMissing { .. } => Self::SecureStorageMissing { reason }, + Self::SecureStorageCorrupted { .. } => Self::SecureStorageCorrupted { reason }, + Self::SecureStorageUnavailable { .. } => Self::SecureStorageUnavailable { reason }, Self::Internal { .. } => Self::Internal { reason }, } } diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 6c54eff..23da0f1 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -14,6 +14,11 @@ mod pairing; mod repository; mod runtime; mod secret; +#[allow( + dead_code, + reason = "the private custody seam is activated by platform credential adapters" +)] +mod secure_secret; mod targeted_transfer; mod ticket; mod transfer_state; diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index e42e48e..b2e5a08 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -21,7 +21,7 @@ use crate::{ util::now_ms, }; -const SCHEMA_VERSION: i64 = 9; +const SCHEMA_VERSION: i64 = 10; #[derive(Debug, Clone)] pub(crate) struct Repository { @@ -317,6 +317,7 @@ impl Repository { } crate::contacts::ensure_schema(&self.pool).await?; + crate::secure_secret::ensure_schema(&self.pool).await?; sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}")) .execute(&self.pool) @@ -330,6 +331,14 @@ impl Repository { ContactStore::new(self.pool.clone()) } + #[allow( + dead_code, + reason = "the private custody seam is activated by platform credential adapters" + )] + pub(crate) fn protected_secrets(&self) -> crate::secure_secret::SecretMetadataStore { + crate::secure_secret::SecretMetadataStore::new(self.pool.clone()) + } + #[cfg(test)] pub(crate) async fn schema_version(&self) -> Result { let row = sqlx::query("PRAGMA user_version") diff --git a/crates/vnidrop/src/secure_secret.rs b/crates/vnidrop/src/secure_secret.rs new file mode 100644 index 0000000..f100d73 --- /dev/null +++ b/crates/vnidrop/src/secure_secret.rs @@ -0,0 +1,630 @@ +use std::{collections::HashSet, fmt, io, path::Path, sync::Arc}; + +#[cfg(test)] +use std::{collections::HashMap, sync::Mutex}; + +use data_encoding::HEXLOWER; +use iroh::SecretKey; +use sqlx::{Row, SqlitePool}; +use uuid::Uuid; + +use crate::{error::VnidropError, util::now_ms}; + +const SECRET_BYTES: usize = 32; +const HANDLE_NAMESPACE: &str = "vnidrop"; + +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct SecretMaterial(Vec); + +impl SecretMaterial { + pub(crate) fn new(bytes: Vec) -> Result { + if bytes.len() != SECRET_BYTES || bytes.iter().all(|byte| *byte == 0) { + return Err(VnidropError::SecureStorageCorrupted { + reason: "protected secret has invalid key material".to_string(), + }); + } + Ok(Self(bytes)) + } + + fn endpoint_id(&self) -> String { + let bytes: [u8; SECRET_BYTES] = self.0.as_slice().try_into().expect("validated length"); + SecretKey::from_bytes(&bytes).public().to_string() + } +} + +impl fmt::Debug for SecretMaterial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretMaterial(redacted)") + } +} + +#[derive(Clone, PartialEq, Eq, Hash)] +pub(crate) struct SecretHandle(String); + +impl SecretHandle { + fn generate(kind: SecretKind) -> Self { + Self(format!( + "{HANDLE_NAMESPACE}/{}/{}", + kind.as_str(), + Uuid::new_v4() + )) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("SecretHandle") + .field(&self.0) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SecretKind { + EndpointIdentity, + RelationshipGrant, + PairingEligibility, +} + +impl SecretKind { + fn as_str(self) -> &'static str { + match self { + Self::EndpointIdentity => "endpoint-identity", + Self::RelationshipGrant => "relationship-grant", + Self::PairingEligibility => "pairing-eligibility", + } + } + + fn parse(value: &str) -> Result { + match value { + "endpoint-identity" => Ok(Self::EndpointIdentity), + "relationship-grant" => Ok(Self::RelationshipGrant), + "pairing-eligibility" => Ok(Self::PairingEligibility), + _ => Err(VnidropError::SecureStorageCorrupted { + reason: "protected secret has an unknown kind".to_string(), + }), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum SecureSecretStoreError { + #[error("credential store is locked")] + Locked, + #[error("credential is missing")] + Missing, + #[error("credential is corrupted")] + Corrupted, + #[error("credential store is unavailable")] + Unavailable, +} + +/// Opaque credential-store boundary implemented by each supported platform. +/// +/// Implementations must persist material outside ordinary application storage and +/// must never include material in errors or diagnostics. +pub(crate) trait SecureSecretStore: Send + Sync { + fn put( + &self, + handle: &SecretHandle, + material: SecretMaterial, + ) -> Result<(), SecureSecretStoreError>; + fn get(&self, handle: &SecretHandle) -> Result; + fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError>; + fn list_handles(&self) -> Result, SecureSecretStoreError>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SecretMetadataState { + Staged, + Active, + Disabled, +} + +impl SecretMetadataState { + fn as_str(self) -> &'static str { + match self { + Self::Staged => "staged", + Self::Active => "active", + Self::Disabled => "disabled", + } + } + + fn parse(value: &str) -> Result { + match value { + "staged" => Ok(Self::Staged), + "active" => Ok(Self::Active), + "disabled" => Ok(Self::Disabled), + _ => Err(VnidropError::SecureStorageCorrupted { + reason: "protected secret has an unknown metadata state".to_string(), + }), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SecretMetadata { + handle: SecretHandle, + kind: SecretKind, + state: SecretMetadataState, + expected_identity: Option, +} + +pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS protected_secret_refs ( + handle TEXT PRIMARY KEY, + kind TEXT NOT NULL, + state TEXT NOT NULL, + expected_identity TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + "#, + ) + .execute(pool) + .await?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub(crate) struct SecretMetadataStore { + pool: SqlitePool, +} + +impl SecretMetadataStore { + pub(crate) fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + async fn stage( + &self, + handle: &SecretHandle, + kind: SecretKind, + expected_identity: Option<&str>, + ) -> Result<(), VnidropError> { + let now = now_ms(); + sqlx::query( + r#" + INSERT INTO protected_secret_refs + (handle, kind, state, expected_identity, created_at, updated_at) + VALUES (?1, ?2, 'staged', ?3, ?4, ?4) + "#, + ) + .bind(handle.as_str()) + .bind(kind.as_str()) + .bind(expected_identity) + .bind(now) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + async fn activate(&self, handle: &SecretHandle) -> Result<(), VnidropError> { + self.set_state(handle, SecretMetadataState::Active).await + } + + async fn disable(&self, handle: &SecretHandle) -> Result<(), VnidropError> { + self.set_state(handle, SecretMetadataState::Disabled).await + } + + async fn set_state( + &self, + handle: &SecretHandle, + state: SecretMetadataState, + ) -> Result<(), VnidropError> { + sqlx::query( + "UPDATE protected_secret_refs SET state = ?2, updated_at = ?3 WHERE handle = ?1", + ) + .bind(handle.as_str()) + .bind(state.as_str()) + .bind(now_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + async fn find(&self, handle: &SecretHandle) -> Result, VnidropError> { + let row = sqlx::query( + "SELECT handle, kind, state, expected_identity FROM protected_secret_refs WHERE handle = ?1", + ) + .bind(handle.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(VnidropError::repository)?; + row.map(row_to_metadata).transpose() + } + + async fn list(&self) -> Result, VnidropError> { + let rows = sqlx::query( + "SELECT handle, kind, state, expected_identity FROM protected_secret_refs ORDER BY handle", + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + rows.into_iter().map(row_to_metadata).collect() + } + + async fn find_active_kind( + &self, + kind: SecretKind, + ) -> Result, VnidropError> { + let row = sqlx::query( + r#" + SELECT handle, kind, state, expected_identity + FROM protected_secret_refs + WHERE kind = ?1 AND state = 'active' + ORDER BY created_at ASC + LIMIT 1 + "#, + ) + .bind(kind.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(VnidropError::repository)?; + row.map(row_to_metadata).transpose() + } +} + +fn row_to_metadata(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(SecretMetadata { + handle: SecretHandle(row.get(0)), + kind: SecretKind::parse(row.get::(1).as_str())?, + state: SecretMetadataState::parse(row.get::(2).as_str())?, + expected_identity: row.get(3), + }) +} + +pub(crate) struct SecretCustody { + metadata: SecretMetadataStore, + store: Arc, + #[cfg(test)] + crash_point: Mutex>, +} + +impl SecretCustody { + pub(crate) async fn start( + metadata: SecretMetadataStore, + store: Arc, + ) -> Result<(Self, ReconciliationSummary), VnidropError> { + let custody = Self::from_parts(metadata, store); + let summary = custody.reconcile().await?; + Ok((custody, summary)) + } + + #[cfg(test)] + pub(crate) fn new(metadata: SecretMetadataStore, store: Arc) -> Self { + Self::from_parts(metadata, store) + } + + fn from_parts(metadata: SecretMetadataStore, store: Arc) -> Self { + Self { + metadata, + store, + #[cfg(test)] + crash_point: Mutex::new(None), + } + } + + pub(crate) async fn protect( + &self, + kind: SecretKind, + material: SecretMaterial, + expected_identity: Option<&str>, + ) -> Result { + validate_material(kind, &material, expected_identity)?; + let handle = SecretHandle::generate(kind); + self.store + .put(&handle, material.clone()) + .map_err(map_store_error)?; + #[cfg(test)] + self.maybe_crash(CustodyCrashPoint::StoreWrite)?; + let stored = self.store.get(&handle).map_err(map_store_error)?; + if stored != material { + return Err(VnidropError::SecureStorageCorrupted { + reason: "credential store did not preserve protected material".to_string(), + }); + } + validate_material(kind, &stored, expected_identity)?; + self.metadata + .stage(&handle, kind, expected_identity) + .await?; + #[cfg(test)] + self.maybe_crash(CustodyCrashPoint::MetadataStage)?; + self.metadata.activate(&handle).await?; + #[cfg(test)] + self.maybe_crash(CustodyCrashPoint::MetadataActivation)?; + Ok(handle) + } + + pub(crate) async fn load(&self, handle: &SecretHandle) -> Result { + let metadata = self.metadata.find(handle).await?.ok_or_else(|| { + VnidropError::SecureStorageMissing { + reason: "protected secret metadata is missing".to_string(), + } + })?; + if metadata.state != SecretMetadataState::Active { + return Err(VnidropError::SecureStorageUnavailable { + reason: "protected secret is not active".to_string(), + }); + } + let material = self.store.get(handle).map_err(map_store_error)?; + validate_material( + metadata.kind, + &material, + metadata.expected_identity.as_deref(), + )?; + Ok(material) + } + + pub(crate) async fn migrate_legacy_endpoint_identity( + &self, + legacy_path: &Path, + ) -> Result { + if let Some(active) = self + .metadata + .find_active_kind(SecretKind::EndpointIdentity) + .await? + { + let protected = self.load(&active.handle).await?; + match read_legacy_endpoint_identity(legacy_path).await { + Ok(legacy) => { + if legacy.endpoint_id() != protected.endpoint_id() { + return Err(VnidropError::SecureStorageCorrupted { + reason: "legacy endpoint key does not match protected identity" + .to_string(), + }); + } + tokio::fs::remove_file(legacy_path) + .await + .map_err(VnidropError::filesystem)?; + } + Err(VnidropError::SecureStorageMissing { .. }) => {} + Err(error) => return Err(error), + } + return Ok(active.handle); + } + + let legacy = read_legacy_endpoint_identity(legacy_path).await?; + let endpoint_id = legacy.endpoint_id(); + let handle = self + .protect( + SecretKind::EndpointIdentity, + legacy, + Some(endpoint_id.as_str()), + ) + .await?; + tokio::fs::remove_file(legacy_path) + .await + .map_err(VnidropError::filesystem)?; + Ok(handle) + } + + pub(crate) async fn reconcile(&self) -> Result { + let metadata = self.metadata.list().await?; + let stored_handles = self.store.list_handles().map_err(map_store_error)?; + let known_handles = metadata + .iter() + .map(|entry| entry.handle.clone()) + .collect::>(); + let mut summary = ReconciliationSummary::default(); + + for entry in metadata { + if entry.state == SecretMetadataState::Disabled { + match self.store.delete(&entry.handle) { + Ok(()) | Err(SecureSecretStoreError::Missing) => {} + Err(error) => return Err(map_store_error(error)), + } + continue; + } + match self.store.get(&entry.handle) { + Ok(material) => { + if validate_material(entry.kind, &material, entry.expected_identity.as_deref()) + .is_err() + { + self.metadata.disable(&entry.handle).await?; + match self.store.delete(&entry.handle) { + Ok(()) | Err(SecureSecretStoreError::Missing) => {} + Err(error) => return Err(map_store_error(error)), + } + summary.disabled += 1; + } else if entry.state == SecretMetadataState::Staged { + self.metadata.activate(&entry.handle).await?; + summary.staged_activated += 1; + } + } + Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => { + self.metadata.disable(&entry.handle).await?; + match self.store.delete(&entry.handle) { + Ok(()) | Err(SecureSecretStoreError::Missing) => {} + Err(error) => return Err(map_store_error(error)), + } + summary.disabled += 1; + } + Err(error) => return Err(map_store_error(error)), + } + } + + for handle in stored_handles { + if !known_handles.contains(&handle) { + self.store.delete(&handle).map_err(map_store_error)?; + summary.orphans_deleted += 1; + } + } + Ok(summary) + } + + #[cfg(test)] + pub(crate) fn crash_once_at(&self, point: CustodyCrashPoint) { + *self.crash_point.lock().unwrap() = Some(point); + } + + #[cfg(test)] + fn maybe_crash(&self, point: CustodyCrashPoint) -> Result<(), VnidropError> { + let mut crash_point = self.crash_point.lock().unwrap(); + if *crash_point == Some(point) { + *crash_point = None; + return Err(VnidropError::Internal { + reason: format!("simulated custody crash at {point:?}"), + }); + } + Ok(()) + } +} + +async fn read_legacy_endpoint_identity(path: &Path) -> Result { + let encoded = match tokio::fs::read_to_string(path).await { + Ok(encoded) => encoded, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(VnidropError::SecureStorageMissing { + reason: "no protected or legacy endpoint identity exists".to_string(), + }); + } + Err(error) => return Err(VnidropError::filesystem(error)), + }; + let bytes = HEXLOWER.decode(encoded.trim().as_bytes()).map_err(|_| { + VnidropError::SecureStorageCorrupted { + reason: "legacy endpoint key encoding is invalid".to_string(), + } + })?; + SecretMaterial::new(bytes) +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct ReconciliationSummary { + pub(crate) orphans_deleted: u64, + pub(crate) staged_activated: u64, + pub(crate) disabled: u64, +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CustodyCrashPoint { + StoreWrite, + MetadataStage, + MetadataActivation, +} + +fn validate_material( + kind: SecretKind, + material: &SecretMaterial, + expected_identity: Option<&str>, +) -> Result<(), VnidropError> { + if kind == SecretKind::EndpointIdentity { + let expected_identity = + expected_identity.ok_or_else(|| VnidropError::SecureStorageCorrupted { + reason: "endpoint identity metadata lacks its expected endpoint id".to_string(), + })?; + if material.endpoint_id() != expected_identity { + return Err(VnidropError::SecureStorageCorrupted { + reason: "protected endpoint identity does not match its endpoint id".to_string(), + }); + } + } + Ok(()) +} + +fn map_store_error(error: SecureSecretStoreError) -> VnidropError { + let reason = error.to_string(); + match error { + SecureSecretStoreError::Locked => VnidropError::SecureStorageLocked { reason }, + SecureSecretStoreError::Missing => VnidropError::SecureStorageMissing { reason }, + SecureSecretStoreError::Corrupted => VnidropError::SecureStorageCorrupted { reason }, + SecureSecretStoreError::Unavailable => VnidropError::SecureStorageUnavailable { reason }, + } +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReferenceStoreFailure { + Locked, + Unavailable, +} + +#[cfg(test)] +#[derive(Default)] +pub(crate) struct FaultInjectingSecretStore { + values: Mutex>, + failure: Mutex>, + corrupted: Mutex>, +} + +#[cfg(test)] +impl FaultInjectingSecretStore { + pub(crate) fn fail_with(&self, failure: Option) { + *self.failure.lock().unwrap() = failure; + } + + pub(crate) fn remove_for_test(&self, handle: &SecretHandle) { + self.values.lock().unwrap().remove(handle); + } + + pub(crate) fn corrupt_for_test(&self, handle: &SecretHandle) { + self.corrupted.lock().unwrap().push(handle.clone()); + } + + pub(crate) fn only_handle_for_test(&self) -> SecretHandle { + let handles = self + .values + .lock() + .unwrap() + .keys() + .cloned() + .collect::>(); + assert_eq!(handles.len(), 1, "expected exactly one protected secret"); + handles.into_iter().next().unwrap() + } + + fn check_available(&self) -> Result<(), SecureSecretStoreError> { + match *self.failure.lock().unwrap() { + Some(ReferenceStoreFailure::Locked) => Err(SecureSecretStoreError::Locked), + Some(ReferenceStoreFailure::Unavailable) => Err(SecureSecretStoreError::Unavailable), + None => Ok(()), + } + } +} + +#[cfg(test)] +impl SecureSecretStore for FaultInjectingSecretStore { + fn put( + &self, + handle: &SecretHandle, + material: SecretMaterial, + ) -> Result<(), SecureSecretStoreError> { + self.check_available()?; + self.values.lock().unwrap().insert(handle.clone(), material); + Ok(()) + } + + fn get(&self, handle: &SecretHandle) -> Result { + self.check_available()?; + if self.corrupted.lock().unwrap().contains(handle) { + return Err(SecureSecretStoreError::Corrupted); + } + self.values + .lock() + .unwrap() + .get(handle) + .cloned() + .ok_or(SecureSecretStoreError::Missing) + } + + fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> { + self.check_available()?; + self.values.lock().unwrap().remove(handle); + Ok(()) + } + + fn list_handles(&self) -> Result, SecureSecretStoreError> { + self.check_available()?; + Ok(self.values.lock().unwrap().keys().cloned().collect()) + } +} diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 289570d..7cc04b3 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -22,6 +22,8 @@ mod repository_tests; mod runtime_tests; #[path = "tests/secret.rs"] mod secret_tests; +#[path = "tests/secure_secret.rs"] +mod secure_secret_tests; #[path = "tests/ticket.rs"] mod ticket_tests; #[path = "tests/transfer_state.rs"] diff --git a/crates/vnidrop/src/tests/repository.rs b/crates/vnidrop/src/tests/repository.rs index d02cfb6..2a7e2d1 100644 --- a/crates/vnidrop/src/tests/repository.rs +++ b/crates/vnidrop/src/tests/repository.rs @@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() { async fn persists_transfers_and_events_across_reopen() { let temp = tempfile::tempdir().unwrap(); let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 9); + assert_eq!(repository.schema_version().await.unwrap(), 10); repository .insert_transfer(transfer( 7, @@ -645,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() { pool.close().await; let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 9); + assert_eq!(repository.schema_version().await.unwrap(), 10); let stored = repository.list_transfers().await.unwrap().remove(0); assert_eq!(stored.transfer_id, 7); assert_eq!(stored.local_id, "legacy-7-send"); diff --git a/crates/vnidrop/src/tests/secure_secret.rs b/crates/vnidrop/src/tests/secure_secret.rs new file mode 100644 index 0000000..51b9eec --- /dev/null +++ b/crates/vnidrop/src/tests/secure_secret.rs @@ -0,0 +1,215 @@ +use std::sync::Arc; + +use data_encoding::HEXLOWER; +use iroh::SecretKey; + +use crate::{ + repository::Repository, + secure_secret::{ + CustodyCrashPoint, FaultInjectingSecretStore, ReferenceStoreFailure, SecretCustody, + SecretKind, SecretMaterial, + }, + VnidropError, +}; + +#[tokio::test] +async fn custody_maps_reference_store_failures_to_typed_core_errors() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + let store = Arc::new(FaultInjectingSecretStore::default()); + let custody = SecretCustody::new(repository.protected_secrets(), store.clone()); + let secret = SecretMaterial::new(vec![0x5a; 32]).unwrap(); + let handle = custody + .protect(SecretKind::RelationshipGrant, secret.clone(), None) + .await + .unwrap(); + + assert_eq!(custody.load(&handle).await.unwrap(), secret); + + store.fail_with(Some(ReferenceStoreFailure::Locked)); + assert!(matches!( + custody.load(&handle).await, + Err(VnidropError::SecureStorageLocked { .. }) + )); + + store.fail_with(Some(ReferenceStoreFailure::Unavailable)); + assert!(matches!( + custody.load(&handle).await, + Err(VnidropError::SecureStorageUnavailable { .. }) + )); + + store.fail_with(None); + store.remove_for_test(&handle); + assert!(matches!( + custody.load(&handle).await, + Err(VnidropError::SecureStorageMissing { .. }) + )); + + let corrupted_handle = custody + .protect(SecretKind::RelationshipGrant, secret, None) + .await + .unwrap(); + store.corrupt_for_test(&corrupted_handle); + assert!(matches!( + custody.load(&corrupted_handle).await, + Err(VnidropError::SecureStorageCorrupted { .. }) + )); +} + +#[tokio::test] +async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + let store = Arc::new(FaultInjectingSecretStore::default()); + let custody = SecretCustody::new(repository.protected_secrets(), store.clone()); + + custody.crash_once_at(CustodyCrashPoint::StoreWrite); + assert!(custody + .protect( + SecretKind::PairingEligibility, + SecretMaterial::new(vec![0x11; 32]).unwrap(), + None, + ) + .await + .is_err()); + drop(custody); + let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone()) + .await + .unwrap(); + assert_eq!(summary.orphans_deleted, 1); + assert_eq!(summary.staged_activated, 0); + + custody.crash_once_at(CustodyCrashPoint::MetadataStage); + assert!(custody + .protect( + SecretKind::PairingEligibility, + SecretMaterial::new(vec![0x22; 32]).unwrap(), + None, + ) + .await + .is_err()); + let staged_handle = store.only_handle_for_test(); + drop(custody); + let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone()) + .await + .unwrap(); + assert_eq!(summary.staged_activated, 1); + assert_eq!( + custody.load(&staged_handle).await.unwrap(), + SecretMaterial::new(vec![0x22; 32]).unwrap() + ); + + store.remove_for_test(&staged_handle); + let summary = custody.reconcile().await.unwrap(); + assert_eq!(summary.disabled, 1); + assert!(matches!( + custody.load(&staged_handle).await, + Err(VnidropError::SecureStorageUnavailable { .. }) + )); + + let corrupted = custody + .protect( + SecretKind::RelationshipGrant, + SecretMaterial::new(vec![0x33; 32]).unwrap(), + None, + ) + .await + .unwrap(); + store.corrupt_for_test(&corrupted); + let summary = custody.reconcile().await.unwrap(); + assert_eq!(summary.disabled, 1); + assert!(matches!( + custody.load(&corrupted).await, + Err(VnidropError::SecureStorageUnavailable { .. }) + )); +} + +#[tokio::test] +async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacement() { + let temp = tempfile::tempdir().unwrap(); + let legacy_path = temp.path().join("iroh.secret"); + let original = SecretKey::generate(); + std::fs::write(&legacy_path, HEXLOWER.encode(&original.to_bytes())).unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + let store = Arc::new(FaultInjectingSecretStore::default()); + let custody = SecretCustody::new(repository.protected_secrets(), store.clone()); + + custody.crash_once_at(CustodyCrashPoint::MetadataActivation); + assert!(custody + .migrate_legacy_endpoint_identity(&legacy_path) + .await + .is_err()); + assert!( + legacy_path.exists(), + "legacy key must survive before activation" + ); + + assert_eq!(custody.reconcile().await.unwrap().staged_activated, 0); + let handle = custody + .migrate_legacy_endpoint_identity(&legacy_path) + .await + .unwrap(); + assert!(!legacy_path.exists()); + assert_eq!( + custody.load(&handle).await.unwrap(), + SecretMaterial::new(original.to_bytes().to_vec()).unwrap() + ); + + let replacement = SecretKey::generate(); + std::fs::write(&legacy_path, HEXLOWER.encode(&replacement.to_bytes())).unwrap(); + assert!(matches!( + custody.migrate_legacy_endpoint_identity(&legacy_path).await, + Err(VnidropError::SecureStorageCorrupted { .. }) + )); + assert!(legacy_path.exists()); + assert_eq!( + custody.load(&handle).await.unwrap(), + SecretMaterial::new(original.to_bytes().to_vec()).unwrap() + ); + + let missing = temp.path().join("missing.secret"); + let empty_store = Arc::new(FaultInjectingSecretStore::default()); + let other_dir = temp.path().join("other"); + std::fs::create_dir(&other_dir).unwrap(); + let other_repository = Repository::open(&other_dir).await.unwrap(); + let empty_custody = SecretCustody::new(other_repository.protected_secrets(), empty_store); + assert!(matches!( + empty_custody + .migrate_legacy_endpoint_identity(&missing) + .await, + Err(VnidropError::SecureStorageMissing { .. }) + )); +} + +#[tokio::test] +async fn protected_material_is_absent_from_database_and_diagnostics() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + let store = Arc::new(FaultInjectingSecretStore::default()); + let custody = SecretCustody::new(repository.protected_secrets(), store.clone()); + let raw = (0u8..32).map(|value| value + 1).collect::>(); + let encoded = HEXLOWER.encode(&raw); + let material = SecretMaterial::new(raw.clone()).unwrap(); + + let handle = custody + .protect(SecretKind::RelationshipGrant, material.clone(), None) + .await + .unwrap(); + + assert_eq!(format!("{material:?}"), "SecretMaterial(redacted)"); + store.corrupt_for_test(&handle); + let error = custody.load(&handle).await.unwrap_err().to_string(); + assert!(!error.contains(&encoded)); + + let mut persisted = Vec::new(); + for entry in std::fs::read_dir(temp.path()).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + persisted.extend(std::fs::read(path).unwrap()); + } + } + assert!(!persisted.windows(raw.len()).any(|window| window == raw)); + assert!(!persisted + .windows(encoded.len()) + .any(|window| window == encoded.as_bytes())); +}