From 88232e5b3481ce37f81473ca40dcb0b5310067d5 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 6 Oct 2026 15:46:41 -0500 Subject: [PATCH 1/2] Use host-supplied app credentials; pick the sensor by its advertised address RoundWhiteDiscKit no longer carries runtime tables. LibreLoop now downloads the app credentials file from Arweave, verifies it against a pinned SHA-256, and installs it with AppIdentities.install(json:). Pairing scanned for the first peripheral advertising the Libre 3 service, so another Libre-family sensor nearby (e.g. a Lingo) could win and fail the handshake. A Libre 3 advertises its BLE address as its local name; match it against the address from the NFC response. Removes the app-identity pairing debug toggle and whitebox leftovers, and bumps RoundWhiteDiscKit to 4c940d2. --- LibreLoop.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../LibreLoopCGMManager.swift | 8 +- LibreLoop/Pairing/LibreLoopKeychain.swift | 3 +- .../Pairing/LibreLoopPairingService.swift | 235 ++++++------------ .../Pairing/LibreLoopRuntimeTables.swift | 113 +++------ LibreLoop/Sensor/LibreLoopSensorMonitor.swift | 6 - .../Views/LibreLoopRuntimeTablesView.swift | 8 +- LibreLoopUI/Views/LibreLoopSettingsView.swift | 2 +- .../Views/LibreLoopStreamDebugView.swift | 7 - 10 files changed, 116 insertions(+), 270 deletions(-) diff --git a/LibreLoop.xcodeproj/project.pbxproj b/LibreLoop.xcodeproj/project.pbxproj index 279930c..775877b 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 = 0e076b2d9278fcd3067ede6d48c33523eda180fb; + revision = 4c940d2266602d2ab0b18120da6867b332e38b50; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 43f730f..f32b5de 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" : "0e076b2d9278fcd3067ede6d48c33523eda180fb" + "revision" : "4c940d2266602d2ab0b18120da6867b332e38b50" } } ], diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift index e248ff8..19103b0 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift @@ -434,10 +434,11 @@ public final class LibreLoopCGMManager: CGMManager { udiDeviceIdentifier: nil) } - /// "app identity ROW", "whitebox", or nil when no session keys are saved. + /// Label of the app credential that paired, or nil when unknown. public var pairedWith: String? { - let keys = state.sensorSerial.flatMap { try? LibreLoopKeychain.load(forSensorSerial: $0) } - return keys.map { $0.appIdentity.map { "app identity \($0)" } ?? "whitebox" } + state.sensorSerial + .flatMap { try? LibreLoopKeychain.load(forSensorSerial: $0) }? + .appIdentity } public var debugDescription: String { @@ -448,7 +449,6 @@ public final class LibreLoopCGMManager: CGMManager { * securityVersion: \(state.securityVersion.map(String.init) ?? "unknown") * region: \(state.region.map(String.init) ?? "unknown") * appIdentitiesInstalled: \(AppIdentities.isInstalled) - * appIdentityPairingEnabled: \(LibreLoopDebugSettings.appIdentityPairingEnabled) * activatedAt: \(String(describing: state.activatedAt)) * latestReadingTimestamp: \(String(describing: state.latestReadingTimestamp)) """ diff --git a/LibreLoop/Pairing/LibreLoopKeychain.swift b/LibreLoop/Pairing/LibreLoopKeychain.swift index 0a18c96..34787ad 100644 --- a/LibreLoop/Pairing/LibreLoopKeychain.swift +++ b/LibreLoop/Pairing/LibreLoopKeychain.swift @@ -18,8 +18,7 @@ import Security /// 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. +/// the `AppIdentity` that paired. enum LibreLoopKeychain { private static let service = "org.loopkit.LibreLoop.sessionKeys" private static let v2Magic: UInt8 = 0x02 diff --git a/LibreLoop/Pairing/LibreLoopPairingService.swift b/LibreLoop/Pairing/LibreLoopPairingService.swift index bd6d35f..18f5198 100644 --- a/LibreLoop/Pairing/LibreLoopPairingService.swift +++ b/LibreLoop/Pairing/LibreLoopPairingService.swift @@ -26,7 +26,7 @@ public final class LibreLoopPairingService { /// 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. + /// standard-AES kAuth. public let appIdentity: String? } @@ -120,7 +120,7 @@ public final class LibreLoopPairingService { scanTimeout: TimeInterval = 120, onStage: @Sendable @escaping (Stage) -> Void = { _ in } ) async throws -> ReconnectOutcome { - try await Self.ensureRuntimeTables() + try await Self.ensureAppIdentities() onStage(.bleSearching) try await Self.awaitReady(scanner: scanner) @@ -221,12 +221,11 @@ 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")") + llog("reconnect: cached key (app credential \(appIdentity ?? "unknown"))") let cached = try await cachedFlow.runCachedReconnectHandshake( tail4: blePIN, phase5RawKey: phase5RawKey, - r2Provider: { try Self.secureRandomBytes(count: 16) }, - phase5Cipher: appIdentity == nil ? LibAES.phase5BlockEncryptor : AppIdentity.phase5Cipher + r2Provider: { try Self.secureRandomBytes(count: 16) } ) let material = cached.sessionMaterial let monitor = try LibreLoopSensorMonitor.make( @@ -244,35 +243,19 @@ public final class LibreLoopPairingService { ) } - let phoneCert = try Self.loadFirstPairCert() - let nativeEphemeral = try SessionKey.makeFirstPairNativeEphemeral( - entropySource: Self.secureRandomBytes(count:) - ) - let pairingFlow = PairingFlow( - transport: transport, - phoneCert: phoneCert, - phoneEph: nativeEphemeral.keyPair, - eventLogger: { message in llog(message) } - ) - - let handshake: FirstPairDerivedHandshakeResult + // No cached key: run the full app-credential handshake. + let identities = try await Self.appIdentities() + guard let identity = identities.first else { + throw Failure.underlying("No app credential available for reconnect") + } + let handshake: FirstPairHandshakeResult + let authKey: Data do { - handshake = try await pairingFlow.runCommandGatedFirstPairHandshake( - blePIN: blePIN, - maxEntropyAttempts: 1, - entropySource: { count in - guard count == nativeEphemeral.nullEntropy11A.count else { - throw Failure.underlying("Entropy size mismatch (\(count) vs \(nativeEphemeral.nullEntropy11A.count))") - } - return nativeEphemeral.nullEntropy11A - }, - r2Provider: { try Self.secureRandomBytes(count: 16) } - ) + (handshake, authKey) = try await Self.appIdentityHandshake(identity, session: session, blePIN: blePIN) } catch { throw Failure.underlying("Reconnect handshake failed: \(error.localizedDescription)") } - - let material = handshake.handshake.sessionMaterial + let material = handshake.sessionMaterial let monitor = try LibreLoopSensorMonitor.make( scanner: scanner, session: session, @@ -283,7 +266,7 @@ public final class LibreLoopPairingService { monitor: monitor, kEnc: material.kEnc, ivEnc: material.ivEnc, - phase5RawKey: handshake.phase5Material.rawKey, + phase5RawKey: authKey, path: .fullFallback ) } @@ -324,7 +307,7 @@ public final class LibreLoopPairingService { ) async throws -> PairOutcome { // Before NFC: activation starts the sensor's wear clock, so don't // activate one we couldn't then authorize. - try await Self.ensureRuntimeTables() + try await Self.ensureAppIdentities() // 1. NFC activation. Switch-receiver only succeeds when the receiverID // matches what the sensor remembers; pass-through here, the sensor @@ -403,7 +386,7 @@ public final class LibreLoopPairingService { onStage(.bleSearching) try await Self.awaitReady(scanner: scanner) - let sensor = try await Self.scanForAnyPeripheralNG(scanner: scanner, timeout: 120) + let sensor = try await Self.scanForSensorNG(scanner: scanner, bleAddress: activation.bleAddressDisplay, timeout: 120) onStage(.bleConnecting) func connect() async throws -> SensorSession { @@ -419,122 +402,53 @@ public final class LibreLoopPairingService { } 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") + // Handshake with a host-supplied app credential. Each is tried in turn; + // a certificate the sensor won't accept drops the link right after the + // cert load, so move on to the next. + onStage(.handshaking) + let patchInfo = scanResult.patchInfo + let identities = try await Self.appIdentities() + for (index, identity) in identities.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) + llog("paired with app credential \(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", _) { + llog("app credential \(identity.label): certificate rejected — sensor disconnected after receiving it") + } catch { + llog("app credential \(identity.label): rejected — \(error)") + } } - - // 3. Handshake -- candidate path with phone_cert_162b (03 03 family). - // - // phone_cert_firstpair (the 03 00 default) has known live-sensor - // rejection, so we use phone_cert_162b and follow the PoC's candidate - // Phase 5 flow: - // - native ephemeral derived via SessionKey.makeFirstPairNativeEphemeral - // - maxEntropyAttempts: 1 - // - Phase 5 entropy = nativeEphemeral.nullEntropy11A - onStage(.handshaking) - let transport = SensorSessionTransport(session: session) - let phoneCert = try Self.loadFirstPairCert() - let nativeEphemeral = try SessionKey.makeFirstPairNativeEphemeral( - entropySource: Self.secureRandomBytes(count:) - ) - let pairingFlow = PairingFlow( - transport: transport, - phoneCert: phoneCert, - phoneEph: nativeEphemeral.keyPair, - eventLogger: { message in llog(message) } - ) - - let handshake: FirstPairDerivedHandshakeResult - do { - handshake = try await pairingFlow.runCommandGatedFirstPairHandshake( - blePIN: activation.blePIN, - maxEntropyAttempts: 1, - entropySource: { requestedCount in - guard requestedCount == nativeEphemeral.nullEntropy11A.count else { - throw Failure.underlying( - "Entropy size mismatch (need \(requestedCount), have \(nativeEphemeral.nullEntropy11A.count))" - ) - } - return nativeEphemeral.nullEntropy11A - }, - r2Provider: { try Self.secureRandomBytes(count: 16) } - ) - } catch { - throw Failure.underlying("Pairing handshake failed: \(error.localizedDescription)") - } - - let material = handshake.handshake.sessionMaterial - let result = Result( - receiverID: receiverID, - sensorSerial: scanResult.patchInfo.serialNumber, - bleAddress: activation.bleAddressDisplay, - blePIN: activation.blePIN, - activatedAt: nfcResponse.activatedAt ?? Date(), - kEnc: material.kEnc, - ivEnc: material.ivEnc, - phase5RawKey: handshake.phase5Material.rawKey, - appIdentity: nil - ) - let monitor = try LibreLoopSensorMonitor.make( - scanner: scanner, - session: session, - kEnc: material.kEnc, - ivEnc: material.ivEnc - ) - return PairOutcome(result: result, monitor: monitor, peripheralID: sensor.id) + throw Failure.underlying("No app credential accepted by the sensor (securityVersion=\(patchInfo.securityVersion) region=\(patchInfo.region)).") } - private static func ensureRuntimeTables() async throws { + private static func ensureAppIdentities() async throws { do { - try await LibreLoopRuntimeTables.ensureInstalled() + try await LibreLoopAppIdentities.ensureInstalled() } catch { - throw Failure.underlying("Couldn't download the sensor data LibreLoop needs. Check your internet connection and try again. (\(error.localizedDescription))") + throw Failure.underlying("No app credential is configured. (\(error.localizedDescription))") } } @@ -666,13 +580,14 @@ public final class LibreLoopPairingService { return discovered.peripheral } - /// Like `scanForPeripheralNG` but returns the first discovery - /// regardless of UUID. Used by initial pair where we don't yet know - /// the peripheral identifier. - static func scanForAnyPeripheralNG( + /// A Libre 3 advertises its BLE address (from the NFC response) as its + /// local name; match on it so another sensor nearby can't win the scan. + static func scanForSensorNG( scanner: SensorScannerNG, + bleAddress: String, timeout: TimeInterval ) async throws -> DiscoveredSensor { + let expectedName = bleAddress.replacingOccurrences(of: ":", with: "").uppercased() scanner.startScan() defer { scanner.stopScan() } return try await withEventStream( @@ -680,8 +595,11 @@ public final class LibreLoopPairingService { timeout: timeout, timeoutError: Failure.bleNoSensorDiscovered ) { event in - if case .didDiscover(let d) = event { return .done(d) } - return .continue + guard case .didDiscover(let d) = event else { return .continue } + let name = d.advertisementData[CBAdvertisementDataLocalNameKey] ?? d.name + let matches = name?.uppercased() == expectedName + llog("ble: discovered \(d.id) name=\(name ?? "nil") rssi=\(d.rssi)\(matches ? "" : " (not \(expectedName))")") + return matches ? .done(d) : .continue } } @@ -788,21 +706,11 @@ public final class LibreLoopPairingService { return Data(buffer) } - /// 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 appIdentities() async throws -> [AppIdentity] { + try await ensureAppIdentities() + let identities = (try? AppIdentities.installed()) ?? [] + llog("app credentials: \(identities.count) available (\(identities.map(\.label).joined(separator: ", ")))") + return identities } private static func appIdentityHandshake( @@ -834,9 +742,6 @@ public final class LibreLoopPairingService { return (handshake, authKey) } - private static func loadFirstPairCert() throws -> PhoneCert { - try PhoneCert.bundled162b() - } } /// Reference cell for capturing which receiverID the NFC reader ended up diff --git a/LibreLoop/Pairing/LibreLoopRuntimeTables.swift b/LibreLoop/Pairing/LibreLoopRuntimeTables.swift index d237d64..8a71985 100644 --- a/LibreLoop/Pairing/LibreLoopRuntimeTables.swift +++ b/LibreLoop/Pairing/LibreLoopRuntimeTables.swift @@ -1,68 +1,22 @@ +import CryptoKit import Foundation import RoundWhiteDiscKit -/// RoundWhiteDiscKit ships without its lookup tables; they come from a blob -/// published on Arweave. It is downloaded once, kept in Application Support, -/// and installed before any pairing or authorization. -public enum LibreLoopRuntimeTables { - static let blob = ArweaveBlob( - name: "runtime tables", - transactionID: "8ran9sd_2k7dvzVPPRGs3sVq5mgP74UZ4A0V-mr2ag0", - fileName: "roundwhitedisckit-runtime-tables-v2.xz", - isInstalled: { RoundWhiteDiscKit.runtimeTablesInstalled }, - install: RoundWhiteDiscKit.installRuntimeTables - ) - - static var isInstalled: Bool { blob.isInstalled() } - - public static func ensureInstalled() async throws { - try await blob.ensureInstalled() - } -} - -/// 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 - ) - - public static func ensureInstalled() async throws { - 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 +/// The app credentials RoundWhiteDiscKit pairs with, published on Arweave as an +/// xz-compressed JSON file. Downloaded once, kept in Application Support, and +/// installed before any pairing or authorization. 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. +public enum LibreLoopAppIdentities { + static let transactionID = "qWbA3dXIHU2sU51oYtdYlouSfkCx-GczN2swLQb0upk" + static let expectedPayloadSHA256 = "ab18ce8bcb978526e102cc5e16440f06d2aee19426c26a15dd0013d66e9995ed" + /// Any gateway is safe: a payload whose digest doesn't match is rejected. 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 - } + private static let lock = NSLock() + nonisolated(unsafe) private static var inFlight: Task? - func ensureInstalled() async throws { - if isInstalled() { return } + public static func ensureInstalled() async throws { + if AppIdentities.isInstalled { return } let task: Task = lock.withLock { if let inFlight { return inFlight } let task = Task { @@ -75,51 +29,52 @@ final class ArweaveBlob: @unchecked Sendable { try await task.value } - private func install() async throws { + private static func install() async throws { if let saved = try? Data(contentsOf: fileURL) { do { try installBlob(saved) - llog("\(name): installed saved blob (\(saved.count) bytes)") + llog("app credentials: installed saved file (\(saved.count) bytes)") return } catch { - llog("\(name): saved blob rejected (\(error)); downloading again") + llog("app credentials: saved file 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 Self.gateways { + for gateway in gateways { guard let url = URL(string: "\(gateway)/\(transactionID)") else { continue } do { - let data = try await download(url) + let (data, response) = try await URLSession.shared.data(from: url) + if let http = response as? HTTPURLResponse, http.statusCode != 200 { + throw URLError(.badServerResponse) + } + try installBlob(data) try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: fileURL, options: .atomic) - llog("\(name): installed \(data.count) bytes from \(gateway)") + llog("app credentials: installed \(data.count) bytes from \(gateway)") return } catch { - llog("\(name): \(gateway) failed: \(error)") + llog("app credentials: \(gateway) failed: \(error)") lastError = error } } throw lastError } - 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 installBlob(data) - return data + private static func installBlob(_ blob: Data) throws { + let payload = try (blob as NSData).decompressed(using: .lzma) as Data + let digest = SHA256.hash(data: payload).map { String(format: "%02x", $0) }.joined() + guard digest == expectedPayloadSHA256 else { throw DigestMismatch(actual: digest) } + try AppIdentities.install(json: payload) + } + + struct DigestMismatch: Error { + let actual: String } - private var fileURL: URL { + private static var fileURL: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("LibreLoop", isDirectory: true) - .appendingPathComponent(fileName) + .appendingPathComponent("app-credentials-v1.xz") } } diff --git a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift index 353022f..fc3706a 100644 --- a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift +++ b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift @@ -16,12 +16,6 @@ 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/LibreLoopRuntimeTablesView.swift b/LibreLoopUI/Views/LibreLoopRuntimeTablesView.swift index e7aec3a..5f654e6 100644 --- a/LibreLoopUI/Views/LibreLoopRuntimeTablesView.swift +++ b/LibreLoopUI/Views/LibreLoopRuntimeTablesView.swift @@ -1,7 +1,7 @@ import SwiftUI import LibreLoop -/// First onboarding step: download the runtime tables once, before any sensor +/// First onboarding step: download the app credentials once, before any sensor /// is applied, so a missing download can't strand a freshly activated sensor. struct LibreLoopRuntimeTablesView: View { let onFinished: () -> Void @@ -16,12 +16,12 @@ struct LibreLoopRuntimeTablesView: View { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 44)) .foregroundStyle(.orange) - Text(LocalizedString("Couldn't download the sensor data LibreLoop needs. Check your internet connection and try again.", comment: "Runtime tables download failure message")) + Text(LocalizedString("Couldn't download the sensor data LibreLoop needs. Check your internet connection and try again.", comment: "App credentials download failure message")) .multilineTextAlignment(.center) .padding(.horizontal, 24) } else { ProgressView() - Text(LocalizedString("Downloading sensor data…", comment: "Shown while downloading the runtime tables during setup")) + Text(LocalizedString("Downloading sensor data…", comment: "Shown while downloading the app credentials during setup")) .foregroundStyle(.secondary) } Spacer() @@ -52,7 +52,7 @@ struct LibreLoopRuntimeTablesView: View { private func download() async { failed = false do { - try await LibreLoopRuntimeTables.ensureInstalled() + try await LibreLoopAppIdentities.ensureInstalled() onFinished() } catch { failed = true diff --git a/LibreLoopUI/Views/LibreLoopSettingsView.swift b/LibreLoopUI/Views/LibreLoopSettingsView.swift index 26960af..8a48e0b 100644 --- a/LibreLoopUI/Views/LibreLoopSettingsView.swift +++ b/LibreLoopUI/Views/LibreLoopSettingsView.swift @@ -188,7 +188,7 @@ struct LibreLoopSettingsView: View { .textSelection(.enabled) } if let pairedWith = viewModel.pairedWith { - LabeledContent(LocalizedString("Paired With", comment: "Row label: pairing method, an app identity or the whitebox"), value: pairedWith) + LabeledContent(LocalizedString("Paired With", comment: "Row label: the app credential the sensor was paired with"), value: pairedWith) .monospaced() .font(.footnote) .textSelection(.enabled) diff --git a/LibreLoopUI/Views/LibreLoopStreamDebugView.swift b/LibreLoopUI/Views/LibreLoopStreamDebugView.swift index 4ff5839..8b922d7 100644 --- a/LibreLoopUI/Views/LibreLoopStreamDebugView.swift +++ b/LibreLoopUI/Views/LibreLoopStreamDebugView.swift @@ -18,7 +18,6 @@ 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) @@ -38,12 +37,6 @@ 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 c707b03ee29c6e9636c49f978c2f3527b21b27a8 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 6 Oct 2026 15:52:50 -0500 Subject: [PATCH 2/2] Bump RoundWhiteDiscKit to e66da6c --- 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 775877b..16ef05b 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 = 4c940d2266602d2ab0b18120da6867b332e38b50; + revision = e66da6cc34680fecbbb87812279163dcc1a842b4; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f32b5de..aaea1a9 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" : "4c940d2266602d2ab0b18120da6867b332e38b50" + "revision" : "e66da6cc34680fecbbb87812279163dcc1a842b4" } } ],