app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit a20e552683916395fa5f3014320374f57fa31bdd
parent db185935de8735e909c1e3180a759762db46df63
Author: triesap <tyson@radroots.org>
Date:   Fri,  2 Oct 2026 11:16:26 +0000

identity: bind completed import replay to original custody

- Match original request kind, identity, revision, and canonical input before replay success.
- Verify request-bound credentials through the existing bounded keyring worker without mutation.
- Generate the current storage API and preserve schema, lock, ABI, and create-only behavior.
- Retain eighteen replay regressions and eleven interface proofs with green checks and independent review.

Diffstat:
Mapp/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/compatibility/harvestcircle-storage-api-v2.txt | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/harvestcircle_application/src/identities.rs | 540++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcore/crates/harvestcircle_application/src/secrets.rs | 318+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcore/crates/harvestcircle_ffi/src/keyring_worker.rs | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/harvestcircle_runtime/tests/durable_import_replay.rs | 738+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/harvestcircle_storage/src/os_keyring.rs | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcore/crates/harvestcircle_storage/tests/package_boundary.rs | 46+++++++++++++++++++++++++++++++++++++++++++++-
Mtools/verify-storage-api.sh | 2+-
Mtools/xtask/src/lib.rs | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
10 files changed, 2162 insertions(+), 84 deletions(-)

diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/NativeRuntimeIntegrationTest.kt @@ -14,6 +14,7 @@ import org.harvestcircle.ffi.compatibilityDescriptor import org.harvestcircle.testbridge.ffi.HarvestCircleTestBridge import org.harvestcircle.testbridge.ffi.TestBridgeException import org.harvestcircle.testbridge.ffi.TestLifecycle +import org.harvestcircle.testbridge.ffi.TestSnapshot import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.readBytes @@ -28,6 +29,122 @@ import kotlin.test.fail class NativeRuntimeIntegrationTest { @Test + fun generatedImportExactReplayAfterAdvanceAndRestart() { + val dataRoot = Files.createTempDirectory("harvestcircle-import-replay-restart-") + try { + val bridge = HarvestCircleTestBridge.open(dataRoot.toString()) + try { + bridge.bootstrap() + val originalSecret = generatedImportFixtureSecret(bridge) + val otherSecret = generatedImportFixtureSecret(bridge) + val originalRequest = "00000000-0000-7000-8000-000000000051" + val originalRevision = bridge.snapshot().revision + val imported = importFixtureIdentity(bridge, originalRequest, originalRevision, originalSecret) + val other = + importFixtureIdentity( + bridge, + "00000000-0000-7000-8000-000000000052", + imported.revision, + otherSecret, + ) + val otherPublicKey = checkNotNull(other.selectedPublicKeyHex) + val advanced = bridge.selectIdentity(otherPublicKey) + val replayAfterAdvance = + runCatching { + importFixtureIdentity(bridge, originalRequest, originalRevision, originalSecret) + } + val afterAdvanceReplay = bridge.snapshot() + val restarted = bridge.restart() + val replayAfterRestart = + runCatching { + importFixtureIdentity(bridge, originalRequest, originalRevision, originalSecret) + } + val afterRestartReplay = bridge.snapshot() + bridge.shutdown() + bridge.close() + + assertTrue(advanced.revision > originalRevision) + assertEquals(otherPublicKey, advanced.selectedPublicKeyHex) + assertEquals(advanced, replayAfterAdvance.getOrThrow()) + assertEquals(advanced, afterAdvanceReplay) + assertEquals(advanced.identities, restarted.identities) + assertEquals(otherPublicKey, restarted.selectedPublicKeyHex) + assertEquals(restarted, replayAfterRestart.getOrThrow()) + assertEquals(restarted, afterRestartReplay) + val publicEvidence = + advanced.toString() + + afterAdvanceReplay + + restarted + + afterRestartReplay + + replayAfterAdvance.safeReplayEvidence() + + replayAfterRestart.safeReplayEvidence() + assertFalse(publicEvidence.contains(originalSecret)) + assertFalse(publicEvidence.contains(otherSecret)) + assertTreeDoesNotContain(dataRoot, originalSecret, otherSecret, "nsec1") + } finally { + try { + runCatching { bridge.shutdown() } + } finally { + bridge.close() + } + } + } finally { + deleteTree(dataRoot) + } + } + + @Test + fun generatedImportChangedInputAndRevisionFailWithoutStateMutation() { + val dataRoot = Files.createTempDirectory("harvestcircle-import-replay-conflicts-") + try { + val bridge = HarvestCircleTestBridge.open(dataRoot.toString()) + try { + bridge.bootstrap() + val originalSecret = generatedImportFixtureSecret(bridge) + val changedSecret = generatedImportFixtureSecret(bridge) + val originalRequest = "00000000-0000-7000-8000-000000000053" + val originalRevision = bridge.snapshot().revision + importFixtureIdentity(bridge, originalRequest, originalRevision, originalSecret) + val before = bridge.snapshot() + val changedInput = + runCatching { + importFixtureIdentity(bridge, originalRequest, originalRevision, changedSecret) + } + val afterChangedInput = bridge.snapshot() + val changedRevision = + runCatching { + importFixtureIdentity(bridge, originalRequest, originalRevision + 1UL, originalSecret) + } + val afterChangedRevision = bridge.snapshot() + bridge.shutdown() + bridge.close() + + assertTrue(changedInput.exceptionOrNull() is TestBridgeException.Failure) + assertTrue(changedRevision.exceptionOrNull() is TestBridgeException.Failure) + assertEquals(before, afterChangedInput) + assertEquals(before, afterChangedRevision) + val publicEvidence = + before.toString() + + afterChangedInput + + afterChangedRevision + + changedInput.safeReplayEvidence() + + changedRevision.safeReplayEvidence() + assertFalse(publicEvidence.contains(originalSecret)) + assertFalse(publicEvidence.contains(changedSecret)) + assertTreeDoesNotContain(dataRoot, originalSecret, changedSecret, "nsec1") + } finally { + try { + runCatching { bridge.shutdown() } + } finally { + bridge.close() + } + } + } finally { + deleteTree(dataRoot) + } + } + + @Test fun generatedActorObserverDiscardsStoppedQueueBeforeRestartAndFullShutdown() { val dataRoot = Files.createTempDirectory("harvestcircle-generated-observer-close-") try { @@ -366,6 +483,39 @@ class NativeRuntimeIntegrationTest { } } +private fun generatedImportFixtureSecret(bridge: HarvestCircleTestBridge): String { + val generated = bridge.beginGeneratedIdentity() + return try { + val secret = generated.takeRecoveryNsec() + check(bridge.cancelGeneratedIdentity(generated)) + secret + } finally { + generated.close() + } +} + +private fun importFixtureIdentity( + bridge: HarvestCircleTestBridge, + requestId: String, + revision: ULong, + secret: String, +): TestSnapshot { + val bytes = secret.encodeToByteArray() + return try { + bridge.importIdentity(requestId, revision, bytes, 2_000UL) + } finally { + bytes.fill(0) + } +} + +private fun Result<TestSnapshot>.safeReplayEvidence(): String = + fold( + onSuccess = TestSnapshot::toString, + onFailure = { failure -> + if (failure is TestBridgeException.Failure) failure.safeMessage else "Unexpected test bridge failure." + }, + ) + private fun request( operationId: String, revision: SnapshotRevision, diff --git a/core/compatibility/harvestcircle-storage-api-v2.txt b/core/compatibility/harvestcircle-storage-api-v2.txt @@ -0,0 +1,87 @@ +pub mod harvestcircle_storage +pub enum harvestcircle_storage::HarvestCircleStorageContractError +pub harvestcircle_storage::HarvestCircleStorageContractError::CanonicalPaths +pub harvestcircle_storage::HarvestCircleStorageContractError::ContextIdentity +pub harvestcircle_storage::HarvestCircleStorageContractError::MigrationCatalog +pub harvestcircle_storage::HarvestCircleStorageContractError::SchemaCatalog +impl core::error::Error for harvestcircle_storage::HarvestCircleStorageContractError +impl core::fmt::Display for harvestcircle_storage::HarvestCircleStorageContractError +pub fn harvestcircle_storage::HarvestCircleStorageContractError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct harvestcircle_storage::Database +impl harvestcircle_storage::Database +pub async fn harvestcircle_storage::Database::capture_online_backup(&self, &std::path::Path, radroots_service_sqlite::backup::manifest::BackupCreatedAtUnixMs) -> core::result::Result<radroots_service_sqlite::backup::manifest::ServiceBackupManifest, harvestcircle_domain::error::SafeError> +pub async fn harvestcircle_storage::Database::restore_verified_backup(&mut self, &radroots_runtime_paths::context::RuntimeContext, harvestcircle_storage::VerifiedHarvestCircleBackup, u64, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<(), harvestcircle_domain::error::SafeError> +impl harvestcircle_storage::Database +pub async fn harvestcircle_storage::Database::close(&self) -> core::result::Result<(), harvestcircle_domain::error::SafeError> +pub const fn harvestcircle_storage::Database::metadata(&self) -> &radroots_service_sqlite::metadata::ServiceDatabaseMetadata +pub async fn harvestcircle_storage::Database::open(&radroots_runtime_paths::context::RuntimeContext, u64, u64, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<Self, harvestcircle_domain::error::SafeError> +impl harvestcircle_storage::Database +pub async fn harvestcircle_storage::Database::initialize_installation_id(&self, &str) -> core::result::Result<alloc::string::String, harvestcircle_domain::error::SafeError> +pub async fn harvestcircle_storage::Database::load_installation_id(&self) -> core::result::Result<core::option::Option<alloc::string::String>, harvestcircle_domain::error::SafeError> +impl harvestcircle_application::ports::AppStateRepository for harvestcircle_storage::Database +pub fn harvestcircle_storage::Database::load_selected_identity(&self) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<core::option::Option<harvestcircle_domain::key::PublicKey>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::save_selected_identity(&self, core::option::Option<harvestcircle_domain::key::PublicKey>) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<(), harvestcircle_domain::error::SafeError>> +impl harvestcircle_application::ports::DurableOperationRepository for harvestcircle_storage::Database +pub fn harvestcircle_storage::Database::advance_durable_operation<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_domain::time::UnixTimestamp, core::option::Option<harvestcircle_application::ports::OperationDiagnostic>) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<harvestcircle_application::ports::DurableIdentityOperation, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::begin_durable_operation<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationKind, harvestcircle_domain::key::PublicKey, core::option::Option<u64>, harvestcircle_application::ports::OperationPriorState, harvestcircle_domain::time::UnixTimestamp) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<harvestcircle_application::ports::DurableOperationStart, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::finalize_durable_operation<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_application::ports::DurableTerminalOutcome, core::option::Option<u64>, harvestcircle_domain::time::UnixTimestamp) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<harvestcircle_application::ports::DurableOperationReceipt, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::list_unfinished_durable_operations(&self) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<alloc::vec::Vec<harvestcircle_application::ports::DurableIdentityOperation>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::load_durable_operation<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<core::option::Option<harvestcircle_application::ports::DurableIdentityOperation>, harvestcircle_domain::error::SafeError>> +impl harvestcircle_application::ports::IdentityNamespaceRepository for harvestcircle_storage::Database +pub fn harvestcircle_storage::Database::clear_owner(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::get_value<'a>(&'a self, harvestcircle_domain::key::PublicKey, harvestcircle_application::ports::IdentityPreferenceKey) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<core::option::Option<alloc::string::String>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::set_value<'a>(&'a self, harvestcircle_domain::key::PublicKey, harvestcircle_application::ports::IdentityPreferenceKey, &'a str) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +impl harvestcircle_application::ports::IdentityRepository for harvestcircle_storage::Database +pub fn harvestcircle_storage::Database::find_identity(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<core::option::Option<harvestcircle_domain::identity::NostrIdentity>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::insert_identity<'a>(&'a self, &'a harvestcircle_domain::identity::NostrIdentity) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::list_identities(&self) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<alloc::vec::Vec<harvestcircle_domain::identity::NostrIdentity>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::remove_identity(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::update_identity<'a>(&'a self, &'a harvestcircle_domain::identity::NostrIdentity) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +impl harvestcircle_application::ports::ProfileRepository for harvestcircle_storage::Database +pub fn harvestcircle_storage::Database::load_profile(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<core::option::Option<harvestcircle_application::ports::CachedProfile>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::record_refresh_status<'a>(&'a self, harvestcircle_domain::key::PublicKey, harvestcircle_domain::time::UnixTimestamp, harvestcircle_application::ports::ProfileRefreshStatus) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::remove_profile(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::save_profile<'a>(&'a self, &'a harvestcircle_application::ports::CachedProfile) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub struct harvestcircle_storage::HarvestCircleStorageContract +impl harvestcircle_storage::HarvestCircleStorageContract +pub fn harvestcircle_storage::HarvestCircleStorageContract::application_id(&self) -> radroots_service_sqlite::metadata::ServiceSqliteApplicationId +pub fn harvestcircle_storage::HarvestCircleStorageContract::from_runtime_context(&radroots_runtime_paths::context::RuntimeContext) -> core::result::Result<Self, harvestcircle_storage::HarvestCircleStorageContractError> +pub const fn harvestcircle_storage::HarvestCircleStorageContract::migrations(&self) -> &radroots_service_sqlite::migration::MigrationCatalog +pub const fn harvestcircle_storage::HarvestCircleStorageContract::paths(&self) -> &radroots_service_sqlite::open::ServiceSqlitePaths +pub const fn harvestcircle_storage::HarvestCircleStorageContract::schema(&self) -> &radroots_service_sqlite::integrity::catalog::SchemaCatalog +pub const fn harvestcircle_storage::HarvestCircleStorageContract::state_schema_version(&self) -> core::num::nonzero::NonZeroU32 +impl core::fmt::Debug for harvestcircle_storage::HarvestCircleStorageContract +pub fn harvestcircle_storage::HarvestCircleStorageContract::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct harvestcircle_storage::OsKeyringSecretStore +impl harvestcircle_application::secrets::SecretStore for harvestcircle_storage::OsKeyringSecretStore +pub fn harvestcircle_storage::OsKeyringSecretStore::contains(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<bool, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::OsKeyringSecretStore::delete<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::OsKeyringSecretStore::load(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<harvestcircle_domain::key::SecretKeyInput, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::OsKeyringSecretStore::put<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_domain::key::PublicKey, harvestcircle_domain::key::SecretKeyInput) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::OsKeyringSecretStore::verify<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_domain::key::PublicKey, harvestcircle_domain::key::SecretKeyInput) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub struct harvestcircle_storage::VerifiedHarvestCircleBackup +impl core::fmt::Debug for harvestcircle_storage::VerifiedHarvestCircleBackup +pub fn harvestcircle_storage::VerifiedHarvestCircleBackup::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub const harvestcircle_storage::CREDENTIAL_SERVICE: &str +pub const harvestcircle_storage::CURRENT_SCHEMA_VERSION: u32 +pub const harvestcircle_storage::HARVESTCIRCLE_ACTOR_MAILBOX_CAPACITY: usize +pub const harvestcircle_storage::HARVESTCIRCLE_APPLICATION_ID: u32 +pub const harvestcircle_storage::HARVESTCIRCLE_COMMAND_DEADLINE_MAX_MS: u64 +pub const harvestcircle_storage::HARVESTCIRCLE_COMMAND_DEADLINE_MIN_MS: u64 +pub const harvestcircle_storage::HARVESTCIRCLE_DURABLE_OPERATION_CAPACITY: usize +pub const harvestcircle_storage::HARVESTCIRCLE_DURABLE_OPERATION_CLEANUP_BATCH: usize +pub const harvestcircle_storage::HARVESTCIRCLE_EVENTS_PER_RELAY_CAPACITY: usize +pub const harvestcircle_storage::HARVESTCIRCLE_EVENTS_TOTAL_CAPACITY: usize +pub const harvestcircle_storage::HARVESTCIRCLE_IDENTITY_CAPACITY: usize +pub const harvestcircle_storage::HARVESTCIRCLE_INSTANCE_ID: &str +pub const harvestcircle_storage::HARVESTCIRCLE_OBSERVER_CAPACITY: usize +pub const harvestcircle_storage::HARVESTCIRCLE_PREFERENCE_VALUE_UTF8_BYTES: usize +pub const harvestcircle_storage::HARVESTCIRCLE_RELAY_ENDPOINT_CAPACITY: usize +pub const harvestcircle_storage::HARVESTCIRCLE_RELAY_URL_UTF8_BYTES: usize +pub const harvestcircle_storage::HARVESTCIRCLE_SERVICE_ID: &str +pub const harvestcircle_storage::HARVESTCIRCLE_STATE_SCHEMA_VERSION: u32 +pub const harvestcircle_storage::HARVESTCIRCLE_TERMINAL_RECEIPT_RETENTION_SECONDS: i64 +pub const harvestcircle_storage::HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY: usize +pub fn harvestcircle_storage::harvestcircle_migration_catalog() -> core::result::Result<radroots_service_sqlite::migration::MigrationCatalog, harvestcircle_storage::HarvestCircleStorageContractError> +pub fn harvestcircle_storage::harvestcircle_schema_catalog() -> core::result::Result<radroots_service_sqlite::integrity::catalog::SchemaCatalog, harvestcircle_storage::HarvestCircleStorageContractError> +pub fn harvestcircle_storage::verify_harvestcircle_backup(&[u8], radroots_service_sqlite::backup::manifest::BackupManifestSha256, &std::path::Path, &radroots_service_sqlite::metadata::ServiceDatabaseIdentity, core::num::nonzero::NonZeroU64) -> core::result::Result<harvestcircle_storage::VerifiedHarvestCircleBackup, harvestcircle_domain::error::SafeError> diff --git a/core/crates/harvestcircle_application/src/identities.rs b/core/crates/harvestcircle_application/src/identities.rs @@ -1,10 +1,10 @@ use std::sync::{Mutex, MutexGuard}; use crate::{ - AppCore, AppStateRepository, BoxFuture, Clock, DurableOperationKind, DurableOperationPhase, - DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome, - IdentityRepository, OperationPriorState, RemovalConfirmationToken, SecretStore, - StagedGeneratedKey, StateTransition, + AppCore, AppStateRepository, BoxFuture, Clock, DurableIdentityOperation, DurableOperationKind, + DurableOperationPhase, DurableOperationRepository, DurableOperationStart, DurableRequestId, + DurableTerminalOutcome, IdentityRepository, OperationPriorState, RemovalConfirmationToken, + SecretStore, StagedGeneratedKey, StateTransition, }; #[cfg(test)] use crate::{ @@ -65,20 +65,21 @@ impl AppCore { let expected_revision = staged.expected_revision(); self.require_revision(expected_revision)?; let (identity, secret) = staged.into_commit_parts(); - self.persist_identity_durable( - request_id, - DurableOperationKind::Create, - expected_revision, - &identity, - secret, - None, - identities, - app_state, - secrets, - operations, - clock, - ) - .await?; + let identity = self + .persist_identity_durable( + request_id, + DurableOperationKind::Create, + expected_revision, + &identity, + secret, + None, + identities, + app_state, + secrets, + operations, + clock, + ) + .await?; Ok(ImportIdentityReceipt { identity }) } @@ -109,20 +110,21 @@ impl AppCore { IdentityCreatedAt::new(clock.now()), None, )?; - self.persist_identity_durable( - request_id, - DurableOperationKind::Create, - expected_revision, - &identity, - secret, - None, - identities, - app_state, - secrets, - operations, - clock, - ) - .await?; + let identity = self + .persist_identity_durable( + request_id, + DurableOperationKind::Create, + expected_revision, + &identity, + secret, + None, + identities, + app_state, + secrets, + operations, + clock, + ) + .await?; Ok(GenerateIdentityReceipt { identity, generated_nsec: nsec, @@ -147,18 +149,30 @@ impl AppCore { clock: &(impl Clock + ?Sized), ) -> Result<ImportIdentityReceipt, SafeError> { if let Some(existing) = operations.load_durable_operation(request_id).await? { - return if existing - .terminal() - .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) - { - identities - .find_identity(existing.identity()) - .await? - .map(|identity| ImportIdentityReceipt { identity }) - .ok_or_else(recovery_required) - } else { - Err(recovery_required()) - }; + if !matches!( + existing.kind(), + DurableOperationKind::Import | DurableOperationKind::Repair + ) { + return Err(operation_conflict()); + } + require_completed_identity_operation(&existing)?; + let imported = self.key_material().import(input)?; + let (public_key, _, secret) = imported.into_parts(); + verify_completed_identity_replay( + &existing, + request_id, + existing.kind(), + expected_revision, + public_key, + secret, + secrets, + ) + .await?; + return identities + .find_identity(public_key) + .await? + .map(|identity| ImportIdentityReceipt { identity }) + .ok_or_else(recovery_required); } self.require_revision(expected_revision)?; let imported = self.key_material().import(input)?; @@ -192,20 +206,21 @@ impl AppCore { } else { DurableOperationKind::Import }; - self.persist_identity_durable( - request_id, - kind, - expected_revision, - &identity, - secret, - previous.as_ref(), - identities, - app_state, - secrets, - operations, - clock, - ) - .await?; + let identity = self + .persist_identity_durable( + request_id, + kind, + expected_revision, + &identity, + secret, + previous.as_ref(), + identities, + app_state, + secrets, + operations, + clock, + ) + .await?; Ok(ImportIdentityReceipt { identity }) } @@ -230,7 +245,7 @@ impl AppCore { secrets: &(impl SecretStore + ?Sized), operations: &(impl DurableOperationRepository + ?Sized), clock: &(impl Clock + ?Sized), - ) -> Result<(), SafeError> { + ) -> Result<NostrIdentity, SafeError> { let prior_availability = previous .map(local_keyring_binding) .transpose()? @@ -252,14 +267,20 @@ impl AppCore { { DurableOperationStart::Started(_) => {} DurableOperationStart::Existing(operation) => { - return if operation - .terminal() - .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) - { - Ok(()) - } else { - Err(recovery_required()) - }; + verify_completed_identity_replay( + &operation, + request_id, + kind, + expected_revision, + identity.public_key(), + secret, + secrets, + ) + .await?; + return identities + .find_identity(operation.identity()) + .await? + .ok_or_else(recovery_required); } } secrets @@ -313,7 +334,7 @@ impl AppCore { clock.now(), ) .await?; - Ok(()) + Ok(identity.clone()) } /// Issues a single-use confirmation bound to the target and current revision. @@ -992,6 +1013,43 @@ impl AppStateRepository for InMemoryIdentityRepository { } } +fn require_completed_identity_operation( + operation: &DurableIdentityOperation, +) -> Result<(), SafeError> { + if operation.phase() != DurableOperationPhase::Finalized + || !operation + .terminal() + .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) + { + return Err(recovery_required()); + } + Ok(()) +} + +async fn verify_completed_identity_replay( + operation: &DurableIdentityOperation, + request_id: &DurableRequestId, + kind: DurableOperationKind, + expected_revision: u64, + public_key: PublicKey, + secret: SecretKeyInput, + secrets: &(impl SecretStore + ?Sized), +) -> Result<(), SafeError> { + if operation.request_id() != request_id + || operation.kind() != kind + || operation.identity() != public_key + || operation.expected_revision() != Some(expected_revision) + { + return Err(operation_conflict()); + } + require_completed_identity_operation(operation)?; + let receipt = operation.terminal().ok_or_else(recovery_required)?; + if receipt.request_id() != request_id || receipt.identity() != public_key { + return Err(operation_conflict()); + } + secrets.verify(request_id, public_key, secret).await +} + const fn identity_exists() -> SafeError { SafeError::new( SafeErrorCode::IdentityAlreadyExists, @@ -1038,11 +1096,12 @@ mod tests { use super::InMemoryIdentityRepository; use crate::{ - AppCore, AppStateRepository, BoxFuture, Clock, DurableOperationKind, DurableOperationPhase, - DurableRequestId, FailureSecretStore, IdentityOperationPhase, IdentityRepository, - InMemoryOperationJournal, InMemorySecretStore, OperationJournal, ProfileRefreshStatus, - ProfileRepository, RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, - StateTransition, + AppCore, AppStateRepository, BoxFuture, Clock, DurableIdentityOperation, + DurableOperationKind, DurableOperationPhase, DurableOperationReceipt, DurableRequestId, + DurableTerminalOutcome, FailureSecretStore, IdentityOperationPhase, IdentityRepository, + InMemoryOperationJournal, InMemorySecretStore, OperationJournal, OperationPriorState, + ProfileRefreshStatus, ProfileRepository, RelayConfiguration, SecretStore, + SecretStoreOperation, SessionState, StateTransition, recovery::tests::{TestDurableRepository, operation as durable_operation}, }; @@ -1762,6 +1821,341 @@ mod tests { ); } + const ADMISSION_SECRET: &str = + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + + struct CompletedAdmissionFixture { + core: AppCore, + identities: InMemoryIdentityRepository, + secrets: FailureSecretStore, + operations: TestDurableRepository, + original: NostrIdentity, + candidate: NostrIdentity, + request: DurableRequestId, + expected_revision: u64, + } + + impl CompletedAdmissionFixture { + async fn new() -> Self { + let core = AppCore::in_memory(RelayConfiguration::default()); + core.bootstrap().expect("bootstrap"); + let identities = InMemoryIdentityRepository::default(); + let secrets = FailureSecretStore::default(); + let material = core + .key_material() + .import(SecretKeyInput::parse(ADMISSION_SECRET.to_owned()).expect("secret")) + .expect("key material"); + let (public_key, npub, secret) = material.into_parts(); + let original = NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub.as_str().to_owned()) + .expect("reference"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), + None, + IdentityCreatedAt::new(FixedClock.now()), + None, + ) + .expect("original identity"); + let candidate = NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub.as_str().to_owned()) + .expect("reference"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), + None, + IdentityCreatedAt::new(LateClock.now()), + None, + ) + .expect("candidate identity"); + identities + .insert_identity(&original) + .await + .expect("insert original identity"); + identities + .save_selected_identity(Some(public_key)) + .await + .expect("selection"); + let request = DurableRequestId::new_v7(); + secrets + .put(&request, public_key, secret) + .await + .expect("original custody"); + let expected_revision = core.snapshot().revision().value(); + let operation = DurableIdentityOperation::new( + request.clone(), + DurableOperationKind::Import, + public_key, + Some(expected_revision), + DurableOperationPhase::Finalized, + OperationPriorState::new(Some(public_key), None), + FixedClock.now(), + None, + Some(DurableOperationReceipt::new( + request.clone(), + public_key, + DurableTerminalOutcome::Completed, + Some(expected_revision + 1), + FixedClock.now(), + )), + ); + Self { + core, + identities, + secrets, + operations: TestDurableRepository::new(operation), + original, + candidate, + request, + expected_revision, + } + } + + fn canonical_secret(&self) -> SecretKeyInput { + self.core + .key_material() + .import(SecretKeyInput::parse(ADMISSION_SECRET.to_owned()).expect("secret")) + .expect("key material") + .into_parts() + .2 + } + + async fn attempt( + &self, + kind: DurableOperationKind, + expected_revision: u64, + secret: SecretKeyInput, + ) -> Result<NostrIdentity, SafeError> { + self.core + .persist_identity_durable( + &self.request, + kind, + expected_revision, + &self.candidate, + secret, + None, + &self.identities, + &self.identities, + &self.secrets, + &self.operations, + &FixedClock, + ) + .await + } + } + + #[tokio::test] + async fn completed_admission_race_verifies_original_binding_and_identity_without_mutation() { + let fixture = CompletedAdmissionFixture::new().await; + let before_operation = fixture.operations.operation().clone(); + let before_state = fixture.core.snapshot(); + let before_identities = fixture + .identities + .list_identities() + .await + .expect("identities"); + let before_selection = fixture + .identities + .load_selected_identity() + .await + .expect("selection"); + let exact = fixture + .attempt( + DurableOperationKind::Import, + fixture.expected_revision, + fixture.canonical_secret(), + ) + .await; + let changed_kind = fixture + .attempt( + DurableOperationKind::Repair, + fixture.expected_revision, + fixture.canonical_secret(), + ) + .await + .expect_err("original kind required"); + let changed_revision = fixture + .attempt( + DurableOperationKind::Import, + fixture.expected_revision + 1, + fixture.canonical_secret(), + ) + .await + .expect_err("original revision required"); + let changed_secret = fixture + .attempt( + DurableOperationKind::Import, + fixture.expected_revision, + SecretKeyInput::parse( + "0000000000000000000000000000000000000000000000000000000000000001".to_owned(), + ) + .expect("different secret"), + ) + .await + .expect_err("full secret required"); + let after_operation = fixture.operations.operation().clone(); + let after_state = fixture.core.snapshot(); + let after_identities = fixture + .identities + .list_identities() + .await + .expect("identities"); + let after_selection = fixture + .identities + .load_selected_identity() + .await + .expect("selection"); + let retained = fixture + .secrets + .load(fixture.original.public_key()) + .await + .expect("credential"); + let mutations = fixture + .secrets + .calls() + .into_iter() + .filter(|call| { + matches!( + call.operation(), + SecretStoreOperation::Put | SecretStoreOperation::Delete, + ) + }) + .collect::<Vec<_>>(); + assert_eq!(exact.expect("exact completed admission"), fixture.original); + assert_ne!(fixture.candidate, fixture.original); + assert_eq!(changed_kind.code(), SafeErrorCode::InvalidApplicationState); + assert_eq!( + changed_revision.code(), + SafeErrorCode::InvalidApplicationState + ); + assert_eq!( + changed_secret.code(), + SafeErrorCode::InvalidApplicationState + ); + assert_eq!(before_operation, after_operation); + assert_eq!(before_state, after_state); + assert_eq!(before_identities, after_identities); + assert_eq!(before_selection, after_selection); + assert_eq!(mutations.len(), 1); + assert_eq!(mutations[0].operation(), SecretStoreOperation::Put); + assert!(retained.with_exposed_secret(|value| { + fixture + .canonical_secret() + .with_exposed_secret(|expected| value == expected) + })); + assert!( + !format!("{changed_kind:?} {changed_revision:?} {changed_secret:?}") + .contains(ADMISSION_SECRET) + ); + } + + #[tokio::test] + async fn completed_admission_race_rejects_missing_and_rebound_custody_without_mutation() { + for rebound in [false, true] { + let fixture = CompletedAdmissionFixture::new().await; + fixture + .secrets + .delete(&fixture.request, fixture.original.public_key()) + .await + .expect("remove custody"); + let another_request = DurableRequestId::new_v7(); + if rebound { + fixture + .secrets + .put( + &another_request, + fixture.original.public_key(), + fixture.canonical_secret(), + ) + .await + .expect("replacement custody"); + } + let before_operation = fixture.operations.operation().clone(); + let before_state = fixture.core.snapshot(); + let before_identities = fixture + .identities + .list_identities() + .await + .expect("identities"); + let before_selection = fixture + .identities + .load_selected_identity() + .await + .expect("selection"); + let before_mutations = fixture + .secrets + .calls() + .into_iter() + .filter(|call| { + matches!( + call.operation(), + SecretStoreOperation::Put | SecretStoreOperation::Delete, + ) + }) + .collect::<Vec<_>>(); + let error = fixture + .attempt( + DurableOperationKind::Import, + fixture.expected_revision, + fixture.canonical_secret(), + ) + .await + .expect_err("unbound custody must fail"); + let after_operation = fixture.operations.operation().clone(); + let after_state = fixture.core.snapshot(); + let after_identities = fixture + .identities + .list_identities() + .await + .expect("identities"); + let after_selection = fixture + .identities + .load_selected_identity() + .await + .expect("selection"); + let after_mutations = fixture + .secrets + .calls() + .into_iter() + .filter(|call| { + matches!( + call.operation(), + SecretStoreOperation::Put | SecretStoreOperation::Delete, + ) + }) + .collect::<Vec<_>>(); + let present = fixture + .secrets + .contains(fixture.original.public_key()) + .await + .expect("availability"); + let replacement_binding = if rebound { + fixture + .secrets + .verify( + &another_request, + fixture.original.public_key(), + fixture.canonical_secret(), + ) + .await + } else { + Ok(()) + }; + assert_eq!( + error.code(), + if rebound { + SafeErrorCode::InvalidApplicationState + } else { + SafeErrorCode::CredentialMissing + } + ); + assert_eq!(before_operation, after_operation); + assert_eq!(before_state, after_state); + assert_eq!(before_identities, after_identities); + assert_eq!(before_selection, after_selection); + assert_eq!(before_mutations, after_mutations); + assert_eq!(present, rebound); + assert!(replacement_binding.is_ok()); + assert!(!format!("{error:?}").contains(ADMISSION_SECRET)); + } + } + #[tokio::test] async fn durable_import_covers_new_and_missing_credential_repair_paths() { const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; diff --git a/core/crates/harvestcircle_application/src/secrets.rs b/core/crates/harvestcircle_application/src/secrets.rs @@ -18,6 +18,23 @@ pub trait SecretStore: Send + Sync { public_key: PublicKey, secret: SecretKeyInput, ) -> BoxFuture<'a, Result<(), SafeError>>; + /// Verifies the original request and full canonical secret without changing custody. + /// + /// # Errors + /// + /// Returns a safe conflict, missing-credential, or keyring error. Adapters without + /// request-bound verification fail closed rather than loading an unbound credential. + fn verify<'a>( + &'a self, + _request_id: &'a DurableRequestId, + _public_key: PublicKey, + secret: SecretKeyInput, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + drop(secret); + Err(keyring_unavailable()) + }) + } /// Loads a credential into a non-cloneable redacted boundary value. /// /// # Errors @@ -44,12 +61,18 @@ pub trait SecretStore: Send + Sync { #[derive(Default)] pub struct InMemorySecretStore { - credentials: Mutex<BTreeMap<PublicKey, SecretString>>, + credentials: Mutex<BTreeMap<PublicKey, StoredCredential>>, +} + +struct StoredCredential { + request_id: DurableRequestId, + secret: SecretString, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum SecretStoreOperation { Put, + Verify, Load, Contains, Delete, @@ -133,6 +156,20 @@ impl SecretStore for FailureSecretStore { }) } + fn verify<'a>( + &'a self, + request_id: &'a DurableRequestId, + public_key: PublicKey, + secret: SecretKeyInput, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + if self.record_and_should_fail(SecretStoreOperation::Verify, public_key) { + return Err(keyring_unavailable()); + } + self.inner.verify(request_id, public_key, secret).await + }) + } + fn load(&self, public_key: PublicKey) -> BoxFuture<'_, Result<SecretKeyInput, SafeError>> { Box::pin(async move { if self.record_and_should_fail(SecretStoreOperation::Load, public_key) { @@ -166,7 +203,9 @@ impl SecretStore for FailureSecretStore { } impl InMemorySecretStore { - fn credentials(&self) -> Result<MutexGuard<'_, BTreeMap<PublicKey, SecretString>>, SafeError> { + fn credentials( + &self, + ) -> Result<MutexGuard<'_, BTreeMap<PublicKey, StoredCredential>>, SafeError> { self.credentials.lock().map_err(|_| keyring_unavailable()) } } @@ -174,7 +213,7 @@ impl InMemorySecretStore { impl SecretStore for InMemorySecretStore { fn put<'a>( &'a self, - _request_id: &'a DurableRequestId, + request_id: &'a DurableRequestId, public_key: PublicKey, secret: SecretKeyInput, ) -> BoxFuture<'a, Result<(), SafeError>> { @@ -184,7 +223,34 @@ impl SecretStore for InMemorySecretStore { return Err(credential_exists()); } let value = secret.with_exposed_secret(ToOwned::to_owned); - credentials.insert(public_key, SecretString::from(value)); + credentials.insert( + public_key, + StoredCredential { + request_id: request_id.clone(), + secret: SecretString::from(value), + }, + ); + Ok(()) + }) + } + + fn verify<'a>( + &'a self, + request_id: &'a DurableRequestId, + public_key: PublicKey, + secret: SecretKeyInput, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + let credentials = self.credentials()?; + let existing = credentials + .get(&public_key) + .ok_or_else(credential_missing)?; + if existing.request_id != *request_id + || !secret + .with_exposed_secret(|expected| existing.secret.expose_secret() == expected) + { + return Err(replay_conflict()); + } Ok(()) }) } @@ -195,7 +261,7 @@ impl SecretStore for InMemorySecretStore { let secret = credentials .get(&public_key) .ok_or_else(credential_missing)?; - SecretKeyInput::parse(secret.expose_secret().to_owned()) + SecretKeyInput::parse(secret.secret.expose_secret().to_owned()) .map_err(|_| credential_missing()) }) } @@ -218,6 +284,13 @@ impl SecretStore for InMemorySecretStore { } } +const fn replay_conflict() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The identity operation conflicts with the stored credential."), + ) +} + const fn credential_exists() -> SafeError { SafeError::new( SafeErrorCode::IdentityAlreadyExists, @@ -241,9 +314,9 @@ const fn keyring_unavailable() -> SafeError { #[cfg(test)] mod tests { - use crate::DurableRequestId; + use crate::{BoxFuture, DurableRequestId}; - use harvestcircle_domain::{PublicKey, SafeErrorCode, SecretKeyInput}; + use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SecretKeyInput}; use super::{FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreOperation}; @@ -253,6 +326,227 @@ mod tests { DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000301").expect("request") } + struct UnverifiedSecretStore(InMemorySecretStore); + + impl SecretStore for UnverifiedSecretStore { + fn put<'a>( + &'a self, + request_id: &'a DurableRequestId, + public_key: PublicKey, + secret: SecretKeyInput, + ) -> BoxFuture<'a, Result<(), SafeError>> { + self.0.put(request_id, public_key, secret) + } + + fn load(&self, public_key: PublicKey) -> BoxFuture<'_, Result<SecretKeyInput, SafeError>> { + self.0.load(public_key) + } + + fn contains(&self, public_key: PublicKey) -> BoxFuture<'_, Result<bool, SafeError>> { + self.0.contains(public_key) + } + + fn delete<'a>( + &'a self, + request_id: &'a DurableRequestId, + public_key: PublicKey, + ) -> BoxFuture<'a, Result<(), SafeError>> { + self.0.delete(request_id, public_key) + } + } + + #[tokio::test] + async fn default_secret_verification_fails_closed_through_object_safe_port() { + let store = UnverifiedSecretStore(InMemorySecretStore::default()); + let port: &dyn SecretStore = &store; + let public_key = PublicKey::from_bytes([7; 32]).expect("public key"); + port.put( + &request_id(), + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + .expect("put"); + let error = port + .verify( + &request_id(), + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + .expect_err("unbound adapter must fail closed"); + let retained = port.load(public_key).await.expect("retained credential"); + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + assert!(retained.with_exposed_secret(|value| value == SECRET)); + assert!(!format!("{error:?}").contains(SECRET)); + } + + #[tokio::test] + async fn memory_secret_verification_binds_request_and_full_secret_without_mutation() { + let store = InMemorySecretStore::default(); + let request = request_id(); + let another_request = DurableRequestId::new_v7(); + let public_key = PublicKey::from_bytes([7; 32]).expect("public key"); + store + .put( + &request, + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + .expect("put"); + let exact = store + .verify( + &request, + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await; + let changed_request = store + .verify( + &another_request, + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + .expect_err("original request required"); + let changed_secret = store + .verify( + &request, + public_key, + SecretKeyInput::parse( + "0000000000000000000000000000000000000000000000000000000000000001".to_owned(), + ) + .expect("different secret"), + ) + .await + .expect_err("full secret required"); + let missing = store + .verify( + &request, + PublicKey::from_bytes([8; 32]).expect("other public key"), + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + .expect_err("missing credential"); + let retained = store.load(public_key).await.expect("retained credential"); + let still_exact = store + .verify( + &request, + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await; + assert!(exact.is_ok()); + assert!(still_exact.is_ok()); + assert_eq!( + changed_request.code(), + SafeErrorCode::InvalidApplicationState + ); + assert_eq!( + changed_secret.code(), + SafeErrorCode::InvalidApplicationState + ); + assert_eq!(missing.code(), SafeErrorCode::CredentialMissing); + assert!(retained.with_exposed_secret(|value| value == SECRET)); + assert_eq!(store.credentials().expect("credentials").len(), 1); + assert!(!format!("{changed_request:?} {changed_secret:?} {missing:?}").contains(SECRET)); + } + + #[tokio::test] + async fn memory_secret_verification_fails_closed_on_poison() { + let store = InMemorySecretStore::default(); + let public_key = PublicKey::from_bytes([7; 32]).expect("public key"); + store + .put( + &request_id(), + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + .expect("put"); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _credentials = store.credentials.lock().expect("credentials lock"); + panic!("injected custody failure"); + })); + let error = store + .verify( + &request_id(), + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + .expect_err("poison must fail closed"); + assert!(panic.is_err()); + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + assert!(!format!("{error:?}").contains(SECRET)); + } + + #[tokio::test] + async fn failure_secret_verification_audits_only_public_identity_and_preserves_custody() { + let store = FailureSecretStore::default(); + let request = request_id(); + let public_key = PublicKey::from_bytes([7; 32]).expect("public key"); + store + .put( + &request, + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + .expect("put"); + store.fail_next(SecretStoreOperation::Verify); + let unavailable = store + .verify( + &request, + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + .expect_err("injected verification failure"); + let exact = store + .verify( + &request, + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await; + let conflict = store + .verify( + &DurableRequestId::new_v7(), + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + .expect_err("request mismatch"); + let retained = store + .inner + .load(public_key) + .await + .expect("retained credential"); + let calls = store.calls(); + assert_eq!(unavailable.code(), SafeErrorCode::KeyringUnavailable); + assert!(exact.is_ok()); + assert_eq!(conflict.code(), SafeErrorCode::InvalidApplicationState); + assert!(retained.with_exposed_secret(|value| value == SECRET)); + assert_eq!( + calls + .iter() + .map(|call| call.operation()) + .collect::<Vec<_>>(), + vec![ + SecretStoreOperation::Put, + SecretStoreOperation::Verify, + SecretStoreOperation::Verify, + SecretStoreOperation::Verify, + ] + ); + assert!(calls.iter().all(|call| call.public_key() == public_key)); + let public_evidence = format!("{calls:?} {unavailable:?} {conflict:?}"); + assert!(!public_evidence.contains(SECRET)); + assert!(!public_evidence.contains(request.as_str())); + } + #[tokio::test] async fn secret_store_puts_loads_checks_and_deletes_redacted_credentials() { let store = InMemorySecretStore::default(); @@ -337,12 +631,22 @@ mod tests { .await .expect("put"); for operation in [ + SecretStoreOperation::Verify, SecretStoreOperation::Load, SecretStoreOperation::Contains, SecretStoreOperation::Delete, ] { store.fail_next(operation); let error = match operation { + SecretStoreOperation::Verify => { + store + .verify( + &request_id(), + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + } SecretStoreOperation::Load => store.load(public_key).await.map(|_| ()), SecretStoreOperation::Contains => store.contains(public_key).await.map(|_| ()), SecretStoreOperation::Delete => store.delete(&request_id(), public_key).await, diff --git a/core/crates/harvestcircle_ffi/src/keyring_worker.rs b/core/crates/harvestcircle_ffi/src/keyring_worker.rs @@ -22,6 +22,13 @@ enum Request { Arc<AtomicU8>, oneshot::Sender<Result<(), SafeError>>, ), + Verify( + DurableRequestId, + PublicKey, + SecretKeyInput, + Arc<AtomicU8>, + oneshot::Sender<Result<(), SafeError>>, + ), Load( PublicKey, oneshot::Sender<Result<SecretKeyInput, SafeError>>, @@ -96,6 +103,15 @@ impl BoundedKeyringWorker { let _ = response.send(result); } } + Request::Verify(request_id, public_key, secret, phase, response) => { + if start_operation(&phase) { + let result = runtime.block_on(async { + store.verify(&request_id, public_key, secret).await + }); + finish_operation(&phase); + let _ = response.send(result); + } + } Request::Load(public_key, response) => { let _ = response .send(runtime.block_on(async { store.load(public_key).await })); @@ -238,6 +254,20 @@ impl SecretStore for BoundedKeyringWorker { }) } + fn verify<'a>( + &'a self, + request_id: &'a DurableRequestId, + public_key: PublicKey, + secret: SecretKeyInput, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + self.submit(|phase, response| { + Request::Verify(request_id.clone(), public_key, secret, phase, response) + }) + .await? + }) + } + fn load(&self, public_key: PublicKey) -> BoxFuture<'_, Result<SecretKeyInput, SafeError>> { Box::pin(async move { self.submit(|_phase, response| Request::Load(public_key, response)) @@ -334,6 +364,10 @@ mod tests { put_started: AtomicBool, release_put: AtomicBool, put_calls: AtomicUsize, + verify_calls: AtomicUsize, + block_next_verify: AtomicBool, + verify_started: AtomicBool, + release_verify: AtomicBool, } #[derive(Clone)] @@ -350,6 +384,10 @@ mod tests { put_started: AtomicBool::new(false), release_put: AtomicBool::new(false), put_calls: AtomicUsize::new(0), + verify_calls: AtomicUsize::new(0), + block_next_verify: AtomicBool::new(false), + verify_started: AtomicBool::new(false), + release_verify: AtomicBool::new(false), }), } } @@ -368,6 +406,16 @@ mod tests { self.state.put_calls.load(Ordering::Acquire) } + async fn wait_until_verification_started(&self) { + while !self.state.verify_started.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + } + + fn release_verification(&self) { + self.state.release_verify.store(true, Ordering::Release); + } + async fn contains_direct(&self, public_key: PublicKey) -> bool { self.state .inner @@ -396,6 +444,27 @@ mod tests { }) } + fn verify<'a>( + &'a self, + request_id: &'a DurableRequestId, + public_key: PublicKey, + secret: SecretKeyInput, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + self.state.verify_calls.fetch_add(1, Ordering::AcqRel); + if self.state.block_next_verify.swap(false, Ordering::AcqRel) { + self.state.verify_started.store(true, Ordering::Release); + while !self.state.release_verify.load(Ordering::Acquire) { + std::thread::yield_now(); + } + } + self.state + .inner + .verify(request_id, public_key, secret) + .await + }) + } + fn load(&self, public_key: PublicKey) -> BoxFuture<'_, Result<SecretKeyInput, SafeError>> { self.state.inner.load(public_key) } @@ -465,6 +534,153 @@ mod tests { assert!(worker.contains(public_key()).await.is_err()); } + #[tokio::test] + async fn worker_readonly_verification_forwards_full_binding_and_closes_without_mutation() { + let store = BlockingPutStore::new(); + store.release(); + let worker = BoundedKeyringWorker::new(store.clone()).expect("worker"); + worker + .put(&request_id(), public_key(), secret()) + .await + .expect("put"); + let exact = worker.verify(&request_id(), public_key(), secret()).await; + let changed_request = worker + .verify(&alternate_request_id(), public_key(), secret()) + .await + .expect_err("request mismatch"); + let changed_secret = worker + .verify( + &request_id(), + public_key(), + SecretKeyInput::parse( + "0000000000000000000000000000000000000000000000000000000000000002".to_owned(), + ) + .expect("different secret"), + ) + .await + .expect_err("secret mismatch"); + let missing = worker + .verify( + &request_id(), + PublicKey::from_bytes([8; 32]).expect("other public key"), + secret(), + ) + .await + .expect_err("missing credential"); + let retained = worker + .load(public_key()) + .await + .expect("retained credential"); + worker.close().await.expect("close after verification"); + let closed = worker + .verify(&request_id(), public_key(), secret()) + .await + .expect_err("closed worker"); + assert!(exact.is_ok()); + assert_eq!( + changed_request.code(), + SafeErrorCode::InvalidApplicationState + ); + assert_eq!( + changed_secret.code(), + SafeErrorCode::InvalidApplicationState + ); + assert_eq!(missing.code(), SafeErrorCode::CredentialMissing); + assert_eq!(closed.code(), SafeErrorCode::KeyringUnavailable); + assert_eq!(store.put_calls(), 1); + assert_eq!(store.state.verify_calls.load(Ordering::Acquire), 4); + assert!(retained.with_exposed_secret(|value| { + secret().with_exposed_secret(|expected| value == expected) + })); + assert!(worker.thread.lock().expect("thread").is_none()); + assert!(*worker.completion.borrow()); + let public_evidence = + format!("{changed_request:?} {changed_secret:?} {missing:?} {closed:?}"); + assert!(!secret().with_exposed_secret(|value| public_evidence.contains(value))); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn queued_readonly_verification_cancellation_skips_adapter_and_joins_on_close() { + let store = BlockingPutStore::new(); + let worker = BoundedKeyringWorker::new(store.clone()).expect("worker"); + let first_worker = Arc::clone(&worker); + let first = tokio::spawn(async move { + first_worker + .put(&request_id(), public_key(), secret()) + .await + }); + store.wait_until_started().await; + let request = request_id(); + let mut verification = worker.verify(&request, public_key(), secret()); + tokio::select! { + biased; + result = &mut verification => panic!("blocked worker completed verification: {result:?}"), + () = tokio::task::yield_now() => {} + } + drop(verification); + store.release(); + let first_result = first.await.expect("first task"); + worker + .close() + .await + .expect("close drains cancelled verification"); + assert!(first_result.is_ok()); + assert_eq!(store.put_calls(), 1); + assert_eq!(store.state.verify_calls.load(Ordering::Acquire), 0); + assert!(store.contains_direct(public_key()).await); + assert!(worker.thread.lock().expect("thread").is_none()); + assert!(*worker.completion.borrow()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn started_readonly_verification_caller_loss_keeps_close_resumable_until_join() { + let store = BlockingPutStore::new(); + store.release(); + let worker = BoundedKeyringWorker::new(store.clone()).expect("worker"); + worker + .put(&request_id(), public_key(), secret()) + .await + .expect("put"); + store.state.block_next_verify.store(true, Ordering::Release); + let verification_worker = Arc::clone(&worker); + let verification = tokio::spawn(async move { + verification_worker + .verify(&request_id(), public_key(), secret()) + .await + }); + store.wait_until_verification_started().await; + verification.abort(); + let caller_loss = verification + .await + .expect_err("cancelled verification caller"); + let timeout = worker.close_with_deadline(Duration::from_millis(1)).await; + let retained_thread = worker.thread.lock().expect("thread").is_some(); + store.release_verification(); + let resumed_close = worker.close_with_deadline(Duration::from_secs(1)).await; + let retained = store + .state + .inner + .load(public_key()) + .await + .expect("retained credential"); + assert!(caller_loss.is_cancelled()); + assert_eq!( + timeout + .expect_err("started verification keeps close pending") + .code(), + SafeErrorCode::PendingOperationRecoveryRequired + ); + assert!(retained_thread); + assert!(resumed_close.is_ok()); + assert_eq!(store.put_calls(), 1); + assert_eq!(store.state.verify_calls.load(Ordering::Acquire), 1); + assert!(retained.with_exposed_secret(|value| { + secret().with_exposed_secret(|expected| value == expected) + })); + assert!(worker.thread.lock().expect("thread").is_none()); + assert!(*worker.completion.borrow()); + } + #[test] fn operation_phases_are_closed_and_cancel_only_queued_work() { let cancelled = Arc::new(AtomicU8::new(OPERATION_QUEUED)); diff --git a/core/crates/harvestcircle_runtime/tests/durable_import_replay.rs b/core/crates/harvestcircle_runtime/tests/durable_import_replay.rs @@ -0,0 +1,738 @@ +use std::fs; +use std::path::PathBuf; + +use harvestcircle_application::{ + AppSnapshot, AppStateRepository, Clock, DurableIdentityOperation, DurableOperationKind, + DurableOperationPhase, DurableOperationRepository, DurableRequestId, FailureSecretStore, + IdentityRepository, ImportIdentityReceipt, KeyMaterialProvider, OperationPriorState, + RelayConfiguration, SecretStore, SecretStoreCall, SecretStoreOperation, +}; +use harvestcircle_domain::{ + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, + SafeError, SafeErrorCode, SecretKeyInput, SignerAvailability, UnixTimestamp, +}; +use harvestcircle_nostr::NostrKeyMaterialProvider; +use harvestcircle_runtime::PersistentAppCore; +use harvestcircle_storage::{ + HARVESTCIRCLE_TERMINAL_RECEIPT_RETENTION_SECONDS, HarvestCircleStorageContract, +}; +use nostr::{Keys, ToBech32}; +use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, +}; +use radroots_service_sqlite::MigrationBuildIdentity; +use tempfile::{TempDir, tempdir}; + +const NOW: i64 = 200; + +struct TestClock(i64); + +impl Clock for TestClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(self.0).expect("fixture timestamp") + } +} + +struct TestKey(Keys); + +impl TestKey { + fn generate() -> Self { + Self(Keys::generate()) + } + + fn opposite(&self) -> Self { + Self(Keys::new(nostr::SecretKey::from( + (**self.0.secret_key()).negate(), + ))) + } + + fn hex(&self) -> SecretKeyInput { + SecretKeyInput::parse(self.0.secret_key().to_secret_hex()).expect("fixture hex") + } + + fn nsec(&self) -> SecretKeyInput { + SecretKeyInput::parse( + self.0 + .secret_key() + .to_bech32() + .expect("fixture nsec encoding"), + ) + .expect("fixture nsec") + } + + fn invalid_nsec(&self) -> SecretKeyInput { + let input = self.nsec(); + input.with_exposed_secret(|value| { + let mut bytes = value.as_bytes().to_vec(); + let last = bytes.last_mut().expect("nonempty fixture nsec"); + *last = if *last == b'q' { b'p' } else { b'q' }; + SecretKeyInput::parse_bytes(bytes).expect("plausible nsec shape") + }) + } + + fn public_key(&self) -> PublicKey { + NostrKeyMaterialProvider + .import(self.hex()) + .expect("real fixture key derivation") + .into_parts() + .0 + } +} + +struct Fixture { + _directory: TempDir, + context: RuntimeContext, + build: MigrationBuildIdentity, + database_path: PathBuf, + secrets: FailureSecretStore, +} + +impl Fixture { + fn new() -> Self { + let directory = tempdir().expect("isolated temporary root"); + let root = directory.path().canonicalize().expect("canonical root"); + let context = RuntimeContext::resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::current(), + RadrootsHostEnvironment::default(), + ), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(root), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::SafeDefault, + ) + .expect("bootstrap input"), + ServiceId::new("harvestcircle").expect("service"), + InstanceId::new("desktop").expect("instance"), + ) + .expect("runtime context"); + fs::create_dir_all(directory.path().join("data")).expect("existing state root"); + let database_path = HarvestCircleStorageContract::from_runtime_context(&context) + .expect("storage contract") + .paths() + .state_database() + .to_path_buf(); + let build = MigrationBuildIdentity::new( + "0.1.0-alpha", + "1111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222", + "1.97.1", + "test", + "test", + 1, + 1, + 1, + 1, + 1, + ) + .expect("fixture build identity"); + Self { + _directory: directory, + context, + build, + database_path, + secrets: FailureSecretStore::default(), + } + } + + async fn open(&self) -> PersistentAppCore { + let adapter = PersistentAppCore::open( + &self.context, + RelayConfiguration::default(), + 200_000, + 200, + &self.build, + ) + .await + .expect("governed persistent core"); + adapter + .bootstrap(&self.secrets, &TestClock(NOW)) + .await + .expect("persistent bootstrap"); + adapter + } + + async fn admit(&self, adapter: &PersistentAppCore, input: SecretKeyInput) -> Admitted { + let request = DurableRequestId::new_v7(); + let revision = adapter.core().snapshot().revision().value(); + let receipt = adapter + .import_secret_key_durable(&request, revision, input, &self.secrets, &TestClock(NOW)) + .await + .expect("original admitted import"); + Admitted { + request, + revision, + receipt, + } + } + + async fn observe( + &self, + adapter: &PersistentAppCore, + request: &DurableRequestId, + ) -> Observation { + Observation { + operation: adapter + .database() + .load_durable_operation(request) + .await + .expect("journal observation"), + snapshot: adapter.core().snapshot(), + identities: adapter + .database() + .list_identities() + .await + .expect("identity observation"), + selected: adapter + .database() + .load_selected_identity() + .await + .expect("selection observation"), + unfinished: adapter + .database() + .list_unfinished_durable_operations() + .await + .expect("unfinished observation"), + mutations: self + .secrets + .calls() + .into_iter() + .filter(|call| { + matches!( + call.operation(), + SecretStoreOperation::Put | SecretStoreOperation::Delete + ) + }) + .collect(), + } + } + + async fn replay_and_close( + &self, + adapter: &PersistentAppCore, + request: &DurableRequestId, + revision: u64, + input: SecretKeyInput, + ) -> ReplayEvidence { + let before = self.observe(adapter, request).await; + let result = adapter + .import_secret_key_durable(request, revision, input, &self.secrets, &TestClock(NOW)) + .await; + let after = self.observe(adapter, request).await; + adapter.close().await.expect("explicit governed host close"); + let database_bytes = fs::read(&self.database_path).expect("closed database bytes"); + ReplayEvidence { + before, + result, + after, + database_bytes, + } + } +} + +struct Admitted { + request: DurableRequestId, + revision: u64, + receipt: ImportIdentityReceipt, +} + +#[derive(Debug, Eq, PartialEq)] +struct Observation { + operation: Option<DurableIdentityOperation>, + snapshot: AppSnapshot, + identities: Vec<NostrIdentity>, + selected: Option<PublicKey>, + unfinished: Vec<DurableIdentityOperation>, + mutations: Vec<SecretStoreCall>, +} + +struct ReplayEvidence { + before: Observation, + result: Result<ImportIdentityReceipt, SafeError>, + after: Observation, + database_bytes: Vec<u8>, +} + +impl ReplayEvidence { + fn assert_unchanged(&self) { + assert_eq!(self.before, self.after); + } + + fn assert_original(&self, admitted: &Admitted, kind: DurableOperationKind) { + let operation = self.before.operation.as_ref().expect("original operation"); + assert_eq!(operation.request_id(), &admitted.request); + assert_eq!(operation.kind(), kind); + assert_eq!(operation.expected_revision(), Some(admitted.revision)); + assert_eq!( + operation.identity(), + admitted.receipt.identity().public_key() + ); + assert_eq!(operation.phase(), DurableOperationPhase::Finalized); + assert!(operation.terminal().is_some()); + } + + fn assert_success(&self, admitted: &Admitted, kind: DurableOperationKind) { + self.assert_unchanged(); + self.assert_original(admitted, kind); + assert_eq!( + self.result.as_ref().expect("exact replay"), + &admitted.receipt + ); + } + + fn assert_error(&self, code: SafeErrorCode) { + self.assert_unchanged(); + assert_eq!( + self.result.as_ref().expect_err("replay must fail").code(), + code + ); + } + + fn assert_secret_absent(&self, input: &SecretKeyInput) { + let public = format!("{:?}{:?}{:?}", self.before, self.result, self.after); + assert!(!input.with_exposed_secret(|value| public.contains(value))); + assert!(!input.with_exposed_secret(|value| { + self.database_bytes + .windows(value.len()) + .any(|bytes| bytes == value.as_bytes()) + })); + assert!( + !self + .database_bytes + .windows(5) + .any(|bytes| bytes == b"nsec1") + ); + } + + fn assert_key_redacted(&self, key: &TestKey) { + self.assert_secret_absent(&key.hex()); + self.assert_secret_absent(&key.nsec()); + } +} + +#[tokio::test] +async fn completed_import_exact_replay_preserves_original_kind_receipt_and_custody() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let admitted = fixture.admit(&adapter, key.hex()).await; + let evidence = fixture + .replay_and_close(&adapter, &admitted.request, admitted.revision, key.hex()) + .await; + evidence.assert_success(&admitted, DurableOperationKind::Import); + evidence.assert_key_redacted(&key); +} + +#[tokio::test] +async fn completed_import_exact_replay_survives_unrelated_revision_advance() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let other = TestKey::generate(); + let admitted = fixture.admit(&adapter, key.hex()).await; + let unrelated = fixture.admit(&adapter, other.hex()).await; + adapter + .select_identity(unrelated.receipt.identity().public_key()) + .await + .expect("unrelated selection advance"); + let evidence = fixture + .replay_and_close(&adapter, &admitted.request, admitted.revision, key.hex()) + .await; + assert!(evidence.before.snapshot.revision().value() > admitted.revision); + assert_eq!( + evidence.before.selected, + Some(unrelated.receipt.identity().public_key()) + ); + evidence.assert_success(&admitted, DurableOperationKind::Import); + evidence.assert_key_redacted(&key); + evidence.assert_key_redacted(&other); +} + +#[tokio::test] +async fn completed_import_exact_replay_survives_database_reopen() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let other = TestKey::generate(); + let admitted = fixture.admit(&adapter, key.hex()).await; + fixture.admit(&adapter, other.hex()).await; + let original_operation = adapter + .database() + .load_durable_operation(&admitted.request) + .await + .expect("original durable receipt"); + adapter.close().await.expect("close before actual reopen"); + let reopened = fixture.open().await; + let evidence = fixture + .replay_and_close(&reopened, &admitted.request, admitted.revision, key.hex()) + .await; + assert_eq!(evidence.before.operation, original_operation); + evidence.assert_success(&admitted, DurableOperationKind::Import); + evidence.assert_key_redacted(&key); + evidence.assert_key_redacted(&other); +} + +#[tokio::test] +async fn completed_repair_exact_replay_preserves_original_kind_after_availability_restored() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let other = TestKey::generate(); + let public_key = key.public_key(); + let missing = NostrIdentity::new( + NostrIdentityReference::derive(public_key).expect("canonical identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::CredentialMissing), + None, + IdentityCreatedAt::new(TestClock(NOW).now()), + None, + ) + .expect("missing credential identity"); + adapter + .database() + .insert_identity(&missing) + .await + .expect("governed repair metadata"); + adapter + .database() + .save_selected_identity(Some(public_key)) + .await + .expect("governed repair selection"); + adapter + .close() + .await + .expect("close before repair bootstrap"); + let adapter = fixture.open().await; + let admitted = fixture.admit(&adapter, key.hex()).await; + fixture.admit(&adapter, other.hex()).await; + let evidence = fixture + .replay_and_close(&adapter, &admitted.request, admitted.revision, key.hex()) + .await; + assert_eq!( + admitted.receipt.identity().signer_binding().availability(), + SignerAvailability::Available + ); + evidence.assert_success(&admitted, DurableOperationKind::Repair); + evidence.assert_key_redacted(&key); + evidence.assert_key_redacted(&other); +} + +#[tokio::test] +async fn completed_import_replay_accepts_equivalent_nsec_and_hex() { + let mut observations = Vec::new(); + for first_nsec in [true, false] { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let first = if first_nsec { key.nsec() } else { key.hex() }; + let replay = if first_nsec { key.hex() } else { key.nsec() }; + let admitted = fixture.admit(&adapter, first).await; + let evidence = fixture + .replay_and_close(&adapter, &admitted.request, admitted.revision, replay) + .await; + observations.push((admitted, evidence, key)); + } + for (admitted, evidence, key) in observations { + evidence.assert_success(&admitted, DurableOperationKind::Import); + evidence.assert_key_redacted(&key); + } +} + +#[tokio::test] +async fn completed_import_replay_rejects_changed_identity() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let other = TestKey::generate(); + let distinct = key.public_key() != other.public_key(); + let admitted = fixture.admit(&adapter, key.hex()).await; + let evidence = fixture + .replay_and_close(&adapter, &admitted.request, admitted.revision, other.hex()) + .await; + assert!(distinct, "fixture identities must differ"); + evidence.assert_error(SafeErrorCode::InvalidApplicationState); + evidence.assert_key_redacted(&key); + evidence.assert_key_redacted(&other); +} + +#[tokio::test] +async fn completed_import_replay_rejects_changed_expected_revision() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let admitted = fixture.admit(&adapter, key.hex()).await; + let evidence = fixture + .replay_and_close( + &adapter, + &admitted.request, + admitted.revision + 1, + key.hex(), + ) + .await; + evidence.assert_error(SafeErrorCode::InvalidApplicationState); + evidence.assert_key_redacted(&key); +} + +#[tokio::test] +async fn completed_import_replay_rejects_completed_create_request() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let request = DurableRequestId::new_v7(); + let revision = adapter.core().snapshot().revision().value(); + let generated = adapter + .generate_identity_durable(&request, revision, &fixture.secrets, &TestClock(NOW)) + .await + .expect("real durable creation"); + let input = generated + .generated_nsec() + .with_exposed_secret(|value| SecretKeyInput::parse(value.to_owned())) + .expect("generated input"); + let canonical = NostrKeyMaterialProvider + .import(input) + .expect("canonical generated key") + .into_parts() + .2; + let replay = canonical + .with_exposed_secret(|value| SecretKeyInput::parse(value.to_owned())) + .expect("replay input"); + let evidence = fixture + .replay_and_close(&adapter, &request, revision, replay) + .await; + assert_eq!( + evidence + .before + .operation + .as_ref() + .expect("create receipt") + .kind(), + DurableOperationKind::Create + ); + evidence.assert_error(SafeErrorCode::InvalidApplicationState); + evidence.assert_secret_absent(&canonical); +} + +#[tokio::test] +async fn completed_import_replay_rejects_completed_remove_request() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let other = TestKey::generate(); + let original = fixture.admit(&adapter, key.hex()).await; + fixture.admit(&adapter, other.hex()).await; + let request = DurableRequestId::new_v7(); + let revision = adapter.core().snapshot().revision().value(); + let token = adapter + .request_identity_removal(original.receipt.identity().public_key(), &TestClock(NOW)) + .expect("explicit removal authority"); + adapter + .confirm_identity_removal_durable(&request, token, &fixture.secrets, &TestClock(NOW)) + .await + .expect("completed removal"); + fixture.admit(&adapter, key.hex()).await; + let evidence = fixture + .replay_and_close(&adapter, &request, revision, key.hex()) + .await; + assert_eq!( + evidence + .before + .operation + .as_ref() + .expect("remove receipt") + .kind(), + DurableOperationKind::Remove + ); + evidence.assert_error(SafeErrorCode::InvalidApplicationState); + evidence.assert_key_redacted(&key); + evidence.assert_key_redacted(&other); +} + +#[tokio::test] +async fn completed_import_replay_rejects_opposite_scalar_with_same_x_only_identity() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let opposite = key.opposite(); + let same_identity = key.public_key() == opposite.public_key(); + let distinct_input = key + .hex() + .with_exposed_secret(|first| opposite.hex().with_exposed_secret(|second| first != second)); + let admitted = fixture.admit(&adapter, key.hex()).await; + let evidence = fixture + .replay_and_close( + &adapter, + &admitted.request, + admitted.revision, + opposite.hex(), + ) + .await; + assert!( + same_identity, + "real opposite scalars must share x-only identity" + ); + assert!(distinct_input, "the complete scalar inputs must differ"); + evidence.assert_error(SafeErrorCode::InvalidApplicationState); + evidence.assert_key_redacted(&key); + evidence.assert_key_redacted(&opposite); +} + +#[tokio::test] +async fn completed_import_replay_rejects_invalid_nsec_checksum() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let admitted = fixture.admit(&adapter, key.hex()).await; + let invalid = key.invalid_nsec(); + let evidence = fixture + .replay_and_close( + &adapter, + &admitted.request, + admitted.revision, + key.invalid_nsec(), + ) + .await; + evidence.assert_error(SafeErrorCode::InvalidSecretKey); + evidence.assert_key_redacted(&key); + evidence.assert_secret_absent(&invalid); +} + +#[tokio::test] +async fn completed_import_replay_rejects_missing_credential() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let admitted = fixture.admit(&adapter, key.hex()).await; + fixture + .secrets + .delete(&admitted.request, admitted.receipt.identity().public_key()) + .await + .expect("isolated missing credential"); + let evidence = fixture + .replay_and_close(&adapter, &admitted.request, admitted.revision, key.hex()) + .await; + evidence.assert_error(SafeErrorCode::CredentialMissing); + evidence.assert_key_redacted(&key); +} + +#[tokio::test] +async fn completed_import_replay_rejects_credential_replaced_under_another_request() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let admitted = fixture.admit(&adapter, key.hex()).await; + let public_key = admitted.receipt.identity().public_key(); + fixture + .secrets + .delete(&admitted.request, public_key) + .await + .expect("remove isolated original credential"); + let replacement = DurableRequestId::new_v7(); + fixture + .secrets + .put(&replacement, public_key, key.hex()) + .await + .expect("replacement with another request"); + let evidence = fixture + .replay_and_close(&adapter, &admitted.request, admitted.revision, key.hex()) + .await; + assert_ne!(replacement, admitted.request); + evidence.assert_error(SafeErrorCode::InvalidApplicationState); + evidence.assert_key_redacted(&key); +} + +#[tokio::test] +async fn completed_import_replay_rejects_replaced_secret_with_same_x_only_identity() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let opposite = key.opposite(); + let same_identity = key.public_key() == opposite.public_key(); + let distinct_input = key + .hex() + .with_exposed_secret(|first| opposite.hex().with_exposed_secret(|second| first != second)); + let admitted = fixture.admit(&adapter, key.hex()).await; + let public_key = admitted.receipt.identity().public_key(); + fixture + .secrets + .delete(&admitted.request, public_key) + .await + .expect("remove isolated original credential"); + fixture + .secrets + .put(&admitted.request, public_key, opposite.hex()) + .await + .expect("replace full scalar under the original request"); + let evidence = fixture + .replay_and_close(&adapter, &admitted.request, admitted.revision, key.hex()) + .await; + assert!( + same_identity, + "real opposite scalars must share x-only identity" + ); + assert!(distinct_input, "the complete scalar inputs must differ"); + evidence.assert_error(SafeErrorCode::InvalidApplicationState); + evidence.assert_key_redacted(&key); + evidence.assert_key_redacted(&opposite); +} + +#[tokio::test] +async fn durable_import_replay_requires_terminal_receipt() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let request = DurableRequestId::new_v7(); + let revision = adapter.core().snapshot().revision().value(); + adapter + .database() + .begin_durable_operation( + &request, + DurableOperationKind::Import, + key.public_key(), + Some(revision), + OperationPriorState::new(None, None), + TestClock(NOW).now(), + ) + .await + .expect("unfinished governed intent"); + let evidence = fixture + .replay_and_close(&adapter, &request, revision, key.hex()) + .await; + assert!( + evidence + .before + .operation + .as_ref() + .expect("intent") + .terminal() + .is_none() + ); + evidence.assert_error(SafeErrorCode::PendingOperationRecoveryRequired); + evidence.assert_key_redacted(&key); +} + +#[tokio::test] +async fn expired_import_receipt_is_not_recreated_as_success() { + let fixture = Fixture::new(); + let adapter = fixture.open().await; + let key = TestKey::generate(); + let other = TestKey::generate(); + let admitted = fixture.admit(&adapter, key.hex()).await; + let cleanup_time = NOW + HARVESTCIRCLE_TERMINAL_RECEIPT_RETENTION_SECONDS + 1; + adapter + .import_secret_key_durable( + &DurableRequestId::new_v7(), + adapter.core().snapshot().revision().value(), + other.hex(), + &fixture.secrets, + &TestClock(cleanup_time), + ) + .await + .expect("unrelated admission triggers existing expired cleanup"); + let evidence = fixture + .replay_and_close(&adapter, &admitted.request, admitted.revision, key.hex()) + .await; + assert!(evidence.before.operation.is_none()); + evidence.assert_error(SafeErrorCode::InvalidApplicationState); + evidence.assert_key_redacted(&key); + evidence.assert_key_redacted(&other); +} diff --git a/core/crates/harvestcircle_storage/src/os_keyring.rs b/core/crates/harvestcircle_storage/src/os_keyring.rs @@ -62,6 +62,20 @@ impl SecretStore for OsKeyringSecretStore { }) } + fn verify<'a>( + &'a self, + request_id: &'a DurableRequestId, + public_key: PublicKey, + secret: SecretKeyInput, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + let _operation = self.operation()?; + let account = public_key.to_hex(); + let encoded = Zeroizing::new(platform_read(&account).map_err(map_read_error)?); + verify_replay_binding(request_id, &secret, encoded.as_slice()) + }) + } + fn load(&self, public_key: PublicKey) -> BoxFuture<'_, Result<SecretKeyInput, SafeError>> { Box::pin(async move { let _operation = self.operation()?; @@ -147,6 +161,23 @@ fn verify_existing_replay( } } +fn verify_replay_binding( + request_id: &DurableRequestId, + secret: &SecretKeyInput, + encoded: &[u8], +) -> Result<(), SafeError> { + verify_existing_replay(request_id, secret, encoded).map_err(|error| { + if error.code() == SafeErrorCode::IdentityAlreadyExists { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The identity operation conflicts with the stored credential."), + ) + } else { + error + } + }) +} + const fn map_read_error(error: ReadError) -> SafeError { match error { ReadError::Missing => credential_missing(), @@ -363,8 +394,9 @@ mod tests { use super::{ CREDENTIAL_ENVELOPE_DOMAIN, CREDENTIAL_SERVICE, OsKeyringSecretStore, decode_credential, - encode_credential, verify_existing_replay, + encode_credential, verify_existing_replay, verify_replay_binding, }; + use zeroize::Zeroizing; const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; @@ -446,6 +478,60 @@ mod tests { } #[test] + fn readonly_envelope_verification_preserves_bytes_and_safe_conflict_errors() { + let secret = SecretKeyInput::parse(SECRET.to_owned()).expect("secret"); + let request = request_id(); + let encoded = encode_credential(&request, &secret); + let before = Zeroizing::new(encoded.to_vec()); + let exact = verify_replay_binding(&request, &secret, encoded.as_slice()); + let another_request = DurableRequestId::new_v7(); + let changed_request = verify_replay_binding(&another_request, &secret, encoded.as_slice()) + .expect_err("original request required"); + let another_secret = SecretKeyInput::parse( + "0000000000000000000000000000000000000000000000000000000000000002".to_owned(), + ) + .expect("different secret"); + let changed_secret = verify_replay_binding(&request, &another_secret, encoded.as_slice()) + .expect_err("full secret required"); + let malformed = verify_replay_binding(&request, &secret, &encoded[..encoded.len() - 1]) + .expect_err("malformed native envelope"); + assert!(exact.is_ok()); + assert_eq!( + changed_request.code(), + SafeErrorCode::InvalidApplicationState + ); + assert_eq!( + changed_secret.code(), + SafeErrorCode::InvalidApplicationState + ); + assert_eq!(malformed.code(), SafeErrorCode::KeyringUnavailable); + assert!(encoded.as_slice() == before.as_slice()); + let public_evidence = format!("{changed_request:?} {changed_secret:?} {malformed:?}"); + assert!(!public_evidence.contains(SECRET)); + assert!(!another_secret.with_exposed_secret(|value| public_evidence.contains(value))); + } + + #[tokio::test] + async fn poisoned_readonly_verification_fails_before_os_custody_access() { + let store = OsKeyringSecretStore::default(); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _operation = store.operation_lock.lock().expect("operation lock"); + panic!("injected operation failure"); + })); + let error = store + .verify( + &request_id(), + public_key(), + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .await + .expect_err("poison must reject before native read"); + assert!(panic.is_err()); + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + assert!(!format!("{error:?}").contains(SECRET)); + } + + #[test] fn keyring_coordinates_are_stable_and_public() { assert_eq!(CREDENTIAL_SERVICE, "org.harvestcircle.desktop.nostr"); assert_eq!( diff --git a/core/crates/harvestcircle_storage/tests/package_boundary.rs b/core/crates/harvestcircle_storage/tests/package_boundary.rs @@ -16,7 +16,7 @@ fn storage_package_keeps_one_sqlite_authority_and_a_sealed_public_surface() { let database_source = read(&crate_root.join("src/db.rs")); let journal_source = read(&crate_root.join("src/journal.rs")); let keyring_source = read(&crate_root.join("src/os_keyring.rs")); - let api = read(&workspace_root.join("compatibility/harvestcircle-storage-api-v1.txt")); + let api = read(&workspace_root.join("compatibility/harvestcircle-storage-api-v2.txt")); for forbidden in ["rusqlite", "refinery", "hmac", "rustix"] { assert!( @@ -61,6 +61,49 @@ fn storage_package_keeps_one_sqlite_authority_and_a_sealed_public_surface() { assert!(keyring_source.contains("add_generic_password")); assert!(keyring_source.contains("CREDENTIAL_OPERATION_ATTRIBUTE")); assert!(keyring_source.contains("false,\n \"application/octet-stream\"")); + let readonly_verification = keyring_source + .split_once(" fn verify<'a>(") + .and_then(|(_, source)| source.split_once("\n fn load(")) + .map(|(body, _)| body) + .expect("request-bound read-only verification method"); + for required in [ + "request_id: &'a DurableRequestId", + "public_key: PublicKey", + "secret: SecretKeyInput", + "self.operation()?", + "Zeroizing::new(platform_read(&account).map_err(map_read_error)?)", + "verify_replay_binding(request_id, &secret, encoded.as_slice())", + ] { + assert!( + readonly_verification.contains(required), + "read-only custody boundary is missing {required}" + ); + } + for forbidden in ["platform_create(", "platform_delete(", ".put(", ".delete("] { + assert!( + !readonly_verification.contains(forbidden), + "verification mutates custody through {forbidden}" + ); + } + assert!(keyring_source.contains("verify_existing_replay(request_id, secret, encoded).map_err")); + assert!(keyring_source.contains("SafeErrorCode::InvalidApplicationState")); + let envelope_comparison = keyring_source + .split_once("fn verify_existing_replay(") + .and_then(|(_, source)| source.split_once("\nfn verify_replay_binding(")) + .map(|(body, _)| body) + .expect("shared complete envelope comparison"); + for required in [ + "decode_credential(encoded)?", + "existing_request == *request_id", + "existing_secret", + "secret.with_exposed_secret(|expected| value == expected)", + "Err(credential_exists())", + ] { + assert!( + envelope_comparison.contains(required), + "complete custody binding is missing {required}" + ); + } assert!(!database_source.contains("pub fn host")); assert!(!database_source.contains("pub const fn host")); @@ -76,6 +119,7 @@ fn storage_package_keeps_one_sqlite_authority_and_a_sealed_public_surface() { "harvestcircle_application::ports::BoxFuture", "pub fn harvestcircle_storage::OsKeyringSecretStore::contains(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture", "pub fn harvestcircle_storage::OsKeyringSecretStore::put<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_domain::key::PublicKey, harvestcircle_domain::key::SecretKeyInput) -> harvestcircle_application::ports::BoxFuture<'a", + "pub fn harvestcircle_storage::OsKeyringSecretStore::verify<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_domain::key::PublicKey, harvestcircle_domain::key::SecretKeyInput) -> harvestcircle_application::ports::BoxFuture<'a", "pub fn harvestcircle_storage::OsKeyringSecretStore::delete<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'a", "pub fn harvestcircle_storage::harvestcircle_migration_catalog()", "pub fn harvestcircle_storage::harvestcircle_schema_catalog()", diff --git a/tools/verify-storage-api.sh b/tools/verify-storage-api.sh @@ -12,4 +12,4 @@ cargo +nightly-2026-07-16 public-api \ -p harvestcircle_storage \ --all-features \ -sss >"$output" -cmp core/compatibility/harvestcircle-storage-api-v1.txt "$output" +cmp core/compatibility/harvestcircle-storage-api-v2.txt "$output" diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -406,6 +406,64 @@ fn native_runtime_boundary(root: &Path, findings: &mut Vec<String>) { if keyring.contains("std::sync::mpsc::Receiver") { findings.push("harvestcircle_ffi: keyring response exposes a blocking receiver".to_owned()); } + let native_keyring = read_text(root, "core/crates/harvestcircle_storage/src/os_keyring.rs"); + let native_verify = native_keyring + .split_once(" fn verify<'a>(") + .and_then(|(_, source)| source.split_once("\n fn load(")) + .map(|(body, _)| body) + .unwrap_or_default(); + for required in [ + "request_id: &'a DurableRequestId", + "secret: SecretKeyInput", + "self.operation()?", + "Zeroizing::new(platform_read(&account).map_err(map_read_error)?)", + "verify_replay_binding(request_id, &secret, encoded.as_slice())", + ] { + if !native_verify.contains(required) { + findings.push(format!( + "harvestcircle_storage: read-only verification is missing {required}" + )); + } + } + for forbidden in ["platform_create(", "platform_delete(", ".put(", ".delete("] { + if native_verify.contains(forbidden) { + findings.push(format!( + "harvestcircle_storage: verification mutates custody through {forbidden}" + )); + } + } + let worker_verify = keyring + .split_once("Request::Verify(request_id, public_key, secret, phase, response) => {") + .and_then(|(_, source)| source.split_once("Request::Load(")) + .map(|(body, _)| body) + .unwrap_or_default(); + for required in [ + "start_operation(&phase)", + "store.verify(&request_id, public_key, secret).await", + "finish_operation(&phase)", + "response.send(result)", + ] { + if !worker_verify.contains(required) { + findings.push(format!( + "harvestcircle_ffi: verification lifecycle is missing {required}" + )); + } + } + let worker_submit = keyring + .split_once(" fn verify<'a>(") + .and_then(|(_, source)| source.split_once("\n fn load(")) + .map(|(body, _)| body) + .unwrap_or_default(); + if !worker_submit.contains("self.submit(|phase, response|") + || !worker_submit + .contains("Request::Verify(request_id.clone(), public_key, secret, phase, response)") + || worker_submit.contains("Request::Put(") + { + findings.push( + "harvestcircle_ffi: verification bypasses the bounded read-only worker submission" + .to_owned(), + ); + } } fn namespace_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<String>) { @@ -1216,7 +1274,7 @@ fn provenance_check(root: &Path, inventory: &Inventory, findings: &mut Vec<Strin { findings.push("app/shared/build.gradle.kts: shared KMP target boundary changed".to_owned()); } - const STORAGE_API_BASELINE: &str = "core/compatibility/harvestcircle-storage-api-v1.txt"; + const STORAGE_API_BASELINE: &str = "core/compatibility/harvestcircle-storage-api-v2.txt"; let storage_api = read_text(root, STORAGE_API_BASELINE); for required in [ "pub struct harvestcircle_storage::HarvestCircleStorageContract", @@ -1231,6 +1289,7 @@ fn provenance_check(root: &Path, inventory: &Inventory, findings: &mut Vec<Strin "pub fn harvestcircle_storage::verify_harvestcircle_backup", "impl harvestcircle_application::ports::DurableOperationRepository for harvestcircle_storage::Database", "harvestcircle_application::ports::BoxFuture", + "pub fn harvestcircle_storage::OsKeyringSecretStore::verify<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_domain::key::PublicKey, harvestcircle_domain::key::SecretKeyInput) -> harvestcircle_application::ports::BoxFuture<'a", ] { if !storage_api.contains(required) { findings.push(format!("{STORAGE_API_BASELINE}: missing {required}"));