mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-11 05:09:55 +02:00
test(core): harden secret custody recovery
This commit is contained in:
@@ -12,6 +12,7 @@ use crate::{error::VnidropError, util::now_ms};
|
|||||||
|
|
||||||
const SECRET_BYTES: usize = 32;
|
const SECRET_BYTES: usize = 32;
|
||||||
const HANDLE_NAMESPACE: &str = "vnidrop";
|
const HANDLE_NAMESPACE: &str = "vnidrop";
|
||||||
|
const HANDLE_VERSION: &str = "v1";
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct SecretMaterial(Vec<u8>);
|
pub(crate) struct SecretMaterial(Vec<u8>);
|
||||||
@@ -44,13 +45,13 @@ pub(crate) struct SecretHandle(String);
|
|||||||
impl SecretHandle {
|
impl SecretHandle {
|
||||||
fn generate(kind: SecretKind) -> Self {
|
fn generate(kind: SecretKind) -> Self {
|
||||||
Self(format!(
|
Self(format!(
|
||||||
"{HANDLE_NAMESPACE}/{}/{}",
|
"{HANDLE_NAMESPACE}/{HANDLE_VERSION}/{}/{}",
|
||||||
kind.as_str(),
|
kind.as_str(),
|
||||||
Uuid::new_v4()
|
Uuid::new_v4()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn as_str(&self) -> &str {
|
pub(crate) fn as_str(&self) -> &str {
|
||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,10 +420,7 @@ impl SecretCustody {
|
|||||||
|
|
||||||
for entry in metadata {
|
for entry in metadata {
|
||||||
if entry.state == SecretMetadataState::Disabled {
|
if entry.state == SecretMetadataState::Disabled {
|
||||||
match self.store.delete(&entry.handle) {
|
self.delete_if_present(&entry.handle)?;
|
||||||
Ok(()) | Err(SecureSecretStoreError::Missing) => {}
|
|
||||||
Err(error) => return Err(map_store_error(error)),
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match self.store.get(&entry.handle) {
|
match self.store.get(&entry.handle) {
|
||||||
@@ -431,10 +429,7 @@ impl SecretCustody {
|
|||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
self.metadata.disable(&entry.handle).await?;
|
self.metadata.disable(&entry.handle).await?;
|
||||||
match self.store.delete(&entry.handle) {
|
self.delete_if_present(&entry.handle)?;
|
||||||
Ok(()) | Err(SecureSecretStoreError::Missing) => {}
|
|
||||||
Err(error) => return Err(map_store_error(error)),
|
|
||||||
}
|
|
||||||
summary.disabled += 1;
|
summary.disabled += 1;
|
||||||
} else if entry.state == SecretMetadataState::Staged {
|
} else if entry.state == SecretMetadataState::Staged {
|
||||||
self.metadata.activate(&entry.handle).await?;
|
self.metadata.activate(&entry.handle).await?;
|
||||||
@@ -443,10 +438,7 @@ impl SecretCustody {
|
|||||||
}
|
}
|
||||||
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {
|
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {
|
||||||
self.metadata.disable(&entry.handle).await?;
|
self.metadata.disable(&entry.handle).await?;
|
||||||
match self.store.delete(&entry.handle) {
|
self.delete_if_present(&entry.handle)?;
|
||||||
Ok(()) | Err(SecureSecretStoreError::Missing) => {}
|
|
||||||
Err(error) => return Err(map_store_error(error)),
|
|
||||||
}
|
|
||||||
summary.disabled += 1;
|
summary.disabled += 1;
|
||||||
}
|
}
|
||||||
Err(error) => return Err(map_store_error(error)),
|
Err(error) => return Err(map_store_error(error)),
|
||||||
@@ -462,6 +454,13 @@ impl SecretCustody {
|
|||||||
Ok(summary)
|
Ok(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delete_if_present(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
|
||||||
|
match self.store.delete(handle) {
|
||||||
|
Ok(()) | Err(SecureSecretStoreError::Missing) => Ok(()),
|
||||||
|
Err(error) => Err(map_store_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn crash_once_at(&self, point: CustodyCrashPoint) {
|
pub(crate) fn crash_once_at(&self, point: CustodyCrashPoint) {
|
||||||
*self.crash_point.lock().unwrap() = Some(point);
|
*self.crash_point.lock().unwrap() = Some(point);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
io::{self, Write},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
use data_encoding::HEXLOWER;
|
use data_encoding::HEXLOWER;
|
||||||
use iroh::SecretKey;
|
use iroh::SecretKey;
|
||||||
@@ -12,6 +15,22 @@ use crate::{
|
|||||||
VnidropError,
|
VnidropError,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct CapturedOutput(Arc<Mutex<Vec<u8>>>);
|
||||||
|
|
||||||
|
struct CapturedWriter(CapturedOutput);
|
||||||
|
|
||||||
|
impl Write for CapturedWriter {
|
||||||
|
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||||
|
self.0 .0.lock().unwrap().extend_from_slice(bytes);
|
||||||
|
Ok(bytes.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn custody_maps_reference_store_failures_to_typed_core_errors() {
|
async fn custody_maps_reference_store_failures_to_typed_core_errors() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -59,7 +78,7 @@ async fn custody_maps_reference_store_failures_to_typed_core_errors() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets() {
|
async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let repository = Repository::open(temp.path()).await.unwrap();
|
let mut repository = Repository::open(temp.path()).await.unwrap();
|
||||||
let store = Arc::new(FaultInjectingSecretStore::default());
|
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||||
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||||
|
|
||||||
@@ -73,6 +92,8 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets()
|
|||||||
.await
|
.await
|
||||||
.is_err());
|
.is_err());
|
||||||
drop(custody);
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
repository = Repository::open(temp.path()).await.unwrap();
|
||||||
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -90,6 +111,8 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets()
|
|||||||
.is_err());
|
.is_err());
|
||||||
let staged_handle = store.only_handle_for_test();
|
let staged_handle = store.only_handle_for_test();
|
||||||
drop(custody);
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
repository = Repository::open(temp.path()).await.unwrap();
|
||||||
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -100,7 +123,12 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets()
|
|||||||
);
|
);
|
||||||
|
|
||||||
store.remove_for_test(&staged_handle);
|
store.remove_for_test(&staged_handle);
|
||||||
let summary = custody.reconcile().await.unwrap();
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(summary.disabled, 1);
|
assert_eq!(summary.disabled, 1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
custody.load(&staged_handle).await,
|
custody.load(&staged_handle).await,
|
||||||
@@ -116,7 +144,12 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets()
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store.corrupt_for_test(&corrupted);
|
store.corrupt_for_test(&corrupted);
|
||||||
let summary = custody.reconcile().await.unwrap();
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(summary.disabled, 1);
|
assert_eq!(summary.disabled, 1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
custody.load(&corrupted).await,
|
custody.load(&corrupted).await,
|
||||||
@@ -144,7 +177,13 @@ async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacem
|
|||||||
"legacy key must survive before activation"
|
"legacy key must survive before activation"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(custody.reconcile().await.unwrap().staged_activated, 0);
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(summary.staged_activated, 0);
|
||||||
let handle = custody
|
let handle = custody
|
||||||
.migrate_legacy_endpoint_identity(&legacy_path)
|
.migrate_legacy_endpoint_identity(&legacy_path)
|
||||||
.await
|
.await
|
||||||
@@ -196,11 +235,27 @@ async fn protected_material_is_absent_from_database_and_diagnostics() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(handle
|
||||||
|
.as_str()
|
||||||
|
.starts_with("vnidrop/v1/relationship-grant/"));
|
||||||
assert_eq!(format!("{material:?}"), "SecretMaterial(redacted)");
|
assert_eq!(format!("{material:?}"), "SecretMaterial(redacted)");
|
||||||
store.corrupt_for_test(&handle);
|
store.corrupt_for_test(&handle);
|
||||||
let error = custody.load(&handle).await.unwrap_err().to_string();
|
let error = custody.load(&handle).await.unwrap_err().to_string();
|
||||||
assert!(!error.contains(&encoded));
|
assert!(!error.contains(&encoded));
|
||||||
|
|
||||||
|
let captured = CapturedOutput::default();
|
||||||
|
let writer_output = captured.clone();
|
||||||
|
let subscriber = tracing_subscriber::fmt()
|
||||||
|
.without_time()
|
||||||
|
.with_writer(move || CapturedWriter(writer_output.clone()))
|
||||||
|
.finish();
|
||||||
|
let _subscriber = tracing::subscriber::set_default(subscriber);
|
||||||
|
tracing::info!(material = ?material, error, "custody diagnostic");
|
||||||
|
let diagnostics = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap();
|
||||||
|
assert!(!diagnostics.contains(&encoded));
|
||||||
|
|
||||||
|
assert!(repository.list_events(None, 500).await.unwrap().is_empty());
|
||||||
|
|
||||||
let mut persisted = Vec::new();
|
let mut persisted = Vec::new();
|
||||||
for entry in std::fs::read_dir(temp.path()).unwrap() {
|
for entry in std::fs::read_dir(temp.path()).unwrap() {
|
||||||
let path = entry.unwrap().path();
|
let path = entry.unwrap().path();
|
||||||
|
|||||||
Reference in New Issue
Block a user