commit 41b6565782051e6539425a2592a8cde448c77566 parent 5404bf8cc25ff8dda5798730c262f4aed86c49e8 Author: triesap <tyson@radroots.org> Date: Thu, 24 Sep 2026 18:03:55 +0000 identity: preserve custody through alignment recovery - Adopt guarded legacy migration and cancellation admission - Recover interrupted custody cleanup without replacement - Clear native import fields and test identity continuity - Verify exact artifacts and unchanged installed namespaces Diffstat:
17 files changed, 523 insertions(+), 67 deletions(-)
diff --git a/Package.resolved b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "ea2a7fbe9259140e0e71c1116044053a9e39b050315019d4613fa31a6b4c8cc8", + "originHash" : "02241e66982285fd76c9b0239f3e5c58b0606b09fdcaab35c3cf411f2de7207a", "pins" : [ { "identity" : "apple_kit", "kind" : "remoteSourceControl", "location" : "https://github.com/radrootslabs/apple_kit.git", "state" : { - "revision" : "1981db67ea91dab278bf0c2a9781cc53b9d1e47e" + "revision" : "1126a77ed87387719a6c6d3b4ce580582af29553" } }, { diff --git a/Package.swift b/Package.swift @@ -14,7 +14,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/radrootslabs/apple_kit.git", - revision: "1981db67ea91dab278bf0c2a9781cc53b9d1e47e" + revision: "1126a77ed87387719a6c6d3b4ce580582af29553" ), ], targets: [ diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ 39B47EE698E9D7CC716A372F /* TeraRuntimeShutdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E82E561F73A1BE6BBF09B7 /* TeraRuntimeShutdownTests.swift */; }; 3D219E759CFB685936EB7068 /* TeraCalendarEditingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C48B4B5442B1A404C1A328 /* TeraCalendarEditingTests.swift */; }; 3DED3C2547AC6F8D00913FE8 /* TeraStoppedUploadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91D82A4781624F36870A6316 /* TeraStoppedUploadTests.swift */; }; + 41BD76CAA07F6071411F7AC1 /* TeraIdentityCancellationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F666094C5E385A2820F0FE82 /* TeraIdentityCancellationTests.swift */; }; 41ED082A17594FE682A381DD /* TeraSubmissionQualificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019FAA997BEDE9CC84785DAE /* TeraSubmissionQualificationSupport.swift */; }; 45328FDB3E5F4715743FE7CD /* TeraRecoverySettlementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E7B696B1BDDBF3B7FE838F /* TeraRecoverySettlementTests.swift */; }; 46ECA0EA5CE33D30BB5E08AF /* TeraOfflineMediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649F88AE533C3BEEBC466894 /* TeraOfflineMediaTests.swift */; }; @@ -130,6 +131,7 @@ E892ED14E1F39D81128A8F0E /* TeraComposerSaveLatencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4A465852C5D5BDFC879D6E /* TeraComposerSaveLatencyTests.swift */; }; E8F853EE5ECE3EC16384A56D /* TeraRecoveryContinuationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB4F87BA96C92630FE795A9D /* TeraRecoveryContinuationTests.swift */; }; E98A5C885E3955D4DC4BB1EC /* TeraTodayCacheFirstTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CB51FEF4CE772320B3264C /* TeraTodayCacheFirstTests.swift */; }; + ED015228F2D757A485C902B8 /* TeraIdentityRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C7C27B35A9F7176AA3416B /* TeraIdentityRecoveryTests.swift */; }; EF7293C553BF1DB45EEEBD49 /* local-social-personas.v1.json in Resources */ = {isa = PBXBuildFile; fileRef = 27D9D40699A01FFB02F30B11 /* local-social-personas.v1.json */; }; F13090A3350CAE9CA3FFF3F3 /* TeraLateSigningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217E44A3221756EC904702D6 /* TeraLateSigningTests.swift */; }; F14217EA66C2A9397E36E3FA /* TeraEditingOperationProtectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC9B19425D3374B899485C17 /* TeraEditingOperationProtectionTests.swift */; }; @@ -278,6 +280,7 @@ DE21EEF3B1A13F1B5A10A3BF /* TeraComposerStartupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraComposerStartupTests.swift; sourceTree = "<group>"; }; DE9E724A23014C6EA9042123 /* Tera.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Tera.app; sourceTree = BUILT_PRODUCTS_DIR; }; E0B9E33BFF3A26F00634BFFA /* TeraNativeExecutionFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraNativeExecutionFixture.swift; sourceTree = "<group>"; }; + E2C7C27B35A9F7176AA3416B /* TeraIdentityRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraIdentityRecoveryTests.swift; sourceTree = "<group>"; }; E351F52E77C74A4E99095E45 /* TeraRemoteQualificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRemoteQualificationTests.swift; sourceTree = "<group>"; }; E55E47B15C42F13ABF2DFF27 /* TeraComposerAutosaveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraComposerAutosaveTests.swift; sourceTree = "<group>"; }; E62DD95FB04526E6E69AB3D8 /* TeraSubmissionFormTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraSubmissionFormTests.swift; sourceTree = "<group>"; }; @@ -290,6 +293,7 @@ F27D9EF66493E92AFDD53213 /* TeraEditingProtectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraEditingProtectionTests.swift; sourceTree = "<group>"; }; F596D3A9B9E236963DCE15D7 /* TeraMediaBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraMediaBudgetTests.swift; sourceTree = "<group>"; }; F65B01F6F5F7E125148EC4FD /* TeraTodayReconciliationFFITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayReconciliationFFITests.swift; sourceTree = "<group>"; }; + F666094C5E385A2820F0FE82 /* TeraIdentityCancellationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraIdentityCancellationTests.swift; sourceTree = "<group>"; }; FA0B5B9169842E64FF792D88 /* TeraNativeRepairPreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraNativeRepairPreviewTests.swift; sourceTree = "<group>"; }; FA1A8EA600F6BDC45333520C /* TeraCalendarComposerFFITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraCalendarComposerFFITests.swift; sourceTree = "<group>"; }; FB77C087F15C2F42CD959607 /* TeraRuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeClientTests.swift; sourceTree = "<group>"; }; @@ -393,6 +397,8 @@ 93A3358AFC23C567DB9CB02F /* TeraErrorRecoveryTests.swift */, 547AD45DC8C58FC4AA0D073D /* TeraFoodDecimalEntryTests.swift */, 99681A076A08E385942344F7 /* TeraForegroundSubmissionTests.swift */, + F666094C5E385A2820F0FE82 /* TeraIdentityCancellationTests.swift */, + E2C7C27B35A9F7176AA3416B /* TeraIdentityRecoveryTests.swift */, 217E44A3221756EC904702D6 /* TeraLateSigningTests.swift */, C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */, 70A358431863E54EDDAA24F4 /* TeraLocalBackupTests.swift */, @@ -718,6 +724,8 @@ D918A664D9A4B9859A1A6503 /* TeraErrorRecoveryTests.swift in Sources */, 116EF45DA06343BA3FA697AD /* TeraFoodDecimalEntryTests.swift in Sources */, 9A7E808CB591126300D3A118 /* TeraForegroundSubmissionTests.swift in Sources */, + 41BD76CAA07F6071411F7AC1 /* TeraIdentityCancellationTests.swift in Sources */, + ED015228F2D757A485C902B8 /* TeraIdentityRecoveryTests.swift in Sources */, F13090A3350CAE9CA3FFF3F3 /* TeraLateSigningTests.swift in Sources */, 671EF50DB4EFBC25AABF26DC /* TeraLifecycleTests.swift in Sources */, 01091F4EEBF255E95BD970F9 /* TeraLocalBackupTests.swift in Sources */, @@ -1136,7 +1144,7 @@ repositoryURL = "https://github.com/radrootslabs/apple_kit.git"; requirement = { kind = revision; - revision = 1981db67ea91dab278bf0c2a9781cc53b9d1e47e; + revision = 1126a77ed87387719a6c6d3b4ce580582af29553; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Tera.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tera.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "077c3c0356c85125c9c100455e7cd5e73595b167ed4c272c753e305ae12e6895", + "originHash" : "4944b79481ffcd5b00dc1ec3a534bb204165da4a87bb9b4de180448e8ac76d8c", "pins" : [ { "identity" : "apple_kit", "kind" : "remoteSourceControl", "location" : "https://github.com/radrootslabs/apple_kit.git", "state" : { - "revision" : "1981db67ea91dab278bf0c2a9781cc53b9d1e47e" + "revision" : "1126a77ed87387719a6c6d3b4ce580582af29553" } }, { diff --git a/Tera/State/TeraIdentityStore.swift b/Tera/State/TeraIdentityStore.swift @@ -126,7 +126,7 @@ actor TeraIdentityStore { func loadAndMigrate() async throws -> TeraAppIdentity { let initial = await custody.snapshot() - guard initial.state == .absent else { + guard [.absent, .locked, .unlocked].contains(initial.state) else { return Self.appIdentity(initial) } @@ -144,14 +144,14 @@ actor TeraIdentityStore { guard hasLegacySecret || legacyMetadata != nil else { return Self.appIdentity(initial) } - guard hasLegacySecret else { + guard hasLegacySecret || initial.state != .absent else { throw TeraIdentityStoreError.corruptLegacyMetadata } return TeraAppIdentity( state: .recoveryRequired, - identityHandle: nil, - publicKeyHex: legacyMetadata?.publicKeyHex.lowercased(), - label: legacyMetadata?.label, + identityHandle: initial.identity?.identityHandle, + publicKeyHex: legacyMetadata?.publicKeyHex.lowercased() ?? initial.identity?.publicKeyHex, + label: legacyMetadata?.label ?? initial.identity?.label, signerGeneration: nil, recoveryCode: "identity.legacy_migration_required" ) @@ -164,14 +164,14 @@ actor TeraIdentityStore { ) let legacyMetadata = try loadLegacyMetadata() do { - let migrated = try await custody.migrateLegacyIdentity( - from: legacySecretKey, - label: legacyMetadata?.label - ) - if let metadata = legacyMetadata, - metadata.publicKeyHex.lowercased() != migrated.identity?.publicKeyHex - { - throw TeraIdentityStoreError.corruptLegacyMetadata + let migrated: RadrootsIdentitySnapshot = if let metadata = legacyMetadata { + try await custody.migrateLegacyIdentity( + from: legacySecretKey, + expectedPublicKeyHex: metadata.publicKeyHex.lowercased(), + label: metadata.label + ) + } else { + try await custody.migrateLegacyIdentity(from: legacySecretKey) } deleteLegacyMetadata() return Self.appIdentity(migrated) @@ -185,14 +185,16 @@ actor TeraIdentityStore { } func create(label: String? = nil) async throws -> TeraAppIdentity { - try await custody.createIdentity(label: label).appValue + try await requireNoLegacyRecovery() + return try await custody.createIdentity(label: label).appValue } func importIdentity( _ material: RadrootsIdentitySecretMaterial, label: String? = nil ) async throws -> TeraAppIdentity { - try await custody.importIdentity(material, label: label).appValue + try await requireNoLegacyRecovery() + return try await custody.importIdentity(material, label: label).appValue } func snapshot() async -> TeraAppIdentity { @@ -204,17 +206,18 @@ actor TeraIdentityStore { } func recover() async throws -> TeraAppIdentity { - let snapshot = await custody.snapshot() - if snapshot.state == .absent { - let legacyKey = RadrootsSecureStoreKey( - namespace: "nostr_identity", - name: "selected_secret_hex" - ) - if try secureStore.contains(legacyKey) { - return try await migrateLegacyIdentity() - } + let recovered = try await custody.recover() + let pending = try await loadAndMigrate() + if pending.recoveryCode == "identity.legacy_migration_required" { + return try await migrateLegacyIdentity() + } + return recovered.appValue + } + + private func requireNoLegacyRecovery() async throws { + guard try await loadAndMigrate().state != .recoveryRequired else { + throw TeraIdentityStoreError.custody("identity.recovery_required") } - return try await custody.recover().appValue } func lock() async { diff --git a/Tera/Views/RuntimeStatusView.swift b/Tera/Views/RuntimeStatusView.swift @@ -152,7 +152,7 @@ struct RuntimeStatusView: View { } } -private struct TeraSecureIdentityImportField: UIViewRepresentable { +struct TeraSecureIdentityImportField: UIViewRepresentable { let submit: @MainActor (RadrootsIdentitySecretMaterial) -> Void func makeCoordinator() -> Coordinator { @@ -203,6 +203,15 @@ private struct TeraSecureIdentityImportField: UIViewRepresentable { func updateUIView(_: UIView, context _: Context) {} + static func dismantleUIView(_: UIView, coordinator: Coordinator) { + coordinator.field?.text = nil + coordinator.field?.resignFirstResponder() + coordinator.field?.delegate = nil + coordinator.errorLabel?.text = nil + coordinator.field = nil + coordinator.errorLabel = nil + } + @MainActor final class Coordinator: NSObject, UITextFieldDelegate { weak var field: UITextField? diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -94,17 +94,17 @@ { "bytes": 134975, "path": "source/aarch64-apple-darwin.json", - "sha256": "667f39d4e1192a188ddc2485b9a3d2a725089f89b878fad2bd845406d2df3c6d" + "sha256": "7e4450590da9bf74d5ad21f96addcb2b3b90029de1d1b967ebcc440de49b6d4a" }, { "bytes": 134819, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "219cd1e9a8116e2bfd141fb2c042e3aac65e676c39cf997b8996fbc7a3fa5214" + "sha256": "5dba02c44dd2c69c11c04c5d07df074f220e9d6d697bdb01870c715fe26018cb" }, { "bytes": 134815, "path": "source/aarch64-apple-ios.json", - "sha256": "76da3cd5baed8f4d872f31b54bc884c5b239058808c21d7c70f52de63a49a8f0" + "sha256": "20fec68fffba1ccc69f3e1aa8ed35de5a107b795200008f6ea1018a07c1c80dc" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "096812616b19a30317fcbdf76a740f36fc92004c" + "tree": "e8926312b18ec840ad895f83a88561f1276c86a6" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -176,17 +176,17 @@ { "bytes": 134975, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "667f39d4e1192a188ddc2485b9a3d2a725089f89b878fad2bd845406d2df3c6d" + "sha256": "7e4450590da9bf74d5ad21f96addcb2b3b90029de1d1b967ebcc440de49b6d4a" }, { "bytes": 134819, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "219cd1e9a8116e2bfd141fb2c042e3aac65e676c39cf997b8996fbc7a3fa5214" + "sha256": "5dba02c44dd2c69c11c04c5d07df074f220e9d6d697bdb01870c715fe26018cb" }, { "bytes": 134815, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "76da3cd5baed8f4d872f31b54bc884c5b239058808c21d7c70f52de63a49a8f0" + "sha256": "20fec68fffba1ccc69f3e1aa8ed35de5a107b795200008f6ea1018a07c1c80dc" } ], "schema": "tera.installed-native-artifacts.v1" diff --git a/TeraFFI/source.lock b/TeraFFI/source.lock @@ -1,7 +1,7 @@ schema = "tera.installed-source.v1" repository = "https://github.com/radrootslabs/tera" -source_tree = "096812616b19a30317fcbdf76a740f36fc92004c" -manifest_sha256 = "b84b493b912cdf9f6b1df1aad1ac6102169cb15e0e53c59eda8c0024b12b6622" +source_tree = "e8926312b18ec840ad895f83a88561f1276c86a6" +manifest_sha256 = "af5f4ab2de8b661452a701d2d1226393a74c27183d2dda4746959282106eccfb" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -2865,13 +2865,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 181442, - "git_blob": "84dfe62cd29a673f68036d897a99d55bfdba3ef7", + "bytes": 184111, + "git_blob": "f958cfe4a099523ca783ee3352a2e511e7508510", "mode": "100644", - "sha256": "936634579cbcd51d4687c55fc6caa16cd895b576d2b83eb191453769a2a0eafa" + "sha256": "645a77558d9547406ca4da56a67b3c5c7353528e5c8cb55948f8bb24ad74a384" } }, "policy": "staged_inputs", - "tree": "096812616b19a30317fcbdf76a740f36fc92004c" + "tree": "e8926312b18ec840ad895f83a88561f1276c86a6" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -2861,13 +2861,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 181442, - "git_blob": "84dfe62cd29a673f68036d897a99d55bfdba3ef7", + "bytes": 184111, + "git_blob": "f958cfe4a099523ca783ee3352a2e511e7508510", "mode": "100644", - "sha256": "936634579cbcd51d4687c55fc6caa16cd895b576d2b83eb191453769a2a0eafa" + "sha256": "645a77558d9547406ca4da56a67b3c5c7353528e5c8cb55948f8bb24ad74a384" } }, "policy": "staged_inputs", - "tree": "096812616b19a30317fcbdf76a740f36fc92004c" + "tree": "e8926312b18ec840ad895f83a88561f1276c86a6" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -2861,13 +2861,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 181442, - "git_blob": "84dfe62cd29a673f68036d897a99d55bfdba3ef7", + "bytes": 184111, + "git_blob": "f958cfe4a099523ca783ee3352a2e511e7508510", "mode": "100644", - "sha256": "936634579cbcd51d4687c55fc6caa16cd895b576d2b83eb191453769a2a0eafa" + "sha256": "645a77558d9547406ca4da56a67b3c5c7353528e5c8cb55948f8bb24ad74a384" } }, "policy": "staged_inputs", - "tree": "096812616b19a30317fcbdf76a740f36fc92004c" + "tree": "e8926312b18ec840ad895f83a88561f1276c86a6" } } diff --git a/TeraTests/TeraIdentityCancellationTests.swift b/TeraTests/TeraIdentityCancellationTests.swift @@ -0,0 +1,69 @@ +import Foundation +import RadrootsKit +@testable import TeraApp +import XCTest + +final class TeraIdentityCancellationTests: XCTestCase { + func testCancelledHostImportCannotInstallAfterSuccessfulPresenceCallback() async throws { + let secure = InMemorySecureStore() + let presence = IdentityCancellationPresence() + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: "radroots_identity_v1", secretPolicy: .secureLocalSecret), + secureStore: secure, metadataStore: InMemoryIdentityMetadataStore(), userPresence: presence + ) + let prefix = "tera.identity.cancellation.\(UUID().uuidString.lowercased())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: prefix)) + defer { UserDefaults(suiteName: prefix)?.removePersistentDomain(forName: prefix) } + let store = TeraIdentityStore(custody: custody, secureStore: secure, servicePrefix: prefix, userDefaults: defaults) + let task = Task { + try await store.importIdentity(RadrootsIdentitySecretMaterial(rawRepresentation: Data(repeating: 1, count: 32))) + } + for await _ in presence.entered { + break + } + task.cancel() + await presence.release() + do { + _ = try await task.value + XCTFail("Cancellation must reject a later successful presence callback") + } catch { + XCTAssertEqual(error as? RadrootsIdentityCustodyError, .cancelled) + } + let state = await store.snapshot() + XCTAssertEqual(state.state, .absent) + XCTAssertFalse(try secure.contains(RadrootsSecureStoreKey(namespace: "radroots_identity_v1", name: "active_secret_v1"))) + } + + func testCustodyErrorPresentationDoesNotExposeSecretOrInternalText() { + let sentinel = "synthetic-secret-sentinel" + let error = TeraIdentityStoreError.custody(sentinel) + XCTAssertFalse(error.localizedDescription.contains(sentinel)) + XCTAssertEqual(error.localizedDescription, "The local identity needs attention before Tera can continue.") + } +} + +private actor IdentityCancellationPresence: RadrootsUserPresence { + nonisolated let entered: AsyncStream<Void> + private let signal: AsyncStream<Void>.Continuation + private var pending: CheckedContinuation<Void, Never>? + init() { + (entered, signal) = AsyncStream.makeStream() + } + + func currentStatus() async throws -> RadrootsUserPresenceStatus { + .unavailable + } + + func release() { + pending?.resume() + pending = nil + } + + func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { + await withCheckedContinuation { continuation in + pending = continuation + signal.yield(()) + } + return RadrootsUserPresenceResult(policy: request.policy, verified: true) + } +} diff --git a/TeraTests/TeraIdentityRecoveryTests.swift b/TeraTests/TeraIdentityRecoveryTests.swift @@ -0,0 +1,265 @@ +import Foundation +import RadrootsKit +@testable import TeraApp +import UIKit +import XCTest + +final class TeraIdentityRecoveryTests: XCTestCase { + func testMetadataMismatchRetainsLegacyAndDoesNotInstallReplacement() async throws { + let fixture = try IdentityRecoveryFixture() + defer { fixture.remove() } + try fixture.legacy(publicKey: String(repeating: "ab", count: 32)) + let store = try fixture.store() + do { + _ = try await store.recover() + XCTFail("Mismatched identity must not migrate") + } catch { + XCTAssertEqual(error as? TeraIdentityStoreError, .custody("identity.inconsistent_state")) + } + let state = await store.snapshot() + XCTAssertEqual(state.state, .absent) + XCTAssertTrue(try fixture.secure.contains(fixture.legacyKey)) + XCTAssertNotNil(fixture.defaults.data(forKey: fixture.metadataKey)) + } + + func testInterruptedLegacyCleanupRetainsOriginalIdentityAcrossRestart() async throws { + let fixture = try IdentityRecoveryFixture() + defer { fixture.remove() } + let store = try fixture.store() + try fixture.legacy() + fixture.secure.failLegacyDeletion = true + do { + _ = try await store.recover() + XCTFail("Interrupted cleanup must require recovery") + } catch { + XCTAssertEqual(error as? TeraIdentityStoreError, .custody("identity.recovery_required")) + } + let installed = await store.snapshot() + let original = try XCTUnwrap(installed.publicKeyHex) + XCTAssertEqual(installed.state, .unlocked) + fixture.secure.failLegacyDeletion = false + fixture.metadata(publicKey: original) + let restarted = try fixture.store() + let pending = try await restarted.loadAndMigrate() + XCTAssertEqual(pending.state, .recoveryRequired) + XCTAssertEqual(pending.publicKeyHex, original) + XCTAssertNil(pending.signerGeneration) + let recovered = try await restarted.recover() + XCTAssertEqual(recovered.publicKeyHex, original) + XCTAssertEqual(recovered.state, .locked) + XCTAssertFalse(try fixture.secure.contains(fixture.legacyKey)) + XCTAssertNil(fixture.defaults.data(forKey: fixture.metadataKey)) + let unlocked = try await restarted.unlock() + XCTAssertEqual(unlocked.publicKeyHex, original) + } + + func testMetadataOnlyReplayValidatesInstalledKeyBeforeCleanup() async throws { + let fixture = try IdentityRecoveryFixture() + defer { fixture.remove() } + let store = try fixture.store() + let installed = try await store.importIdentity(fixture.material()) + let original = try XCTUnwrap(installed.publicKeyHex) + fixture.metadata(publicKey: String(repeating: "ab", count: 32)) + do { + _ = try await store.recover() + XCTFail("Mismatched metadata cannot be discarded") + } catch { + XCTAssertEqual(error as? TeraIdentityStoreError, .custody("identity.inconsistent_state")) + } + XCTAssertNotNil(fixture.defaults.data(forKey: fixture.metadataKey)) + fixture.metadata(publicKey: original) + let restarted = try fixture.store() + let recovered = try await restarted.recover() + XCTAssertEqual(recovered.publicKeyHex, original) + XCTAssertNil(fixture.defaults.data(forKey: fixture.metadataKey)) + } + + func testPendingLegacyRecoveryRefusesStaleCreateAndImport() async throws { + let fixture = try IdentityRecoveryFixture() + defer { fixture.remove() } + try fixture.legacy() + let store = try fixture.store() + for create in [true, false] { + do { + if create { + _ = try await store.create() + } else { + _ = try await store.importIdentity(fixture.material()) + } + XCTFail("Pending legacy identity must not be replaced") + } catch { + XCTAssertEqual(error as? TeraIdentityStoreError, .custody("identity.recovery_required")) + } + } + let state = await store.snapshot() + XCTAssertEqual(state.state, .absent) + XCTAssertTrue(try fixture.secure.contains(fixture.legacyKey)) + } + + func testLostOrCorruptInstalledKeyNeverCreatesReplacement() async throws { + for missing in [true, false] { + let fixture = try IdentityRecoveryFixture() + defer { fixture.remove() } + let store = try fixture.store() + let original = try await store.importIdentity(fixture.material()) + await store.lock() + let key = RadrootsSecureStoreKey(namespace: "radroots_identity_v1", name: "active_secret_v1") + if missing { + try fixture.secure.delete(key) + } else { + try fixture.secure.put(Data(repeating: 0, count: 32), for: key, policy: .secureLocalSecret) + } + let restarted = try fixture.store() + do { + _ = try await restarted.unlock() + XCTFail("Unavailable original key must not unlock") + } catch { + XCTAssertTrue(error is RadrootsIdentityCustodyError) + } + do { + _ = try await restarted.create() + XCTFail("Existing identity must not be silently replaced") + } catch { + XCTAssertTrue(error is RadrootsIdentityCustodyError) + } + let after = await restarted.snapshot() + XCTAssertEqual(after.publicKeyHex, original.publicKeyHex) + XCTAssertNil(after.signerGeneration) + XCTAssertEqual(try fixture.secure.get(key), missing ? nil : Data(repeating: 0, count: 32)) + } + } + + func testProtectedDataAndDeniedPresenceRetainInstalledKey() async throws { + let fixture = try IdentityRecoveryFixture() + defer { fixture.remove() } + let original = try await fixture.store().importIdentity(fixture.material()) + let protected = try fixture.store(protected: true) + let state = try await protected.loadAndMigrate() + XCTAssertEqual(state.state, .protectedDataUnavailable) + XCTAssertEqual(state.publicKeyHex, original.publicKeyHex) + let denied = try fixture.store(presence: RefusingIdentityPresence()) + do { + _ = try await denied.unlock() + XCTFail("Presence denial must not unlock") + } catch { + XCTAssertTrue(error is RadrootsIdentityCustodyError) + } + let after = await denied.snapshot() + XCTAssertEqual(after.state, .locked) + XCTAssertEqual(after.publicKeyHex, original.publicKeyHex) + } + + @MainActor + func testImportTeardownClearsSecretAndRejectsLateSubmit() { + var submissions = 0 + let coordinator = TeraSecureIdentityImportField.Coordinator { _ in submissions += 1 } + let field = UITextField() + field.text = String(repeating: "01", count: 32) + field.delegate = coordinator + let error = UILabel() + error.text = "Validation failed" + coordinator.field = field + coordinator.errorLabel = error + TeraSecureIdentityImportField.dismantleUIView(UIView(), coordinator: coordinator) + coordinator.submitIdentity() + XCTAssertEqual(field.text ?? "", "") + XCTAssertNil(field.delegate) + XCTAssertNil(error.text) + XCTAssertNil(coordinator.field) + XCTAssertEqual(submissions, 0) + } +} + +private struct IdentityRecoveryFixture { + let secure = LegacyDeletionFaultStore() + let metadataStore = InMemoryIdentityMetadataStore() + let prefix = "tera.identity.recovery.\(UUID().uuidString.lowercased())" + let defaults: UserDefaults + let legacyKey = RadrootsSecureStoreKey(namespace: "nostr_identity", name: "selected_secret_hex") + var metadataKey: String { + "field_ios.identity.public_metadata.\(prefix)" + } + + init() throws { + defaults = try XCTUnwrap(UserDefaults(suiteName: prefix)) + } + + func remove() { + defaults.removePersistentDomain(forName: prefix) + } + + func material() throws -> RadrootsIdentitySecretMaterial { + try RadrootsIdentitySecretMaterial(rawRepresentation: Data(repeating: 1, count: 32)) + } + + func legacy(publicKey: String? = nil) throws { + try secure.put(Data(String(repeating: "01", count: 32).utf8), for: legacyKey, policy: .secureLocalSecret) + if let publicKey { + metadata(publicKey: publicKey) + } + } + + func metadata(publicKey: String) { + defaults.set(try? JSONSerialization.data(withJSONObject: [ + "selectedIdentityId": "legacy", "publicKeyHex": publicKey, + "publicKeyNpub": "legacy", "updatedAtUnix": 1, + ]), forKey: metadataKey) + } + + func store( + presence: any RadrootsUserPresence = AllowingUserPresence(), + protected: Bool = false + ) throws -> TeraIdentityStore { + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: "radroots_identity_v1", secretPolicy: .secureLocalSecret), + secureStore: secure, metadataStore: metadataStore, userPresence: presence, + protectedData: RadrootsProtectedDataProvider { protected ? .unavailable : .available } + ) + let suiteName = prefix + let custodyDefaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + return TeraIdentityStore(custody: custody, secureStore: secure, servicePrefix: suiteName, userDefaults: custodyDefaults) + } +} + +private final class LegacyDeletionFaultStore: RadrootsSecureStore, @unchecked Sendable { + private let backing = InMemorySecureStore() + private let lock = NSLock() + private var fails = false + var failLegacyDeletion: Bool { + get { lock.withLock { fails } } + set { lock.withLock { fails = newValue } } + } + + func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws { + try backing.put(value, for: key, policy: policy) + } + + func get(_ key: RadrootsSecureStoreKey) throws -> Data? { + try backing.get(key) + } + + func contains(_ key: RadrootsSecureStoreKey) throws -> Bool { + try backing.contains(key) + } + + func delete(_ key: RadrootsSecureStoreKey) throws { + if key.namespace == "nostr_identity", failLegacyDeletion { + throw TeraIdentityStoreError.unavailable + } + try backing.delete(key) + } + + func deleteNamespace(_ namespace: String) throws { + try backing.deleteNamespace(namespace) + } +} + +private struct RefusingIdentityPresence: RadrootsUserPresence { + func currentStatus() async throws -> RadrootsUserPresenceStatus { + .unavailable + } + + func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { + RadrootsUserPresenceResult(policy: request.policy, verified: false) + } +} diff --git a/project.yml b/project.yml @@ -13,7 +13,7 @@ packages: path: . RadrootsKit: url: https://github.com/radrootslabs/apple_kit.git - revision: 1981db67ea91dab278bf0c2a9781cc53b9d1e47e + revision: 1126a77ed87387719a6c6d3b4ce580582af29553 targets: Tera: @@ -113,6 +113,8 @@ targets: - path: TeraTests/TeraStateTestFixtures.swift - path: TeraTests/TeraRootShellTests.swift - path: TeraTests/TeraStateMigrationTests.swift + - path: TeraTests/TeraIdentityRecoveryTests.swift + - path: TeraTests/TeraIdentityCancellationTests.swift - path: TeraTests/TeraTodayStoreTests.swift - path: TeraTests/TeraTodaySyncFixtures.swift - path: TeraTests/TeraTodaySyncReceiptTests.swift diff --git a/release/provenance.json b/release/provenance.json @@ -2,11 +2,11 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "c99defef3c2712a88dfe5059908780032f395c9919950d0d7fef05a6d67b98d6", - "ffi_provenance_sha256": "b84b493b912cdf9f6b1df1aad1ac6102169cb15e0e53c59eda8c0024b12b6622", + "ffi_provenance_sha256": "af5f4ab2de8b661452a701d2d1226393a74c27183d2dda4746959282106eccfb", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", - "sbom_sha256": "ca4a9a96aac1c0b565f71632090369f76b4e5bc3eba06154968658479f07bdd7", - "xcode_project_sha256": "bc2e74e03e8b1894545f212d0cc21ddda20368bd52dce8cdb6549b23e4c1efc9" + "sbom_sha256": "904302bddb22ac1e4f9e79ab1145e590a8eeac7dd75e42539fb72eb0b7ebd4b1", + "xcode_project_sha256": "6770334fe420c82077dfc0fef6b37f67693dbc256230918b62527e1aa4a83419" }, "disposition": "unsigned", "platforms": [ @@ -21,9 +21,9 @@ "consumer_source_lock_sha256": "d517af2028088eb0c8b6d3fde6d4e4efe5e17bf7e2ccb67dac2592c2fee4c124", "lib_revision": "8dbf27459be1729709a0ea36bba7470e90479ca3", "source_date_epoch": 1787871027, - "swift_package_lock_sha256": "255908c67161d0459c5132289c104d52276627548c48c5040211910f6a337c0e", - "tera_ffi_source_tree": "096812616b19a30317fcbdf76a740f36fc92004c", - "xcode_package_lock_sha256": "4d0fcac8e7af95c47ef6346b5a128ecb227fb16b2c92b6cdb6877d03b3e2020a" + "swift_package_lock_sha256": "322eaec80d4b85ef9eb20da95ff95c4d26d8555ca358ba0c8eb890adf0b6671e", + "tera_ffi_source_tree": "e8926312b18ec840ad895f83a88561f1276c86a6", + "xcode_package_lock_sha256": "4e29fed46339d5e382fa78ec85b61be4296455ece4ddc40602d793c049e673b3" }, "version": "0.1.0-alpha" } diff --git a/release/sbom.cdx.json b/release/sbom.cdx.json @@ -7856,7 +7856,7 @@ "version": "1.0.23" }, { - "bom-ref": "swift:apple_kit@1981db67ea91dab278bf0c2a9781cc53b9d1e47e?source=https://github.com/radrootslabs/apple_kit.git", + "bom-ref": "swift:apple_kit@1126a77ed87387719a6c6d3b4ce580582af29553?source=https://github.com/radrootslabs/apple_kit.git", "name": "apple_kit", "properties": [ { @@ -7869,11 +7869,11 @@ }, { "name": "radroots.package.revision", - "value": "1981db67ea91dab278bf0c2a9781cc53b9d1e47e" + "value": "1126a77ed87387719a6c6d3b4ce580582af29553" } ], "type": "library", - "version": "1981db67ea91dab278bf0c2a9781cc53b9d1e47e" + "version": "1126a77ed87387719a6c6d3b4ce580582af29553" }, { "bom-ref": "swift:swift-secp256k1@e70a10e036a55fffea31568f0af92d69b6d449cd?source=https://github.com/21-DOT-DEV/swift-secp256k1.git", @@ -10767,14 +10767,14 @@ "cargo:tera_core@0.1.0-alpha?source=workspace", "cargo:tera_ffi@0.1.0-alpha?source=workspace", "cargo:tera_wasm@0.1.0-alpha?source=workspace", - "swift:apple_kit@1981db67ea91dab278bf0c2a9781cc53b9d1e47e?source=https://github.com/radrootslabs/apple_kit.git", + "swift:apple_kit@1126a77ed87387719a6c6d3b4ce580582af29553?source=https://github.com/radrootslabs/apple_kit.git", "swift:swift-secp256k1@e70a10e036a55fffea31568f0af92d69b6d449cd?source=https://github.com/21-DOT-DEV/swift-secp256k1.git" ], "ref": "pkg:generic/tera@0.1.0-alpha" }, { "dependsOn": [], - "ref": "swift:apple_kit@1981db67ea91dab278bf0c2a9781cc53b9d1e47e?source=https://github.com/radrootslabs/apple_kit.git" + "ref": "swift:apple_kit@1126a77ed87387719a6c6d3b4ce580582af29553?source=https://github.com/radrootslabs/apple_kit.git" }, { "dependsOn": [], diff --git a/test-fixtures/legacy-identifiers.v1.json b/test-fixtures/legacy-identifiers.v1.json @@ -1517,6 +1517,14 @@ "count": 5 }, { + "path": "TeraTests/TeraIdentityCancellationTests.swift", + "count": 1 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 1 + }, + { "path": "TeraTests/TeraLateSigningTests.swift", "count": 1 }, @@ -1539,6 +1547,14 @@ "count": 4 }, { + "path": "TeraTests/TeraIdentityCancellationTests.swift", + "count": 1 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 1 + }, + { "path": "TeraTests/TeraLateSigningTests.swift", "count": 1 }, @@ -1563,6 +1579,14 @@ { "path": "Tera/State/TeraIdentityStore.swift", "count": 2 + }, + { + "path": "TeraTests/TeraIdentityCancellationTests.swift", + "count": 1 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 3 } ] }, @@ -1617,6 +1641,14 @@ "count": 5 }, { + "path": "TeraTests/TeraIdentityCancellationTests.swift", + "count": 1 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 2 + }, + { "path": "TeraTests/TeraLateSigningTests.swift", "count": 1 } @@ -1628,7 +1660,7 @@ "occurrences": [ { "path": "Tera/State/TeraIdentityStore.swift", - "count": 2 + "count": 3 } ] }, @@ -1815,6 +1847,14 @@ "count": 1 }, { + "path": "TeraTests/TeraIdentityCancellationTests.swift", + "count": 1 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 1 + }, + { "path": "TeraTests/TeraLateSigningTests.swift", "count": 1 }, @@ -2167,6 +2207,10 @@ { "path": "Tera/State/TeraIdentityStore.swift", "count": 1 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 1 } ] }, @@ -2209,6 +2253,10 @@ "category": "shared_apple_api", "occurrences": [ { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 1 + }, + { "path": "TeraTests/TeraStateTestFixtures.swift", "count": 1 } @@ -2223,6 +2271,10 @@ "count": 2 }, { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 1 + }, + { "path": "TeraTests/TeraStateTestFixtures.swift", "count": 1 } @@ -2234,7 +2286,15 @@ "occurrences": [ { "path": "Tera/State/TeraIdentityStore.swift", - "count": 3 + "count": 2 + }, + { + "path": "TeraTests/TeraIdentityCancellationTests.swift", + "count": 1 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 6 }, { "path": "TeraTests/TeraStateMigrationTests.swift", @@ -2527,6 +2587,14 @@ "count": 1 }, { + "path": "TeraTests/TeraIdentityCancellationTests.swift", + "count": 1 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 2 + }, + { "path": "TeraTests/TeraStateTestFixtures.swift", "count": 1 } @@ -2571,6 +2639,14 @@ "count": 1 }, { + "path": "TeraTests/TeraIdentityCancellationTests.swift", + "count": 1 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 1 + }, + { "path": "TeraTests/TeraRemoteQualificationTests.swift", "count": 1 }, @@ -2589,6 +2665,14 @@ "count": 2 }, { + "path": "TeraTests/TeraIdentityCancellationTests.swift", + "count": 2 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 2 + }, + { "path": "TeraTests/TeraStateTestFixtures.swift", "count": 2 } @@ -2603,6 +2687,14 @@ "count": 2 }, { + "path": "TeraTests/TeraIdentityCancellationTests.swift", + "count": 1 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 1 + }, + { "path": "TeraTests/TeraStateTestFixtures.swift", "count": 2 } @@ -5531,6 +5623,14 @@ "count": 1 }, { + "path": "TeraTests/TeraIdentityCancellationTests.swift", + "count": 2 + }, + { + "path": "TeraTests/TeraIdentityRecoveryTests.swift", + "count": 2 + }, + { "path": "TeraTests/TeraLateSigningTests.swift", "count": 1 },