mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-10 20:59:57 +02:00
feat(core): add recoverable secret custody
This commit is contained in:
@@ -28,6 +28,14 @@ pub enum VnidropError {
|
|||||||
InvalidInput { reason: String },
|
InvalidInput { reason: String },
|
||||||
#[error("invalid targeted transfer transition: {reason}")]
|
#[error("invalid targeted transfer transition: {reason}")]
|
||||||
InvalidTransition { reason: String },
|
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}")]
|
#[error("internal error: {reason}")]
|
||||||
Internal { reason: String },
|
Internal { reason: String },
|
||||||
}
|
}
|
||||||
@@ -96,6 +104,10 @@ impl VnidropError {
|
|||||||
Self::Cancelled { .. } => "cancelled",
|
Self::Cancelled { .. } => "cancelled",
|
||||||
Self::InvalidInput { .. } => "invalid_input",
|
Self::InvalidInput { .. } => "invalid_input",
|
||||||
Self::InvalidTransition { .. } => "invalid_transition",
|
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",
|
Self::Internal { .. } => "internal",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +127,10 @@ impl VnidropError {
|
|||||||
| Self::Cancelled { reason }
|
| Self::Cancelled { reason }
|
||||||
| Self::InvalidInput { reason }
|
| Self::InvalidInput { reason }
|
||||||
| Self::InvalidTransition { reason }
|
| Self::InvalidTransition { reason }
|
||||||
|
| Self::SecureStorageLocked { reason }
|
||||||
|
| Self::SecureStorageMissing { reason }
|
||||||
|
| Self::SecureStorageCorrupted { reason }
|
||||||
|
| Self::SecureStorageUnavailable { reason }
|
||||||
| Self::Internal { reason } => reason,
|
| Self::Internal { reason } => reason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +184,10 @@ impl VnidropError {
|
|||||||
Self::Cancelled { .. } => Self::Cancelled { reason },
|
Self::Cancelled { .. } => Self::Cancelled { reason },
|
||||||
Self::InvalidInput { .. } => Self::InvalidInput { reason },
|
Self::InvalidInput { .. } => Self::InvalidInput { reason },
|
||||||
Self::InvalidTransition { .. } => Self::InvalidTransition { 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 },
|
Self::Internal { .. } => Self::Internal { reason },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ mod pairing;
|
|||||||
mod repository;
|
mod repository;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
mod secret;
|
mod secret;
|
||||||
|
#[allow(
|
||||||
|
dead_code,
|
||||||
|
reason = "the private custody seam is activated by platform credential adapters"
|
||||||
|
)]
|
||||||
|
mod secure_secret;
|
||||||
mod targeted_transfer;
|
mod targeted_transfer;
|
||||||
mod ticket;
|
mod ticket;
|
||||||
mod transfer_state;
|
mod transfer_state;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use crate::{
|
|||||||
util::now_ms,
|
util::now_ms,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCHEMA_VERSION: i64 = 9;
|
const SCHEMA_VERSION: i64 = 10;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct Repository {
|
pub(crate) struct Repository {
|
||||||
@@ -317,6 +317,7 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
crate::contacts::ensure_schema(&self.pool).await?;
|
crate::contacts::ensure_schema(&self.pool).await?;
|
||||||
|
crate::secure_secret::ensure_schema(&self.pool).await?;
|
||||||
|
|
||||||
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
@@ -330,6 +331,14 @@ impl Repository {
|
|||||||
ContactStore::new(self.pool.clone())
|
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)]
|
#[cfg(test)]
|
||||||
pub(crate) async fn schema_version(&self) -> Result<i64> {
|
pub(crate) async fn schema_version(&self) -> Result<i64> {
|
||||||
let row = sqlx::query("PRAGMA user_version")
|
let row = sqlx::query("PRAGMA user_version")
|
||||||
|
|||||||
630
crates/vnidrop/src/secure_secret.rs
Normal file
630
crates/vnidrop/src/secure_secret.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ 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.rs"]
|
||||||
|
mod secure_secret_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"]
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
|
|||||||
async fn persists_transfers_and_events_across_reopen() {
|
async fn persists_transfers_and_events_across_reopen() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let repository = Repository::open(temp.path()).await.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
|
repository
|
||||||
.insert_transfer(transfer(
|
.insert_transfer(transfer(
|
||||||
7,
|
7,
|
||||||
@@ -645,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
|
|||||||
pool.close().await;
|
pool.close().await;
|
||||||
|
|
||||||
let repository = Repository::open(temp.path()).await.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);
|
||||||
let stored = repository.list_transfers().await.unwrap().remove(0);
|
let stored = repository.list_transfers().await.unwrap().remove(0);
|
||||||
assert_eq!(stored.transfer_id, 7);
|
assert_eq!(stored.transfer_id, 7);
|
||||||
assert_eq!(stored.local_id, "legacy-7-send");
|
assert_eq!(stored.local_id, "legacy-7-send");
|
||||||
|
|||||||
215
crates/vnidrop/src/tests/secure_secret.rs
Normal file
215
crates/vnidrop/src/tests/secure_secret.rs
Normal 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()));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user