commit 9811d487b3948cd9d5417995f400ae9fdb576efa
parent bce7f8d924bc33c7009eb98a51dcae425341f9d3
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 04:07:15 +0000
test: make integration recovery custody opaque
- replace the secret-bearing bridge record with a UniFFI request object
- resolve acknowledge and cancel against the exact one-use request
- clear and close Kotlin recovery custody on every terminal path
- prove redaction, duplicate resolution, and package isolation behavior
Diffstat:
3 files changed, 165 insertions(+), 61 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
@@ -8,12 +8,14 @@ import org.harvestcircle.application.SecretKeyInput
import org.harvestcircle.application.SessionLifecycle
import org.harvestcircle.application.SnapshotRevision
import org.harvestcircle.ffi.compatibilityDescriptor
+import org.harvestcircle.testbridge.ffi.HarvestCircleTestBridge
import org.harvestcircle.testbridge.ffi.TestBridgeException
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.readBytes
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -21,6 +23,59 @@ import kotlin.test.fail
class NativeRuntimeIntegrationTest {
@Test
+ fun generatedRecoveryRequestIsOpaqueOneUseAndResolvedByIdentity() {
+ val dataRoot = Files.createTempDirectory("harvestcircle-recovery-request-")
+ val bridge = HarvestCircleTestBridge.open(dataRoot.toString())
+ try {
+ val initial = bridge.bootstrap()
+ val request = bridge.beginGeneratedIdentity()
+ val secret = request.takeRecoveryNsec()
+ assertTrue(secret.startsWith("nsec1"))
+ val secondRead = assertFailsWith<TestBridgeException.Failure> { request.takeRecoveryNsec() }
+ assertFalse(secondRead.safeMessage.contains(secret))
+ assertFalse(request.toString().contains(secret))
+
+ val committed =
+ bridge.acknowledgeGeneratedIdentity(
+ "00000000-0000-7000-8000-000000000011",
+ initial.revision,
+ 2_000UL,
+ request,
+ )
+ assertEquals(request.identity().publicKeyHex, committed.selectedPublicKeyHex)
+ val duplicateAcknowledge =
+ assertFailsWith<TestBridgeException.Failure> {
+ bridge.acknowledgeGeneratedIdentity(
+ "00000000-0000-7000-8000-000000000012",
+ committed.revision,
+ 2_000UL,
+ request,
+ )
+ }
+ assertFalse(duplicateAcknowledge.safeMessage.contains(secret))
+ request.close()
+
+ val cancelled = bridge.beginGeneratedIdentity()
+ val cancelledSecret = cancelled.takeRecoveryNsec()
+ assertTrue(bridge.cancelGeneratedIdentity(cancelled))
+ assertFalse(bridge.cancelGeneratedIdentity(cancelled))
+ assertFalse(cancelled.toString().contains(cancelledSecret))
+ cancelled.close()
+
+ val publicEvidence = bridge.snapshot().toString() + secondRead.safeMessage + duplicateAcknowledge.safeMessage
+ assertFalse(publicEvidence.contains(secret))
+ assertFalse(publicEvidence.contains(cancelledSecret))
+ val databaseBytes = dataRoot.resolve("harvestcircle-integration.sqlite3").readBytes()
+ assertFalse(databaseBytes.containsBytes(secret.encodeToByteArray()))
+ assertFalse(databaseBytes.containsBytes(cancelledSecret.encodeToByteArray()))
+ bridge.shutdown()
+ } finally {
+ bridge.close()
+ deleteTree(dataRoot)
+ }
+ }
+
+ @Test
fun nativeBridgeCoversIdentityRelayRestartObserverTimeoutAndRedaction() =
runBlocking {
val dataRoot = Files.createTempDirectory("harvestcircle-native-integration-")
diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/TestBridgeHarvestCircleRuntime.kt
@@ -40,6 +40,7 @@ import org.harvestcircle.application.SnapshotRevision
import org.harvestcircle.application.UnixSeconds
import org.harvestcircle.testbridge.ffi.HarvestCircleTestBridge
import org.harvestcircle.testbridge.ffi.TestBridgeException
+import org.harvestcircle.testbridge.ffi.TestGeneratedRecoveryRequest
import org.harvestcircle.testbridge.ffi.TestIdentity
import org.harvestcircle.testbridge.ffi.TestSnapshot
@@ -49,7 +50,7 @@ internal class TestBridgeHarvestCircleRuntime private constructor(
AutoCloseable {
override val buildInfo: BuildInfo = BuildInfo.unknown()
- private var generatedRequest: RecoveryRequestId? = null
+ private var generatedRequest: PendingGeneratedRequest? = null
private var closed = false
private var shutdownReceipt: ShutdownReceipt? = null
@@ -76,25 +77,32 @@ internal class TestBridgeHarvestCircleRuntime private constructor(
override suspend fun execute(command: ApplicationCommand): ApplicationCommandResult =
when (command) {
is ApplicationCommand.AcknowledgeGeneratedIdentity -> {
- require(command.requestId == generatedRequest) { "Generated recovery request does not match" }
- generatedRequest = null
- val snapshot =
- callBridge {
- bridge
- .acknowledgeGeneratedIdentity(
- command.context.operationId.value,
- command.context.expectedRevision.value,
- command.context.deadlineMillis,
- ).toApplicationSnapshot()
- }
- ApplicationCommandResult.Committed(command.context.operationId, snapshot.revision, snapshot)
+ val pending = takeGeneratedRequest(command.requestId)
+ try {
+ val snapshot =
+ callBridge {
+ bridge
+ .acknowledgeGeneratedIdentity(
+ command.context.operationId.value,
+ command.context.expectedRevision.value,
+ command.context.deadlineMillis,
+ pending.native,
+ ).toApplicationSnapshot()
+ }
+ ApplicationCommandResult.Committed(command.context.operationId, snapshot.revision, snapshot)
+ } finally {
+ pending.close()
+ }
}
is ApplicationCommand.CancelGeneratedIdentity -> {
- require(command.requestId == generatedRequest) { "Generated recovery request does not match" }
- generatedRequest = null
- callBridge { bridge.cancelGeneratedIdentity() }
- ApplicationCommandResult.Updated(currentSnapshot())
+ val pending = takeGeneratedRequest(command.requestId)
+ try {
+ callBridge { check(bridge.cancelGeneratedIdentity(pending.native)) }
+ ApplicationCommandResult.Updated(currentSnapshot())
+ } finally {
+ pending.close()
+ }
}
is ApplicationCommand.ImportLocalIdentity -> {
@@ -131,15 +139,25 @@ internal class TestBridgeHarvestCircleRuntime private constructor(
}
override suspend fun prepareLocalIdentity(): GeneratedIdentityRecovery {
- val generated = callBridge { bridge.beginGeneratedIdentity() }
- val requestId = RecoveryRequestId.from("bridge:${generated.stageId}")
- generatedRequest = requestId
- return GeneratedIdentityRecovery(
- requestId = requestId,
- identity = generated.identity.toIdentitySummary(),
- expiresAt = UnixSeconds(generated.expiresAtSeconds),
- backup = GeneratedKeyBackup(generated.identity.npub, generated.recoveryNsec),
- )
+ val native = callBridge { bridge.beginGeneratedIdentity() }
+ var backup: GeneratedKeyBackup? = null
+ return try {
+ val identity = callBridge { native.identity() }.toIdentitySummary()
+ val createdBackup = GeneratedKeyBackup(identity.npub, callBridge { native.takeRecoveryNsec() })
+ backup = createdBackup
+ val requestId = RecoveryRequestId.from("bridge:${identity.id.value}")
+ generatedRequest = PendingGeneratedRequest(requestId, native, createdBackup)
+ GeneratedIdentityRecovery(
+ requestId = requestId,
+ identity = identity,
+ expiresAt = UnixSeconds(callBridge { native.expiresAtSeconds() }),
+ backup = createdBackup,
+ )
+ } catch (error: Exception) {
+ backup?.clear()
+ native.close()
+ throw error
+ }
}
override suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest = throw unsupportedRemoval()
@@ -148,6 +166,8 @@ internal class TestBridgeHarvestCircleRuntime private constructor(
override suspend fun shutdown(): ShutdownReceipt {
shutdownReceipt?.let { return it }
+ generatedRequest?.close()
+ generatedRequest = null
val snapshot = callBridge { bridge.shutdown().toApplicationSnapshot() }
closed = true
return ShutdownReceipt(snapshot.revision, snapshot.lifecycle == ApplicationLifecycle.Closed).also {
@@ -158,6 +178,8 @@ internal class TestBridgeHarvestCircleRuntime private constructor(
fun seedSelectedProfile(displayName: String) = callBridge { bridge.seedSelectedProfile(displayName) }
fun restart(): ApplicationSnapshot {
+ generatedRequest?.close()
+ generatedRequest = null
val snapshot = callBridge { bridge.restart().toApplicationSnapshot() }
closed = false
shutdownReceipt = null
@@ -167,6 +189,8 @@ internal class TestBridgeHarvestCircleRuntime private constructor(
fun nativeBridge(): HarvestCircleTestBridge = bridge
override fun close() {
+ generatedRequest?.close()
+ generatedRequest = null
if (!closed) runCatching { bridge.shutdown() }
closed = true
bridge.close()
@@ -184,12 +208,30 @@ internal class TestBridgeHarvestCircleRuntime private constructor(
),
)
+ private fun takeGeneratedRequest(requestId: RecoveryRequestId): PendingGeneratedRequest {
+ val pending = generatedRequest
+ require(pending?.requestId == requestId) { "Generated recovery request does not match" }
+ generatedRequest = null
+ return pending
+ }
+
companion object {
fun open(dataDirectory: String): TestBridgeHarvestCircleRuntime =
TestBridgeHarvestCircleRuntime(HarvestCircleTestBridge.open(dataDirectory))
}
}
+private class PendingGeneratedRequest(
+ val requestId: RecoveryRequestId,
+ val native: TestGeneratedRecoveryRequest,
+ private val backup: GeneratedKeyBackup,
+) {
+ fun close() {
+ backup.clear()
+ native.close()
+ }
+}
+
private fun TestIdentity.toIdentitySummary(): IdentitySummary =
IdentitySummary(
id = IdentityId.fromPublicKeyHex(publicKeyHex),
diff --git a/core/crates/harvestcircle_test_bridge/src/lib.rs b/core/crates/harvestcircle_test_bridge/src/lib.rs
@@ -4,6 +4,7 @@ use std::fmt::{self, Display, Formatter};
use std::fs;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -47,12 +48,28 @@ pub struct TestSnapshot {
pub profile_display_name: Option<String>,
}
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct TestGeneratedRecovery {
- pub stage_id: u64,
- pub identity: TestIdentity,
- pub recovery_nsec: String,
- pub expires_at_seconds: i64,
+#[derive(uniffi::Object)]
+pub struct TestGeneratedRecoveryRequest {
+ handle: GeneratedKeyRecoveryHandle,
+ resolved: AtomicBool,
+}
+
+#[uniffi::export]
+impl TestGeneratedRecoveryRequest {
+ pub fn identity(&self) -> TestIdentity {
+ to_identity(self.handle.view().identity())
+ }
+
+ pub fn expires_at_seconds(&self) -> i64 {
+ self.handle.view().expires_at().as_seconds()
+ }
+
+ pub fn take_recovery_nsec(&self) -> Result<String, TestBridgeError> {
+ self.handle
+ .take_recovery_nsec()
+ .map(|nsec| nsec.with_exposed_secret(ToOwned::to_owned))
+ .map_err(TestBridgeError::from)
+ }
}
#[derive(Debug, uniffi::Error)]
@@ -108,7 +125,6 @@ pub struct HarvestCircleTestBridge {
runtime: Runtime,
actor: Mutex<Option<RuntimeActorHandle>>,
observer: Mutex<Option<RuntimeChangeSubscription>>,
- pending_generation: Mutex<Option<GeneratedKeyRecoveryHandle>>,
secrets: Arc<InMemorySecretStore>,
clock: Arc<FixedClock>,
relay: Mutex<Option<MockRelay>>,
@@ -147,7 +163,6 @@ impl HarvestCircleTestBridge {
runtime,
actor: Mutex::new(Some(actor)),
observer: Mutex::new(None),
- pending_generation: Mutex::new(None),
secrets,
clock,
relay: Mutex::new(Some(relay)),
@@ -165,23 +180,15 @@ impl HarvestCircleTestBridge {
Ok(to_snapshot(self.actor()?.snapshot()))
}
- pub fn begin_generated_identity(&self) -> Result<TestGeneratedRecovery, TestBridgeError> {
+ pub fn begin_generated_identity(
+ &self,
+ ) -> Result<Arc<TestGeneratedRecoveryRequest>, TestBridgeError> {
let actor = self.actor()?;
let handle = self.runtime.block_on(actor.begin_generated_key_stage())?;
- let recovery_nsec = handle
- .take_recovery_nsec()?
- .with_exposed_secret(ToOwned::to_owned);
- let recovery = TestGeneratedRecovery {
- stage_id: handle.id().value(),
- identity: to_identity(handle.view().identity()),
- recovery_nsec,
- expires_at_seconds: handle.view().expires_at().as_seconds(),
- };
- *self
- .pending_generation
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(handle);
- Ok(recovery)
+ Ok(Arc::new(TestGeneratedRecoveryRequest {
+ handle,
+ resolved: AtomicBool::new(false),
+ }))
}
pub fn acknowledge_generated_identity(
@@ -189,18 +196,16 @@ impl HarvestCircleTestBridge {
request_id: String,
expected_revision: u64,
timeout_millis: u64,
+ request: Arc<TestGeneratedRecoveryRequest>,
) -> Result<TestSnapshot, TestBridgeError> {
- let handle = self
- .pending_generation
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .take()
- .ok_or_else(request_unavailable)?;
+ if request.resolved.swap(true, Ordering::AcqRel) {
+ return Err(request_unavailable());
+ }
let actor = self.actor()?;
let snapshot = self
.runtime
.block_on(actor.acknowledge_generated_key_stage(
- handle.id(),
+ request.handle.id(),
DurableRequestId::parse(request_id)?,
SnapshotRevision::from_value(expected_revision),
Duration::from_millis(timeout_millis),
@@ -208,11 +213,13 @@ impl HarvestCircleTestBridge {
Ok(to_snapshot(snapshot))
}
- pub fn cancel_generated_identity(&self) -> Result<bool, TestBridgeError> {
- self.pending_generation
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .take();
+ pub fn cancel_generated_identity(
+ &self,
+ request: Arc<TestGeneratedRecoveryRequest>,
+ ) -> Result<bool, TestBridgeError> {
+ if request.resolved.swap(true, Ordering::AcqRel) {
+ return Ok(false);
+ }
let actor = self.actor()?;
Ok(self.runtime.block_on(actor.cancel_generated_key_stage())?)
}