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:
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}"));