Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Fixtures/Modernization/ios-migration-qualification-README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,12 @@ Required identity and aggregate fields:
exporter, which continues to export only independently verified database and
settings artifacts for support. Existing-target rollback and absent-target
withdrawal are both exercised by weakening the actual published inode to `.none`
inside the existing 207-method source suite.
inside the existing 209-method source suite.
- A positive `retainedReleaseSnapshotCount`, the reviewed
`retainedReleaseSnapshotManifestSha256`, both checked-in Core Data model SHA-256 values, the
exact executed `WalletModernizationTests`, `WalletRecoveryCapabilityGateTests`, and
`WalletRecoveryExporterTests` method counts, zero failure counts, and a reviewed
`testResultBundleSha256` covering all four suite inventories (207 + 11 + 12 + 3 = 233).
`testResultBundleSha256` covering all four suite inventories (209 + 11 + 12 + 3 = 235).
- True parity for account count, selected wallet, preferences, Keychain identity and accessibility,
legacy dual-read retention, existing SORA2 identity/signatures, and zero lost accounts.
- Missing-store qualification must separately retain and exercise raw selected-account settings,
Expand Down Expand Up @@ -263,7 +263,7 @@ with six success cohorts fails admission. All affected sources are bound by
be recollected and independently reviewed for the current candidate.

`SoraPassportMigrationEvidence.xcscheme` is the dedicated Release/physical-device evidence
scheme. Its exact test inventory is 207 `WalletModernizationTests`, 11
scheme. Its exact test inventory is 209 `WalletModernizationTests`, 11
`WalletRecoveryCapabilityGateTests`, 12 `WalletRecoveryExporterTests`, and three
`WalletMigrationRetainedDeviceEvidenceTests`. The last three tests bind their schema-v3 attachments
to the installed production bundle identifier, the exact production IPA, the canonical projection
Expand Down Expand Up @@ -545,7 +545,7 @@ the repository with this fixed layout:
- `application/SoraPassport.app`, the exact archive-derived installable clone;
- `application/canonical-projection-receipt-v2.json`, the canonical observed projection receipt;
- `application/installable-clone-receipt-v1.json`, the protected non-authorizing clone receipt;
- `tests/Migration.xcresult`, containing the exact 233 passing test identifiers and three
- `tests/Migration.xcresult`, containing the exact 235 passing test identifiers and three
test-associated reserved JSON attachments;
- `snapshots/index.json` and `snapshots/data/<snapshotId>/{source,migrated}`, containing the
retained Core Data/settings bundles.
Expand Down Expand Up @@ -693,7 +693,7 @@ Keychain aggregate must exactly match successful/failing source counts and the r
identity/accessibility assertions, with no credential rewrite or raw values. The device aggregate
must exactly match Core Data and interruption counts plus reinstall/upgrade, rollback, low-storage,
recovery-export, and process-death/restart assertions. The ZIP summary must exactly match all four
declared suite counts (207 + 11 + 12 + 3 = 233) and zero failure, unexpected-failure, skipped, and
declared suite counts (209 + 11 + 12 + 3 = 235) and zero failure, unexpected-failure, skipped, and
expected-failure counters. Independent byte reproduction proves that these public aggregates are
the collector's derivation from the pinned raw namespace; producer and reviewer signatures remain
necessary authentication and do not replace review of the restricted scenario material.
Expand Down
26 changes: 26 additions & 0 deletions SoraPassport/Common/Extensions/SettingsExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,32 @@ struct WalletMigrationRecoveryMarker: Equatable, Codable {
].contains(reason ?? "")
}

static let accountCommitInterruptionReason =
"An unfinished wallet account commit blocks signing and wallet changes. Existing wallet material was preserved."

var isLegacyAccountCommitInterruption: Bool {
isDatabaseInterruption || (required && generation == reasonGeneration &&
reason == Self.accountCommitInterruptionReason)
}

func requireUnchangedForLegacyAccountRecovery(_ settings: SettingsManagerProtocol) throws {
guard (!required || isLegacyAccountCommitInterruption), Self.capture(settings) == self else {
throw WalletNetworkMigrationError.walletRecoveryRequired
}
}

func clearAfterVerifiedLegacyAccountActivation(_ settings: SettingsManagerProtocol) throws {
try Self.synchronized {
try requireUnchangedForLegacyAccountRecovery(settings)
let generation = UUID().uuidString
let cleared = Self(required: false, reason: nil, generation: generation, reasonGeneration: generation)
Self.publish(cleared, to: settings)
guard Self.capture(settings) == cleared else {
throw WalletNetworkMigrationError.walletRecoveryRequired
}
}
}

func requireUnchanged(_ settings: SettingsManagerProtocol) throws {
guard isDatabaseInterruption, Self.capture(settings) == self else {
throw WalletNetworkMigrationError.walletRecoveryRequired
Expand Down
168 changes: 161 additions & 7 deletions SoraPassport/Common/Model/WalletNetworkModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1639,6 +1639,8 @@ struct WalletAccountCommitJournal: Codable, Equatable {
var stage: WalletAccountCommitStage
let createdAt: Date
var updatedAt: Date
// Optional for journals written before restart recovery was supported.
var recoveryMarker: WalletMigrationRecoveryMarker? = nil
}

/// Non-secret interruption journal for new/imported wallets. An unfinished
Expand Down Expand Up @@ -1790,6 +1792,49 @@ final class WalletAccountCommitJournalStore {
}
}

func journalsForLegacyRecovery() throws -> [WalletAccountCommitJournal] {
Self.lock.lock()
defer { Self.lock.unlock() }
return try loadUnlocked()
}

func requireCurrentForLegacyRecovery(_ journal: WalletAccountCommitJournal) throws {
try withCurrentForLegacyRecovery(journal) {}
}

func withCurrentForLegacyRecovery<T>(
_ journal: WalletAccountCommitJournal,
_ body: () throws -> T
) throws -> T {
Self.lock.lock()
defer { Self.lock.unlock() }
let journals = try loadUnlocked()
guard journals.count == 1, Self.journalsMatch(journals[0], journal) else {
throw WalletNetworkMigrationError.snapshotVerificationFailed
}
return try body()
}

func bindLegacyRecoveryMarker(
_ journal: WalletAccountCommitJournal,
marker: WalletMigrationRecoveryMarker
) throws -> WalletAccountCommitJournal {
try recoveryGate.requireAuthorizedLifecycleContinuation()
Self.lock.lock()
defer { Self.lock.unlock() }
let journals = try loadUnlocked()
guard journals.count == 1, Self.journalsMatch(journals[0], journal),
journal.expectedExistingWalletIds.isEmpty,
journal.recoveryMarker == nil || journal.recoveryMarker == marker
else { throw WalletNetworkMigrationError.snapshotVerificationFailed }
if journal.recoveryMarker == marker { return journals[0] }
var bound = journals[0]
bound.recoveryMarker = marker
bound.updatedAt = Date()
try writeUnlocked(bound)
return bound
}

func unresolved() throws -> [WalletAccountCommitJournal] {
Self.lock.lock()
defer { Self.lock.unlock() }
Expand Down Expand Up @@ -2047,6 +2092,7 @@ final class WalletAccountCommitJournalStore {
lhs.expectedExistingWalletIds ==
rhs.expectedExistingWalletIds &&
lhs.stage == rhs.stage &&
lhs.recoveryMarker == rhs.recoveryMarker &&
Int64(lhs.createdAt.timeIntervalSince1970) ==
Int64(rhs.createdAt.timeIntervalSince1970) &&
Int64(lhs.updatedAt.timeIntervalSince1970) ==
Expand Down Expand Up @@ -3184,18 +3230,21 @@ final class WalletRecoveryCapabilityGate: @unchecked Sendable {
private let stateLock = NSLock()
private var didVerifyMigrationNamespace = false
private let migrationRecoveryMarker: WalletMigrationRecoveryMarker?
private let legacyAccountRecoveryMarker: WalletMigrationRecoveryMarker?

init(
settings: SettingsManagerProtocol,
unresolvedMigrationJournal: @escaping () -> Bool,
unresolvedWalletCommitJournal: @escaping () throws -> Bool,
migrationRecoveryMarker: WalletMigrationRecoveryMarker? = nil
migrationRecoveryMarker: WalletMigrationRecoveryMarker? = nil,
legacyAccountRecoveryMarker: WalletMigrationRecoveryMarker? = nil
) {
self.settings = settings
self.unresolvedMigrationJournal = unresolvedMigrationJournal
self.unresolvedWalletCommitJournal =
unresolvedWalletCommitJournal
self.migrationRecoveryMarker = migrationRecoveryMarker
self.legacyAccountRecoveryMarker = legacyAccountRecoveryMarker
}

func requireMutableWalletAccess() throws {
Expand Down Expand Up @@ -3240,6 +3289,10 @@ final class WalletRecoveryCapabilityGate: @unchecked Sendable {
/// own expected in-flight commit journal, but a sticky recovery marker
/// still aborts the next write phase.
func requireAuthorizedLifecycleContinuation() throws {
if let legacyAccountRecoveryMarker {
try legacyAccountRecoveryMarker.requireUnchangedForLegacyAccountRecovery(settings)
return
}
if let migrationRecoveryMarker {
// A private startup verifier may read/prove the exact interrupted
// attempt while its marker continues to block all ordinary gates.
Expand All @@ -3251,6 +3304,13 @@ final class WalletRecoveryCapabilityGate: @unchecked Sendable {
}
}

func requireLegacyAccountRecoveryVerification(pending: Bool = false) throws {
guard let legacyAccountRecoveryMarker,
!pending || legacyAccountRecoveryMarker.required
else { throw WalletNetworkMigrationError.walletRecoveryRequired }
try legacyAccountRecoveryMarker.requireUnchangedForLegacyAccountRecovery(settings)
}

/// A terminal journal write may have reached durable storage even when
/// its verification read reports an error. Latch recovery immediately;
/// a later successful read is not proof that the multi-store commit was
Expand Down Expand Up @@ -3796,6 +3856,12 @@ enum NexusKeyDerivation {
/// a small atomic pointer write performed only after decoding and equality
/// checks pass, so an interrupted upgrade continues to use the old snapshot.
final class WalletNetworkStore {
struct LegacyFirstSnapshotEvidence {
fileprivate let fileName: String
fileprivate let data: Data
let snapshot: WalletNetworkSnapshot
}

private struct ActivePointer: Codable {
let schemaVersion: Int
let fileName: String
Expand Down Expand Up @@ -3867,6 +3933,67 @@ final class WalletNetworkStore {
return try loadUnlocked()
}

/// Only the private legacy-account verifier may inspect a first snapshot
/// whose durable write completed before its initial active pointer.
func loadForLegacyFirstActivationRecovery() throws
-> (active: WalletNetworkSnapshot?, staged: LegacyFirstSnapshotEvidence?) {
try recoveryGate.requireLegacyAccountRecoveryVerification()
Self.lock.lock()
defer { Self.lock.unlock() }
let namespace = try validatedNamespaceUnlocked()
if namespace.pointerURL != nil || namespace.snapshotURLs.isEmpty {
return (try loadUnlocked(), nil)
}
guard namespace.snapshotURLs.count == 1, let url = namespace.snapshotURLs.first else {
throw WalletNetworkMigrationError.snapshotVerificationFailed
}
let data = try readBoundedData(at: url, maximumBytes: Self.maximumSnapshotBytes)
let snapshot = try decoder.decode(WalletNetworkSnapshot.self, from: data)
try validate(snapshot)
return (nil, LegacyFirstSnapshotEvidence(fileName: url.lastPathComponent, data: data, snapshot: snapshot))
}

/// The caller holds the startup lease and exact bound journal/marker CAS.
/// Publish only a pointer to the already retained, independently proven bytes.
func activateVerifiedLegacyFirstSnapshot(
_ evidence: LegacyFirstSnapshotEvidence,
expected: WalletNetworkSnapshot
) throws {
try recoveryGate.requireLegacyAccountRecoveryVerification(pending: true)
Self.lock.lock()
defer { Self.lock.unlock() }
let namespace = try validatedNamespaceUnlocked()
guard namespace.pointerURL == nil, namespace.snapshotURLs.count == 1,
let snapshotURL = namespace.snapshotURLs.first,
snapshotURL.lastPathComponent == evidence.fileName,
try readBoundedData(at: snapshotURL, maximumBytes: Self.maximumSnapshotBytes) == evidence.data,
evidence.snapshot.schemaVersion == expected.schemaVersion,
evidence.snapshot.selectedWalletId == expected.selectedWalletId,
evidence.snapshot.wallets == expected.wallets,
evidence.snapshot.accounts == expected.accounts
else { throw WalletNetworkMigrationError.snapshotVerificationFailed }
try validate(expected)
try verifyTopologyAdmission(current: nil, proposed: evidence.snapshot)
let pointer = ActivePointer(schemaVersion: WalletNetworkSnapshot.currentSchemaVersion,
fileName: evidence.fileName, sha256: Self.sha256(evidence.data))
let pointerData = try encoder.encode(pointer)
guard pointerData.count <= Self.maximumPointerBytes else {
throw WalletNetworkMigrationError.snapshotVerificationFailed
}
try recoveryGate.requireLegacyAccountRecoveryVerification(pending: true)
// An error after atomic publication retains the pointer and snapshot;
// the next restart verifies that active state through the ordinary path.
try DurableFileWriter.write(pointerData, to: directoryURL.appendingPathComponent("active.json"),
fileManager: fileManager, protection: .completeUntilFirstUserAuthentication)
let activatedNamespace = try validatedNamespaceUnlocked()
guard activatedNamespace.pointerURL != nil, activatedNamespace.snapshotURLs.count == 1,
activatedNamespace.snapshotURLs.first?.lastPathComponent == evidence.fileName,
try readBoundedData(at: snapshotURL, maximumBytes: Self.maximumSnapshotBytes) == evidence.data,
let activated = try loadUnlocked(), try snapshotsMatch(activated, evidence.snapshot)
else { throw WalletNetworkMigrationError.snapshotVerificationFailed }
try recoveryGate.requireLegacyAccountRecoveryVerification(pending: true)
}

func stageAndActivate(_ snapshot: WalletNetworkSnapshot) throws {
try recoveryGate
.requireAuthorizedLifecycleContinuation()
Expand Down Expand Up @@ -4930,21 +5057,46 @@ final class WalletNetworkModelMigrator {
selectedAddress: String?,
lifecycleLease: WalletLifecycleLease? = nil
) throws {
try lifecycleCoordinator.withExclusiveAccess(
_ = try lifecycleCoordinator.withExclusiveAccess(
using: lifecycleLease
) {
try migrateLocked(
accounts: accounts,
selectedAddress: selectedAddress
selectedAddress: selectedAddress,
current: try store.load(),
activate: true
)
}
}

/// Runs the same identity and child-key proofs without publishing a snapshot or settings.
func verifiedSnapshot(
accounts: [AccountItem],
selectedAddress: String?,
lifecycleLease: WalletLifecycleLease? = nil
) throws -> WalletNetworkSnapshot {
try lifecycleCoordinator.withExclusiveAccess(using: lifecycleLease) {
try migrateLocked(accounts: accounts, selectedAddress: selectedAddress,
current: store.load(), activate: false)
}
}

/// Independently derives the first snapshot while an orphan remains intact.
/// This entry cannot activate state and is restricted to the startup verifier.
func verifiedFirstLegacySnapshot(accounts: [AccountItem], selectedAddress: String) throws
-> WalletNetworkSnapshot {
try recoveryGate.requireLegacyAccountRecoveryVerification()
return try lifecycleCoordinator.withExclusiveAccess {
try migrateLocked(accounts: accounts, selectedAddress: selectedAddress, current: nil, activate: false)
}
}

private func migrateLocked(
accounts: [AccountItem],
selectedAddress: String?
) throws {
let current = try store.load()
selectedAddress: String?,
current: WalletNetworkSnapshot?,
activate: Bool
) throws -> WalletNetworkSnapshot {
if let current {
guard
current.schemaVersion ==
Expand Down Expand Up @@ -5201,17 +5353,19 @@ final class WalletNetworkModelMigrator {
)
}

guard activate else { return snapshot }
if let current,
current.schemaVersion == snapshot.schemaVersion,
current.selectedWalletId == snapshot.selectedWalletId,
current.wallets == snapshot.wallets,
current.accounts == snapshot.accounts {
settings.walletNetworkStoreVersion = WalletNetworkSnapshot.currentSchemaVersion
return
return snapshot
}

try store.stageAndActivate(snapshot)
settings.walletNetworkStoreVersion = WalletNetworkSnapshot.currentSchemaVersion
return snapshot
}

private static func wipeSensitive(_ value: inout Data?) {
Expand Down
Loading
Loading