commit 06c1dc402ec42601719422a9f07d7c8093cfee52
parent 9af86307f7de9b4914660fab768be1b9a048a401
Author: triesap <tyson@radroots.org>
Date: Mon, 15 Jun 2026 13:24:05 -0700
user-presence: add identity gate
Add the field-owned user-presence gate around the AppleKit service with deterministic UI-test fakes.
Add the Face ID usage string required for local Nostr identity custody.
Diffstat:
3 files changed, 197 insertions(+), 0 deletions(-)
diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj
@@ -12,6 +12,7 @@
04AA409CFECBA11BFC175C5C /* RadrootsFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */; };
1C6EA551530A46CA77BD9E1C /* FieldLocationCheckIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */; };
1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0274A0260D1C04F40C71AF /* HomeView.swift */; };
+ 25654E50F9519809A237759D /* FieldUserPresenceGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */; };
275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71A93F98C7B93188748B99B /* ProfileView.swift */; };
299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */; };
2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; };
@@ -105,6 +106,7 @@
CCF0F7B3C57D8D770F178329 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D448C9655B708CA3FA8712B9 /* AppEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntry.swift; sourceTree = "<group>"; };
D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingSettings.swift; sourceTree = "<group>"; };
+ D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldUserPresenceGate.swift; sourceTree = "<group>"; };
DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsKitBindings.swift; sourceTree = "<group>"; };
E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldDocumentInterchange.swift; sourceTree = "<group>"; };
@@ -200,6 +202,7 @@
2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */,
E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */,
8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */,
+ D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */,
D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */,
63189EB90A86A9929BECD9ED /* Nostr.swift */,
8F0F21496E7A8490EB14AC5B /* Radroots.swift */,
@@ -444,6 +447,7 @@
1C6EA551530A46CA77BD9E1C /* FieldLocationCheckIn.swift in Sources */,
D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */,
D9BF5BE7E4AB5EACBF342539 /* FieldSecureIdentityStore.swift in Sources */,
+ 25654E50F9519809A237759D /* FieldUserPresenceGate.swift in Sources */,
1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */,
C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */,
B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */,
diff --git a/Radroots/Info.plist b/Radroots/Info.plist
@@ -24,6 +24,8 @@
<string>Radroots uses your location only when you tap Location Check-in.</string>
<key>NSCameraUsageDescription</key>
<string>Radroots uses the camera only when you capture photo evidence or scan a document.</string>
+ <key>NSFaceIDUsageDescription</key>
+ <string>Radroots uses Face ID only to unlock, save, or delete the local Nostr identity stored on this iPhone.</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>nostr</string>
diff --git a/Radroots/Runtime/FieldUserPresenceGate.swift b/Radroots/Runtime/FieldUserPresenceGate.swift
@@ -0,0 +1,191 @@
+import Foundation
+import RadrootsKit
+import RadrootsKitTesting
+
+enum FieldUserPresenceAction: Equatable, Sendable {
+ case unlockIdentity
+ case saveIdentity
+ case deleteIdentity
+
+ var reason: String {
+ switch self {
+ case .unlockIdentity:
+ "Unlock your local Nostr identity."
+ case .saveIdentity:
+ "Save your local Nostr identity on this iPhone."
+ case .deleteIdentity:
+ "Delete your local Nostr identity from this iPhone."
+ }
+ }
+
+ var verifiedStatusText: String {
+ switch self {
+ case .unlockIdentity:
+ "Verified user presence to unlock identity."
+ case .saveIdentity:
+ "Verified user presence to save identity."
+ case .deleteIdentity:
+ "Verified user presence to delete identity."
+ }
+ }
+}
+
+struct FieldUserPresenceRequestRecord: Equatable, Sendable {
+ let action: FieldUserPresenceAction
+ let statusText: String
+}
+
+enum FieldUserPresenceGateError: LocalizedError, Equatable {
+ case notVerified
+
+ var errorDescription: String? {
+ switch self {
+ case .notVerified:
+ "User presence was not verified."
+ }
+ }
+}
+
+final class FieldUserPresenceGate: Sendable {
+ private let userPresence: any RadrootsUserPresence
+
+ init(userPresence: any RadrootsUserPresence) {
+ self.userPresence = userPresence
+ }
+
+ static func configured() -> FieldUserPresenceGate {
+ if uiTestWasRequested {
+ return FieldUserPresenceGate(userPresence: uiTestUserPresence())
+ }
+ return FieldUserPresenceGate(userPresence: RadrootsAppleUserPresence())
+ }
+
+ func requirePresence(for action: FieldUserPresenceAction) async throws -> FieldUserPresenceRequestRecord {
+ let request = try RadrootsUserPresenceRequest(reason: action.reason)
+ let result = try await userPresence.verify(request)
+ guard result.verified else {
+ throw FieldUserPresenceGateError.notVerified
+ }
+ return FieldUserPresenceRequestRecord(action: action, statusText: action.verifiedStatusText)
+ }
+
+ private static var uiTestWasRequested: Bool {
+ let arguments = ProcessInfo.processInfo.arguments
+ let environment = ProcessInfo.processInfo.environment
+ return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" ||
+ arguments.contains("--radroots-field-ios-ui-test")
+ }
+
+ private static func uiTestUserPresence() -> any RadrootsUserPresence {
+ let outcomes = uiTestOutcomes()
+ let status = uiTestStatus()
+ if outcomes.count <= 1 {
+ return RadrootsFakeUserPresence(
+ status: status,
+ verificationOutcome: outcomes.first?.result ?? .success(true)
+ )
+ }
+ return FieldSequentialUserPresence(status: status, outcomes: outcomes.map(\.result))
+ }
+
+ private static func uiTestStatus() -> RadrootsUserPresenceStatus {
+ let raw = ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_STATUS"]?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ switch raw {
+ case "unavailable":
+ return .unavailable
+ case "device_credential":
+ return RadrootsUserPresenceStatus(
+ support: .deviceCredential,
+ biometryKind: .none,
+ canEvaluateDeviceCredential: true,
+ canEvaluateBiometrics: false
+ )
+ case nil, "", "available", "biometrics":
+ return RadrootsUserPresenceStatus(
+ support: .biometricsOrDeviceCredential,
+ biometryKind: .faceID,
+ canEvaluateDeviceCredential: true,
+ canEvaluateBiometrics: true
+ )
+ default:
+ return .unavailable
+ }
+ }
+
+ private static func uiTestOutcomes() -> [FieldUserPresenceUITestOutcome] {
+ let raw = ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_OUTCOME"]?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let parts = raw
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+ .filter { !$0.isEmpty }
+ if parts.isEmpty {
+ return [.success]
+ }
+ return parts.map { FieldUserPresenceUITestOutcome(rawValue: $0) ?? .denied }
+ }
+}
+
+private actor FieldSequentialUserPresence: RadrootsUserPresence {
+ private let statusValue: RadrootsUserPresenceStatus
+ private let outcomes: [Result<Bool, RadrootsUserPresenceError>]
+ private var requestCount: Int
+
+ init(
+ status: RadrootsUserPresenceStatus,
+ outcomes: [Result<Bool, RadrootsUserPresenceError>]
+ ) {
+ self.statusValue = status
+ self.outcomes = outcomes
+ self.requestCount = 0
+ }
+
+ func currentStatus() async throws -> RadrootsUserPresenceStatus {
+ statusValue
+ }
+
+ func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult {
+ let outcome = outcomes[min(requestCount, outcomes.count - 1)]
+ requestCount += 1
+ switch outcome {
+ case .success(let verified):
+ return RadrootsUserPresenceResult(policy: request.policy, verified: verified)
+ case .failure(let error):
+ throw error
+ }
+ }
+}
+
+private enum FieldUserPresenceUITestOutcome: String {
+ case success
+ case unverified
+ case cancelled
+ case denied
+ case unavailable
+ case timeout
+ case transientFailure = "transient_failure"
+ case permanentFailure = "permanent_failure"
+
+ var result: Result<Bool, RadrootsUserPresenceError> {
+ switch self {
+ case .success:
+ .success(true)
+ case .unverified:
+ .success(false)
+ case .cancelled:
+ .failure(.userCancelled("User presence was cancelled."))
+ case .denied:
+ .failure(.permissionDenied("User presence permission is denied."))
+ case .unavailable:
+ .failure(.unavailable("User presence is unavailable."))
+ case .timeout:
+ .failure(.timeout("User presence timed out."))
+ case .transientFailure:
+ .failure(.transientFailure("User presence failed. Please retry."))
+ case .permanentFailure:
+ .failure(.permanentFailure("User presence failed."))
+ }
+ }
+}