diff --git a/Config.xcconfig b/Config.xcconfig new file mode 100644 index 0000000..0eac5ab --- /dev/null +++ b/Config.xcconfig @@ -0,0 +1,9 @@ +// OmnipodKit build configuration + +// Inherits overrides from Trio's ConfigOverride.xcconfig (if present) +#include? "../ConfigOverride.xcconfig" +#include? "../../ConfigOverride.xcconfig" + +// Inherits overrides from Loop's LoopConfigOverride.xcconfig (if present) +#include? "../LoopConfigOverride.xcconfig" +#include? "../../LoopConfigOverride.xcconfig" diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 177f894..290a03d 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -3401,6 +3401,10 @@ } } }, + "%@" : { + "comment" : "A label displaying an error message.", + "isCommentAutoGenerated" : true + }, "%@ ago" : { "comment" : "Format string for last status date on pod details screen", "extractionState" : "manual", @@ -8259,6 +8263,9 @@ } } }, + "Attesting with Apple…" : { + "comment" : "O5 fetch progress: attestation" + }, "Auto-off" : { "comment" : "Description for auto-off alert", "extractionState" : "manual", @@ -10689,8 +10696,11 @@ } } }, + "Built-in (compiled into app)" : { + "comment" : "O5 cert source: built-in" + }, "Cancel" : { - "comment" : "Button title for cancelling confidence reminders edit\nButton title for cancelling low reservoir edit\nButton title for cancelling scheduled reminder date edit\nButton title for cancelling silence pod edit\nCancel button text in navigation bar on insert cannula screen\nCancel button text in navigation bar on pair pod UI\nCancel button title\nPairing interface navigation bar button text for cancel action", + "comment" : "Button title for cancelling confidence reminders edit\nButton title for cancelling low reservoir edit\nButton title for cancelling scheduled reminder date edit\nButton title for cancelling silence pod edit\nCancel button\nCancel button for O5 key fetch\nCancel button text in navigation bar on insert cannula screen\nCancel button text in navigation bar on pair pod UI\nCancel button title\nPairing interface navigation bar button text for cancel action", "extractionState" : "manual", "localizations" : { "ar" : { @@ -12309,6 +12319,9 @@ } } }, + "Checking device support…" : { + "comment" : "O5 fetch progress: device support" + }, "Checking Insertion" : { "comment" : "Insert cannula action button accessibility label checking insertion", "extractionState" : "manual", @@ -12471,6 +12484,12 @@ } } }, + "Checking Internet connection…" : { + "comment" : "O5 fetch progress: internet pre-check" + }, + "Checking server status…" : { + "comment" : "O5 fetch progress: server status" + }, "Checking..." : { "comment" : "Cannula insertion button text while checking insertion", "extractionState" : "manual", @@ -14740,7 +14759,7 @@ } }, "Continue" : { - "comment" : "Action button description when deactivated\nAction button title for attach pod view\nButton title to continue\nCannula insertion button text when inserted\nPod pairing action button text when paired\nText for continue button\nText for continue button on PodSetupView\nTitle of button to continue discard", + "comment" : "Action button description when deactivated\nAction button title for attach pod view\nButton title to continue\nCannula insertion button text when inserted\nPod pairing action button text when paired\nText for Continue button on O5KeySetupView\nText for continue button\nText for continue button on PodSetupView\nTitle of button to continue discard", "extractionState" : "manual", "localizations" : { "ar" : { @@ -14901,6 +14920,9 @@ } } }, + "Could not connect to the Internet to fetch an Omnipod 5 Pod Certificate." : { + "comment" : "O5 fetch failure: offline at pre-flight, primary line" + }, "Critical Alerts" : { "comment" : "Title for critical alerts description", "extractionState" : "manual", @@ -17817,6 +17839,9 @@ } } }, + "Downloading certificate…" : { + "comment" : "O5 fetch progress: download" + }, "Empty reservoir" : { "comment" : "Description for Empty reservoir pod fault", "extractionState" : "manual", @@ -23001,6 +23026,9 @@ } } }, + "Fetched (downloaded from API)" : { + "comment" : "O5 cert source: fetched" + }, "Fill a new %1$@ pod with U-100 Insulin (leave %2$@ needle cap on)." : { "comment" : "Label text for step 1 of non-Eros pair pod instructions (1: pod type name) (2: pod tab color", "extractionState" : "manual", @@ -24297,6 +24325,12 @@ } } }, + "Forget Saved Certificate" : { + "comment" : "Confirm destructive forget action\nDestructive button to remove a saved O5 certificate" + }, + "Generating App Attest key…" : { + "comment" : "O5 fetch progress: generate key" + }, "Greater than %1$@ units remaining at %2$@" : { "comment" : "Accessibility format string for (1: localized volume)(2: time)", "extractionState" : "manual", @@ -24945,6 +24979,15 @@ } } }, + "Import .o5keypair file" : { + "comment" : "Toolbar action to import an o5keypair file" + }, + "Import Failed" : { + "comment" : "Title of o5keypair import-failed alert" + }, + "Imported (.o5keypair file)" : { + "comment" : "O5 cert source: imported" + }, "Incorrect Response" : { "comment" : "Error message description for PeripheralManagerError.incorrectResponse", "extractionState" : "manual", @@ -33531,6 +33574,9 @@ } } }, + "No certificates loaded" : { + "comment" : "Empty state for the Pod Certificate view" + }, "No confidence reminders are used." : { "comment" : "Description for BeepPreference.silent", "extractionState" : "manual", @@ -37741,6 +37787,9 @@ } } }, + "Omnipod 5 Setup" : { + "comment" : "Title for O5 key fetch view\nTitle for the Omnipod 5 key setup screen" + }, "Omnipod Classic" : { "comment" : "Title string for Omnipod Classic", "extractionState" : "manual", @@ -41141,6 +41190,9 @@ } } }, + "Please connect to Wi-Fi or Cellular Data and try again." : { + "comment" : "O5 fetch failure: offline at pre-flight, recovery suggestion" + }, "Please deactivate the pod. When deactivation is complete, you may pair a new pod." : { "comment" : "Instructions for deactivate pod when pod not on body", "extractionState" : "manual", @@ -42437,6 +42489,9 @@ } } }, + "Please try again later." : { + "comment" : "O5 fetch: malformed status response, recovery suggestion" + }, "Please try repositioning the pod or the RileyLink and try again" : { "comment" : "Recovery suggestion when no response is received from pod", "extractionState" : "manual", @@ -43247,6 +43302,9 @@ } } }, + "Pod Certificate" : { + "comment" : "Text for pod certificate navigation link in OmniSettingsView\nnavigation title for pod certificate view" + }, "Pod deactivated successfully. Continue." : { "comment" : "Deactivate pod action button accessibility label when deactivation complete", "extractionState" : "manual", @@ -51350,167 +51408,8 @@ } } }, - "Rebuild app with needed certificate data" : { - "comment" : "Recovery suggestion with missing certificate", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إعادة إنشاء التطبيق ببيانات الشهادة المطلوبة" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obnovení aplikace s potřebnými daty certifikátu" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Genopbyg appen med de nødvendige certifikatdata" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "App mit benötigten Zertifikatsdaten neu erstellen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rebuild app with needed certificate data" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reconstruir la aplicación con los datos del certificado necesarios" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rakenna sovellus uudelleen tarvittavilla varmenteen tiedoilla" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reconstruire l'application avec les données de certificat nécessaires" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בנה מחדש את האפליקציה עם נתוני האישור הדרושים" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Az alkalmazás újrakészítése a szükséges tanúsítványadatokkal" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ricostruire l'applicazione con i dati del certificato necessari" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "必要な証明書データでアプリを再構築する" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "필요한 인증서 데이터로 앱 다시 빌드" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gjenoppbygg appen med nødvendige sertifikatdata" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Herbouw app met benodigde certificaatgegevens" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odbuduj aplikację z wymaganymi danymi certyfikatu" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reconstruir a aplicação com os dados de certificado necessários" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reconstruiți aplicația cu datele de certificat necesare" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Пересоздайте приложение с необходимыми данными сертификата" - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obnovenie aplikácie s potrebnými údajmi certifikátu" - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ponovna vzpostavitev aplikacije s potrebnimi podatki o certifikatu" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Återskapa appen med nödvändiga certifikatdata" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uygulamayı gerekli sertifika verileriyle yeniden oluşturun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перезберіть додаток з потрібними даними сертифіката" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Xây dựng lại ứng dụng với dữ liệu chứng chỉ cần thiết" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用所需证书数据重建应用程序" - } - } - } + "Ready to pair with an Omnipod 5 Pod." : { + "comment" : "Description when O5 keypairs are available" }, "Ref: " : { "comment" : "PDM Ref string line", @@ -52484,6 +52383,12 @@ } } }, + "Requesting server challenge…" : { + "comment" : "O5 fetch progress: challenge" + }, + "Resolving app identity…" : { + "comment" : "O5 fetch progress: team id / bundle id" + }, "Resume" : { "comment" : "Pump Event title for UnfinalizedDose with doseType of .resume", "extractionState" : "manual", @@ -53456,6 +53361,14 @@ } } }, + "Retrieve an Omnipod 5 Pod Certificate to continue." : { + "comment" : "Recovery suggestion with missing certificate", + "extractionState" : "manual" + }, + "Retrieve Pod Certificate" : { + "comment" : "Button text to navigate to O5 certificate download from a pair pod failure", + "extractionState" : "manual" + }, "Retrieving Pump Manager Details..." : { "comment" : "button title when retrieving pump manager details", "extractionState" : "manual", @@ -53619,7 +53532,7 @@ } }, "Retry" : { - "comment" : "Action button description for deactivate after failed attempt\nCannula insertion button text while showing error\nPod pairing action button text while showing error", + "comment" : "Action button description for deactivate after failed attempt\nCannula insertion button text while showing error\nO5 key fetch retry button\nPod pairing action button text while showing error", "extractionState" : "manual", "localizations" : { "ar" : { @@ -58152,6 +58065,20 @@ } } }, + "Starting…" : { + "comment" : "O5 key fetch initial loading text" + }, + "Step %d of %d" : { + "comment" : "Step counter, e.g. 'Step 2 of 6'", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Step %1$d of %2$d" + } + } + } + }, "Suspend" : { "comment" : "Pump Event title for UnfinalizedDose with doseType of .suspend", "extractionState" : "manual", @@ -62040,6 +61967,12 @@ } } }, + "The key-management server is temporarily unavailable: received unexpected response." : { + "comment" : "O5 fetch: malformed status response, primary line" + }, + "The key-management server is temporarily unavailable." : { + "comment" : "O5 fetch: keymanager-reported unavailable, no message" + }, "The reminders above will not sound on your device when it is in Silent or Do Not Disturb mode. There are other critical Pod alerts that will sound on your device even when set to Silent or Do Not Disturb mode.\n\nThe Pod will also use audible beeps for all Pod reminders and alerts except when the Pod is Silenced." : { "comment" : "Description text for critical alerts", "extractionState" : "manual", @@ -62202,6 +62135,9 @@ } } }, + "The selected file is not a valid .o5keypair file." : { + "comment" : "Error when o5keypair file import fails" + }, "The time on your pump is different from the current time. Do you want to update the time on your pump to the current time?" : { "comment" : "Message for pod sync time action sheet", "extractionState" : "manual", @@ -69004,6 +68940,9 @@ } } }, + "When you hit 'Continue,' a certificate will be downloaded and stored in order to pair with Omnipod 5 Pods." : { + "comment" : "Description when O5 keypairs are not available" + }, "Yes" : { "comment" : "Button label for user to answer cannula was properly inserted", "extractionState" : "manual", @@ -70300,6 +70239,9 @@ } } }, + "You will be unable to pair with an Omnipod 5 Pod until you reconnect to the Internet to download a new certificate." : { + "comment" : "Confirmation message when forgetting a saved O5 certificate" + }, "You will now begin the process of configuring your reminders, selecting your insulin type, selecting the Omnipod pod type you will be using, filling your Pod with insulin, pairing to your device and placing it on your body." : { "comment" : "bodyText for PodSetupView", "extractionState" : "manual", @@ -70462,6 +70404,9 @@ } } }, + "Your current Omnipod 5 Pod session will not be affected, but you will be unable to pair with a new Omnipod 5 Pod until you reconnect to the Internet to download a new certificate." : { + "comment" : "Confirmation message when forgetting a saved O5 certificate while a pod session is active" + }, "Your insulin delivery will not be automatically adjusted until the temporary basal rate finishes or is canceled." : { "comment" : "Description text on manual temp basal action sheet", "extractionState" : "manual", @@ -71111,5 +71056,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/OmnipodKit.xcodeproj/project.pbxproj b/OmnipodKit.xcodeproj/project.pbxproj index 09fc57c..81a2c2f 100644 --- a/OmnipodKit.xcodeproj/project.pbxproj +++ b/OmnipodKit.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ D8D0ED182D74EF41000B0AF4 /* OmniTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OmniTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D8DF84DD2D5D243B00798277 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; D8F83F882D155ADA0005D165 /* OmnipodKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OmnipodKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D8F83FC02D155ADA0005D165 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -244,6 +245,7 @@ D8F83F7E2D155ADA0005D165 = { isa = PBXGroup; children = ( + D8F83FC02D155ADA0005D165 /* Config.xcconfig */, B66A6F082E6CFD4B00F1641B /* Localization */, D8DF84DD2D5D243B00798277 /* README.md */, D8F83F8A2D155ADA0005D165 /* OmnipodKit */, @@ -793,6 +795,7 @@ }; D8F83F912D155ADA0005D165 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = D8F83FC02D155ADA0005D165 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -868,6 +871,7 @@ }; D8F83F922D155ADA0005D165 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = D8F83FC02D155ADA0005D165 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; diff --git a/OmnipodKit/Bluetooth/Pair/O5CertificateKeychain.swift b/OmnipodKit/Bluetooth/Pair/O5CertificateKeychain.swift new file mode 100644 index 0000000..d175418 --- /dev/null +++ b/OmnipodKit/Bluetooth/Pair/O5CertificateKeychain.swift @@ -0,0 +1,166 @@ +// +// O5CertificateKeychain.swift +// OmnipodKit +// +// Persists O5RegistrationData entries in the iOS Keychain so the user does +// not have to redo the O5 key fetch on every cold start. +// +// Note: the "forget pod" flow intentionally does NOT call into this module — +// these credentials are tied to the controller identity, not to any pod +// session, and must outlive pod un-pair. Removal happens only via the +// explicit "Forget Saved Certificate" UI in PodCertificatesView. +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import Security +import os.log + +enum O5CertificateKeychain { + + private static let log = OSLog(subsystem: "com.loopkit.OmnipodKit", category: "O5CertificateKeychain") + + private static let service = "org.nightscout.o5certificates" + private static let schemaVersion = 1 + + private static var restored = false + private static let restoreLock = NSLock() + + enum Error: Swift.Error { + case encodingFailed + case unhandled(OSStatus) + } + + /// One persisted entry: registration data plus the source that originally produced it. + struct Entry { + let data: O5RegistrationData + let source: O5RegistrationSource + } + + // MARK: - Public API + + static func save(_ data: O5RegistrationData, source: O5RegistrationSource) throws { + let payload = try encode(data, source: source) + let account = String(data.controllerId) + + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let updateAttrs: [String: Any] = [ + kSecValueData as String: payload, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecAttrSynchronizable as String: false, + ] + + let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttrs as CFDictionary) + if updateStatus == errSecSuccess { return } + if updateStatus != errSecItemNotFound { + throw Error.unhandled(updateStatus) + } + + var addQuery = updateQuery + addQuery[kSecValueData as String] = payload + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + addQuery[kSecAttrSynchronizable as String] = false + + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus != errSecSuccess { + throw Error.unhandled(addStatus) + } + } + + static func delete(controllerId: UInt32) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: String(controllerId), + ] + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw Error.unhandled(status) + } + } + + static func deleteAll() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw Error.unhandled(status) + } + } + + static func loadAll() -> [Entry] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { return [] } + guard let items = result as? [[String: Any]] else { return [] } + + return items.compactMap { item -> Entry? in + guard let data = item[kSecValueData as String] as? Data else { return nil } + return decode(data) + } + } + + static func contains(controllerId: UInt32) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: String(controllerId), + kSecMatchLimit as String: kSecMatchLimitOne, + ] + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } + + /// Loads every persisted certificate into the in-memory `O5RegistrationData` registry. + /// Idempotent and cheap to call — the actual Keychain read happens at most once per process. + static func restoreIntoRegistry() { + restoreLock.lock() + defer { restoreLock.unlock() } + if restored { return } + restored = true + for entry in loadAll() { + O5RegistrationData.install(entry.data, source: entry.source) + } + } + + // MARK: - Codec + + private static func encode(_ data: O5RegistrationData, source: O5RegistrationSource) throws -> Data { + var json = data.toJSON() + json["v"] = schemaVersion + json["source"] = source.rawValue + do { + return try JSONSerialization.data(withJSONObject: json, options: []) + } catch { + throw Error.encodingFailed + } + } + + private static func decode(_ blob: Data) -> Entry? { + guard let obj = try? JSONSerialization.jsonObject(with: blob), + let json = obj as? [String: Any], + let data = O5RegistrationData.fromJSON(json) + else { return nil } + let source: O5RegistrationSource = { + if let raw = json["source"] as? String, let s = O5RegistrationSource(rawValue: raw) { + return s + } + return .imported + }() + return Entry(data: data, source: source) + } +} diff --git a/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift b/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift index f5792cb..c0fa685 100644 --- a/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift +++ b/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift @@ -37,6 +37,7 @@ class O5CertificateStore { /// Randomly picks an available O5 controllerId with or 0 if none available static var pickControllerId: UInt32 { loadOptionalO5Data() + O5CertificateKeychain.restoreIntoRegistry() if let data = O5RegistrationData.allValues.randomElement() { return data.controllerId } @@ -46,12 +47,14 @@ class O5CertificateStore { // Returns true if no O5RegistrationData is available static var isEmpty: Bool { loadOptionalO5Data() + O5CertificateKeychain.restoreIntoRegistry() return O5RegistrationData.isEmpty } // Returns true if O5RegistrationData exists for the specific controllerId static func contains(_ controllerId: UInt32) -> Bool { loadOptionalO5Data() + O5CertificateKeychain.restoreIntoRegistry() return O5RegistrationData.get(controllerId) != nil } @@ -62,6 +65,7 @@ class O5CertificateStore { init(controllerId: UInt32) throws { loadOptionalO5Data() + O5CertificateKeychain.restoreIntoRegistry() guard let data = O5RegistrationData.get(controllerId) else { log.debug("@@@ O5CertificateStore has no data for 0x%08X", controllerId) throw PodCommsError.noCertificateFound @@ -214,17 +218,40 @@ class O5CertificateStore { } } +// MARK: - Public availability helper + +/// Whether Omnipod 5 pairing should be presented as available in the UI. +/// +/// In ENABLE_O5 builds we surface the O5 flow unconditionally — the build is +/// expected to ship with built-in registration data, or the user is expected +/// to import / fetch a keypair as part of setup. In other builds we only +/// consider O5 available if at least one registration record is currently +/// loaded (built-in, imported, fetched, or restored from Keychain). +public func isOmnipod5Enabled() -> Bool { + #if ENABLE_O5 + return true + #else + return !O5CertificateStore.isEmpty + #endif +} + // MARK: - Runtime Installer -/// Load the data from the optional O5Data file if present by invoking its install() function using a unsafeBitCast +/// Load the data from the optional O5Data file if present by invoking its install() function using a unsafeBitCast. +/// Any registry entries that appear as a result of the call are tagged with `.builtIn`. fileprivate func loadOptionalO5Data() { // Use RTLD_DEFAULT (-2) to find the symbol if it was compiled into the binary - if let installSym = dlsym( + guard let installSym = dlsym( UnsafeMutableRawPointer(bitPattern: -2), "O5RegistrationDataInstall" - ) { - typealias InstallFunc = @convention(c) () -> Void - let install = unsafeBitCast(installSym, to: InstallFunc.self) - install() + ) else { return } + + let before = Set(O5RegistrationData.allValues.map { $0.controllerId }) + typealias InstallFunc = @convention(c) () -> Void + let install = unsafeBitCast(installSym, to: InstallFunc.self) + install() + let after = Set(O5RegistrationData.allValues.map { $0.controllerId }) + for newId in after.subtracting(before) { + O5RegistrationData.markSource(newId, .builtIn) } } diff --git a/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift b/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift index 6b6e143..37c3e0d 100644 --- a/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift +++ b/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift @@ -9,16 +9,52 @@ import Foundation import CryptoSwift +enum O5RegistrationSource: String { + case builtIn // compiled into the binary via the optional O5Data symbol + case imported // loaded from a user-supplied .o5keypair file + case fetched // downloaded from the keypair API +} + struct O5RegistrationData { private static var _registry: [UInt32: O5RegistrationData] = [:] + private static var _sources: [UInt32: O5RegistrationSource] = [:] private static let lock = NSLock() + /// Plain install (used by the embedded built-in installer that ships as compiled code + /// and cannot be modified to pass a source). Source is tagged separately by + /// `loadOptionalO5Data` once the symbol-call returns. static func install(_ value: O5RegistrationData) { lock.lock() defer { lock.unlock() } _registry[value.controllerId] = value } + static func install(_ value: O5RegistrationData, source: O5RegistrationSource) { + lock.lock() + defer { lock.unlock() } + _registry[value.controllerId] = value + _sources[value.controllerId] = source + } + + static func markSource(_ controllerId: UInt32, _ source: O5RegistrationSource) { + lock.lock() + defer { lock.unlock() } + _sources[controllerId] = source + } + + static func source(for controllerId: UInt32) -> O5RegistrationSource? { + lock.lock() + defer { lock.unlock() } + return _sources[controllerId] + } + + static func remove(controllerId: UInt32) { + lock.lock() + defer { lock.unlock() } + _registry.removeValue(forKey: controllerId) + _sources.removeValue(forKey: controllerId) + } + static func get(_ controllerId: UInt32) -> O5RegistrationData? { lock.lock() defer { lock.unlock() } @@ -51,6 +87,35 @@ struct O5RegistrationData { return _registry.isEmpty } + /// Inverse of `fromJSON`. The shape matches the .o5keypair file format so that + /// persisted entries and imported files share a single representation. + func toJSON() -> [String: Any] { + return [ + "controllerId": NSNumber(value: controllerId), + "privateKey": privateKeyHex, + "publicKey": publicKeyHex, + "intermediateCA": intermediateCABase64, + "tlsCertificate": tlsCertificateBase64, + ] + } + + /// Parse an O5RegistrationData from a JSON dictionary (e.g. from an .o5keypair file or API response). + static func fromJSON(_ json: [String: Any]) -> O5RegistrationData? { + guard let controllerId = (json["controllerId"] as? NSNumber)?.uint32Value, + let privateKeyHex = json["privateKey"] as? String, + let publicKeyHex = json["publicKey"] as? String, + let intermediateCABase64 = json["intermediateCA"] as? String, + let tlsCertificateBase64 = json["tlsCertificate"] as? String + else { return nil } + return O5RegistrationData( + controllerId: controllerId, + privateKeyHex: privateKeyHex, + publicKeyHex: publicKeyHex, + intermediateCABase64: intermediateCABase64, + tlsCertificateBase64: tlsCertificateBase64 + ) + } + // MARK: - Identity /// Becomes the 4-byte controller ID. diff --git a/OmnipodKit/Info.plist b/OmnipodKit/Info.plist index 9bcb244..38ae315 100644 --- a/OmnipodKit/Info.plist +++ b/OmnipodKit/Info.plist @@ -18,5 +18,7 @@ 1.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) + OmnipodKitTeamIdentifier + $(DEVELOPMENT_TEAM) diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index b75abeb..190c452 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -981,6 +981,15 @@ extension OmniPumpManager { // MARK: - Pod comms + /// Refresh the cached O5 controllerId / podId from the cert store after the + /// user has fetched or imported a new certificate. Only valid before a pod + /// session exists; rotating these mid-session would orphan a live pod since + /// the values are baked into the session keys derived at pairing time. + func refreshO5IdsFromCertStore() { + guard state.podType.isO5, state.podState == nil else { return } + prepForNewPod() + } + private func prepForNewPod() { let podType = state.podType diff --git a/OmnipodKit/PumpManager/PodCommsSession.swift b/OmnipodKit/PumpManager/PodCommsSession.swift index 290d2b8..f89a314 100644 --- a/OmnipodKit/PumpManager/PodCommsSession.swift +++ b/OmnipodKit/PumpManager/PodCommsSession.swift @@ -186,7 +186,7 @@ extension PodCommsError: LocalizedError { case .setupNotComplete: return nil case .noCertificateFound: - return LocalizedString("Rebuild app with needed certificate data", comment: "Recovery suggestion with missing certificate") + return LocalizedString("Retrieve an Omnipod 5 Pod Certificate to continue.", comment: "Recovery suggestion with missing certificate") } } diff --git a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift index b39aed7..6db8bd0 100644 --- a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift +++ b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift @@ -24,6 +24,8 @@ enum OmniUIScreen { case lowReservoirReminderSetup case insulinTypeSelection case selectPodType + case podTypeSelected // virtual routing step — never presented; resolves to o5KeySetup / rileyLinkSetup / pairAndPrime + case o5KeySetup case rileyLinkSetup // will be skipped for non-Eros pods case pairAndPrime case insertCannula @@ -46,7 +48,14 @@ enum OmniUIScreen { case .insulinTypeSelection: return .selectPodType case .selectPodType: + return .podTypeSelected + case .podTypeSelected: + // Resolved by `navigateTo` to one of o5KeySetup / rileyLinkSetup / pairAndPrime. + // The fallback here (rileyLinkSetup) is never reached because this case is + // never the "currentScreen" — it's intercepted before being pushed. return .rileyLinkSetup + case .o5KeySetup: + return .pairAndPrime case .rileyLinkSetup: // will be skipped for non-Eros pods return .pairAndPrime case .pairAndPrime: @@ -64,7 +73,7 @@ enum OmniUIScreen { case .uncertaintyRecovered: return nil case .deactivate: - return .pairAndPrime + return .podTypeSelected case .settings: return nil } @@ -180,12 +189,30 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi self?.setupCanceled() } - let o5NotAvailable = O5CertificateStore.isEmpty + let o5NotAvailable = !isOmnipod5Enabled() let podTypeSelectionView = PodTypeSelection(initialValue: self.podType, o5NotAvailable: o5NotAvailable, didConfirm: didConfirm, didCancel: didCancel) let hostedView = hostingController(rootView: podTypeSelectionView) hostedView.navigationItem.title = LocalizedString("Pod Type", comment: "Title for Pod Type selection screen") return hostedView + case .podTypeSelected: + // Virtual step: navigateTo resolves it before push. Reaching here would mean + // someone instantiated a view controller for the routing step itself. + fatalError("podTypeSelected is a virtual routing step and must be resolved before presentation") + + case .o5KeySetup: + let view = O5KeySetupView( + o5KeypairsNotAvailable: O5CertificateStore.isEmpty, + didContinue: { [weak self] in + self?.pumpManager.refreshO5IdsFromCertStore() + self?.stepFinished() + }, + didCancel: { [weak self] in self?.setupCanceled() } + ) + let hostedView = hostingController(rootView: view) + hostedView.navigationItem.title = LocalizedString("Omnipod 5 Setup", comment: "Title for the Omnipod 5 key setup screen") + return hostedView + case .rileyLinkSetup: // This step will be skipped for non-Eros pods let dataSource = RileyLinkListDataSource(rileyLinkPumpManager: pumpManager) @@ -260,6 +287,9 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi viewModel.didRequestDeactivation = { [weak self] in self?.navigateTo(.deactivate) } + viewModel.didRequestO5KeySetup = { [weak self] in + self?.navigateTo(.o5KeySetup) + } let view = hostingController(rootView: PairPodView(viewModel: viewModel).onAppear(perform: {UIApplication.shared.isIdleTimerDisabled = true}), onDisappear: {UIApplication.shared.isIdleTimerDisabled = false}) view.navigationItem.title = String(format: LocalizedString("Pair %1$@ Pod", comment: "Title for pod pairing screen (1: pod type brief name)"), self.podType.briefName) @@ -388,14 +418,29 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi return DismissibleHostingController(content: rootView, onDisappear: onDisappear, colorPalette: colorPalette) } + /// Resolves the virtual `.podTypeSelected` routing step (and intercepts direct + /// `.pairAndPrime` jumps that need an O5 key fetch first) into the concrete + /// next screen for the currently selected pod type. Other screens pass through. + private func resolveRoutingStep(_ screen: OmniUIScreen) -> OmniUIScreen { + // Hard guard: O5 always needs a cert before pair/prime can succeed. + // Any caller asking to start pairing — whether through the routing step + // or directly via `.pairAndPrime` (e.g. the Pair Pod button in settings) + // gets diverted to the key setup screen if no cert is loaded. + if podType == omnipod5Type && O5CertificateStore.isEmpty { + if screen == .podTypeSelected || screen == .pairAndPrime { + return .o5KeySetup + } + } + guard screen == .podTypeSelected else { return screen } + if podType.usesRileyLink { + return .rileyLinkSetup + } + return .pairAndPrime + } + private func stepFinished() { if let nextStep = currentScreen.next() { - if nextStep == .rileyLinkSetup && !podType.usesRileyLink { - // Skip rileyLinkSetup to pairAndPrme for non-Eros - navigateTo(.pairAndPrime) - } else { - navigateTo(nextStep) - } + navigateTo(nextStep) } else if pumpManager.podType == unknownOmnipodType { // User selected switch pod type at bottom of pod settings with // no active pod, so we need to reselect the new pod type now. @@ -466,11 +511,21 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi if self.podType == unknownOmnipodType { return .selectPodType // need to first select a pod type } - return .pairAndPrime // pair and prime a new pod + return .podTypeSelected // route to o5KeySetup / rileyLinkSetup / pairAndPrime as appropriate } else { if self.podType == unknownOmnipodType { return .selectPodType // need to first select a pod type } + // O5 selected, no cert, no active pod: route through pod-type + // reselection so the user can either switch pod type or proceed + // into the O5 cert download (via resolveRoutingStep's + // .podTypeSelected → .o5KeySetup interception) instead of + // landing in Settings where Pair Pod would fail. + if self.podType == omnipod5Type + && O5CertificateStore.isEmpty + && pumpManager.podCommState == .noPod { + return .selectPodType + } return .settings } } @@ -523,8 +578,9 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi extension OmniUICoordinator: OmniUINavigator { func navigateTo(_ screen: OmniUIScreen) { - screenStack.append(screen) - let viewController = viewControllerForScreen(screen) + let resolved = resolveRoutingStep(screen) + screenStack.append(resolved) + let viewController = viewControllerForScreen(resolved) viewController.isModalInPresentation = false self.pushViewController(viewController, animated: true) viewController.view.layoutSubviews() diff --git a/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift b/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift index a83d78c..a8439e2 100644 --- a/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift +++ b/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift @@ -178,6 +178,8 @@ class PairPodViewModel: ObservableObject, Identifiable { var didCancelSetup: (() -> Void)? + var didRequestO5KeySetup: (() -> Void)? + var podPairer: PodPairer var autoRetryAttempted: Bool @@ -291,6 +293,18 @@ enum OmniPairingError : LocalizedError { } */ + var isMissingO5Certificate: Bool { + switch self { + case .pumpManagerError(let error): + if case .configuration(let inner) = error, + let podCommsError = inner as? PodCommsError, + case .noCertificateFound = podCommsError { + return true + } + return false + } + } + var recoverable: Bool { switch self { case .pumpManagerError(let error): diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift new file mode 100644 index 0000000..de9f1c5 --- /dev/null +++ b/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift @@ -0,0 +1,106 @@ +// +// O5KeyFetchView.swift +// OmnipodKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct O5KeyFetchView: View { + + @State private var errorMessage: String? + @State private var errorDetail: String? + @State private var currentStep: O5KeyFetchProgress? + + let onKeypairReceived: (O5RegistrationData) -> Void + let onCancel: () -> Void + + var body: some View { + VStack { + Spacer() + + if let errorMessage = errorMessage { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundColor(.red) + Text("\(errorMessage)") + .foregroundColor(.red) + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.horizontal) + + if let errorDetail = errorDetail { + Text(errorDetail) + .foregroundColor(.red) + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.top, 8) + } + + Button(action: { performFetch() }) { + Text(LocalizedString("Retry", comment: "O5 key fetch retry button")) + .actionButtonStyle(.primary) + .padding() + } + } + } else { + VStack(spacing: 16) { + ProgressView(value: progressFraction) + .progressViewStyle(.linear) + .padding(.horizontal, 40) + + if let step = currentStep { + Text(String(format: LocalizedString("Step %d of %d", + comment: "Step counter, e.g. 'Step 2 of 6'"), + step.index, + O5KeyFetchProgress.totalSteps)) + .font(.caption) + .foregroundColor(.secondary) + Text(step.localizedDescription) + .foregroundColor(.primary) + .multilineTextAlignment(.center) + } else { + Text(LocalizedString("Starting…", comment: "O5 key fetch initial loading text")) + .foregroundColor(.secondary) + } + } + } + + Spacer() + } + .onAppear { + performFetch() + } + } + + private var progressFraction: Double { + guard let step = currentStep else { return 0 } + return Double(step.index) / Double(O5KeyFetchProgress.totalSteps) + } + + private func performFetch() { + errorMessage = nil + errorDetail = nil + currentStep = nil + + O5AppAttestService().fetchKeypair( + progress: { step in + self.currentStep = step + }, + completion: { result in + switch result { + case .success(let registrationData): + self.onKeypairReceived(registrationData) + case .failure(let error): + self.errorMessage = error.message + self.errorDetail = error.recoverySuggestion + } + } + ) + } +} diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift new file mode 100644 index 0000000..c4f660a --- /dev/null +++ b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift @@ -0,0 +1,91 @@ +// +// O5KeySetupView.swift +// OmnipodKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct O5KeySetupView: View { + + @Environment(\.appName) private var appName + + @State private var o5KeypairsNotAvailable: Bool + @State private var showingFetchSheet = false + private var didContinue: () -> Void + private var didCancel: () -> Void + + init(o5KeypairsNotAvailable: Bool, didContinue: @escaping () -> Void, didCancel: @escaping () -> Void) { + self._o5KeypairsNotAvailable = State(initialValue: o5KeypairsNotAvailable) + self.didContinue = didContinue + self.didCancel = didCancel + } + + var body: some View { + VStack(alignment: .leading) { + List { + Section { + if o5KeypairsNotAvailable { + Text(LocalizedString("When you hit 'Continue,' a certificate will be downloaded and stored in order to pair with Omnipod 5 Pods.", comment: "Description when O5 keypairs are not available")) + .padding(.vertical, 4) + } else { + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.title2) + Text(LocalizedString("Ready to pair with an Omnipod 5 Pod.", comment: "Description when O5 keypairs are available")) + } + .padding(.vertical, 4) + } + } + } + .insetGroupedListStyle() + + Button(action: { + if o5KeypairsNotAvailable { + showingFetchSheet = true + } else { + didContinue() + } + }) { + Text(LocalizedString("Continue", comment: "Text for Continue button on O5KeySetupView")) + .actionButtonStyle(.primary) + .padding() + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(LocalizedString("Cancel", comment: "Cancel button title"), action: { + didCancel() + }) + } + } + .sheet(isPresented: $showingFetchSheet) { + NavigationView { + O5KeyFetchView( + onKeypairReceived: { registrationData in + O5RegistrationData.install(registrationData, source: .fetched) + try? O5CertificateKeychain.save(registrationData, source: .fetched) + o5KeypairsNotAvailable = false + showingFetchSheet = false + }, + onCancel: { + showingFetchSheet = false + } + ) + .navigationTitle(LocalizedString("Omnipod 5 Setup", comment: "Title for O5 key fetch view")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(LocalizedString("Cancel", comment: "Cancel button for O5 key fetch")) { + showingFetchSheet = false + } + } + } + } + } + } +} diff --git a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift index 7af03c5..ee3e00e 100644 --- a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift @@ -582,6 +582,15 @@ struct OmniSettingsView: View { } } + if self.viewModel.podType.isO5 { + Section() { + NavigationLink(destination: PodCertificatesView(hasActivePod: !viewModel.noPod)) { + FrameworkLocalText("Pod Certificate", comment: "Text for pod certificate navigation link in OmniSettingsView") + .foregroundColor(Color.primary) + } + } + } + Section() { let localizedPodDiagnosticsStr = LocalizedString("Pod Diagnostics", comment: "Title for the Pod Diagnostic row and page") NavigationLink(destination: PodDiagnosticsView( diff --git a/OmnipodKit/PumpManagerUI/Views/PairPodView.swift b/OmnipodKit/PumpManagerUI/Views/PairPodView.swift index f600982..2b663db 100644 --- a/OmnipodKit/PumpManagerUI/Views/PairPodView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PairPodView.swift @@ -90,19 +90,30 @@ struct PairPodView: View { } .disabled(self.viewModel.state.isProcessing) .zIndex(1) - } else { - // Some non-recoverable error occurred - if (self.viewModel.error?.recoverable == false) { - Button(action: { - self.viewModel.continueButtonTapped() - }) { - Text("Abort") - .actionButtonStyle(self.viewModel.podIsActivated ? - .destructive : .primary) - } - .disabled(false) - .zIndex(1) + } + + if self.viewModel.error?.isMissingO5Certificate == true { + Button(action: { + self.viewModel.didRequestO5KeySetup?() + }) { + Text(LocalizedString("Retrieve Pod Certificate", comment: "Button text to navigate to O5 certificate download from a pair pod failure")) + .actionButtonStyle(.primary) } + .accessibility(identifier: "button_retrieve_pod_certificate") + .disabled(self.viewModel.state.isProcessing) + .zIndex(1) + } + + if (self.viewModel.error?.recoverable == false) { + Button(action: { + self.viewModel.continueButtonTapped() + }) { + Text("Abort") + .actionButtonStyle(self.viewModel.podIsActivated ? + .destructive : .primary) + } + .disabled(false) + .zIndex(1) } } .transition(AnyTransition.opacity.combined(with: .move(edge: .bottom))) diff --git a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift new file mode 100644 index 0000000..6f4a657 --- /dev/null +++ b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift @@ -0,0 +1,262 @@ +// +// PodCertificatesView.swift +// OmnipodKit +// +// Shows the loaded O5 certificate(s). For the common single-cert case the +// detail content is rendered inline; with multiple certs we fall back to a +// list-of-rows with per-cert navigation. A "+" toolbar button imports a +// .o5keypair file directly into the registry and Keychain. +// +// Built-in certificates (compiled into the binary) are listed read-only — +// they cannot be forgotten without rebuilding the app. +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import UniformTypeIdentifiers +import LoopKit +import LoopKitUI + +struct PodCertificatesView: View { + + private struct Row: Identifiable { + let data: O5RegistrationData + let source: O5RegistrationSource + var id: UInt32 { data.controllerId } + } + + let hasActivePod: Bool + + @State private var rows: [Row] = [] + @State private var showingFileImporter = false + @State private var importError: String? + + private let title = LocalizedString("Pod Certificate", comment: "navigation title for pod certificate view") + + var body: some View { + List { + if rows.isEmpty { + Section { + Text(LocalizedString("No certificates loaded", comment: "Empty state for the Pod Certificate view")) + .foregroundColor(.secondary) + } + } else if rows.count == 1 { + certificateContent( + data: rows[0].data, + source: rows[0].source, + hasActivePod: hasActivePod, + onForgotten: { reload() } + ) + } else { + ForEach(rows) { row in + NavigationLink(destination: PodCertificateDetailView( + data: row.data, + source: row.source, + hasActivePod: hasActivePod, + onForgotten: { reload() } + )) { + VStack(alignment: .leading, spacing: 4) { + Text(String(format: "Controller 0x%08X", row.data.controllerId)) + .foregroundColor(.primary) + Text(label(for: row.source)) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + + } + .insetGroupedListStyle() + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + importError = nil + showingFileImporter = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel(LocalizedString("Import .o5keypair file", comment: "Toolbar action to import an o5keypair file")) + } + } + .fileImporter(isPresented: $showingFileImporter, allowedContentTypes: [.json, .item]) { result in + handleImport(result) + } + .alert( + LocalizedString("Import Failed", comment: "Title of o5keypair import-failed alert"), + isPresented: Binding( + get: { importError != nil }, + set: { if !$0 { importError = nil } } + ), + presenting: importError + ) { _ in + Button(LocalizedString("OK", comment: "OK button for import-failed alert")) { + importError = nil + } + } message: { message in + Text(message) + } + .task { reload() } + } + + private func reload() { + // Make sure both built-in (dlsym) and Keychain-persisted certs are populated + // before we read the registry — opening this view shouldn't depend on the + // pairing flow having run first. + _ = O5CertificateStore.isEmpty + + rows = O5RegistrationData.allValues + .sorted { $0.controllerId < $1.controllerId } + .map { data in + Row(data: data, source: O5RegistrationData.source(for: data.controllerId) ?? .imported) + } + } + + private func handleImport(_ result: Result) { + switch result { + case .success(let url): + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + + guard let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let registrationData = O5RegistrationData.fromJSON(json) + else { + importError = LocalizedString("The selected file is not a valid .o5keypair file.", comment: "Error when o5keypair file import fails") + return + } + O5RegistrationData.install(registrationData, source: .imported) + try? O5CertificateKeychain.save(registrationData, source: .imported) + importError = nil + reload() + case .failure(let error): + importError = error.localizedDescription + } + } + + private func label(for source: O5RegistrationSource) -> String { + switch source { + case .builtIn: return LocalizedString("Built-in (compiled into app)", comment: "O5 cert source: built-in") + case .imported: return LocalizedString("Imported (.o5keypair file)", comment: "O5 cert source: imported") + case .fetched: return LocalizedString("Fetched (downloaded from API)", comment: "O5 cert source: fetched") + } + } +} + +struct PodCertificateDetailView: View { + + let data: O5RegistrationData + let source: O5RegistrationSource + let hasActivePod: Bool + let onForgotten: () -> Void + + @Environment(\.presentationMode) private var presentationMode + + var body: some View { + List { + certificateContent( + data: data, + source: source, + hasActivePod: hasActivePod, + onForgotten: { + onForgotten() + presentationMode.wrappedValue.dismiss() + } + ) + } + .insetGroupedListStyle() + .navigationTitle(String(format: "Controller 0x%08X", data.controllerId)) + .navigationBarTitleDisplayMode(.inline) + } +} + +@ViewBuilder +fileprivate func certificateContent( + data: O5RegistrationData, + source: O5RegistrationSource, + hasActivePod: Bool, + onForgotten: @escaping () -> Void +) -> some View { + Section { + Text(dump(data: data, source: source)) + .font(Font.system(size: 12).monospaced()) + .textSelection(.enabled) + } + + if source != .builtIn { + Section { + ForgetCertificateButton( + controllerId: data.controllerId, + hasActivePod: hasActivePod, + onForgotten: onForgotten + ) + } + } +} + +private struct ForgetCertificateButton: View { + + let controllerId: UInt32 + let hasActivePod: Bool + let onForgotten: () -> Void + + @State private var pendingForget = false + + private var confirmMessage: String { + if hasActivePod { + return LocalizedString( + "Your current Omnipod 5 Pod session will not be affected, but you will be unable to pair with a new Omnipod 5 Pod until you reconnect to the Internet to download a new certificate.", + comment: "Confirmation message when forgetting a saved O5 certificate while a pod session is active" + ) + } else { + return LocalizedString( + "You will be unable to pair with an Omnipod 5 Pod until you reconnect to the Internet to download a new certificate.", + comment: "Confirmation message when forgetting a saved O5 certificate" + ) + } + } + + var body: some View { + Button(role: .destructive) { + pendingForget = true + } label: { + Text(LocalizedString("Forget Saved Certificate", comment: "Destructive button to remove a saved O5 certificate")) + } + .confirmationDialog( + confirmMessage, + isPresented: $pendingForget, + titleVisibility: .visible + ) { + Button(LocalizedString("Forget Saved Certificate", comment: "Confirm destructive forget action"), role: .destructive) { + forget() + } + Button(LocalizedString("Cancel", comment: "Cancel button"), role: .cancel) {} + } + } + + private func forget() { + try? O5CertificateKeychain.delete(controllerId: controllerId) + O5RegistrationData.remove(controllerId: controllerId) + onForgotten() + } +} + +fileprivate func dump(data: O5RegistrationData, source: O5RegistrationSource) -> String { + var lines: [String] = [] + lines.append("## O5RegistrationData") + lines.append("* source: \(sourceLabel(source))") + lines.append(String(format: "* controllerId: %u (0x%08X)", data.controllerId, data.controllerId)) + lines.append("* publicKey: \(data.publicKeyHex)") + return lines.joined(separator: "\n") +} + +fileprivate func sourceLabel(_ source: O5RegistrationSource) -> String { + switch source { + case .builtIn: return "Built-in (compiled into app)" + case .imported: return "Imported (.o5keypair file)" + case .fetched: return "Fetched (downloaded from API)" + } +} diff --git a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift index 69b5089..97a9e71 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift @@ -96,6 +96,7 @@ struct PodDiagnosticsView: View { FrameworkLocalText("Pump Manager Details", comment: "Text for pump manager details navigation link") .foregroundColor(Color.primary) } + } .insetGroupedListStyle() .navigationTitle(title) diff --git a/OmnipodKit/Services/O5AppAttestService.swift b/OmnipodKit/Services/O5AppAttestService.swift new file mode 100644 index 0000000..b02c792 --- /dev/null +++ b/OmnipodKit/Services/O5AppAttestService.swift @@ -0,0 +1,412 @@ +// +// O5AppAttestService.swift +// OmnipodKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import CryptoKit +import DeviceCheck +import Network + +let o5KeyManagerBaseURL = "https://api.osaid-keymanager.org" +private let omnipodkitApiVersion = "1.0.0" + +struct O5AuthError: Error { + let message: String + let recoverySuggestion: String? + let httpStatusCode: Int? + let underlyingError: Error? + + init(message: String, recoverySuggestion: String? = nil, httpStatusCode: Int? = nil, underlyingError: Error? = nil) { + self.message = message + self.recoverySuggestion = recoverySuggestion + self.httpStatusCode = httpStatusCode + self.underlyingError = underlyingError + } +} + +/// Ordered phases of the keypair fetch flow. UI consumers can use `index` / +/// `totalSteps` to drive a determinate progress bar. +enum O5KeyFetchProgress: Int, CaseIterable { + case checkingInternetConnection + case checkingKeymanagerServiceStatus + case checkingDeviceSupport + case resolvingAppIdentity + case generatingAttestKey + case requestingChallenge + case attestingWithApple + case downloadingCertificate + + var index: Int { rawValue + 1 } + static var totalSteps: Int { Self.allCases.count } + + var localizedDescription: String { + switch self { + case .checkingInternetConnection: + return LocalizedString("Checking Internet connection…", comment: "O5 fetch progress: internet pre-check") + case .checkingKeymanagerServiceStatus: + return LocalizedString("Checking server status…", comment: "O5 fetch progress: server status") + case .checkingDeviceSupport: + return LocalizedString("Checking device support…", comment: "O5 fetch progress: device support") + case .resolvingAppIdentity: + return LocalizedString("Resolving app identity…", comment: "O5 fetch progress: team id / bundle id") + case .generatingAttestKey: + return LocalizedString("Generating App Attest key…", comment: "O5 fetch progress: generate key") + case .requestingChallenge: + return LocalizedString("Requesting server challenge…", comment: "O5 fetch progress: challenge") + case .attestingWithApple: + return LocalizedString("Attesting with Apple…", comment: "O5 fetch progress: attestation") + case .downloadingCertificate: + return LocalizedString("Downloading certificate…", comment: "O5 fetch progress: download") + } + } +} + +class O5AppAttestService { + + private let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 30 + return URLSession(configuration: config) + }() + + /// Runs the full App Attest + keypair fetch flow. + /// Calls `progress` on the main queue before starting each phase, then `completion` + /// on the main queue with the final result. + func fetchKeypair( + progress: @escaping (O5KeyFetchProgress) -> Void = { _ in }, + completion: @escaping (Result) -> Void + ) { + let report: (O5KeyFetchProgress) -> Void = { step in + DispatchQueue.main.async { progress(step) } + } + Task { + do { + let result = try await performFetchFlow(progress: report) + DispatchQueue.main.async { completion(.success(result)) } + } catch let error as O5AuthError { + DispatchQueue.main.async { completion(.failure(error)) } + } catch { + DispatchQueue.main.async { + completion(.failure(O5AuthError(message: error.localizedDescription, underlyingError: error))) + } + } + } + } + + // MARK: - Async flow + + private func performFetchFlow(progress: (O5KeyFetchProgress) -> Void) async throws -> O5RegistrationData { + progress(.checkingInternetConnection) + try await checkInternetConnection() + + progress(.checkingKeymanagerServiceStatus) + try await checkServerStatus() + + progress(.checkingDeviceSupport) + let attestService = DCAppAttestService.shared + guard attestService.isSupported else { + throw O5AuthError(message: "App Attest is not supported on this device.") + } + + // Resolve app identity early so failures (e.g. missing team ID) surface before + // we burn an App Attest key generation, and so the user sees which step failed. + progress(.resolvingAppIdentity) + let appId = try getAppId() + + progress(.generatingAttestKey) + let keyId = try await generateKey(attestService) + + progress(.requestingChallenge) + let challenge = try await getChallenge() + + progress(.attestingWithApple) + let challengeHash = Data(SHA256.hash(data: Data(challenge.utf8))) + let attestation = try await attestKey(attestService, keyId: keyId, clientDataHash: challengeHash) + + progress(.downloadingCertificate) + return try await claimKeypair( + attestation: attestation, + keyId: keyId, + challenge: challenge, + appId: appId + ) + } + + // MARK: - Pre-flight checks + + /// One-shot link-layer reachability check. Throws an offline-flavored + /// `O5AuthError` when the device has no usable path within `timeout`. + /// Cheap optimistic check — cannot detect captive portals or DNS + /// failures; those still surface as URLErrors from later HTTP requests. + private func checkInternetConnection(timeout: TimeInterval = 2.0) async throws { + let monitor = NWPathMonitor() + defer { monitor.cancel() } + + let satisfied: Bool = await withCheckedContinuation { continuation in + let queue = DispatchQueue(label: "org.nightscout.o5-reachability") + var resumed = false + let resume: (Bool) -> Void = { value in + queue.async { + guard !resumed else { return } + resumed = true + continuation.resume(returning: value) + } + } + monitor.pathUpdateHandler = { path in + resume(path.status == .satisfied) + } + monitor.start(queue: queue) + queue.asyncAfter(deadline: .now() + timeout) { + resume(false) + } + } + + if !satisfied { + throw O5AuthError( + message: LocalizedString( + "Could not connect to the Internet to fetch an Omnipod 5 Pod Certificate.", + comment: "O5 fetch failure: offline at pre-flight, primary line"), + recoverySuggestion: LocalizedString( + "Please connect to Wi-Fi or Cellular Data and try again.", + comment: "O5 fetch failure: offline at pre-flight, recovery suggestion")) + } + } + + /// Calls the OSAID Keymanager service-status endpoint. If the server + /// reports `available: false`, the user-facing `message` is surfaced + /// verbatim and the flow aborts. Non-2xx HTTP and transport failures + /// flow through the existing `performRequest` → `authError` path. + /// Unparseable 2xx responses are treated as failures (fail-closed), + /// not as implicit availability. + private func checkServerStatus() async throws { + var request = URLRequest(url: URL(string: "\(o5KeyManagerBaseURL)/api/status/ios")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "omnipodkit_api_version": omnipodkitApiVersion, + ]) + + let (data, response) = try await performRequest(request) + + let malformedMessage = LocalizedString( + "The key-management server is temporarily unavailable: received unexpected response.", + comment: "O5 fetch: malformed status response, primary line") + let malformedRecovery = LocalizedString( + "Please try again later.", + comment: "O5 fetch: malformed status response, recovery suggestion") + + guard let json = parseJSON(data) else { + throw O5AuthError( + message: malformedMessage, + recoverySuggestion: malformedRecovery, + httpStatusCode: response.statusCode) + } + guard let available = json["available"] as? Bool else { + throw O5AuthError( + message: malformedMessage, + recoverySuggestion: malformedRecovery, + httpStatusCode: response.statusCode) + } + + if available { return } + + let trimmed = (json["message"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let unavailable = LocalizedString( + "The key-management server is temporarily unavailable.", + comment: "O5 fetch: keymanager-reported unavailable, no message") + let displayed = (trimmed?.isEmpty == false) ? trimmed! : unavailable + throw O5AuthError(message: displayed, httpStatusCode: response.statusCode) + } + + // MARK: - App Attest + + private func generateKey(_ service: DCAppAttestService) async throws -> String { + do { + return try await service.generateKey() + } catch { + throw O5AuthError(message: "Failed to generate App Attest key: \(error.localizedDescription)", underlyingError: error) + } + } + + private func attestKey(_ service: DCAppAttestService, keyId: String, clientDataHash: Data) async throws -> Data { + do { + return try await service.attestKey(keyId, clientDataHash: clientDataHash) + } catch { + throw O5AuthError(message: "App Attest attestation failed: \(error.localizedDescription)", underlyingError: error) + } + } + + // MARK: - App Identity + + private func getAppId() throws -> String { + guard let bundleId = Bundle.main.bundleIdentifier else { + throw O5AuthError(message: "Could not determine bundle identifier.") + } + guard let teamId = getTeamId() else { + throw O5AuthError(message: "Could not determine Team ID from provisioning profile.") + } + return "\(teamId).\(bundleId)" + } + + /// Resolves the Apple Team ID across environments. Tries, in order: + /// 1. The signed `embedded.mobileprovision` (real-device / TestFlight / App Store builds) + /// 2. The Keychain access-group prefix (works in simulator and on device whenever + /// the process has a code-signing identity — does not require a provisioning profile) + /// 3. An `OmnipodKitTeamIdentifier` key in OmnipodKit's Info.plist, populated from the + /// `$(DEVELOPMENT_TEAM)` build setting (last-ditch override for environments + /// where neither runtime source is available, e.g. unsigned test harnesses). + private func getTeamId() -> String? { + if let id = teamIdFromMobileProvision(), !id.isEmpty { return id } + if let id = teamIdFromKeychainAccessGroup(), !id.isEmpty { return id } + if let id = teamIdFromInfoPlist(), !id.isEmpty, !id.contains("$(") { return id } + return nil + } + + private func teamIdFromMobileProvision() -> String? { + guard let path = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision"), + let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { + return nil + } + + // The mobileprovision file is a CMS signed plist. Find the plist XML within it. + guard let plistString = String(data: data, encoding: .ascii), + let plistStart = plistString.range(of: "") else { + return nil + } + + let xml = String(plistString[plistStart.lowerBound...plistEnd.upperBound]) + guard let plistData = xml.data(using: .utf8), + let plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any], + let teamIds = plist["TeamIdentifier"] as? [String], + let teamId = teamIds.first else { + return nil + } + return teamId + } + + /// Adds (or finds) a probe Keychain item and reads its `kSecAttrAccessGroup`, + /// which iOS prefixes with the team ID: `.`. + private func teamIdFromKeychainAccessGroup() -> String? { + let probeAccount = "org.nightscout.o5.teamid-probe" + let probeService = "org.nightscout.o5.teamid-probe" + + let baseQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: probeAccount, + kSecAttrService as String: probeService, + ] + + var query = baseQuery + query[kSecReturnAttributes as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + var status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + var addQuery = baseQuery + addQuery[kSecValueData as String] = Data() + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + addQuery[kSecReturnAttributes as String] = true + status = SecItemAdd(addQuery as CFDictionary, &result) + } + + guard status == errSecSuccess, + let attrs = result as? [String: Any], + let accessGroup = attrs[kSecAttrAccessGroup as String] as? String, + let prefix = accessGroup.split(separator: ".").first + else { return nil } + + return String(prefix) + } + + /// Reads `OmnipodKitTeamIdentifier` from OmnipodKit's Info.plist. The xcconfig + /// substitutes `$(DEVELOPMENT_TEAM)` at build time. We read from the framework's + /// bundle (not `Bundle.main`) because the substitution happens in OmnipodKit's plist. + private func teamIdFromInfoPlist() -> String? { + let frameworkBundle = Bundle(for: O5AppAttestService.self) + return frameworkBundle.object(forInfoDictionaryKey: "OmnipodKitTeamIdentifier") as? String + } + + // MARK: - Server API + + private func getChallenge() async throws -> String { + var request = URLRequest(url: URL(string: "\(o5KeyManagerBaseURL)/api/auth/ios/challenge")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await performRequest(request) + + guard let json = parseJSON(data), + let challenge = json["challenge"] as? String + else { + throw authError(data: data, response: response, fallback: "Failed to get challenge.") + } + return challenge + } + + private func claimKeypair(attestation: Data, keyId: String, challenge: String, appId: String) async throws -> O5RegistrationData { + var request = URLRequest(url: URL(string: "\(o5KeyManagerBaseURL)/api/o5/keypair")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "attestation": attestation.base64EncodedString(), + "key_id": keyId, + "challenge": challenge, + "app_id": appId, + ]) + + let (data, response) = try await performRequest(request) + + guard let json = parseJSON(data), + let registrationData = O5RegistrationData.fromJSON(json) + else { + throw authError(data: data, response: response, fallback: "Failed to claim keypair.") + } + return registrationData + } + + // MARK: - Helpers + + private func performRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw O5AuthError(message: error.localizedDescription, underlyingError: error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw O5AuthError(message: "Invalid response from server.") + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw authError(data: data, response: httpResponse, fallback: "HTTP \(httpResponse.statusCode)") + } + + return (data, httpResponse) + } + + private func parseJSON(_ data: Data?) -> [String: Any]? { + guard let data = data else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + private func authError(data: Data?, response: URLResponse?, fallback: String) -> O5AuthError { + let statusCode = (response as? HTTPURLResponse)?.statusCode + let message: String + if let json = parseJSON(data) { + message = (json["message"] as? String) ?? (json["error"] as? String) ?? fallback + } else { + message = fallback + } + return O5AuthError(message: message, httpStatusCode: statusCode) + } +}