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:
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