diff --git a/.gitignore b/.gitignore index 00e3195..1a4a67f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ apple/.build/ apple/Packages/WayfinderRoutingBridge/.build/ apple/Packages/WayfinderRoutingBridge/Artifacts/ apple/Packages/WayfinderRoutingBridge/Sources/WayfinderRoutingBridge/Generated/ + +# Per-user SwiftPM and Xcode workspace state +**/.swiftpm/ +**/xcuserdata/ diff --git a/ios/WayfinderIOS/README.md b/ios/WayfinderIOS/README.md index 3f45bad..857e5d7 100644 --- a/ios/WayfinderIOS/README.md +++ b/ios/WayfinderIOS/README.md @@ -9,8 +9,11 @@ Chat lifecycle through a deterministic network-free provider. Ordered deltas, stop, interruption recovery, failure, retry, threads, drafts, terminal message states, and compact route receipts persist locally through the `ConversationStore` boundary and a versioned SwiftData implementation. -Credentials, live providers, Apple Foundation Models, and optional Mac pairing -land in later review boundaries. +API keys can be added, replaced, and removed through the native Settings flow. +Secrets remain in the device-only iOS Keychain; app-visible state contains only +configured/not-configured snapshots. Adding a key does not publish a +destination or change Automatic routing. Live providers, Apple Foundation +Models, and optional Mac pairing land in later review boundaries. ## Build diff --git a/ios/WayfinderIOS/WayfinderIOS.xcodeproj/project.pbxproj b/ios/WayfinderIOS/WayfinderIOS.xcodeproj/project.pbxproj index 6656888..afa1394 100644 --- a/ios/WayfinderIOS/WayfinderIOS.xcodeproj/project.pbxproj +++ b/ios/WayfinderIOS/WayfinderIOS.xcodeproj/project.pbxproj @@ -13,13 +13,16 @@ 2663961FFDA2EEAEF58529A8 /* ConversationStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A79F801F4849CBD50C8A61 /* ConversationStoreTests.swift */; }; 2FDE760EEAA633A74BF8B379 /* ChatExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E92E195F328A16C8A4C9159 /* ChatExecution.swift */; }; 383CBBDE76DCB1296EE2B2FF /* ChatTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25109E76A4BFCC3FD9297E97 /* ChatTabView.swift */; }; + 505322426BA37D7C41706A05 /* CredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7B4FB8866C8CE81BA3E3A9 /* CredentialStore.swift */; }; 5289F042BE903630416D2DC7 /* ThreadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA6AA772008E2B7C904EA80 /* ThreadsView.swift */; }; A7AFF77BAFD88E83930B3DC6 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D3D621AD229ED0AF9703D04 /* AppModel.swift */; }; A95064FBE316BC583A492270 /* WayfinderRoutingBridge in Frameworks */ = {isa = PBXBuildFile; productRef = 2B873E1C9EC13707AC9AB4B1 /* WayfinderRoutingBridge */; }; AD9F05EEA94FD7440F0DD8A9 /* AppModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17D8ECF221517590115EA9F /* AppModelTests.swift */; }; C04837E936D39B59FC3DAEDE /* DestinationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375A787B3245E247D326C13E /* DestinationsView.swift */; }; + D060F612A4C672A8932A47B9 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B75781C7C160DEB4257207 /* APIKeysView.swift */; }; DDF893703A284EDEF6594AE6 /* WayfinderTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B27149D56F517F17D1F971B /* WayfinderTheme.swift */; }; EBF78778DA2B68EDA8347CAF /* WayfinderIOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2C2884452C3AECE92EE0E1 /* WayfinderIOSApp.swift */; }; + F4E9D5D8ED49654292FFA71E /* CredentialStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4110CCB21039F8900BB91 /* CredentialStoreTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -37,9 +40,12 @@ 25109E76A4BFCC3FD9297E97 /* ChatTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTabView.swift; sourceTree = ""; }; 375A787B3245E247D326C13E /* DestinationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationsView.swift; sourceTree = ""; }; 39BC65B89F3C0433A3716A07 /* WayfinderRoutingBridge */ = {isa = PBXFileReference; lastKnownFileType = folder; name = WayfinderRoutingBridge; path = ../../apple/Packages/WayfinderRoutingBridge; sourceTree = SOURCE_ROOT; }; + 3AD4110CCB21039F8900BB91 /* CredentialStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialStoreTests.swift; sourceTree = ""; }; 4A2C2884452C3AECE92EE0E1 /* WayfinderIOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WayfinderIOSApp.swift; sourceTree = ""; }; 4DA6AA772008E2B7C904EA80 /* ThreadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadsView.swift; sourceTree = ""; }; 5F6D147CFBC7918BCCD92B9E /* WayfinderIOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = WayfinderIOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 64B75781C7C160DEB4257207 /* APIKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysView.swift; sourceTree = ""; }; + 8A7B4FB8866C8CE81BA3E3A9 /* CredentialStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialStore.swift; sourceTree = ""; }; 910171253EA965875F0C979A /* WayfinderIOSTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WayfinderIOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9B27149D56F517F17D1F971B /* WayfinderTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WayfinderTheme.swift; sourceTree = ""; }; 9E92E195F328A16C8A4C9159 /* ChatExecution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExecution.swift; sourceTree = ""; }; @@ -65,10 +71,12 @@ 0F27F14F5F65590F365CE447 /* WayfinderIOS */ = { isa = PBXGroup; children = ( + 64B75781C7C160DEB4257207 /* APIKeysView.swift */, 0D3D621AD229ED0AF9703D04 /* AppModel.swift */, 9E92E195F328A16C8A4C9159 /* ChatExecution.swift */, 25109E76A4BFCC3FD9297E97 /* ChatTabView.swift */, D89C6EA7388FA2CCF9F5865E /* ConversationStore.swift */, + 8A7B4FB8866C8CE81BA3E3A9 /* CredentialStore.swift */, 375A787B3245E247D326C13E /* DestinationsView.swift */, A28A0256D98D907F1BAF7A34 /* RootView.swift */, BE0BB131143C117BDF129E06 /* SettingsView.swift */, @@ -93,6 +101,7 @@ children = ( C17D8ECF221517590115EA9F /* AppModelTests.swift */, D8A79F801F4849CBD50C8A61 /* ConversationStoreTests.swift */, + 3AD4110CCB21039F8900BB91 /* CredentialStoreTests.swift */, ); path = WayfinderIOSTests; sourceTree = ""; @@ -199,6 +208,7 @@ files = ( AD9F05EEA94FD7440F0DD8A9 /* AppModelTests.swift in Sources */, 2663961FFDA2EEAEF58529A8 /* ConversationStoreTests.swift in Sources */, + F4E9D5D8ED49654292FFA71E /* CredentialStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -206,10 +216,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D060F612A4C672A8932A47B9 /* APIKeysView.swift in Sources */, A7AFF77BAFD88E83930B3DC6 /* AppModel.swift in Sources */, 2FDE760EEAA633A74BF8B379 /* ChatExecution.swift in Sources */, 383CBBDE76DCB1296EE2B2FF /* ChatTabView.swift in Sources */, 165C905840F4DCF56C27BF8F /* ConversationStore.swift in Sources */, + 505322426BA37D7C41706A05 /* CredentialStore.swift in Sources */, C04837E936D39B59FC3DAEDE /* DestinationsView.swift in Sources */, 014B0F0F1659B0478F68FAF3 /* RootView.swift in Sources */, 17923336D4226B2F812CF7CA /* SettingsView.swift in Sources */, diff --git a/ios/WayfinderIOS/WayfinderIOS/APIKeysView.swift b/ios/WayfinderIOS/WayfinderIOS/APIKeysView.swift new file mode 100644 index 0000000..a549cb1 --- /dev/null +++ b/ios/WayfinderIOS/WayfinderIOS/APIKeysView.swift @@ -0,0 +1,187 @@ +import SwiftUI + +struct APIKeysView: View { + @Environment(AppModel.self) private var appModel + @State private var editingProvider: APIKeyProviderDescriptor? + @State private var removalProvider: APIKeyProviderDescriptor? + + var body: some View { + List { + Section { + ForEach(APIKeyProviderDescriptor.supported) { provider in + APIKeyProviderRow( + provider: provider, + isConfigured: appModel.isCredentialConfigured(provider.id), + edit: { editingProvider = provider }, + remove: { removalProvider = provider } + ) + } + } footer: { + Text( + "Keys remain in this device's Keychain. Adding one does not change Automatic routing or enable a provider until its destination is configured." + ) + } + + Section("Account access") { + LabeledContent("ChatGPT / Codex", value: "Not an API key") + Text( + "OpenAI Platform keys and ChatGPT subscriptions are separate. Native account sign-in is not part of this build." + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("API Keys") + .sheet(item: $editingProvider) { provider in + APIKeyEditorSheet(provider: provider) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + .confirmationDialog( + "Remove API key?", + isPresented: Binding( + get: { removalProvider != nil }, + set: { isPresented in + if !isPresented { + removalProvider = nil + } + } + ), + titleVisibility: .visible + ) { + if let provider = removalProvider { + Button("Remove \(provider.displayName) Key", role: .destructive) { + removalProvider = nil + Task { + await appModel.removeAPIKey(for: provider) + } + } + } + Button("Cancel", role: .cancel) { + removalProvider = nil + } + } message: { + Text( + "Wayfinder will delete this credential from the iOS Keychain. Existing conversations are unaffected." + ) + } + .task { + await appModel.restoreCredentialStatuses() + } + } +} + +private struct APIKeyProviderRow: View { + let provider: APIKeyProviderDescriptor + let isConfigured: Bool + let edit: () -> Void + let remove: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 3) { + Text(provider.displayName) + .font(.body.weight(.medium)) + Text(provider.detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 12) + + Label( + isConfigured ? "Saved" : "Not configured", + systemImage: isConfigured ? "checkmark.circle.fill" : "circle" + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(isConfigured ? WayfinderTheme.accent : .secondary) + } + + HStack { + Button(isConfigured ? "Replace Key" : "Add Key", action: edit) + .buttonStyle(.bordered) + + if isConfigured { + Button("Remove", role: .destructive, action: remove) + .buttonStyle(.borderless) + } + } + } + .padding(.vertical, 5) + .accessibilityElement(children: .contain) + } +} + +private struct APIKeyEditorSheet: View { + @Environment(AppModel.self) private var appModel + @Environment(\.dismiss) private var dismiss + @State private var key = "" + @State private var isSaving = false + + let provider: APIKeyProviderDescriptor + + var body: some View { + NavigationStack { + Form { + Section { + SecureField(provider.placeholder, text: $key) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityLabel("\(provider.displayName) API key") + .accessibilityValue(key.isEmpty ? "Empty" : "Entered") + } header: { + Text(provider.displayName) + } footer: { + Text( + "The key is stored with device-only Keychain protection. Wayfinder will never display it again after saving." + ) + } + + Section { + Label( + "Adding this key does not change your routing preferences.", + systemImage: "checkmark.shield" + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle( + appModel.isCredentialConfigured(provider.id) ? "Replace Key" : "Add Key" + ) + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(isSaving) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + key = "" + dismiss() + } + .disabled(isSaving) + } + + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + isSaving = true + Task { + let saved = await appModel.saveAPIKey(key, for: provider) + isSaving = false + if saved { + key = "" + dismiss() + } + } + } + .disabled( + isSaving + || key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) + } + } + .onDisappear { + key = "" + } + } + } +} diff --git a/ios/WayfinderIOS/WayfinderIOS/AppModel.swift b/ios/WayfinderIOS/WayfinderIOS/AppModel.swift index 3d14517..5cd3c73 100644 --- a/ios/WayfinderIOS/WayfinderIOS/AppModel.swift +++ b/ios/WayfinderIOS/WayfinderIOS/AppModel.swift @@ -140,9 +140,11 @@ final class AppModel { var threads: [ConversationThreadSnapshot] = [] var activeThreadID: UUID? var persistenceNotice: String? + var credentialNotice: String? var isRestoringConversations = false var retentionPolicy: ConversationRetentionPolicy = .forever var executionPhase: ChatExecutionPhase = .idle + var credentialStatuses: [ProviderCredentialStatus] = [] let destinations: [PreviewDestination] = [ PreviewDestination( @@ -165,18 +167,22 @@ final class AppModel { private let routingEngine: RoutingEngine private let conversationStore: any ConversationStore + private let credentialStore: any CredentialStore private let providerExecutor: any ProviderExecutor private let now: () -> Date private var hasRestoredConversations = false + private var hasRestoredCredentialStatuses = false private var draftSaveTask: Task? init( conversationStore: any ConversationStore = InMemoryConversationStore(), + credentialStore: any CredentialStore = KeychainCredentialStore(), providerExecutor: any ProviderExecutor = DeterministicMockProvider(), initialPersistenceNotice: String? = nil, now: @escaping () -> Date = Date.init ) { self.conversationStore = conversationStore + self.credentialStore = credentialStore self.providerExecutor = providerExecutor self.persistenceNotice = initialPersistenceNotice self.now = now @@ -207,6 +213,52 @@ final class AppModel { return threads.first { $0.id == activeThreadID } } + var configuredCredentialCount: Int { + credentialStatuses.count(where: \.isConfigured) + } + + func isCredentialConfigured(_ id: CredentialID) -> Bool { + credentialStatuses.first(where: { $0.id == id })?.isConfigured ?? false + } + + func restoreCredentialStatuses() async { + guard !hasRestoredCredentialStatuses else { + return + } + hasRestoredCredentialStatuses = await refreshCredentialStatuses() + } + + func saveAPIKey( + _ key: String, + for provider: APIKeyProviderDescriptor + ) async -> Bool { + let normalized = key.trimmingCharacters(in: .whitespacesAndNewlines) + do { + try await credentialStore.save(secret: normalized, for: provider.id) + credentialNotice = nil + _ = await refreshCredentialStatuses() + return true + } catch { + credentialNotice = userFacingCredentialError(error) + return false + } + } + + func removeAPIKey(for provider: APIKeyProviderDescriptor) async { + do { + try await credentialStore.delete(provider.id) + let remainsConfigured = try await credentialStore.contains(provider.id) + guard !remainsConfigured else { + credentialNotice = "Wayfinder could not remove that API key." + return + } + credentialNotice = nil + _ = await refreshCredentialStatuses() + } catch { + credentialNotice = userFacingCredentialError(error) + } + } + func restoreConversations() async { guard !hasRestoredConversations else { return @@ -735,6 +787,35 @@ final class AppModel { return "The preview provider could not finish this reply." } + private func refreshCredentialStatuses() async -> Bool { + do { + var statuses: [ProviderCredentialStatus] = [] + for provider in APIKeyProviderDescriptor.supported { + statuses.append( + ProviderCredentialStatus( + id: provider.id, + isConfigured: try await credentialStore.contains(provider.id) + ) + ) + } + credentialStatuses = statuses + credentialNotice = nil + return true + } catch { + credentialNotice = userFacingCredentialError(error) + return false + } + } + + private func userFacingCredentialError(_ error: Error) -> String { + if let credentialError = error as? CredentialStoreError, + let description = credentialError.errorDescription + { + return description + } + return "Wayfinder could not update the iOS Keychain." + } + private func persistActiveDraft() async { guard var thread = activeThread else { return diff --git a/ios/WayfinderIOS/WayfinderIOS/CredentialStore.swift b/ios/WayfinderIOS/WayfinderIOS/CredentialStore.swift new file mode 100644 index 0000000..33f9ce4 --- /dev/null +++ b/ios/WayfinderIOS/WayfinderIOS/CredentialStore.swift @@ -0,0 +1,255 @@ +import Foundation +import Security + +struct CredentialID: Hashable, RawRepresentable, Sendable { + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } +} + +struct APIKeyProviderDescriptor: Hashable, Identifiable, Sendable { + let id: CredentialID + let displayName: String + let detail: String + let placeholder: String + + static let supported: [APIKeyProviderDescriptor] = [ + APIKeyProviderDescriptor( + id: CredentialID(rawValue: "openai-platform.api-key"), + displayName: "OpenAI Platform", + detail: "For API usage billed through platform.openai.com", + placeholder: "sk-…" + ), + APIKeyProviderDescriptor( + id: CredentialID(rawValue: "moonshot-platform.api-key"), + displayName: "Moonshot / Kimi Platform", + detail: "For Moonshot Platform API access", + placeholder: "API key" + ), + APIKeyProviderDescriptor( + id: CredentialID(rawValue: "openrouter.api-key"), + displayName: "OpenRouter", + detail: "For models accessed through OpenRouter", + placeholder: "sk-or-…" + ), + ] +} + +struct ProviderCredentialStatus: Equatable, Identifiable, Sendable { + let id: CredentialID + let isConfigured: Bool +} + +enum CredentialStoreError: LocalizedError, Equatable, Sendable { + case invalidSecret + case invalidStoredSecret + case keychainUnavailable + + var errorDescription: String? { + switch self { + case .invalidSecret: + "Enter a valid API key." + case .invalidStoredSecret: + "The saved credential could not be read." + case .keychainUnavailable: + "The iOS Keychain is currently unavailable." + } + } +} + +protocol CredentialStore: Sendable { + func save(secret: String, for id: CredentialID) async throws + func readSecret(for id: CredentialID) async throws -> String? + func contains(_ id: CredentialID) async throws -> Bool + func delete(_ id: CredentialID) async throws +} + +enum CredentialProtection: Hashable, Sendable { + case whenUnlockedThisDeviceOnly +} + +struct KeychainItemRequest: Hashable, Sendable { + let service: String + let account: String + let protection: CredentialProtection + let synchronizable: Bool +} + +struct KeychainReadResult: Sendable { + let status: OSStatus + let data: Data? +} + +protocol KeychainBackend: Sendable { + func save(data: Data, request: KeychainItemRequest) async -> OSStatus + func read(request: KeychainItemRequest) async -> KeychainReadResult + func status(request: KeychainItemRequest) async -> OSStatus + func delete(request: KeychainItemRequest) async -> OSStatus +} + +actor SystemKeychainBackend: KeychainBackend { + func save(data: Data, request: KeychainItemRequest) -> OSStatus { + let status = SecItemUpdate( + baseQuery(for: request) as CFDictionary, + [ + kSecValueData: data, + kSecAttrAccessible: accessibility(for: request.protection), + ] as CFDictionary + ) + + guard status == errSecItemNotFound else { + return status + } + + var query = baseQuery(for: request) + query[kSecValueData] = data + query[kSecAttrAccessible] = accessibility(for: request.protection) + return SecItemAdd(query as CFDictionary, nil) + } + + func read(request: KeychainItemRequest) -> KeychainReadResult { + var query = baseQuery(for: request) + query[kSecReturnData] = kCFBooleanTrue + query[kSecMatchLimit] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + return KeychainReadResult(status: status, data: result as? Data) + } + + func status(request: KeychainItemRequest) -> OSStatus { + var query = baseQuery(for: request) + query[kSecMatchLimit] = kSecMatchLimitOne + return SecItemCopyMatching(query as CFDictionary, nil) + } + + func delete(request: KeychainItemRequest) -> OSStatus { + SecItemDelete(baseQuery(for: request) as CFDictionary) + } + + private func baseQuery( + for request: KeychainItemRequest + ) -> [CFString: Any] { + [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: request.service, + kSecAttrAccount: request.account, + kSecAttrSynchronizable: request.synchronizable + ? kCFBooleanTrue as Any + : kCFBooleanFalse as Any, + ] + } + + private func accessibility( + for protection: CredentialProtection + ) -> CFString { + switch protection { + case .whenUnlockedThisDeviceOnly: + kSecAttrAccessibleWhenUnlockedThisDeviceOnly + } + } +} + +actor KeychainCredentialStore: CredentialStore { + static let defaultService = "com.wayfinder.router.ios.credentials" + static let maximumSecretBytes = 16_384 + + private let service: String + private let backend: any KeychainBackend + + init( + service: String = KeychainCredentialStore.defaultService, + backend: any KeychainBackend = SystemKeychainBackend() + ) { + self.service = service + self.backend = backend + } + + func save(secret: String, for id: CredentialID) async throws { + guard + !secret.isEmpty, + let data = secret.data(using: .utf8), + data.count <= Self.maximumSecretBytes + else { + throw CredentialStoreError.invalidSecret + } + + let status = await backend.save( + data: data, + request: request(for: id) + ) + if status != errSecSuccess { + throw CredentialStoreError.keychainUnavailable + } + } + + func readSecret(for id: CredentialID) async throws -> String? { + let result = await backend.read(request: request(for: id)) + if result.status == errSecItemNotFound { + return nil + } + guard result.status == errSecSuccess, let data = result.data else { + throw CredentialStoreError.keychainUnavailable + } + guard let secret = String(data: data, encoding: .utf8) else { + throw CredentialStoreError.invalidStoredSecret + } + return secret + } + + func contains(_ id: CredentialID) async throws -> Bool { + let status = await backend.status(request: request(for: id)) + switch status { + case errSecSuccess: + return true + case errSecItemNotFound: + return false + default: + throw CredentialStoreError.keychainUnavailable + } + } + + func delete(_ id: CredentialID) async throws { + let status = await backend.delete(request: request(for: id)) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw CredentialStoreError.keychainUnavailable + } + } + + private func request(for id: CredentialID) -> KeychainItemRequest { + KeychainItemRequest( + service: service, + account: id.rawValue, + protection: .whenUnlockedThisDeviceOnly, + synchronizable: false + ) + } +} + +actor InMemoryCredentialStore: CredentialStore { + private var secrets: [CredentialID: String] = [:] + + func save(secret: String, for id: CredentialID) throws { + guard + !secret.isEmpty, + secret.utf8.count <= KeychainCredentialStore.maximumSecretBytes + else { + throw CredentialStoreError.invalidSecret + } + secrets[id] = secret + } + + func readSecret(for id: CredentialID) -> String? { + secrets[id] + } + + func contains(_ id: CredentialID) -> Bool { + secrets[id] != nil + } + + func delete(_ id: CredentialID) { + secrets[id] = nil + } +} diff --git a/ios/WayfinderIOS/WayfinderIOS/SettingsView.swift b/ios/WayfinderIOS/WayfinderIOS/SettingsView.swift index 76a5692..bc05be2 100644 --- a/ios/WayfinderIOS/WayfinderIOS/SettingsView.swift +++ b/ios/WayfinderIOS/WayfinderIOS/SettingsView.swift @@ -10,6 +10,17 @@ struct SettingsView: View { @Bindable var appModel = appModel Form { + Section("Connections") { + NavigationLink { + APIKeysView() + } label: { + LabeledContent( + "API Keys", + value: credentialSummary + ) + } + } + Section("Privacy") { Picker("Maximum execution boundary", selection: $appModel.privacyPosture) { ForEach(PrivacyPostureOption.allCases) { posture in @@ -24,7 +35,7 @@ struct SettingsView: View { Section("Runtime") { LabeledContent("Router", value: "Embedded Rust core") - LabeledContent("Provider execution", value: "Not in this build slice") + LabeledContent("Provider execution", value: "Deterministic preview") LabeledContent("Mac required", value: "No") } @@ -87,6 +98,23 @@ struct SettingsView: View { } message: { Text("This permanently removes saved threads and the current draft.") } + .alert( + "Keychain", + isPresented: Binding( + get: { appModel.credentialNotice != nil }, + set: { isPresented in + if !isPresented { + appModel.credentialNotice = nil + } + } + ) + ) { + Button("OK") { + appModel.credentialNotice = nil + } + } message: { + Text(appModel.credentialNotice ?? "") + } } private var retentionBinding: Binding { @@ -100,4 +128,12 @@ struct SettingsView: View { } ) } + + private var credentialSummary: String { + switch appModel.configuredCredentialCount { + case 0: "None saved" + case 1: "1 saved" + default: "\(appModel.configuredCredentialCount) saved" + } + } } diff --git a/ios/WayfinderIOS/WayfinderIOS/WayfinderIOSApp.swift b/ios/WayfinderIOS/WayfinderIOS/WayfinderIOSApp.swift index e7fcb5b..cac3266 100644 --- a/ios/WayfinderIOS/WayfinderIOS/WayfinderIOSApp.swift +++ b/ios/WayfinderIOS/WayfinderIOS/WayfinderIOSApp.swift @@ -32,6 +32,7 @@ struct WayfinderIOSApp: App { .tint(WayfinderTheme.accent) .task { await appModel.restoreConversations() + await appModel.restoreCredentialStatuses() } } } diff --git a/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift b/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift index 290812d..a26bc3d 100644 --- a/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift +++ b/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift @@ -4,6 +4,51 @@ import XCTest @MainActor final class AppModelTests: XCTestCase { + func testCredentialSnapshotNeverContainsSavedSecret() async { + let store = InMemoryCredentialStore() + let model = AppModel(credentialStore: store) + let provider = APIKeyProviderDescriptor.supported[0] + let secret = "do-not-expose-this-key" + + let saved = await model.saveAPIKey(secret, for: provider) + + XCTAssertTrue(saved) + XCTAssertTrue(model.isCredentialConfigured(provider.id)) + XCTAssertEqual(model.configuredCredentialCount, 1) + XCTAssertFalse( + String(reflecting: model.credentialStatuses).contains(secret) + ) + let export = await model.exportConversations() + XCTAssertFalse( + export.map { String(decoding: $0, as: UTF8.self).contains(secret) } + ?? true + ) + } + + func testRemovingCredentialUpdatesOnlyReadinessSnapshot() async { + let store = InMemoryCredentialStore() + let model = AppModel(credentialStore: store) + let provider = APIKeyProviderDescriptor.supported[0] + model.privacyPosture = .onDeviceOnly + _ = await model.saveAPIKey("temporary-key", for: provider) + + await model.removeAPIKey(for: provider) + + XCTAssertFalse(model.isCredentialConfigured(provider.id)) + XCTAssertEqual(model.configuredCredentialCount, 0) + XCTAssertEqual(model.privacyPosture, .onDeviceOnly) + } + + func testSupportedAPIKeyProvidersKeepPlatformAndAccountAccessDistinct() { + XCTAssertEqual( + APIKeyProviderDescriptor.supported.map(\.displayName), + ["OpenAI Platform", "Moonshot / Kimi Platform", "OpenRouter"] + ) + XCTAssertFalse( + APIKeyProviderDescriptor.supported.map(\.displayName).contains("ChatGPT") + ) + } + func testSimplePromptRoutesToOnDeviceCandidate() async { let model = AppModel() model.draft = "Hello" diff --git a/ios/WayfinderIOS/WayfinderIOSTests/CredentialStoreTests.swift b/ios/WayfinderIOS/WayfinderIOSTests/CredentialStoreTests.swift new file mode 100644 index 0000000..2fdf6b9 --- /dev/null +++ b/ios/WayfinderIOS/WayfinderIOSTests/CredentialStoreTests.swift @@ -0,0 +1,163 @@ +import Security +import XCTest + +@testable import WayfinderIOS + +final class CredentialStoreTests: XCTestCase { + func testKeychainCreateReadUpdateDeleteLifecycle() async throws { + let service = "com.wayfinder.router.tests.\(UUID().uuidString)" + let backend = InMemoryKeychainBackend() + let store = KeychainCredentialStore(service: service, backend: backend) + let id = CredentialID(rawValue: "provider.api-key") + + let initiallyConfigured = try await store.contains(id) + let initialSecret = try await store.readSecret(for: id) + XCTAssertFalse(initiallyConfigured) + XCTAssertNil(initialSecret) + + try await store.save(secret: "first-secret", for: id) + let configured = try await store.contains(id) + let firstSecret = try await store.readSecret(for: id) + XCTAssertTrue(configured) + XCTAssertEqual(firstSecret, "first-secret") + + try await store.save(secret: "replacement-secret", for: id) + let replacementSecret = try await store.readSecret(for: id) + XCTAssertEqual(replacementSecret, "replacement-secret") + + try await store.delete(id) + let configuredAfterDelete = try await store.contains(id) + let secretAfterDelete = try await store.readSecret(for: id) + XCTAssertFalse(configuredAfterDelete) + XCTAssertNil(secretAfterDelete) + } + + func testKeychainItemUsesDeviceOnlyNonSynchronizingProtection() async throws { + let service = "com.wayfinder.router.tests.\(UUID().uuidString)" + let backend = InMemoryKeychainBackend() + let store = KeychainCredentialStore(service: service, backend: backend) + let id = CredentialID(rawValue: "provider.api-key") + try await store.save(secret: "bounded-secret", for: id) + + let savedRequest = await backend.lastSavedRequest() + let request = try XCTUnwrap(savedRequest) + XCTAssertEqual( + request, + KeychainItemRequest( + service: service, + account: id.rawValue, + protection: .whenUnlockedThisDeviceOnly, + synchronizable: false + ) + ) + } + + func testSystemKeychainSmokeWhenTestHostIsSigned() async throws { + let service = "com.wayfinder.router.tests.\(UUID().uuidString)" + let store = KeychainCredentialStore(service: service) + let id = CredentialID(rawValue: "provider.api-key") + defer { + deleteKeychainItem(service: service, id: id) + } + + do { + try await store.save(secret: "system-keychain-secret", for: id) + } catch CredentialStoreError.keychainUnavailable { + throw XCTSkip( + "The unsigned CI test host cannot access the simulator Keychain." + ) + } + + let stored = try await store.readSecret(for: id) + XCTAssertEqual(stored, "system-keychain-secret") + try await store.delete(id) + } + + func testCredentialStoreRejectsEmptyAndOversizedSecrets() async { + let store = InMemoryCredentialStore() + let id = CredentialID(rawValue: "provider.api-key") + + await XCTAssertThrowsErrorAsync( + try await store.save(secret: "", for: id) + ) { error in + XCTAssertEqual(error as? CredentialStoreError, .invalidSecret) + } + + let oversized = String( + repeating: "x", + count: KeychainCredentialStore.maximumSecretBytes + 1 + ) + await XCTAssertThrowsErrorAsync( + try await store.save(secret: oversized, for: id) + ) { error in + XCTAssertEqual(error as? CredentialStoreError, .invalidSecret) + } + } + + func testDeletingMissingCredentialIsIdempotent() async throws { + let store = InMemoryCredentialStore() + let id = CredentialID(rawValue: "missing.api-key") + + await store.delete(id) + await store.delete(id) + + let configured = await store.contains(id) + XCTAssertFalse(configured) + } + + private func deleteKeychainItem(service: String, id: CredentialID) { + SecItemDelete( + [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: id.rawValue, + ] as CFDictionary + ) + } +} + +private actor InMemoryKeychainBackend: KeychainBackend { + private var records: [KeychainItemRequest: Data] = [:] + private var savedRequest: KeychainItemRequest? + + func save(data: Data, request: KeychainItemRequest) -> OSStatus { + records[request] = data + savedRequest = request + return errSecSuccess + } + + func read(request: KeychainItemRequest) -> KeychainReadResult { + guard let data = records[request] else { + return KeychainReadResult(status: errSecItemNotFound, data: nil) + } + return KeychainReadResult(status: errSecSuccess, data: data) + } + + func status(request: KeychainItemRequest) -> OSStatus { + records[request] == nil ? errSecItemNotFound : errSecSuccess + } + + func delete(request: KeychainItemRequest) -> OSStatus { + records.removeValue(forKey: request) == nil + ? errSecItemNotFound + : errSecSuccess + } + + func lastSavedRequest() -> KeychainItemRequest? { + savedRequest + } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + _ errorHandler: (Error) -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected expression to throw", file: file, line: line) + } catch { + errorHandler(error) + } +} diff --git a/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md b/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md index 5a10ec7..0a9286f 100644 --- a/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md +++ b/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md @@ -210,6 +210,13 @@ iPad; lifecycle, restoration, cancellation UI, and accessibility tests pass. ## Phase 3 — Keychain and direct API providers +Implementation note: the first Phase 3 slice adds the `CredentialStore` actor +boundary, Security framework backend, device-only non-synchronizing Keychain +policy, and native add/replace/remove UI for OpenAI Platform, Moonshot/Kimi +Platform, and OpenRouter keys. App-visible state contains readiness only. +Adding or removing a key does not publish a destination or mutate routing. +Live provider execution remains in the next review boundary. + - implement `CredentialStore`; - API-key lifecycle UI; - generic OpenAI-compatible direct provider;