Skip to content

Commit 150d2b1

Browse files
authored
Use host-supplied app credentials; pick the sensor by its advertised address (#18)
* 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. * Bump RoundWhiteDiscKit to e66da6c
1 parent cfd1e71 commit 150d2b1

10 files changed

Lines changed: 116 additions & 270 deletions

File tree

‎LibreLoop.xcodeproj/project.pbxproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1047,7 +1047,7 @@
10471047
repositoryURL = "https://github.com/airedev326/RoundWhiteDiscKit.git";
10481048
requirement = {
10491049
kind = revision;
1050-
revision = 0e076b2d9278fcd3067ede6d48c33523eda180fb;
1050+
revision = e66da6cc34680fecbbb87812279163dcc1a842b4;
10511051
};
10521052
};
10531053
/* End XCRemoteSwiftPackageReference section */

‎LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -434,10 +434,11 @@ public final class LibreLoopCGMManager: CGMManager {
434434
udiDeviceIdentifier: nil)
435435
}
436436

437-
/// "app identity ROW", "whitebox", or nil when no session keys are saved.
437+
/// Label of the app credential that paired, or nil when unknown.
438438
public var pairedWith: String? {
439-
let keys = state.sensorSerial.flatMap { try? LibreLoopKeychain.load(forSensorSerial: $0) }
440-
return keys.map { $0.appIdentity.map { "app identity \($0)" } ?? "whitebox" }
439+
state.sensorSerial
440+
.flatMap { try? LibreLoopKeychain.load(forSensorSerial: $0) }?
441+
.appIdentity
441442
}
442443

443444
public var debugDescription: String {
@@ -448,7 +449,6 @@ public final class LibreLoopCGMManager: CGMManager {
448449
* securityVersion: \(state.securityVersion.map(String.init) ?? "unknown")
449450
* region: \(state.region.map(String.init) ?? "unknown")
450451
* appIdentitiesInstalled: \(AppIdentities.isInstalled)
451-
* appIdentityPairingEnabled: \(LibreLoopDebugSettings.appIdentityPairingEnabled)
452452
* activatedAt: \(String(describing: state.activatedAt))
453453
* latestReadingTimestamp: \(String(describing: state.latestReadingTimestamp))
454454
"""

‎LibreLoop/Pairing/LibreLoopKeychain.swift‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ import Security
1818
/// receiver; needed to issue switchReceiver after
1919
/// a CGMManager rawState wipe.
2020
/// v4: v3 with `0x04` magic, followed by the UTF-8 label of
21-
/// the `AppIdentity` that paired. phase5RawKey is then
22-
/// a standard-AES kAuth rather than a whitebox key.
21+
/// the `AppIdentity` that paired.
2322
enum LibreLoopKeychain {
2423
private static let service = "org.loopkit.LibreLoop.sessionKeys"
2524
private static let v2Magic: UInt8 = 0x02

‎LibreLoop/Pairing/LibreLoopPairingService.swift‎

Lines changed: 70 additions & 165 deletions
Large diffs are not rendered by default.
Lines changed: 34 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,22 @@
1+
import CryptoKit
12
import Foundation
23
import RoundWhiteDiscKit
34

4-
/// RoundWhiteDiscKit ships without its lookup tables; they come from a blob
5-
/// published on Arweave. It is downloaded once, kept in Application Support,
6-
/// and installed before any pairing or authorization.
7-
public enum LibreLoopRuntimeTables {
8-
static let blob = ArweaveBlob(
9-
name: "runtime tables",
10-
transactionID: "8ran9sd_2k7dvzVPPRGs3sVq5mgP74UZ4A0V-mr2ag0",
11-
fileName: "roundwhitedisckit-runtime-tables-v2.xz",
12-
isInstalled: { RoundWhiteDiscKit.runtimeTablesInstalled },
13-
install: RoundWhiteDiscKit.installRuntimeTables
14-
)
15-
16-
static var isInstalled: Bool { blob.isInstalled() }
17-
18-
public static func ensureInstalled() async throws {
19-
try await blob.ensureInstalled()
20-
}
21-
}
22-
23-
/// The plain-key app identities used to pair without the whitebox, published
24-
/// the same way as the runtime tables.
25-
public enum LibreLoopAppIdentities {
26-
static let blob = ArweaveBlob(
27-
name: "app identities",
28-
transactionID: "9XxAxx8xSsr8uPNYPCycBjNSzVZFVpgxJsCoAo4OW1Y",
29-
fileName: "roundwhitedisckit-app-identities-v1.xz",
30-
isInstalled: { AppIdentities.isInstalled },
31-
install: AppIdentities.install
32-
)
33-
34-
public static func ensureInstalled() async throws {
35-
try await blob.ensureInstalled()
36-
}
37-
}
38-
39-
/// A blob on Arweave that RoundWhiteDiscKit verifies against a pinned digest.
40-
/// Downloaded once and kept in Application Support; concurrent callers share a
5+
/// The app credentials RoundWhiteDiscKit pairs with, published on Arweave as an
6+
/// xz-compressed JSON file. Downloaded once, kept in Application Support, and
7+
/// installed before any pairing or authorization. Concurrent callers share a
418
/// single attempt.
42-
final class ArweaveBlob: @unchecked Sendable {
43-
/// Any gateway is safe: the package rejects a blob whose digest doesn't match.
9+
public enum LibreLoopAppIdentities {
10+
static let transactionID = "qWbA3dXIHU2sU51oYtdYlouSfkCx-GczN2swLQb0upk"
11+
static let expectedPayloadSHA256 = "ab18ce8bcb978526e102cc5e16440f06d2aee19426c26a15dd0013d66e9995ed"
12+
/// Any gateway is safe: a payload whose digest doesn't match is rejected.
4413
static let gateways = ["https://arweave.net", "https://turbo-gateway.com"]
4514

46-
let name: String
47-
let transactionID: String
48-
let fileName: String
49-
let isInstalled: () -> Bool
50-
private let installBlob: (Data) throws -> Void
51-
52-
private let lock = NSLock()
53-
private var inFlight: Task<Void, Error>?
54-
55-
init(name: String, transactionID: String, fileName: String,
56-
isInstalled: @escaping () -> Bool, install: @escaping (Data) throws -> Void) {
57-
self.name = name
58-
self.transactionID = transactionID
59-
self.fileName = fileName
60-
self.isInstalled = isInstalled
61-
self.installBlob = install
62-
}
15+
private static let lock = NSLock()
16+
nonisolated(unsafe) private static var inFlight: Task<Void, Error>?
6317

64-
func ensureInstalled() async throws {
65-
if isInstalled() { return }
18+
public static func ensureInstalled() async throws {
19+
if AppIdentities.isInstalled { return }
6620
let task: Task<Void, Error> = lock.withLock {
6721
if let inFlight { return inFlight }
6822
let task = Task {
@@ -75,51 +29,52 @@ final class ArweaveBlob: @unchecked Sendable {
7529
try await task.value
7630
}
7731

78-
private func install() async throws {
32+
private static func install() async throws {
7933
if let saved = try? Data(contentsOf: fileURL) {
8034
do {
8135
try installBlob(saved)
82-
llog("\(name): installed saved blob (\(saved.count) bytes)")
36+
llog("app credentials: installed saved file (\(saved.count) bytes)")
8337
return
8438
} catch {
85-
llog("\(name): saved blob rejected (\(error)); downloading again")
39+
llog("app credentials: saved file rejected (\(error)); downloading again")
8640
}
8741
}
88-
guard !transactionID.isEmpty else {
89-
llog("\(name): no Arweave transaction configured")
90-
throw URLError(.badURL)
91-
}
9242

9343
var lastError: Error = URLError(.badURL)
94-
for gateway in Self.gateways {
44+
for gateway in gateways {
9545
guard let url = URL(string: "\(gateway)/\(transactionID)") else { continue }
9646
do {
97-
let data = try await download(url)
47+
let (data, response) = try await URLSession.shared.data(from: url)
48+
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
49+
throw URLError(.badServerResponse)
50+
}
51+
try installBlob(data)
9852
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
9953
try data.write(to: fileURL, options: .atomic)
100-
llog("\(name): installed \(data.count) bytes from \(gateway)")
54+
llog("app credentials: installed \(data.count) bytes from \(gateway)")
10155
return
10256
} catch {
103-
llog("\(name): \(gateway) failed: \(error)")
57+
llog("app credentials: \(gateway) failed: \(error)")
10458
lastError = error
10559
}
10660
}
10761
throw lastError
10862
}
10963

110-
private func download(_ url: URL) async throws -> Data {
111-
let (data, response) = try await URLSession.shared.data(from: url)
112-
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
113-
throw URLError(.badServerResponse)
114-
}
115-
// Verifies the payload against the digest pinned in RoundWhiteDiscKit.
116-
try installBlob(data)
117-
return data
64+
private static func installBlob(_ blob: Data) throws {
65+
let payload = try (blob as NSData).decompressed(using: .lzma) as Data
66+
let digest = SHA256.hash(data: payload).map { String(format: "%02x", $0) }.joined()
67+
guard digest == expectedPayloadSHA256 else { throw DigestMismatch(actual: digest) }
68+
try AppIdentities.install(json: payload)
69+
}
70+
71+
struct DigestMismatch: Error {
72+
let actual: String
11873
}
11974

120-
private var fileURL: URL {
75+
private static var fileURL: URL {
12176
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
12277
.appendingPathComponent("LibreLoop", isDirectory: true)
123-
.appendingPathComponent(fileName)
78+
.appendingPathComponent("app-credentials-v1.xz")
12479
}
12580
}

‎LibreLoop/Sensor/LibreLoopSensorMonitor.swift‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,6 @@ public enum LibreLoopDebugSettings {
1616
get { UserDefaults.standard.bool(forKey: continuousClinicalKey) }
1717
set { UserDefaults.standard.set(newValue, forKey: continuousClinicalKey) }
1818
}
19-
20-
/// Pair with a plain-key `AppIdentity` before falling back to the whitebox.
21-
public static let appIdentityPairingKey = "org.loopkit.LibreLoop.appIdentityPairing"
22-
public static var appIdentityPairingEnabled: Bool {
23-
UserDefaults.standard.object(forKey: appIdentityPairingKey) as? Bool ?? true
24-
}
2519
}
2620

2721

‎LibreLoopUI/Views/LibreLoopRuntimeTablesView.swift‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import SwiftUI
22
import LibreLoop
33

4-
/// First onboarding step: download the runtime tables once, before any sensor
4+
/// First onboarding step: download the app credentials once, before any sensor
55
/// is applied, so a missing download can't strand a freshly activated sensor.
66
struct LibreLoopRuntimeTablesView: View {
77
let onFinished: () -> Void
@@ -16,12 +16,12 @@ struct LibreLoopRuntimeTablesView: View {
1616
Image(systemName: "exclamationmark.triangle.fill")
1717
.font(.system(size: 44))
1818
.foregroundStyle(.orange)
19-
Text(LocalizedString("Couldn't download the sensor data LibreLoop needs. Check your internet connection and try again.", comment: "Runtime tables download failure message"))
19+
Text(LocalizedString("Couldn't download the sensor data LibreLoop needs. Check your internet connection and try again.", comment: "App credentials download failure message"))
2020
.multilineTextAlignment(.center)
2121
.padding(.horizontal, 24)
2222
} else {
2323
ProgressView()
24-
Text(LocalizedString("Downloading sensor data…", comment: "Shown while downloading the runtime tables during setup"))
24+
Text(LocalizedString("Downloading sensor data…", comment: "Shown while downloading the app credentials during setup"))
2525
.foregroundStyle(.secondary)
2626
}
2727
Spacer()
@@ -52,7 +52,7 @@ struct LibreLoopRuntimeTablesView: View {
5252
private func download() async {
5353
failed = false
5454
do {
55-
try await LibreLoopRuntimeTables.ensureInstalled()
55+
try await LibreLoopAppIdentities.ensureInstalled()
5656
onFinished()
5757
} catch {
5858
failed = true

‎LibreLoopUI/Views/LibreLoopSettingsView.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ struct LibreLoopSettingsView: View {
188188
.textSelection(.enabled)
189189
}
190190
if let pairedWith = viewModel.pairedWith {
191-
LabeledContent(LocalizedString("Paired With", comment: "Row label: pairing method, an app identity or the whitebox"), value: pairedWith)
191+
LabeledContent(LocalizedString("Paired With", comment: "Row label: the app credential the sensor was paired with"), value: pairedWith)
192192
.monospaced()
193193
.font(.footnote)
194194
.textSelection(.enabled)

‎LibreLoopUI/Views/LibreLoopStreamDebugView.swift‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import LibreLoop
1818
struct LibreLoopStreamDebugView: View {
1919
@StateObject private var viewModel: LibreLoopStreamDebugViewModel
2020
@AppStorage(LibreLoopDebugSettings.continuousClinicalKey) private var continuousClinical = false
21-
@AppStorage(LibreLoopDebugSettings.appIdentityPairingKey) private var appIdentityPairing = true
2221

2322
init(viewModel: LibreLoopStreamDebugViewModel) {
2423
_viewModel = StateObject(wrappedValue: viewModel)
@@ -38,12 +37,6 @@ struct LibreLoopStreamDebugView: View {
3837
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.")
3938
}
4039

41-
Section {
42-
Toggle("Pair with app identity", isOn: $appIdentityPairing)
43-
} footer: {
44-
Text("Tries the plain-key app identities before the whitebox when pairing. Takes effect on the next sensor scan.")
45-
}
46-
4740
glucoseSection
4841
rawSection
4942
noiseSection

0 commit comments

Comments
 (0)