field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 90aff2dd3c0e5a602a6d113124feb9fe5c9fda0f
parent 2939e86b60f41999c2ec2ade2037e3151c232346
Author: triesap <tyson@radroots.org>
Date:   Tue,  1 Sep 2026 02:34:11 +0000

fix(ios): redact qualification authorization evidence

- replace replayable Documents events with bounded temporary digests
- domain-separate and length-frame every authorization digest input
- clean legacy and ephemeral evidence on launch and orderly stop
- fail signing when qualification evidence cannot be recorded

Diffstat:
MPackage.swift | 1+
MRadroots/App/RadrootsRemoteQualification.swift | 61-------------------------------------------------------------
ARadroots/App/RadrootsRemoteQualificationEvidence.swift | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Runtime/RadrootsGeneratedRuntimeBackend.swift | 28+++++++++++++++++++---------
MRadroots/State/RadrootsSessionStore.swift | 14++++++++++++--
MRadrootsTests/RadrootsRemoteQualificationTests.swift | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/verify-package-contract.sh | 12++++++++++++
7 files changed, 345 insertions(+), 72 deletions(-)

diff --git a/Package.swift b/Package.swift @@ -51,6 +51,7 @@ let package = Package( "App/RadrootsAppModel.swift", "App/RadrootsProvider.swift", "App/RadrootsRemoteQualification.swift", + "App/RadrootsRemoteQualificationEvidence.swift", "App/RadrootsRootShell.swift", "Runtime/RadrootsAddMediaCoordinator.swift", "Runtime/RadrootsCheckedTime.swift", diff --git a/Radroots/App/RadrootsRemoteQualification.swift b/Radroots/App/RadrootsRemoteQualification.swift @@ -214,67 +214,6 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { } #if DEBUG - private struct RadrootsRemoteQualificationBlossomAuthorization: Codable { - let schema: String - let schemaVersion: UInt16 - let id: String - let pubkey: String - let createdAt: UInt64 - let kind: UInt32 - let tags: [[String]] - let content: String - let signature: String - - enum CodingKeys: String, CodingKey { - case schema - case schemaVersion = "schema_version" - case id - case pubkey - case createdAt = "created_at" - case kind - case tags - case content - case signature = "sig" - } - } - - enum RadrootsRemoteQualificationEvidence { - static func recordBlossomAuthorization( - request: HostSigningRequest, - signatureHex: String - ) throws { - guard try RadrootsRemoteQualificationEnvironment.current() != nil else { return } - let evidence = RadrootsRemoteQualificationBlossomAuthorization( - schema: "radroots-ios-remote-qualification-bud11-v1", - schemaVersion: 1, - id: request.expectedEventId, - pubkey: request.publicKey, - createdAt: request.createdAtUnixS, - kind: request.kind, - tags: request.tags, - content: request.content, - signature: signatureHex - ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(evidence) - guard - let documents = FileManager.default.urls( - for: .documentDirectory, - in: .userDomainMask - ).first - else { - throw CocoaError(.fileNoSuchFile) - } - try data.write( - to: documents.appendingPathComponent( - "radroots-remote-qualification-bud11.json" - ), - options: .atomic - ) - } - } - final class RadrootsRemoteQualificationUserPresence: RadrootsUserPresence, Sendable { init(mode: RadrootsQualificationNetworkMode) throws { guard mode.permitsAutomatedUserPresence, mode.permitsTestSecretPolicy else { diff --git a/Radroots/App/RadrootsRemoteQualificationEvidence.swift b/Radroots/App/RadrootsRemoteQualificationEvidence.swift @@ -0,0 +1,171 @@ +import CryptoKit +import Foundation +import RadrootsKitBindings + +struct RadrootsRemoteQualificationAuthorizationEvidence: Codable, Equatable { + static let schema = "radroots.ios.remote-qualification.authorization-evidence.v1" + static let digestDomain = "radroots.ios.remote-qualification.authorization-digest.v1\0" + static let maximumEncodedBytes = 512 + + let schema: String + let schemaVersion: UInt16 + let authorizationDigestSHA256: String + + enum CodingKeys: String, CodingKey { + case schema + case schemaVersion = "schema_version" + case authorizationDigestSHA256 = "authorization_digest_sha256" + } + + init(runID: String, request: HostSigningRequest, signatureHex: String) { + schema = Self.schema + schemaVersion = 1 + authorizationDigestSHA256 = Self.digest( + fields: [ + Data(runID.utf8), + Data(request.operationId.utf8), + Data(request.artifactId.utf8), + Data(request.signerRequestId.utf8), + Data(request.publicKey.utf8), + Data(request.expectedEventId.utf8), + request.eventIdDigest, + Data(signatureHex.utf8), + ] + ) + } + + private static func digest(fields: [Data]) -> String { + var hasher = SHA256() + hasher.update(data: Data(digestDomain.utf8)) + for field in fields { + var length = UInt64(field.count).bigEndian + withUnsafeBytes(of: &length) { hasher.update(bufferPointer: $0) } + hasher.update(data: field) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } +} + +struct RadrootsRemoteQualificationEvidenceStore { + static let evidenceFileName = "authorization-evidence.v1.json" + static let legacyAuthorizationFileName = "radroots-remote-qualification-bud11.json" + + let runID: String + let directory: URL + let evidenceFile: URL + let legacyAuthorizationFile: URL + let fileManager: FileManager + + init( + runID: String, + temporaryRoot: URL, + documentsRoot: URL, + fileManager: FileManager = .default + ) { + self.runID = runID + directory = temporaryRoot + .appendingPathComponent("radroots-qualification-evidence", isDirectory: true) + .appendingPathComponent(runID, isDirectory: true) + evidenceFile = directory.appendingPathComponent(Self.evidenceFileName) + legacyAuthorizationFile = documentsRoot.appendingPathComponent( + Self.legacyAuthorizationFileName + ) + self.fileManager = fileManager + } + + static func current(fileManager: FileManager = .default) throws -> Self? { + guard let environment = try RadrootsRemoteQualificationEnvironment.current() else { + return nil + } + guard let documentsRoot = fileManager.urls( + for: .documentDirectory, + in: .userDomainMask + ).first else { + throw CocoaError(.fileNoSuchFile) + } + return Self( + runID: environment.runID, + temporaryRoot: fileManager.temporaryDirectory, + documentsRoot: documentsRoot, + fileManager: fileManager + ) + } + + static func cleanupLegacyAuthorization(fileManager: FileManager = .default) throws { + guard let documentsRoot = fileManager.urls( + for: .documentDirectory, + in: .userDomainMask + ).first else { + throw CocoaError(.fileNoSuchFile) + } + let legacy = documentsRoot.appendingPathComponent(Self.legacyAuthorizationFileName) + if fileManager.fileExists(atPath: legacy.path) { + try fileManager.removeItem(at: legacy) + } + } + + func prepare() throws { + try cleanup() + } + + func record(request: HostSigningRequest, signatureHex: String) throws { + let evidence = RadrootsRemoteQualificationAuthorizationEvidence( + runID: runID, + request: request, + signatureHex: signatureHex + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(evidence) + guard data.count <= RadrootsRemoteQualificationAuthorizationEvidence.maximumEncodedBytes else { + throw CocoaError(.fileWriteOutOfSpace) + } + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let values = try directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isDirectory == true, values.isSymbolicLink != true else { + throw CocoaError(.fileWriteInvalidFileName) + } + try data.write(to: evidenceFile, options: [.atomic, .completeFileProtection]) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: evidenceFile.path) + } + + func cleanup() throws { + try removeIfPresent(legacyAuthorizationFile) + try removeIfPresent(evidenceFile) + if fileManager.fileExists(atPath: directory.path) { + try fileManager.removeItem(at: directory) + } + } + + private func removeIfPresent(_ url: URL) throws { + if fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + } + } +} + +enum RadrootsRemoteQualificationEvidence { + static func prepare() throws -> RadrootsRemoteQualificationEvidenceStore? { + try RadrootsRemoteQualificationEvidenceStore.cleanupLegacyAuthorization() + let store = try RadrootsRemoteQualificationEvidenceStore.current() + try store?.prepare() + return store + } + + static func recordBlossomAuthorization( + request: HostSigningRequest, + signatureHex: String + ) throws { + guard try RadrootsRemoteQualificationEnvironment.current()?.networkMode + == .isolatedLoopback + else { return } + try RadrootsRemoteQualificationEvidenceStore.current()?.record( + request: request, + signatureHex: signatureHex + ) + } +} diff --git a/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift b/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift @@ -1,13 +1,22 @@ import Foundation import RadrootsKitBindings -private final class RadrootsGeneratedHostSigner: RadrootsHostSigner, @unchecked Sendable { +final class RadrootsGeneratedHostSigner: RadrootsHostSigner, @unchecked Sendable { + typealias QualificationEvidenceRecorder = @Sendable (HostSigningRequest, String) throws -> Void + private let signer: any RadrootsRuntimeSigner private let clock: RadrootsClock + private let qualificationEvidenceRecorder: QualificationEvidenceRecorder - init(signer: any RadrootsRuntimeSigner, clock: RadrootsClock = .system) { + init( + signer: any RadrootsRuntimeSigner, + clock: RadrootsClock = .system, + qualificationEvidenceRecorder: @escaping QualificationEvidenceRecorder = + RadrootsRemoteQualificationEvidence.recordBlossomAuthorization + ) { self.signer = signer self.clock = clock + self.qualificationEvidenceRecorder = qualificationEvidenceRecorder } func signerStatus() async -> SignerStatusRecord { @@ -19,7 +28,7 @@ private final class RadrootsGeneratedHostSigner: RadrootsHostSigner, @unchecked func sign(request: HostSigningRequest) async -> HostSigningResult { guard (try? clock.unixMilliseconds()) != nil else { - return failedClockResult(for: request) + return failedSigningResult(for: request) } let purpose = request.purpose.appValue let outcome = await signer.sign( @@ -36,14 +45,15 @@ private final class RadrootsGeneratedHostSigner: RadrootsHostSigner, @unchecked if request.purpose == .blossomUpload, let signatureHex = outcome.signatureHex { - try? RadrootsRemoteQualificationEvidence.recordBlossomAuthorization( - request: request, - signatureHex: signatureHex - ) + do { + try qualificationEvidenceRecorder(request, signatureHex) + } catch { + return failedSigningResult(for: request) + } } #endif guard let completedAtUnixMilliseconds = try? clock.unixMilliseconds() else { - return failedClockResult(for: request) + return failedSigningResult(for: request) } return HostSigningResult( schemaVersion: 1, @@ -57,7 +67,7 @@ private final class RadrootsGeneratedHostSigner: RadrootsHostSigner, @unchecked ) } - private func failedClockResult(for request: HostSigningRequest) -> HostSigningResult { + private func failedSigningResult(for request: HostSigningRequest) -> HostSigningResult { HostSigningResult( schemaVersion: 1, outcome: .failed, diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift @@ -44,6 +44,7 @@ actor RadrootsSessionStore { private let roots: RadrootsAppleFileRoots private let protectedData: RadrootsProtectedDataMonitor private let automatesQualificationIdentity: Bool + private let qualificationEvidenceStore: RadrootsRemoteQualificationEvidenceStore? private var generation: UInt64 = 0 private var phase: RadrootsSessionPhase = .starting @@ -53,7 +54,8 @@ actor RadrootsSessionStore { runtimeClient: RadrootsRuntimeClient, roots: RadrootsAppleFileRoots, protectedData: RadrootsProtectedDataMonitor, - automatesQualificationIdentity: Bool = false + automatesQualificationIdentity: Bool = false, + qualificationEvidenceStore: RadrootsRemoteQualificationEvidenceStore? = nil ) { self.configurationStore = configurationStore self.identityStore = identityStore @@ -61,6 +63,7 @@ actor RadrootsSessionStore { self.roots = roots self.protectedData = protectedData self.automatesQualificationIdentity = automatesQualificationIdentity + self.qualificationEvidenceStore = qualificationEvidenceStore } @MainActor @@ -72,6 +75,11 @@ actor RadrootsSessionStore { throw RadrootsConfigurationError.missing("bundle_identifier") } let qualification = try RadrootsRemoteQualificationEnvironment.current() + #if DEBUG + let qualificationEvidenceStore = try RadrootsRemoteQualificationEvidence.prepare() + #else + let qualificationEvidenceStore: RadrootsRemoteQualificationEvidenceStore? = nil + #endif let servicePrefix = try qualification?.keychainServicePrefix ?? requiredString( @@ -112,7 +120,8 @@ actor RadrootsSessionStore { runtimeClient: runtimeClient, roots: roots, protectedData: protectedData, - automatesQualificationIdentity: qualification?.automatesIdentity == true + automatesQualificationIdentity: qualification?.automatesIdentity == true, + qualificationEvidenceStore: qualificationEvidenceStore ) } @@ -303,6 +312,7 @@ actor RadrootsSessionStore { do { _ = try await runtimeClient.stop() await identityStore.lock() + try qualificationEvidenceStore?.cleanup() phase = .stopped } catch let RadrootsRuntimeClientError.shutdown(failure) { phase = .failed(failure) diff --git a/RadrootsTests/RadrootsRemoteQualificationTests.swift b/RadrootsTests/RadrootsRemoteQualificationTests.swift @@ -1,6 +1,7 @@ import CryptoKit @testable import RadrootsApp import RadrootsKit +import RadrootsKitBindings import XCTest final class RadrootsRemoteQualificationTests: XCTestCase { @@ -207,4 +208,133 @@ final class RadrootsRemoteQualificationTests: XCTestCase { "431ced6916a2a21a156e38701afe55bbd7f88969fbbfc56d7fe099d47f265460" ) } + + func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let temporary = root.appendingPathComponent("temporary", isDirectory: true) + let documents = root.appendingPathComponent("documents", isDirectory: true) + try FileManager.default.createDirectory( + at: temporary, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: documents, + withIntermediateDirectories: true + ) + let store = RadrootsRemoteQualificationEvidenceStore( + runID: "persona-p01-12345678", + temporaryRoot: temporary, + documentsRoot: documents + ) + try Data("signed-authorization-canary".utf8).write( + to: store.legacyAuthorizationFile + ) + + try store.prepare() + XCTAssertFalse(FileManager.default.fileExists(atPath: store.legacyAuthorizationFile.path)) + try store.record(request: qualificationSigningRequest(), signatureHex: String(repeating: "b", count: 128)) + + let data = try Data(contentsOf: store.evidenceFile) + XCTAssertLessThanOrEqual( + data.count, + RadrootsRemoteQualificationAuthorizationEvidence.maximumEncodedBytes + ) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual( + Set(object.keys), + Set(["schema", "schema_version", "authorization_digest_sha256"]) + ) + XCTAssertEqual( + object["authorization_digest_sha256"] as? String, + "fc3a74758a976c6f8dc37a4168520627de9ae8bc6a8433af3064fda94ea3e21b" + ) + let rendered = try XCTUnwrap(String(bytes: data, encoding: .utf8)) + for forbidden in [ + "signed-authorization-canary", "operation-canary", "artifact-canary", + "request-canary", "content-canary", String(repeating: "a", count: 64), + String(repeating: "b", count: 128), + ] { + XCTAssertFalse(rendered.contains(forbidden)) + } + let attributes = try FileManager.default.attributesOfItem(atPath: store.evidenceFile.path) + XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) + + try store.prepare() + XCTAssertFalse(FileManager.default.fileExists(atPath: store.evidenceFile.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: store.directory.path)) + } + + func testAuthorizationEvidenceDigestIsDomainSeparatedAndFieldSensitive() { + let request = qualificationSigningRequest() + let baseline = RadrootsRemoteQualificationAuthorizationEvidence( + runID: "persona-p01-12345678", + request: request, + signatureHex: String(repeating: "b", count: 128) + ) + let changedRun = RadrootsRemoteQualificationAuthorizationEvidence( + runID: "persona-p02-12345678", + request: request, + signatureHex: String(repeating: "b", count: 128) + ) + let changedSignature = RadrootsRemoteQualificationAuthorizationEvidence( + runID: "persona-p01-12345678", + request: request, + signatureHex: String(repeating: "c", count: 128) + ) + XCTAssertNotEqual(baseline.authorizationDigestSHA256, changedRun.authorizationDigestSHA256) + XCTAssertNotEqual( + baseline.authorizationDigestSHA256, + changedSignature.authorizationDigestSHA256 + ) + XCTAssertTrue( + RadrootsRemoteQualificationAuthorizationEvidence.digestDomain.hasSuffix("\0") + ) + } + + func testAuthorizationEvidenceWriteFailureFailsSigningResult() async { + let signer = RadrootsGeneratedHostSigner( + signer: QualificationSignedSigner(), + clock: .fixed(unixSeconds: 1_800_000_000), + qualificationEvidenceRecorder: { _, _ in + throw CocoaError(.fileWriteNoPermission) + } + ) + let result = await signer.sign(request: qualificationSigningRequest()) + XCTAssertEqual(result.outcome, .failed) + XCTAssertNil(result.signatureHex) + XCTAssertEqual(result.completedAtUnixMs, 0) + } + + private func qualificationSigningRequest() -> HostSigningRequest { + HostSigningRequest( + schemaVersion: 1, + operationKind: "blossom-upload", + operationId: "operation-canary", + artifactId: "artifact-canary", + signerRequestId: "request-canary", + publicKey: String(repeating: "a", count: 64), + purpose: .blossomUpload, + deadlineUnixMs: 1_800_000_300_000, + eventIdDigest: Data(repeating: 0xAA, count: 32), + expectedEventId: String(repeating: "a", count: 64), + createdAtUnixS: 1_800_000_000, + kind: 24242, + tags: [["t", "content-canary"]], + content: "content-canary" + ) + } +} + +private struct QualificationSignedSigner: RadrootsRuntimeSigner { + func availability() async -> RadrootsRuntimeSignerAvailability { + .ready + } + + func sign(_: RadrootsRuntimeSigningRequest) async -> RadrootsRuntimeSigningOutcome { + .signed(signatureHex: String(repeating: "b", count: 128)) + } } diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh @@ -201,6 +201,18 @@ grep -Fq 'if let qualification, qualification.automatesIdentity' \ "$repo_root/Radroots/State/RadrootsIdentityStore.swift" grep -Fq 'qualification?.automatesIdentity == true' \ "$repo_root/Radroots/State/RadrootsSessionStore.swift" +grep -Fq 'radroots.ios.remote-qualification.authorization-digest.v1\0' \ + "$repo_root/Radroots/App/RadrootsRemoteQualificationEvidence.swift" +grep -Fq 'try qualificationEvidenceStore?.cleanup()' \ + "$repo_root/Radroots/State/RadrootsSessionStore.swift" +if rg -n \ + 'try\?[[:space:]]+RadrootsRemoteQualificationEvidence|RadrootsRemoteQualificationBlossomAuthorization|data\.write\([[:space:]]*to:[^\n]*document' \ + "$repo_root/Radroots" \ + --glob '*.swift' +then + echo "error: qualification evidence may persist or ignore signed authorization material" >&2 + exit 1 +fi if rg -n 'if qualification != nil|automatesQualificationIdentity: qualification != nil' \ "$repo_root/Radroots" --glob '*.swift' then