feat(core): add recoverable secret custody

This commit is contained in:
2026-08-09 18:37:54 +02:00
parent b128457137
commit 1cd09a2ec3
7 changed files with 884 additions and 3 deletions

View File

@@ -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 },
}
}

View File

@@ -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;

View File

@@ -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<i64> {
let row = sqlx::query("PRAGMA user_version")

View File

@@ -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<u8>);
impl SecretMaterial {
pub(crate) fn new(bytes: Vec<u8>) -> Result<Self, VnidropError> {
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<Self, VnidropError> {
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<SecretMaterial, SecureSecretStoreError>;
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError>;
fn list_handles(&self) -> Result<Vec<SecretHandle>, 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<Self, VnidropError> {
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<String>,
}
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<Option<SecretMetadata>, 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<Vec<SecretMetadata>, 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<Option<SecretMetadata>, 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<SecretMetadata, VnidropError> {
Ok(SecretMetadata {
handle: SecretHandle(row.get(0)),
kind: SecretKind::parse(row.get::<String, _>(1).as_str())?,
state: SecretMetadataState::parse(row.get::<String, _>(2).as_str())?,
expected_identity: row.get(3),
})
}
pub(crate) struct SecretCustody {
metadata: SecretMetadataStore,
store: Arc<dyn SecureSecretStore>,
#[cfg(test)]
crash_point: Mutex<Option<CustodyCrashPoint>>,
}
impl SecretCustody {
pub(crate) async fn start(
metadata: SecretMetadataStore,
store: Arc<dyn SecureSecretStore>,
) -> 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<dyn SecureSecretStore>) -> Self {
Self::from_parts(metadata, store)
}
fn from_parts(metadata: SecretMetadataStore, store: Arc<dyn SecureSecretStore>) -> 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<SecretHandle, VnidropError> {
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<SecretMaterial, VnidropError> {
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<SecretHandle, VnidropError> {
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<ReconciliationSummary, VnidropError> {
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::<HashSet<_>>();
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<SecretMaterial, VnidropError> {
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<HashMap<SecretHandle, SecretMaterial>>,
failure: Mutex<Option<ReferenceStoreFailure>>,
corrupted: Mutex<Vec<SecretHandle>>,
}
#[cfg(test)]
impl FaultInjectingSecretStore {
pub(crate) fn fail_with(&self, failure: Option<ReferenceStoreFailure>) {
*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::<Vec<_>>();
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<SecretMaterial, SecureSecretStoreError> {
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<Vec<SecretHandle>, SecureSecretStoreError> {
self.check_available()?;
Ok(self.values.lock().unwrap().keys().cloned().collect())
}
}

View File

@@ -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"]

View File

@@ -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");

View File

@@ -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::<Vec<_>>();
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()));
}