mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-11 05:09:55 +02:00
feat(core): add recoverable secret custody
This commit is contained in:
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
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;
|
||||
#[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"]
|
||||
|
||||
@@ -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");
|
||||
|
||||
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