apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

commit 1126a77ed87387719a6c6d3b4ce580582af29553
parent 1981db67ea91dab278bf0c2a9781cc53b9d1e47e
Author: triesap <tyson@radroots.org>
Date:   Thu, 24 Sep 2026 17:10:49 +0000

identity: guard legacy migration and cancelled custody

- Validate expected public identity before legacy migration
- Refuse cancelled presence before new custody effects
- Retain committed keys and legacy material for recovery
- Verify 309 package tests and one additive public method

Diffstat:
MREADME | 4++++
MSources/RadrootsKit/RadrootsIdentityCustody.swift | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsCustodyAdmissionTests.swift | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsLegacyMigrationCancellationTests.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/api_baselines/apple_kit.txt | 2++
5 files changed, 309 insertions(+), 0 deletions(-)

diff --git a/README b/README @@ -32,6 +32,10 @@ context. Cancellation before evaluation prevents its launch. Legacy identity migration removes the legacy secret only after reading and validating the retained active key against the same public identity. An unavailable or invalid active key preserves legacy custody for explicit recovery. +Hosts with retained public metadata can supply its expected public key to the +guarded migration overload; a mismatch fails before import or legacy deletion. +Cancelled custody requests cannot use a later successful presence callback to +authorize another effect. Cancellation does not roll back an already committed key. ## Copyright diff --git a/Sources/RadrootsKit/RadrootsIdentityCustody.swift b/Sources/RadrootsKit/RadrootsIdentityCustody.swift @@ -165,6 +165,7 @@ public actor RadrootsIdentityCustody { @discardableResult public func createIdentity(label: String? = nil) async throws -> RadrootsIdentitySnapshot { + try requireUncancelledTask() _ = try recover() guard try loadRecord() == nil, try !secureStore.contains(secretKey(.active)) else { throw RadrootsIdentityCustodyError.identityAlreadyExists @@ -189,6 +190,7 @@ public actor RadrootsIdentityCustody { label: String? = nil, replaceExisting: Bool = false ) async throws -> RadrootsIdentitySnapshot { + try requireUncancelledTask() _ = try recover() _ = try cryptography.publicKeyHex(for: material.copyBytes()) let existing = try loadRecord() @@ -218,6 +220,7 @@ public actor RadrootsIdentityCustody { passphrase: RadrootsIdentityPassphrase, replaceExisting: Bool = false ) async throws -> RadrootsIdentitySnapshot { + try requireUncancelledTask() let opened = try RadrootsIdentityPortabilityCodec.open(envelope, passphrase: passphrase) _ = try recover() let existing = try loadRecord() @@ -244,6 +247,7 @@ public actor RadrootsIdentityCustody { public func exportPortableIdentity( passphrase: RadrootsIdentityPassphrase ) async throws -> RadrootsIdentityPortabilityEnvelope { + try requireUncancelledTask() try requireProtectedData() guard let session else { throw RadrootsIdentityCustodyError.identityLocked @@ -273,10 +277,42 @@ public actor RadrootsIdentityCustody { from legacyKey: RadrootsSecureStoreKey, label: String? = nil ) async throws -> RadrootsIdentitySnapshot { + try await migrateLegacyIdentity(from: legacyKey, expectedIdentity: nil, label: label) + } + + @discardableResult + public func migrateLegacyIdentity( + from legacyKey: RadrootsSecureStoreKey, + expectedPublicKeyHex: String, + label: String? = nil + ) async throws -> RadrootsIdentitySnapshot { + guard expectedPublicKeyHex.count == 64, + expectedPublicKeyHex.utf8.allSatisfy({ (48 ... 57).contains($0) || (97 ... 102).contains($0) }) + else { + throw RadrootsIdentityCustodyError.invalidMetadata + } + return try await migrateLegacyIdentity( + from: legacyKey, expectedIdentity: expectedPublicKeyHex, label: label + ) + } + + private func migrateLegacyIdentity( + from legacyKey: RadrootsSecureStoreKey, + expectedIdentity: String?, + label: String? + ) async throws -> RadrootsIdentitySnapshot { + try requireUncancelledTask() _ = try recover() try requireProtectedData() if let existing = try loadRecord(), try secureStore.contains(secretKey(.active)) { + if let expectedIdentity, expectedIdentity != existing.publicKeyHex { + throw RadrootsIdentityCustodyError.inconsistentState + } guard let legacy = try secureStore.get(legacyKey) else { + if expectedIdentity != nil { + try validateActiveSecret(for: existing) + try requireUncancelledTask() + } return snapshot() } guard let text = String(data: legacy, encoding: .utf8), @@ -286,6 +322,7 @@ public actor RadrootsIdentityCustody { throw RadrootsIdentityCustodyError.inconsistentState } try validateActiveSecret(for: existing) + try requireUncancelledTask() try secureStore.delete(legacyKey) return snapshot() } @@ -295,8 +332,14 @@ public actor RadrootsIdentityCustody { throw RadrootsIdentityCustodyError.identityNotFound } let material = try RadrootsIdentitySecretMaterial(importText: text) + if let expectedIdentity, + try cryptography.publicKeyHex(for: material.copyBytes()) != expectedIdentity + { + throw RadrootsIdentityCustodyError.inconsistentState + } let result = try await importIdentity(material, label: label) try validateActiveSecret(for: requiredRecord()) + try requireUncancelledTask() do { try secureStore.delete(legacyKey) } catch { @@ -315,6 +358,7 @@ public actor RadrootsIdentityCustody { @discardableResult public func unlockIdentity() async throws -> RadrootsIdentitySnapshot { + try requireUncancelledTask() _ = try recover() try requireProtectedData() let record = try requiredRecord() @@ -358,6 +402,7 @@ public actor RadrootsIdentityCustody { @discardableResult public func deleteIdentity() async throws -> RadrootsIdentitySnapshot { + try requireUncancelledTask() _ = try recover() try requireProtectedData() let record = try requiredRecord() @@ -630,6 +675,7 @@ public actor RadrootsIdentityCustody { } private func requireUserPresence(reason: String) async throws { + try requireUncancelledTask() try requireProtectedData() let request: RadrootsUserPresenceRequest do { @@ -639,6 +685,7 @@ public actor RadrootsIdentityCustody { } do { let result = try await userPresence.verify(request) + try requireUncancelledTask() guard result.verified else { throw RadrootsIdentityCustodyError.userPresenceRequired } @@ -659,6 +706,12 @@ public actor RadrootsIdentityCustody { try requireProtectedData() } + private func requireUncancelledTask() throws { + guard !Task.isCancelled else { + throw RadrootsIdentityCustodyError.cancelled + } + } + private func requireProtectedData() throws { guard protectedData.currentState() == .available else { throw RadrootsIdentityCustodyError.protectedDataUnavailable diff --git a/Tests/RadrootsKitTests/RadrootsCustodyAdmissionTests.swift b/Tests/RadrootsKitTests/RadrootsCustodyAdmissionTests.swift @@ -0,0 +1,201 @@ +import Foundation +import RadrootsKitTesting +import Testing + +@testable import RadrootsKit + +@Test func guardedLegacyMigrationRejectsMetadataMismatchBeforeImportOrDeletion() async throws { + let fixture = try AdmissionFixture() + let legacy = Data(String(repeating: "01", count: 32).utf8) + try fixture.secure.put(legacy, for: fixture.legacyKey) + await #expect(throws: RadrootsIdentityCustodyError.inconsistentState) { + try await fixture.custody.migrateLegacyIdentity( + from: fixture.legacyKey, expectedPublicKeyHex: String(repeating: "ab", count: 32) + ) + } + #expect(await fixture.custody.snapshot().state == .absent) + #expect(try fixture.secure.get(fixture.legacyKey) == legacy) +} + +@Test func guardedLegacyReplayPreservesInstalledIdentityAndValidatesAbsentLegacy() async throws { + let fixture = try AdmissionFixture() + let installed = try await fixture.importIdentity() + let record = try #require(installed.identity) + let legacy = Data(String(repeating: "01", count: 32).utf8) + try fixture.secure.put(legacy, for: fixture.legacyKey) + await #expect(throws: RadrootsIdentityCustodyError.inconsistentState) { + try await fixture.custody.migrateLegacyIdentity( + from: fixture.legacyKey, expectedPublicKeyHex: String(repeating: "ab", count: 32) + ) + } + #expect(try fixture.secure.get(fixture.legacyKey) == legacy) + let replayed = try await fixture.custody.migrateLegacyIdentity( + from: fixture.legacyKey, expectedPublicKeyHex: record.publicKeyHex + ) + #expect(replayed.identity == record) + #expect(try fixture.secure.get(fixture.legacyKey) == nil) + #expect(try await fixture.custody.migrateLegacyIdentity( + from: fixture.legacyKey, expectedPublicKeyHex: record.publicKeyHex + ).identity == record) + try fixture.secure.put(Data(repeating: 0, count: 32), for: fixture.activeKey) + await #expect(throws: RadrootsIdentityCustodyError.invalidSecret) { + try await fixture.custody.migrateLegacyIdentity( + from: fixture.legacyKey, expectedPublicKeyHex: record.publicKeyHex + ) + } + #expect(await fixture.custody.snapshot().identity == record) +} + +@Test(arguments: ["", "ab", String(repeating: "AB", count: 32), String(repeating: "g", count: 64)]) +func guardedLegacyMigrationRejectsNoncanonicalExpectedIdentity(expected: String) async throws { + let fixture = try AdmissionFixture() + await #expect(throws: RadrootsIdentityCustodyError.invalidMetadata) { + try await fixture.custody.migrateLegacyIdentity( + from: fixture.legacyKey, expectedPublicKeyHex: expected + ) + } + #expect(await fixture.custody.snapshot().state == .absent) + #expect(fixture.secure.keys().isEmpty) +} + +@Test func alreadyCancelledCustodyRequestNeverStartsPresenceOrCreatesKey() async throws { + let fixture = try AdmissionFixture() + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await fixture.custody.createIdentity() + } + await #expect(throws: RadrootsIdentityCustodyError.cancelled) { try await task.value } + #expect(await fixture.presence.requests == 0) + #expect(fixture.secure.keys().isEmpty) + #expect(await fixture.custody.snapshot().state == .absent) +} + +@Test(arguments: ["create", "import", "replace", "unlock", "export", "delete"]) +func cancelledPresenceSuccessCannotAuthorizeCustodyEffects(operation: String) async throws { + let fixture = try AdmissionFixture() + if !["create", "import"].contains(operation) { _ = try await fixture.importIdentity() } + if operation == "unlock" { await fixture.custody.lockIdentity() } + let before = await fixture.custody.snapshot() + let original = try fixture.secure.get(fixture.activeKey) + await fixture.presence.arm() + let task = Task { + switch operation { + case "create": _ = try await fixture.custody.createIdentity() + case "import", "replace": + _ = try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(rawRepresentation: Data(repeating: 2, count: 32)), + replaceExisting: operation == "replace" + ) + case "unlock": _ = try await fixture.custody.unlockIdentity() + case "export": + _ = try await fixture.custody.exportPortableIdentity( + passphrase: RadrootsIdentityPassphrase("synthetic custody test passphrase") + ) + case "delete": _ = try await fixture.custody.deleteIdentity() + default: Issue.record("Unknown test operation") + } + } + for await _ in fixture.presence.entered { break } + task.cancel() + await fixture.presence.release() + await #expect(throws: RadrootsIdentityCustodyError.cancelled) { try await task.value } + let after = await fixture.custody.snapshot() + #expect(after.identity == before.identity) + #expect(after.state == before.state) + #expect(try fixture.secure.get(fixture.activeKey) == original) + #expect(fixture.secure.keys().allSatisfy { $0 == fixture.activeKey }) +} + +@Test func guardedLegacyImportKeepsExpectedIdentityAcrossFreshCustodyInstance() async throws { + let reference = try AdmissionFixture() + let expected = try #require(try await reference.importIdentity().identity?.publicKeyHex) + let secure = RadrootsInMemorySecureStore() + let metadata = RadrootsInMemoryIdentityMetadataStore() + let configuration = try RadrootsIdentityCustodyConfiguration(namespace: UUID().uuidString.lowercased()) + let key = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") + try secure.put(Data(String(repeating: "01", count: 32).utf8), for: key) + let first = RadrootsIdentityCustody( + configuration: configuration, secureStore: secure, metadataStore: metadata, + userPresence: CustodyPresenceGate() + ) + let migrated = try await first.migrateLegacyIdentity(from: key, expectedPublicKeyHex: expected) + #expect(migrated.identity?.publicKeyHex == expected) + #expect(try secure.get(key) == nil) + let restarted = RadrootsIdentityCustody( + configuration: configuration, secureStore: secure, metadataStore: metadata, + userPresence: CustodyPresenceGate() + ) + let replayed = try await restarted.migrateLegacyIdentity(from: key, expectedPublicKeyHex: expected) + #expect(replayed.identity == migrated.identity) + #expect(replayed.state == .locked) +} + +@Test func cancelledPortableImportDoesNotInstallOpenedIdentity() async throws { + let source = try AdmissionFixture() + _ = try await source.importIdentity() + let passphrase = try RadrootsIdentityPassphrase("synthetic portable test passphrase") + let envelope = try await source.custody.exportPortableIdentity(passphrase: passphrase) + let destination = try AdmissionFixture() + await destination.presence.arm() + let task = Task { + try await destination.custody.importPortableIdentity(envelope, passphrase: passphrase) + } + for await _ in destination.presence.entered { break } + task.cancel() + await destination.presence.release() + await #expect(throws: RadrootsIdentityCustodyError.cancelled) { try await task.value } + #expect(await destination.custody.snapshot().state == .absent) + #expect(destination.secure.keys().isEmpty) +} + +private struct AdmissionFixture: Sendable { + let secure = RadrootsInMemorySecureStore() + let presence = CustodyPresenceGate() + let custody: RadrootsIdentityCustody + let activeKey: RadrootsSecureStoreKey + let legacyKey = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") + + init() throws { + let namespace = UUID().uuidString.lowercased() + activeKey = RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1") + custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace), + secureStore: secure, metadataStore: RadrootsInMemoryIdentityMetadataStore(), + userPresence: presence + ) + } + + func importIdentity() async throws -> RadrootsIdentitySnapshot { + try await custody.importIdentity( + RadrootsIdentitySecretMaterial(rawRepresentation: Data(repeating: 1, count: 32)) + ) + } +} + +private actor CustodyPresenceGate: RadrootsUserPresence { + nonisolated let entered: AsyncStream<Void> + private let signal: AsyncStream<Void>.Continuation + private var pending: CheckedContinuation<Void, Never>? + private var blocks = false + private(set) var requests = 0 + + init() { (entered, signal) = AsyncStream.makeStream() } + func currentStatus() async throws -> RadrootsUserPresenceStatus { .unavailable } + func arm() { blocks = true } + func release() { + pending?.resume() + pending = nil + } + + func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { + requests += 1 + if blocks { + blocks = false + await withCheckedContinuation { continuation in + pending = continuation + signal.yield(()) + } + } + return RadrootsUserPresenceResult(policy: request.policy, verified: true) + } +} diff --git a/Tests/RadrootsKitTests/RadrootsLegacyMigrationCancellationTests.swift b/Tests/RadrootsKitTests/RadrootsLegacyMigrationCancellationTests.swift @@ -0,0 +1,49 @@ +import Foundation +import RadrootsKitTesting +import Testing + +@testable import RadrootsKit + +@Test func cancellationAfterImportRetainsCommittedKeyAndLegacyUntilExplicitRecovery() async throws { + let key = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") + let store = CancelOnActiveReadStore() + let legacy = Data(String(repeating: "01", count: 32).utf8) + try store.put(legacy, for: key) + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: UUID().uuidString.lowercased()), + secureStore: store, metadataStore: RadrootsInMemoryIdentityMetadataStore(), + userPresence: RadrootsFakeUserPresence() + ) + let task = Task { try await custody.migrateLegacyIdentity(from: key) } + await #expect(throws: RadrootsIdentityCustodyError.cancelled) { try await task.value } + let retained = await custody.snapshot() + let expected = try #require(retained.identity?.publicKeyHex) + #expect(retained.state == .unlocked) + #expect(try store.get(key) == legacy) + let recovered = try await custody.migrateLegacyIdentity(from: key, expectedPublicKeyHex: expected) + #expect(recovered.identity == retained.identity) + #expect(try store.get(key) == nil) +} + +// The backing store synchronizes bytes; the lock protects the one-shot fault. +private final class CancelOnActiveReadStore: RadrootsSecureStore, @unchecked Sendable { + private let backing = RadrootsInMemorySecureStore() + private let lock = NSLock() + private var shouldCancel = true + + func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws { + try backing.put(value, for: key, policy: policy) + } + func contains(_ key: RadrootsSecureStoreKey) throws -> Bool { try backing.contains(key) } + func delete(_ key: RadrootsSecureStoreKey) throws { try backing.delete(key) } + func deleteNamespace(_ namespace: String) throws { try backing.deleteNamespace(namespace) } + func get(_ key: RadrootsSecureStoreKey) throws -> Data? { + let value = try backing.get(key) + lock.lock() + let cancel = key.name == "active_secret_v1" && shouldCancel + if cancel { shouldCancel = false } + lock.unlock() + if cancel { withUnsafeCurrentTask { $0?.cancel() } } + return value + } +} diff --git a/contracts/api_baselines/apple_kit.txt b/contracts/api_baselines/apple_kit.txt @@ -871,6 +871,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 129:s:11RadrootsKit0A15OpaqueSignatureV11operationID12publicKeyHex9signature7purposeACSS_SS10Foundation4DataVAA0aC11SignPurposeOtKcfc 35:s:11RadrootsKit0A15OpaqueSignatureV 0: 12:relationship 11:RadrootsKit 8:memberOf 131:s:11RadrootsKit0A15AppleFileAccessC013stageExternalD0_9mediaType12filenameHintAA0A19StagedBlobReferenceV10Foundation3URLV_SSSgALtKF 35:s:11RadrootsKit0A15AppleFileAccessC 0: 12:relationship 11:RadrootsKit 8:memberOf 131:s:11RadrootsKit0A27DocumentPresentationAdapterO12transferItem3for10fileAccessAA0a13ShareTransferG0VAA0aK7RequestV_AA0a4FileJ0_ptKFZ 47:s:11RadrootsKit0A27DocumentPresentationAdapterO 0: +12:relationship 11:RadrootsKit 8:memberOf 132:s:11RadrootsKit0A15IdentityCustodyC013migrateLegacyC04from20expectedPublicKeyHex5labelAA0aC8SnapshotVAA0a11SecureStoreJ0V_S2SSgtYaKF 35:s:11RadrootsKit0A15IdentityCustodyC 0: 12:relationship 11:RadrootsKit 8:memberOf 132:s:11RadrootsKit0A18AppleMediaPreparerC5roots11fileManager13protectedDataAcA0aC9FileRootsV_So06NSFileH0CAA0a9ProtectedJ8ProviderVtcfc 38:s:11RadrootsKit0A18AppleMediaPreparerC 0: 12:relationship 11:RadrootsKit 8:memberOf 132:s:11RadrootsKit0A28AppleExternalActionsAdaptersV14appSettingsURL07canOpenI004openI0AC10Foundation0I0VSgyYaYbc_SbAIYaYbcSbAIYaYbctcfc 48:s:11RadrootsKit0A28AppleExternalActionsAdaptersV 0: 12:relationship 11:RadrootsKit 8:memberOf 135:s:11RadrootsKit0A27AppleVerifiedArtifactAccessV11mobileStore13protectedDataAcA0ac6MobileH13ConfigurationV_AA0a9ProtectedJ8ProviderVtcfc 47:s:11RadrootsKit0A27AppleVerifiedArtifactAccessV 0: @@ -2158,6 +2159,7 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 12:swift.method 126:s:11RadrootsKit0A10FileAccessP013stageExternalC0_9mediaType12filenameHintAA0A19StagedBlobReferenceV10Foundation3URLV_SSSgALtKF 44:stageExternalFile(_:mediaType:filenameHint:) 140:@discardableResult func stageExternalFile(_ sourceURL: URL, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference 6:symbol 11:RadrootsKit 12:swift.method 127:s:11RadrootsKit0A23BackgroundTransferStoreP13withAdmission3for9operationqd__AA0acD10IdentifierV_qd__yYaYbKctYaKs8SendableRd__lF 29:withAdmission(for:operation:) 185:func withAdmission<Result>(for identifier: RadrootsBackgroundTransferIdentifier, operation: @escaping @Sendable () async throws -> Result) async throws -> Result where Result : Sendable 6:symbol 11:RadrootsKit 12:swift.method 131:s:11RadrootsKit0A15AppleFileAccessC013stageExternalD0_9mediaType12filenameHintAA0A19StagedBlobReferenceV10Foundation3URLV_SSSgALtKF 44:stageExternalFile(_:mediaType:filenameHint:) 152:@discardableResult func stageExternalFile(_ sourceURL: URL, mediaType: String? = nil, filenameHint: String? = nil) throws -> RadrootsStagedBlobReference +6:symbol 11:RadrootsKit 12:swift.method 132:s:11RadrootsKit0A15IdentityCustodyC013migrateLegacyC04from20expectedPublicKeyHex5labelAA0aC8SnapshotVAA0a11SecureStoreJ0V_S2SSgtYaKF 55:migrateLegacyIdentity(from:expectedPublicKeyHex:label:) 178:@discardableResult func migrateLegacyIdentity(from legacyKey: RadrootsSecureStoreKey, expectedPublicKeyHex: String, label: String? = nil) async throws -> RadrootsIdentitySnapshot 6:symbol 11:RadrootsKit 12:swift.method 144:s:11RadrootsKit0A23AppleBackgroundTransferC21withInactiveExecution3for9operationxAA0adE10IdentifierV_xAA0adE8SnapshotVSgYaYbKctYaKs8SendableRzlF 37:withInactiveExecution(for:operation:) 228:func withInactiveExecution<Result>(for identifier: RadrootsBackgroundTransferIdentifier, operation: @escaping @Sendable (RadrootsBackgroundTransferSnapshot?) async throws -> Result) async throws -> Result where Result : Sendable 6:symbol 11:RadrootsKit 12:swift.method 146:s:11RadrootsKit0A10FileAccessP012copyExternalC0_2to9mediaType17suggestedFilenameAA0A16ImportedDocumentV10Foundation3URLV_AA0aC9ReferenceVSSSgAOtKF 51:copyExternalFile(_:to:mediaType:suggestedFilename:) 173:@discardableResult func copyExternalFile(_ sourceURL: URL, to file: RadrootsFileReference, mediaType: String?, suggestedFilename: String?) throws -> RadrootsImportedDocument 6:symbol 11:RadrootsKit 12:swift.method 147:s:11RadrootsKit0A18BackgroundTransferP21withInactiveExecution3for9operationqd__AA0acD10IdentifierV_qd__AA0acD8SnapshotVSgYaYbKctYaKs8SendableRd__lF 37:withInactiveExecution(for:operation:) 228:func withInactiveExecution<Result>(for identifier: RadrootsBackgroundTransferIdentifier, operation: @escaping @Sendable (RadrootsBackgroundTransferSnapshot?) async throws -> Result) async throws -> Result where Result : Sendable