From 81077b819ad5295e1c16fa535468efd6379bacc7 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 5 Oct 2026 15:33:23 -0500 Subject: [PATCH 1/2] Pair with plain-key app identities before the whitebox On a fresh pair, select an RoundWhiteDiscKit AppIdentity from the NFC patch info and run the first-pair handshake with it: its certificate, a random ephemeral, kAuth from plain ECDH, and standard AES for Phase 5/6. If the sensor refuses it, try the remaining identities, then fall back to the whitebox, all within the same NFC scan. The identities blob is downloaded from Arweave like the runtime tables; if it can't be fetched, pairing logs why and uses the whitebox. The Keychain record gains a v4 form carrying the identity label, so a cached reconnect uses the cipher that matches how its kAuth was made. For field reports: - Selection is logged with the raw patch info, and every rejection is logged. A certificate the sensor won't accept shows up as a disconnect right after SendCertificateLoadDone and is reported as such. Pairing on an identity other than the one selected is logged as GUESS WRONG. - Device-log lines from a first-time pair, made before Loop adopts the manager, are held and flushed when the delegate arrives instead of being dropped. Lines mentioning a rejection are logged as errors. - The Issue Report shows pairedWith, appIdentitiesInstalled and appIdentityPairingEnabled. The tables downloader becomes a shared ArweaveBlob used for both blobs. A debug toggle on the Glucose Streams screen turns the identity path off. Region 2 maps to ROW: a US-region Libre 3 Plus disconnected on the US certificate and paired with ROW. --- .../LibreLoopCGMManager.swift | 56 ++++++-- .../Pairing/LibreLoopCGMManager+Pairing.swift | 10 +- LibreLoop/Pairing/LibreLoopKeychain.swift | 16 ++- .../Pairing/LibreLoopPairingService.swift | 126 ++++++++++++++++-- .../Pairing/LibreLoopRuntimeTables.swift | 87 +++++++++--- LibreLoop/Sensor/LibreLoopSensorMonitor.swift | 6 + .../Views/LibreLoopStreamDebugView.swift | 7 + 7 files changed, 261 insertions(+), 47 deletions(-) diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift index 0cb6bd1..cf975d2 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift @@ -19,7 +19,14 @@ public final class LibreLoopCGMManager: CGMManager { public var pluginIdentifier: String { Self.pluginIdentifier } public func markAsDepedency(_ isDependency: Bool) {} - public weak var cgmManagerDelegate: CGMManagerDelegate? + public weak var cgmManagerDelegate: CGMManagerDelegate? { + didSet { flushPendingDeviceLog() } + } + + /// First-time pairing runs on a manager Loop hasn't adopted yet, so its + /// device-log lines are held until the delegate arrives. + private let pendingDeviceLogLock = NSLock() + private var pendingDeviceLog: [(identifier: String?, type: DeviceLogEntryType, message: String)] = [] public var delegateQueue: DispatchQueue! public internal(set) var state: LibreLoopCGMManagerState @@ -428,9 +435,14 @@ public final class LibreLoopCGMManager: CGMManager { } public var debugDescription: String { - """ + let keys = state.sensorSerial.flatMap { try? LibreLoopKeychain.load(forSensorSerial: $0) } + let pairing = keys.map { $0.appIdentity.map { "app identity \($0)" } ?? "whitebox" } ?? "unknown" + return """ ## LibreLoopCGMManager * sensorSerial: \(state.sensorSerial ?? "nil") + * pairedWith: \(pairing) + * appIdentitiesInstalled: \(AppIdentities.isInstalled) + * appIdentityPairingEnabled: \(LibreLoopDebugSettings.appIdentityPairingEnabled) * activatedAt: \(String(describing: state.activatedAt)) * latestReadingTimestamp: \(String(describing: state.latestReadingTimestamp)) """ @@ -647,7 +659,7 @@ public final class LibreLoopCGMManager: CGMManager { guard let self else { return } let lower = message.lowercased() let type: DeviceLogEntryType - if lower.contains("error") || lower.contains("fail") { + if lower.contains("error") || lower.contains("fail") || lower.contains("reject") { type = .error } else if lower.contains("connect") || lower.contains("disconnect") { type = .connection @@ -655,18 +667,42 @@ public final class LibreLoopCGMManager: CGMManager { type = .receive } let identifier = self.state.sensorSerial - let forward = { [weak self] in - guard let self else { return } - self.cgmManagerDelegate?.deviceManager(self, logEventForDeviceIdentifier: identifier, type: type, message: message, completion: nil) + let held: Bool = self.pendingDeviceLogLock.withLock { + guard self.cgmManagerDelegate == nil else { return false } + if self.pendingDeviceLog.count < 1000 { + self.pendingDeviceLog.append((identifier, type, message)) + } + return true } - if let queue = self.delegateQueue { - queue.async(execute: forward) - } else { - forward() + if !held { + self.forwardDeviceLog(identifier: identifier, type: type, message: message) } } } + private func flushPendingDeviceLog() { + guard cgmManagerDelegate != nil else { return } + let pending = pendingDeviceLogLock.withLock { + defer { pendingDeviceLog.removeAll() } + return pendingDeviceLog + } + for entry in pending { + forwardDeviceLog(identifier: entry.identifier, type: entry.type, message: entry.message) + } + } + + private func forwardDeviceLog(identifier: String?, type: DeviceLogEntryType, message: String) { + let forward = { [weak self] in + guard let self else { return } + self.cgmManagerDelegate?.deviceManager(self, logEventForDeviceIdentifier: identifier, type: type, message: message, completion: nil) + } + if let queue = delegateQueue { + queue.async(execute: forward) + } else { + forward() + } + } + private func observeAppLifecycle() { let nc = NotificationCenter.default let names: [(Notification.Name, String)] = [ diff --git a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift index a93cea1..149b07c 100644 --- a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift +++ b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift @@ -106,7 +106,8 @@ extension LibreLoopCGMManager { kEnc: outcome.result.kEnc, ivEnc: outcome.result.ivEnc, phase5RawKey: outcome.result.phase5RawKey, - receiverID: Self.receiverIDFromState(state.receiverID) + receiverID: Self.receiverIDFromState(state.receiverID), + appIdentity: outcome.result.appIdentity ), forSensorSerial: outcome.result.sensorSerial ) @@ -994,7 +995,8 @@ extension LibreLoopCGMManager { } // Pull whatever we previously persisted, but reconnect can still run // if Keychain has nothing — it'll just use the full handshake path. - let cachedPhase5 = (try? LibreLoopKeychain.load(forSensorSerial: serial))?.phase5RawKey + let cachedKeys = try? LibreLoopKeychain.load(forSensorSerial: serial) + let cachedPhase5 = cachedKeys?.phase5RawKey if cachedPhase5 != nil { llog("reconnect: cached phase5RawKey available; will try fast path first") } else { @@ -1005,6 +1007,7 @@ extension LibreLoopCGMManager { scanner: scanner, blePIN: blePIN, phase5RawKey: cachedPhase5, + appIdentity: cachedKeys?.appIdentity, expectedPeripheralID: expectedPeripheral ) { [weak self] stage in llog("reconnect stage: \(String(describing: stage))") @@ -1018,7 +1021,8 @@ extension LibreLoopCGMManager { kEnc: outcome.kEnc, ivEnc: outcome.ivEnc, phase5RawKey: phase5ToPersist, - receiverID: Self.receiverIDFromState(state.receiverID) + receiverID: Self.receiverIDFromState(state.receiverID), + appIdentity: outcome.phase5RawKey == nil ? cachedKeys?.appIdentity : nil ), forSensorSerial: serial ) diff --git a/LibreLoop/Pairing/LibreLoopKeychain.swift b/LibreLoop/Pairing/LibreLoopKeychain.swift index fa5420c..0a18c96 100644 --- a/LibreLoop/Pairing/LibreLoopKeychain.swift +++ b/LibreLoop/Pairing/LibreLoopKeychain.swift @@ -17,10 +17,14 @@ import Security /// is what the sensor remembers as its current /// receiver; needed to issue switchReceiver after /// a CGMManager rawState wipe. +/// v4: v3 with `0x04` magic, followed by the UTF-8 label of +/// the `AppIdentity` that paired. phase5RawKey is then +/// a standard-AES kAuth rather than a whitebox key. enum LibreLoopKeychain { private static let service = "org.loopkit.LibreLoop.sessionKeys" private static let v2Magic: UInt8 = 0x02 private static let v3Magic: UInt8 = 0x03 + private static let v4Magic: UInt8 = 0x04 struct SessionKeys: Equatable { let kEnc: Data @@ -34,6 +38,7 @@ enum LibreLoopKeychain { /// after a rawState wipe by issuing switchReceiver with the stored /// ID. Nil for sensors paired before this field was persisted. let receiverID: UInt32? + var appIdentity: String? = nil } static func save(_ keys: SessionKeys, forSensorSerial serial: String) throws { @@ -45,7 +50,11 @@ enum LibreLoopKeychain { ridLE[1] = UInt8(truncatingIfNeeded: receiverID >> 8) ridLE[2] = UInt8(truncatingIfNeeded: receiverID >> 16) ridLE[3] = UInt8(truncatingIfNeeded: receiverID >> 24) - payload = Data([v3Magic]) + keys.kEnc + keys.ivEnc + phase5RawKey + ridLE + if let label = keys.appIdentity { + payload = Data([v4Magic]) + keys.kEnc + keys.ivEnc + phase5RawKey + ridLE + Data(label.utf8) + } else { + payload = Data([v3Magic]) + keys.kEnc + keys.ivEnc + phase5RawKey + ridLE + } } else { payload = Data([v2Magic]) + keys.kEnc + keys.ivEnc + phase5RawKey } @@ -83,7 +92,7 @@ enum LibreLoopKeychain { guard status == errSecSuccess, let data = item as? Data else { throw LibreLoopKeychainError.osStatus(status) } - if data.count == 45 && data.first == v3Magic { + if (data.count == 45 && data.first == v3Magic) || (data.count > 45 && data.first == v4Magic) { // v3: 0x03 || kEnc(16) || ivEnc(8) || phase5RawKey(16) || receiverID(4 LE) let kEnc = data.subdata(in: 1..<17) let ivEnc = data.subdata(in: 17..<25) @@ -93,7 +102,8 @@ enum LibreLoopKeychain { | (UInt32(ridLE[1]) << 8) | (UInt32(ridLE[2]) << 16) | (UInt32(ridLE[3]) << 24) - return SessionKeys(kEnc: kEnc, ivEnc: ivEnc, phase5RawKey: phase5RawKey, receiverID: receiverID) + let label = data.first == v4Magic ? String(decoding: data.suffix(from: data.startIndex + 45), as: UTF8.self) : nil + return SessionKeys(kEnc: kEnc, ivEnc: ivEnc, phase5RawKey: phase5RawKey, receiverID: receiverID, appIdentity: label) } if data.count == 41 && data.first == v2Magic { // v2: 0x02 || kEnc(16) || ivEnc(8) || phase5RawKey(16) diff --git a/LibreLoop/Pairing/LibreLoopPairingService.swift b/LibreLoop/Pairing/LibreLoopPairingService.swift index 56c62e1..8c64026 100644 --- a/LibreLoop/Pairing/LibreLoopPairingService.swift +++ b/LibreLoop/Pairing/LibreLoopPairingService.swift @@ -25,6 +25,9 @@ public final class LibreLoopPairingService { /// callers must persist this alongside kEnc/ivEnc. Nil only on /// legacy/cached reconnect outcomes where the value isn't re-derived. public let phase5RawKey: Data? + /// Label of the `AppIdentity` that paired, making `phase5RawKey` a + /// standard-AES kAuth. Nil when the whitebox path paired. + public let appIdentity: String? } /// Intermediate state captured immediately after a successful NFC @@ -110,6 +113,7 @@ public final class LibreLoopPairingService { scanner: SensorScannerNG, blePIN: Data, phase5RawKey: Data? = nil, + appIdentity: String? = nil, expectedPeripheralID: UUID? = nil, scanTimeout: TimeInterval = 120, onStage: @Sendable @escaping (Stage) -> Void = { _ in } @@ -215,10 +219,12 @@ public final class LibreLoopPairingService { // persisted), whose only option it is. if let phase5RawKey { let cachedFlow = PairingFlow(transport: transport, eventLogger: { message in llog(message) }) + llog("reconnect: cached key from \(appIdentity.map { "app identity \($0), standard AES" } ?? "whitebox")") let cached = try await cachedFlow.runCachedReconnectHandshake( tail4: blePIN, phase5RawKey: phase5RawKey, - r2Provider: { try Self.secureRandomBytes(count: 16) } + r2Provider: { try Self.secureRandomBytes(count: 16) }, + phase5Cipher: appIdentity == nil ? LibAES.phase5BlockEncryptor : AppIdentity.phase5Cipher ) let material = cached.sessionMaterial let monitor = try LibreLoopSensorMonitor.make( @@ -396,15 +402,67 @@ public final class LibreLoopPairingService { let sensor = try await Self.scanForAnyPeripheralNG(scanner: scanner, timeout: 120) onStage(.bleConnecting) - let session: SensorSession - do { - session = try await Self.connectAndBuildSession( - scanner: scanner, - peripheral: sensor.peripheral, - timeout: 120 - ) - } catch { - throw Failure.underlying("BLE connection failed: \(error.localizedDescription)") + func connect() async throws -> SensorSession { + do { + return try await Self.connectAndBuildSession( + scanner: scanner, + peripheral: sensor.peripheral, + timeout: 120 + ) + } catch { + throw Failure.underlying("BLE connection failed: \(error.localizedDescription)") + } + } + var session = try await connect() + + // 3a. Handshake with a plain-key app identity, chosen from the NFC + // patch info. Each identity is tried in turn, then the whitebox below. + if LibreLoopDebugSettings.appIdentityPairingEnabled { + onStage(.handshaking) + let patchInfo = scanResult.patchInfo + let attempts = await Self.appIdentityAttempts(for: patchInfo) + for (index, identity) in attempts.enumerated() { + if index > 0 { + scanner.cancelConnection(sensor.peripheral) + session = try await connect() + } + do { + let (handshake, authKey) = try await Self.appIdentityHandshake(identity, session: session, blePIN: activation.blePIN) + if index > 0 { + llog("app identity: GUESS WRONG — sensor securityVersion=\(patchInfo.securityVersion) region=\(patchInfo.region) paired with \(identity.label), not \(attempts[0].label)") + } + llog("app identity: paired with \(identity.label)") + let material = handshake.sessionMaterial + let result = Result( + receiverID: receiverID, + sensorSerial: patchInfo.serialNumber, + bleAddress: activation.bleAddressDisplay, + blePIN: activation.blePIN, + activatedAt: nfcResponse.activatedAt ?? Date(), + kEnc: material.kEnc, + ivEnc: material.ivEnc, + phase5RawKey: authKey, + appIdentity: identity.label + ) + let monitor = try LibreLoopSensorMonitor.make( + scanner: scanner, + session: session, + kEnc: material.kEnc, + ivEnc: material.ivEnc + ) + return PairOutcome(result: result, monitor: monitor, peripheralID: sensor.id) + } catch PairingFlowError.writeTimeout(label: "SendCertificateLoadDone", _) { + // The sensor drops the link on a certificate it won't accept. + llog("app identity: \(identity.label) certificate rejected — sensor disconnected after receiving it") + } catch { + llog("app identity: \(identity.label) rejected — \(error)") + } + } + if !attempts.isEmpty { + llog("app identity: no identity accepted for securityVersion=\(patchInfo.securityVersion) region=\(patchInfo.region); falling back to whitebox") + scanner.cancelConnection(sensor.peripheral) + session = try await connect() + } } // 3. Handshake -- candidate path with phone_cert_162b (03 03 family). @@ -456,7 +514,8 @@ public final class LibreLoopPairingService { activatedAt: nfcResponse.activatedAt ?? Date(), kEnc: material.kEnc, ivEnc: material.ivEnc, - phase5RawKey: handshake.phase5Material.rawKey + phase5RawKey: handshake.phase5Material.rawKey, + appIdentity: nil ) let monitor = try LibreLoopSensorMonitor.make( scanner: scanner, @@ -726,6 +785,51 @@ public final class LibreLoopPairingService { } /// The 03 03 first-pair cert, from the installed runtime tables. + /// The selected identity first, then the rest; empty if none load. + private static func appIdentityAttempts(for patchInfo: Libre3NFCPatchInfo) async -> [AppIdentity] { + do { + try await LibreLoopAppIdentities.ensureInstalled() + let identities = try AppIdentities.installed() + let selection = try AppIdentities.select(from: identities, for: patchInfo) + let how = selection.matched ? "matched" : "NO MATCH, using default" + let raw = patchInfo.raw.map { String(format: "%02x", $0) }.joined() + llog("app identity: productType=\(patchInfo.productType) securityVersion=\(patchInfo.securityVersion) region=\(patchInfo.region) -> \(selection.identity.label) (\(how)) patchInfo=\(raw)") + return [selection.identity] + identities.filter { $0.label != selection.identity.label } + } catch { + llog("app identity: unavailable (\(error)); whitebox only") + return [] + } + } + + private static func appIdentityHandshake( + _ identity: AppIdentity, + session: SensorSession, + blePIN: Data + ) async throws -> (FirstPairHandshakeResult, authKey: Data) { + let phoneEph = EphemeralKeyPair() + let flow = PairingFlow( + transport: SensorSessionTransport(session: session), + phoneCert: identity.certificate, + phoneEph: phoneEph, + eventLogger: { message in llog(message) } + ) + var authKey = Data() + let handshake = try await flow.runCommandGatedFirstPairHandshake( + blePIN: blePIN, + phase5RawKeyProvider: { preamble in + authKey = try identity.authKey( + phoneEphemeral: phoneEph.privateKey, + sensorEphemeral: preamble.phaseHandshake.sensorEphPub, + sensorStatic: EphemeralExchange.parsePeerPubkey(preamble.phaseHandshake.sensorCert.staticPub) + ) + return authKey + }, + r2Provider: { try secureRandomBytes(count: 16) }, + phase5Cipher: AppIdentity.phase5Cipher + ) + return (handshake, authKey) + } + private static func loadFirstPairCert() throws -> PhoneCert { try PhoneCert.bundled162b() } diff --git a/LibreLoop/Pairing/LibreLoopRuntimeTables.swift b/LibreLoop/Pairing/LibreLoopRuntimeTables.swift index 0af1a82..d237d64 100644 --- a/LibreLoop/Pairing/LibreLoopRuntimeTables.swift +++ b/LibreLoop/Pairing/LibreLoopRuntimeTables.swift @@ -5,22 +5,64 @@ import RoundWhiteDiscKit /// published on Arweave. It is downloaded once, kept in Application Support, /// and installed before any pairing or authorization. public enum LibreLoopRuntimeTables { - static let transactionID = "8ran9sd_2k7dvzVPPRGs3sVq5mgP74UZ4A0V-mr2ag0" + static let blob = ArweaveBlob( + name: "runtime tables", + transactionID: "8ran9sd_2k7dvzVPPRGs3sVq5mgP74UZ4A0V-mr2ag0", + fileName: "roundwhitedisckit-runtime-tables-v2.xz", + isInstalled: { RoundWhiteDiscKit.runtimeTablesInstalled }, + install: RoundWhiteDiscKit.installRuntimeTables + ) - /// Any gateway is safe: the package rejects a blob whose digest doesn't match. - static let gateways = ["https://arweave.net", "https://turbo-gateway.com"] + static var isInstalled: Bool { blob.isInstalled() } - static var isInstalled: Bool { - RoundWhiteDiscKit.runtimeTablesInstalled + public static func ensureInstalled() async throws { + try await blob.ensureInstalled() } +} - private static let lock = NSLock() - private static var inFlight: Task? +/// The plain-key app identities used to pair without the whitebox, published +/// the same way as the runtime tables. +public enum LibreLoopAppIdentities { + static let blob = ArweaveBlob( + name: "app identities", + transactionID: "9XxAxx8xSsr8uPNYPCycBjNSzVZFVpgxJsCoAo4OW1Y", + fileName: "roundwhitedisckit-app-identities-v1.xz", + isInstalled: { AppIdentities.isInstalled }, + install: AppIdentities.install + ) - /// Installs the saved blob, downloading and saving it first if there isn't - /// one. Concurrent callers share a single attempt. public static func ensureInstalled() async throws { - if isInstalled { return } + try await blob.ensureInstalled() + } +} + +/// A blob on Arweave that RoundWhiteDiscKit verifies against a pinned digest. +/// Downloaded once and kept in Application Support; concurrent callers share a +/// single attempt. +final class ArweaveBlob: @unchecked Sendable { + /// Any gateway is safe: the package rejects a blob whose digest doesn't match. + static let gateways = ["https://arweave.net", "https://turbo-gateway.com"] + + let name: String + let transactionID: String + let fileName: String + let isInstalled: () -> Bool + private let installBlob: (Data) throws -> Void + + private let lock = NSLock() + private var inFlight: Task? + + init(name: String, transactionID: String, fileName: String, + isInstalled: @escaping () -> Bool, install: @escaping (Data) throws -> Void) { + self.name = name + self.transactionID = transactionID + self.fileName = fileName + self.isInstalled = isInstalled + self.installBlob = install + } + + func ensureInstalled() async throws { + if isInstalled() { return } let task: Task = lock.withLock { if let inFlight { return inFlight } let task = Task { @@ -33,46 +75,51 @@ public enum LibreLoopRuntimeTables { try await task.value } - private static func install() async throws { + private func install() async throws { if let saved = try? Data(contentsOf: fileURL) { do { - try RoundWhiteDiscKit.installRuntimeTables(saved) + try installBlob(saved) + llog("\(name): installed saved blob (\(saved.count) bytes)") return } catch { - llog("runtime tables: saved blob rejected (\(error)); downloading again") + llog("\(name): saved blob rejected (\(error)); downloading again") } } + guard !transactionID.isEmpty else { + llog("\(name): no Arweave transaction configured") + throw URLError(.badURL) + } var lastError: Error = URLError(.badURL) - for gateway in gateways { + for gateway in Self.gateways { guard let url = URL(string: "\(gateway)/\(transactionID)") else { continue } do { let data = try await download(url) try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: fileURL, options: .atomic) - llog("runtime tables: installed \(data.count) bytes from \(gateway)") + llog("\(name): installed \(data.count) bytes from \(gateway)") return } catch { - llog("runtime tables: \(gateway) failed: \(error)") + llog("\(name): \(gateway) failed: \(error)") lastError = error } } throw lastError } - private static func download(_ url: URL) async throws -> Data { + private func download(_ url: URL) async throws -> Data { let (data, response) = try await URLSession.shared.data(from: url) if let http = response as? HTTPURLResponse, http.statusCode != 200 { throw URLError(.badServerResponse) } // Verifies the payload against the digest pinned in RoundWhiteDiscKit. - try RoundWhiteDiscKit.installRuntimeTables(data) + try installBlob(data) return data } - private static var fileURL: URL { + private var fileURL: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("LibreLoop", isDirectory: true) - .appendingPathComponent("roundwhitedisckit-runtime-tables-v2.xz") + .appendingPathComponent(fileName) } } diff --git a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift index fc3706a..353022f 100644 --- a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift +++ b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift @@ -16,6 +16,12 @@ public enum LibreLoopDebugSettings { get { UserDefaults.standard.bool(forKey: continuousClinicalKey) } set { UserDefaults.standard.set(newValue, forKey: continuousClinicalKey) } } + + /// Pair with a plain-key `AppIdentity` before falling back to the whitebox. + public static let appIdentityPairingKey = "org.loopkit.LibreLoop.appIdentityPairing" + public static var appIdentityPairingEnabled: Bool { + UserDefaults.standard.object(forKey: appIdentityPairingKey) as? Bool ?? true + } } diff --git a/LibreLoopUI/Views/LibreLoopStreamDebugView.swift b/LibreLoopUI/Views/LibreLoopStreamDebugView.swift index 8b922d7..4ff5839 100644 --- a/LibreLoopUI/Views/LibreLoopStreamDebugView.swift +++ b/LibreLoopUI/Views/LibreLoopStreamDebugView.swift @@ -18,6 +18,7 @@ import LibreLoop struct LibreLoopStreamDebugView: View { @StateObject private var viewModel: LibreLoopStreamDebugViewModel @AppStorage(LibreLoopDebugSettings.continuousClinicalKey) private var continuousClinical = false + @AppStorage(LibreLoopDebugSettings.appIdentityPairingKey) private var appIdentityPairing = true init(viewModel: LibreLoopStreamDebugViewModel) { _viewModel = StateObject(wrappedValue: viewModel) @@ -37,6 +38,12 @@ struct LibreLoopStreamDebugView: View { Text("Keeps the clinical channel subscribed so the clinical/raw charts update every minute. Off by default — only needed for stream inspection, and it adds per-reconnect clinical traffic. Takes effect on the next reconnect.") } + Section { + Toggle("Pair with app identity", isOn: $appIdentityPairing) + } footer: { + Text("Tries the plain-key app identities before the whitebox when pairing. Takes effect on the next sensor scan.") + } + glucoseSection rawSection noiseSection From 892d2359cec97da507dd8ed95301a9f8cb47ab00 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 5 Oct 2026 15:58:24 -0500 Subject: [PATCH 2/2] Pin RoundWhiteDiscKit to 0e076b2, which adds AppIdentities The standalone LibreLoop project resolves RoundWhiteDiscKit by exact revision; dccf013 predates the app-identity API this branch uses. --- LibreLoop.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LibreLoop.xcodeproj/project.pbxproj b/LibreLoop.xcodeproj/project.pbxproj index 0ff0cf6..279930c 100644 --- a/LibreLoop.xcodeproj/project.pbxproj +++ b/LibreLoop.xcodeproj/project.pbxproj @@ -1047,7 +1047,7 @@ repositoryURL = "https://github.com/airedev326/RoundWhiteDiscKit.git"; requirement = { kind = revision; - revision = dccf013622cc6f6fa67751894d5255e09dc3b2a6; + revision = 0e076b2d9278fcd3067ede6d48c33523eda180fb; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d33c678..43f730f 100644 --- a/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/airedev326/RoundWhiteDiscKit.git", "state" : { - "revision" : "dccf013622cc6f6fa67751894d5255e09dc3b2a6" + "revision" : "0e076b2d9278fcd3067ede6d48c33523eda180fb" } } ],