Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LibreLoop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,7 @@
repositoryURL = "https://github.com/airedev326/RoundWhiteDiscKit.git";
requirement = {
kind = revision;
revision = 0e076b2d9278fcd3067ede6d48c33523eda180fb;
revision = e66da6cc34680fecbbb87812279163dcc1a842b4;
};
};
/* End XCRemoteSwiftPackageReference section */
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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))
"""
Expand Down
3 changes: 1 addition & 2 deletions LibreLoop/Pairing/LibreLoopKeychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
235 changes: 70 additions & 165 deletions LibreLoop/Pairing/LibreLoopPairingService.swift

Large diffs are not rendered by default.

113 changes: 34 additions & 79 deletions LibreLoop/Pairing/LibreLoopRuntimeTables.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Error>?

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<Void, Error>?

func ensureInstalled() async throws {
if isInstalled() { return }
public static func ensureInstalled() async throws {
if AppIdentities.isInstalled { return }
let task: Task<Void, Error> = lock.withLock {
if let inFlight { return inFlight }
let task = Task {
Expand All @@ -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")
}
}
6 changes: 0 additions & 6 deletions LibreLoop/Sensor/LibreLoopSensorMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}


Expand Down
8 changes: 4 additions & 4 deletions LibreLoopUI/Views/LibreLoopRuntimeTablesView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion LibreLoopUI/Views/LibreLoopSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 0 additions & 7 deletions LibreLoopUI/Views/LibreLoopStreamDebugView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down