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 02a6aabad7f01ffe6cfa8b1976521d94bbabec39
parent d1c36a8617f5893351408aee3f7fa840761a4615
Author: triesap <tyson@radroots.org>
Date:   Mon, 31 Aug 2026 18:43:09 +0000

ios: isolate qualification identity authority

- Add a closed typed network mode for loopback and public qualification.
- Permit automated presence and test secret policy only for isolated loopback.
- Keep public qualification on normal Apple presence without automatic unlock.
- Cover mode mutations, redaction, and package-boundary source guards.

Diffstat:
MREADME.md | 5++++-
MRadroots/App/RadrootsRemoteQualification.swift | 59+++++++++++++++++++++++++++++++++++++++++++++++++----------
MRadroots/State/RadrootsIdentityStore.swift | 6++++--
MRadroots/State/RadrootsSessionStore.swift | 2+-
MRadrootsTests/RadrootsRemoteQualificationTests.swift | 27++++++++++++++++++++++++++-
Mscripts/verify-package-contract.sh | 14+++++++++++++-
6 files changed, 97 insertions(+), 16 deletions(-)

diff --git a/README.md b/README.md @@ -75,7 +75,10 @@ cargo extbuild run -- scripts/xcode.sh local-social-ui-test \ This harness is Debug-only, binds only explicit loopback ports, records bounded protocol evidence under the extbuild results root, and cannot become a -production endpoint fallback. +production endpoint fallback. Its typed isolated-loopback mode is the only +mode permitted to use automated user presence or test secret policy. Public +and physical qualification retain normal Apple user presence, remain optional, +and are not claimed by the deterministic simulator lane. Passing `accessibility` as the final launcher argument runs Apple's accessibility audit over every progressively disclosed Add composition at the diff --git a/Radroots/App/RadrootsRemoteQualification.swift b/Radroots/App/RadrootsRemoteQualification.swift @@ -2,6 +2,35 @@ import Foundation import RadrootsKit import RadrootsKitBindings +enum RadrootsQualificationNetworkMode: Sendable, Equatable { + case isolatedLoopback + case publicEndpoint + + init(profile: String?) throws { + switch profile { + case "simulator": self = .isolatedLoopback + case "public": self = .publicEndpoint + default: + throw RadrootsConfigurationError.invalid("qualification_network_profile") + } + } + + var runtimeMode: String { + switch self { + case .isolatedLoopback: "simulator" + case .publicEndpoint: "production" + } + } + + var permitsAutomatedUserPresence: Bool { + self == .isolatedLoopback + } + + var permitsTestSecretPolicy: Bool { + self == .isolatedLoopback + } +} + struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { static let enabledKey = "RADROOTS_IOS_UI_TEST_REMOTE" static let runIDKey = "RADROOTS_IOS_UI_TEST_RUN_ID" @@ -14,7 +43,15 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { let relayURLs: [String] let blossomOrigins: [String] let mediaFile: RadrootsFileReference? - let runtimeMode: String + let networkMode: RadrootsQualificationNetworkMode + + var runtimeMode: String { + networkMode.runtimeMode + } + + var automatesIdentity: Bool { + networkMode.permitsAutomatedUserPresence && networkMode.permitsTestSecretPolicy + } private static let mediaFixtureBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" @@ -75,17 +112,13 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { let runID = try requiredRunID(environment[runIDKey]) let relays = separatedValues(environment[relayURLsKey]) let blossoms = separatedValues(environment[blossomOriginsKey]) - let runtimeMode: String - switch environment[networkProfileKey] { - case "public": runtimeMode = "production" - case "simulator": runtimeMode = "simulator" - default: - throw RadrootsConfigurationError.invalid("qualification_network_profile") - } + let networkMode = try RadrootsQualificationNetworkMode( + profile: environment[networkProfileKey] + ) guard !relays.isEmpty, blossoms.count == 1 else { throw RadrootsConfigurationError.invalid("qualification_blossom_origin") } - if runtimeMode == "simulator" { + if networkMode == .isolatedLoopback { guard relays.allSatisfy({ isLoopbackEndpoint($0, schemes: ["ws"]) }), blossoms.allSatisfy({ isLoopbackEndpoint($0, schemes: ["http"]) }) @@ -104,7 +137,7 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { relayURLs: relays, blossomOrigins: blossoms, mediaFile: mediaFile, - runtimeMode: runtimeMode + networkMode: networkMode ) } @@ -218,6 +251,12 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { } final class RadrootsRemoteQualificationUserPresence: RadrootsUserPresence, Sendable { + init(mode: RadrootsQualificationNetworkMode) throws { + guard mode.permitsAutomatedUserPresence, mode.permitsTestSecretPolicy else { + throw RadrootsConfigurationError.invalid("qualification_user_presence") + } + } + func currentStatus() async throws -> RadrootsUserPresenceStatus { RadrootsUserPresenceStatus( support: .deviceCredential, diff --git a/Radroots/State/RadrootsIdentityStore.swift b/Radroots/State/RadrootsIdentityStore.swift @@ -87,12 +87,14 @@ actor RadrootsIdentityStore { let configuration: RadrootsIdentityCustodyConfiguration let userPresence: any RadrootsUserPresence #if DEBUG - if qualification != nil { + if let qualification, qualification.automatesIdentity { configuration = try RadrootsIdentityCustodyConfiguration( namespace: namespace, secretPolicy: .secureLocalSecret ) - userPresence = RadrootsRemoteQualificationUserPresence() + userPresence = try RadrootsRemoteQualificationUserPresence( + mode: qualification.networkMode + ) } else { configuration = try RadrootsIdentityCustodyConfiguration(namespace: namespace) userPresence = RadrootsAppleUserPresence() diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift @@ -112,7 +112,7 @@ actor RadrootsSessionStore { runtimeClient: runtimeClient, roots: roots, protectedData: protectedData, - automatesQualificationIdentity: qualification != nil + automatesQualificationIdentity: qualification?.automatesIdentity == true ) } diff --git a/RadrootsTests/RadrootsRemoteQualificationTests.swift b/RadrootsTests/RadrootsRemoteQualificationTests.swift @@ -22,7 +22,11 @@ final class RadrootsRemoteQualificationTests: XCTestCase { XCTAssertEqual(value.runID, "gbrqv1-12345678") XCTAssertEqual(value.relayURLs, ["wss://write.example"]) XCTAssertEqual(value.blossomOrigins, ["https://media.example"]) + XCTAssertEqual(value.networkMode, .publicEndpoint) XCTAssertEqual(value.runtimeMode, "production") + XCTAssertFalse(value.networkMode.permitsAutomatedUserPresence) + XCTAssertFalse(value.networkMode.permitsTestSecretPolicy) + XCTAssertFalse(value.automatesIdentity) XCTAssertEqual( value.mediaFile, RadrootsFileReference(scope: .data, relativePath: "qualification/input.png") @@ -68,6 +72,8 @@ final class RadrootsRemoteQualificationTests: XCTestCase { ]) { _, new in new } ) XCTAssertEqual(simulator?.runtimeMode, "simulator") + XCTAssertEqual(simulator?.networkMode, .isolatedLoopback) + XCTAssertEqual(simulator?.automatesIdentity, true) XCTAssertThrowsError( try RadrootsRemoteQualificationEnvironment.current( environment: base.merging([ @@ -98,7 +104,7 @@ final class RadrootsRemoteQualificationTests: XCTestCase { } func testQualificationUserPresenceIsNoninteractiveAndDebugOnly() async throws { - let presence = RadrootsRemoteQualificationUserPresence() + let presence = try RadrootsRemoteQualificationUserPresence(mode: .isolatedLoopback) let request = try RadrootsUserPresenceRequest(reason: "Automated remote qualification") let result = try await presence.verify(request) let status = try await presence.currentStatus() @@ -107,6 +113,25 @@ final class RadrootsRemoteQualificationTests: XCTestCase { XCTAssertFalse(status.canEvaluateBiometrics) } + func testPublicQualificationCannotSelectAutomatedPresence() throws { + let publicMode = RadrootsQualificationNetworkMode.publicEndpoint + XCTAssertFalse(publicMode.permitsAutomatedUserPresence) + XCTAssertFalse(publicMode.permitsTestSecretPolicy) + XCTAssertThrowsError( + try RadrootsRemoteQualificationUserPresence(mode: publicMode) + ) + } + + func testQualificationModeRejectsMutationsWithoutRenderingInput() { + let canary = "private-key-canary" + for profile in [nil, "", "SIMULATOR", "public ", "automatic", canary] { + XCTAssertThrowsError(try RadrootsQualificationNetworkMode(profile: profile)) { error in + XCTAssertFalse(String(describing: error).contains(canary)) + XCTAssertFalse(error.localizedDescription.contains(canary)) + } + } + } + func testQualificationUsesRunScopedRootsAndBackgroundSession() throws { let base = try RadrootsAppleFileRoots( appIdentifier: "org.radroots.field-ios", diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh @@ -130,8 +130,20 @@ grep -Fq -- '-UIAccessibilityReduceMotionEnabled' \ "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" grep -Fq 'accessibility-extra-extra-extra-large' "$repo_root/scripts/xcode.sh" grep -Fq '#if DEBUG' "$repo_root/Radroots/App/RadrootsRemoteQualification.swift" -grep -Fq 'case "simulator": runtimeMode = "simulator"' \ +grep -Fq 'case "simulator": self = .isolatedLoopback' \ "$repo_root/Radroots/App/RadrootsRemoteQualification.swift" +grep -Fq 'case "public": self = .publicEndpoint' \ + "$repo_root/Radroots/App/RadrootsRemoteQualification.swift" +grep -Fq 'if let qualification, qualification.automatesIdentity' \ + "$repo_root/Radroots/State/RadrootsIdentityStore.swift" +grep -Fq 'qualification?.automatesIdentity == true' \ + "$repo_root/Radroots/State/RadrootsSessionStore.swift" +if rg -n 'if qualification != nil|automatesQualificationIdentity: qualification != nil' \ + "$repo_root/Radroots" --glob '*.swift' +then + echo "error: qualification existence must not grant automated identity authority" >&2 + exit 1 +fi test -f "$repo_root/scripts/local-social-fixture.py" test -f "$repo_root/.swiftformat" test -f "$repo_root/.swiftlint.yml"