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 = dccf013622cc6f6fa67751894d5255e09dc3b2a6;
revision = 0e076b2d9278fcd3067ede6d48c33523eda180fb;
};
};
/* End XCRemoteSwiftPackageReference section */
Expand Down

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

56 changes: 46 additions & 10 deletions LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
"""
Expand Down Expand Up @@ -647,26 +659,50 @@ 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
} else {
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)] = [
Expand Down
10 changes: 7 additions & 3 deletions LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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 {
Expand All @@ -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))")
Expand All @@ -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
)
Expand Down
16 changes: 13 additions & 3 deletions LibreLoop/Pairing/LibreLoopKeychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
126 changes: 115 additions & 11 deletions LibreLoop/Pairing/LibreLoopPairingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}
Expand Down
Loading