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 acfe5d3..c7a72ac 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -130,6 +130,10 @@ } } }, + "%@" : { + "comment" : "A label displaying an error message.", + "isCommentAutoGenerated" : true + }, "%@ ago" : { "comment" : "Format string for last status date on pod details screen", "localizations" : { @@ -413,6 +417,9 @@ } } }, + "Attesting with Apple…" : { + "comment" : "O5 fetch progress: attestation" + }, "Auto-off" : { "comment" : "Description for auto-off alert", "localizations" : { @@ -578,8 +585,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", "localizations" : { "fr" : { "stringUnit" : { @@ -680,6 +690,9 @@ "Check pod, apply to site, then confirm pod attachment." : { "comment" : "Label text for step three of attach pod instructions" }, + "Checking device support…" : { + "comment" : "O5 fetch progress: device support" + }, "Checking Insertion" : { "comment" : "Insert cannula action button accessibility label checking insertion", "localizations" : { @@ -831,7 +844,7 @@ "comment" : "Text for Confirm Pod Type button on PodTypeSelection" }, "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", "localizations" : { "fr" : { "stringUnit" : { @@ -999,6 +1012,9 @@ } } }, + "Downloading certificate…" : { + "comment" : "O5 fetch progress: download" + }, "Empty reservoir" : { "comment" : "Description for Empty reservoir pod fault", "localizations" : { @@ -1140,6 +1156,17 @@ } } }, + "Failed at step %d of %d: %@" : { + "comment" : "Format for fetch failure with step number", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed at step %1$d of %2$d: %3$@" + } + } + } + }, "Failed to Cancel Manual Basal" : { "comment" : "Alert title for failing to cancel manual basal error", "localizations" : { @@ -1248,6 +1275,9 @@ "Faulted" : { "comment" : "Label for Faulted row\ndescription label for faulted pod details row" }, + "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" }, @@ -1309,6 +1339,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)", "localizations" : { @@ -1320,6 +1356,9 @@ } } }, + "Have an '.o5keypair' file to use instead?" : { + "comment" : "Link to import an o5keypair file" + }, "hour" : { "comment" : "Unit for singular hour in pod life remaining", "localizations" : { @@ -1353,6 +1392,12 @@ } } }, + "Import .o5keypair file" : { + "comment" : "Toolbar action to import an o5keypair file" + }, + "Imported (.o5keypair file)" : { + "comment" : "O5 cert source: imported" + }, "Incorrect Response" : { "comment" : "Error message description for PeripheralManagerError.incorrectResponse", "localizations" : { @@ -1857,6 +1902,9 @@ "No certificate found" : { "comment" : "Error message when no certificate found" }, + "No certificates loaded." : { + "comment" : "Empty state for the Pod Certificate Details view" + }, "No confidence reminders are used." : { "comment" : "Description for BeepPreference.silent", "localizations" : { @@ -2108,9 +2156,15 @@ "Omnipod 5" : { "comment" : "Title string for Omnipod 5" }, + "Omnipod 5 Keys" : { + "comment" : "Title for O5 key fetch view" + }, "Omnipod 5 Pods have a clear needle tab with a 12-character LOT number typically starting with 'PH1'. The Pod's \"SmartAdjust\" technology will not be used for closed loop control." : { "comment" : "Description for Omnipod 5 pods" }, + "Omnipod 5 Setup" : { + "comment" : "Title for the Omnipod 5 key setup screen" + }, "Omnipod Classic" : { "comment" : "Title string for Omnipod Classic" }, @@ -2448,6 +2502,9 @@ } } }, + "Pod Certificate Details" : { + "comment" : "Text for pod certificate details navigation link\nnavigation title for pod certificate details" + }, "Pod deactivated successfully. Continue." : { "comment" : "Deactivate pod action button accessibility label when deactivation complete", "localizations" : { @@ -2905,6 +2962,9 @@ } } }, + "Ready to connect to an Omnipod 5 pod." : { + "comment" : "Description when O5 keypairs are available" + }, "Rebuild app with needed certificate data" : { "comment" : "Recovery suggestion with missing certificate" }, @@ -2966,6 +3026,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", "localizations" : { @@ -3044,7 +3110,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", "localizations" : { "fr" : { "stringUnit" : { @@ -3318,6 +3384,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", "localizations" : { @@ -3546,6 +3626,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", "localizations" : { @@ -3943,6 +4026,9 @@ } } }, + "We need to briefly connect to the internet to download a certificate in order to pair Omnipod 5 pods. An internet connection won't be required after you complete this one-time step." : { + "comment" : "Description when O5 keypairs are not available" + }, "Yes" : { "comment" : "Button label for user to answer cannula was properly inserted", "localizations" : { @@ -4017,6 +4103,9 @@ } } }, + "You will be unable to pair to 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" }, diff --git a/OmnipodKit.xcodeproj/project.pbxproj b/OmnipodKit.xcodeproj/project.pbxproj index 1ec8595..dd408a7 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/PumpManagerUI/ViewControllers/OmniUICoordinator.swift b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift index b39aed7..2968507 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,27 @@ 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?.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) @@ -388,14 +412,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,7 +505,7 @@ 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 @@ -523,8 +562,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/Views/O5KeyFetchView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift new file mode 100644 index 0000000..fb8c311 --- /dev/null +++ b/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift @@ -0,0 +1,105 @@ +// +// 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 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) + if let step = currentStep { + Text(String(format: LocalizedString("Failed at step %d of %d: %@", + comment: "Format for fetch failure with step number"), + step.index, + O5KeyFetchProgress.totalSteps, + step.localizedDescription)) + .foregroundColor(.secondary) + .font(.footnote) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + Text("\(errorMessage)") + .foregroundColor(.red) + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.horizontal) + + 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 + 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 + } + } + ) + } +} diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift new file mode 100644 index 0000000..01f32fd --- /dev/null +++ b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift @@ -0,0 +1,129 @@ +// +// O5KeySetupView.swift +// OmnipodKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import UniformTypeIdentifiers +import LoopKit +import LoopKitUI + +struct O5KeySetupView: View { + + @Environment(\.appName) private var appName + + @State private var o5KeypairsNotAvailable: Bool + @State private var showingFetchSheet = false + @State private var showingFileImporter = false + @State private var fileImportError: String? + 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("We need to briefly connect to the internet to download a certificate in order to pair Omnipod 5 pods. An internet connection won't be required after you complete this one-time step.", 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 connect to an Omnipod 5 pod.", comment: "Description when O5 keypairs are available")) + } + .padding(.vertical, 4) + } + } + + if o5KeypairsNotAvailable { + Section { + Button(action: { showingFileImporter = true }) { + Text(LocalizedString("Have an '.o5keypair' file to use instead?", comment: "Link to import an o5keypair file")) + } + + if let fileImportError = fileImportError { + Text(fileImportError) + .foregroundColor(.red) + .font(.subheadline) + } + } + } + } + .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() + }) + } + } + .fileImporter(isPresented: $showingFileImporter, allowedContentTypes: [.json, .item]) { result in + fileImportError = nil + 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 { + fileImportError = 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) + o5KeypairsNotAvailable = false + case .failure(let error): + fileImportError = error.localizedDescription + } + } + .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 Keys", 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/PodCertificatesView.swift b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift new file mode 100644 index 0000000..3f8831e --- /dev/null +++ b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift @@ -0,0 +1,205 @@ +// +// PodCertificatesView.swift +// OmnipodKit +// +// Lists the loaded O5 certificates one row per controller; tapping a row +// navigates to a per-certificate detail view that holds the destructive +// "Forget Saved Certificate" action. 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 } + } + + @State private var rows: [Row] = [] + @State private var showingFileImporter = false + @State private var importError: String? + + private let title = LocalizedString("Pod Certificate Details", comment: "navigation title for pod certificate details") + + var body: some View { + List { + if rows.isEmpty { + Section { + Text(LocalizedString("No certificates loaded.", comment: "Empty state for the Pod Certificate Details view")) + .foregroundColor(.secondary) + } + } else { + ForEach(rows) { row in + NavigationLink(destination: PodCertificateDetailView( + data: row.data, + source: row.source, + 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) + } + } + } + } + + if let importError { + Section { + Text(importError) + .foregroundColor(.red) + .font(.subheadline) + } + } + } + .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) + } + .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 in the diagnostics screen + // 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 onForgotten: () -> Void + + @Environment(\.presentationMode) private var presentationMode + @State private var pendingForget = false + + private let confirmMessage = LocalizedString( + "You will be unable to pair to 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 { + List { + Section { + Text(dump()) + .font(Font.system(size: 12).monospaced()) + .textSelection(.enabled) + } + + if source != .builtIn { + Section { + Button(role: .destructive) { + pendingForget = true + } label: { + Text(LocalizedString("Forget Saved Certificate", comment: "Destructive button to remove a saved O5 certificate")) + } + } + } + } + .insetGroupedListStyle() + .navigationTitle(String(format: "Controller 0x%08X", data.controllerId)) + .navigationBarTitleDisplayMode(.inline) + .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: data.controllerId) + O5RegistrationData.remove(controllerId: data.controllerId) + onForgotten() + presentationMode.wrappedValue.dismiss() + } + + private func dump() -> String { + var lines: [String] = [] + lines.append("## O5RegistrationData") + lines.append("* source: \(label(for: source))") + lines.append(String(format: "* controllerId: %u (0x%08X)", data.controllerId, data.controllerId)) + lines.append("* privateKey: \(data.privateKeyHex)") + lines.append("* publicKey: \(data.publicKeyHex)") + lines.append("* intermediateCA: \(data.intermediateCABase64)") + lines.append("* tlsCertificate: \(data.tlsCertificateBase64)") + return lines.joined(separator: "\n") + } + + private func label(for 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..df24f31 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift @@ -96,6 +96,11 @@ struct PodDiagnosticsView: View { FrameworkLocalText("Pump Manager Details", comment: "Text for pump manager details navigation link") .foregroundColor(Color.primary) } + + NavigationLink(destination: PodCertificatesView()) { + FrameworkLocalText("Pod Certificate Details", comment: "Text for pod certificate 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..83617cc --- /dev/null +++ b/OmnipodKit/Services/O5AppAttestService.swift @@ -0,0 +1,308 @@ +// +// O5AppAttestService.swift +// OmnipodKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import CryptoKit +import DeviceCheck + +let o5KeyManagerBaseURL = "https://api.osaid-keymanager.org" + +struct O5AuthError: Error { + let message: String + let httpStatusCode: Int? + let underlyingError: Error? + + init(message: String, httpStatusCode: Int? = nil, underlyingError: Error? = nil) { + self.message = message + 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 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 .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(.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: - 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) + } +}