From 886a6420e993df57a56bfe32904031fc3f9b12d3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 10 Oct 2026 22:37:27 -0500 Subject: [PATCH] Identify genuine vs simulated G7 sensors from their identity certificate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real G7 proves itself during pairing: it streams an X.509 P-256 certificate chain and signs the key challenge (ECDSA r‖s over SHA256(nonce)) with its leaf key. Loop discarded both. This validates them and records whether the sensor is a genuine Dexcom, the g7sim development emulator, or could not be verified. - G7SensorIdentity: SecTrust-pins the Dexcom sensor intermediate (DEX01PG2) and the emulator's dummy CA as trust anchors, and verifies the challenge signature against the leaf with CryptoKit. Chains to Dexcom → genuine, to the dummy CA → simulated, else unverified. - G7Authenticator captures the leaf cert, nonce, and signature during both pairing and session auth and evaluates authenticity; the verdict is threaded through the pairing path and session path into state and persisted across reconnects. - Settings shows a critical "Simulated Sensor" warning for the emulator, tappable to hide (resets on the next pairing) for development screenshots. - Every reading's HKDevice reports a "g7sim (Simulator)" manufacturer and model and drops Dexcom's UDI when simulated, so data synced to HealthKit and onward is never mistaken for a genuine G7. Validation never blocks a reading: an unrecognized sensor is treated as real. Six unit tests cover the verdicts against a real DXCMTR capture and the emulator's own certificate. --- G7SensorKit.xcodeproj/project.pbxproj | 8 ++ G7SensorKit/Crypto/G7SensorIdentity.swift | 115 ++++++++++++++++++ .../G7CGMManager/G7Authenticator.swift | 46 +++++-- G7SensorKit/G7CGMManager/G7CGMManager.swift | 26 +++- .../G7CGMManager/G7CGMManagerState.swift | 15 +++ G7SensorKit/G7CGMManager/G7Sensor.swift | 5 +- G7SensorKit/Pairing/G7PairingService.swift | 8 +- G7SensorKitTests/G7SensorIdentityTests.swift | 77 ++++++++++++ .../G7SessionModeMigrationTests.swift | 6 +- .../G7CGMManager/G7UICoordinator.swift | 7 +- .../ViewModels/G7PairingViewModel.swift | 12 +- G7SensorKitUI/Views/G7SettingsView.swift | 22 ++++ G7SensorKitUI/Views/G7SettingsViewModel.swift | 9 ++ 13 files changed, 326 insertions(+), 30 deletions(-) create mode 100644 G7SensorKit/Crypto/G7SensorIdentity.swift create mode 100644 G7SensorKitTests/G7SensorIdentityTests.swift diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index 2518453..6695356 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 69AE26EEA16A596AA47DF83A /* G7DexcomAppWarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ED0ADBD1E5197198A0B215 /* G7DexcomAppWarningView.swift */; }; 6F0232BD04ED7FD3626E0D52 /* G7ShareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA54130B258935826428FB7C /* G7ShareTests.swift */; }; 70DD3285A1241531F66439E3 /* G7DexcomCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31D1424E75ED6303B966EF89 /* G7DexcomCredentials.swift */; }; + FB2B6214AAB554E3EE99A456 /* G7SensorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DD3001208B28CF3DE59FB6 /* G7SensorIdentity.swift */; }; 72A6F9D68F93AE44814C3C3D /* G7StartupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090FCA4FBE539EA494492C16 /* G7StartupView.swift */; }; 74F6EF71F734A96B0DB47F52 /* G7PeripheralManager+Handshake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6180916611D0990A46355025 /* G7PeripheralManager+Handshake.swift */; }; 77A93A0B05D5ADEDF217770B /* G7PairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7CEC5E1884364C2F6DCCF4 /* G7PairingView.swift */; }; @@ -111,6 +112,7 @@ EE80FBB62E9FF65D5946BBD7 /* G7ChallengeSigner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A771C4DCFFA924E278E352 /* G7ChallengeSigner.swift */; }; EF41639FF51B727324A04A49 /* G7NotificationPermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BAC6D6BA20FCF904FA1465 /* G7NotificationPermissionsView.swift */; }; F5F8174E48028D9B111B8C59 /* G7JPAKETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6888359A74F9F636FBDCA8BB /* G7JPAKETests.swift */; }; + CE1322361390E1AAD4E94A46 /* G7SensorIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 051E43BB8C8DAC1C57549FF0 /* G7SensorIdentityTests.swift */; }; F998BB980898C160A2440473 /* G7AuthCryptoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A7A4E8FF66BE03B02B2E01 /* G7AuthCryptoTests.swift */; }; FD0DF29C00B05DECD53A4E2C /* G7CalibrationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF238DACCD930310363D3EB5 /* G7CalibrationMessage.swift */; }; /* End PBXBuildFile section */ @@ -173,6 +175,7 @@ 21F6EDAE3E7F2B5FF3E87922 /* G7PairingPlannerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PairingPlannerTests.swift; sourceTree = ""; }; 2A301AE5854C83E5BD9C6539 /* G7SensorModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SensorModel.swift; sourceTree = ""; }; 31D1424E75ED6303B966EF89 /* G7DexcomCredentials.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7DexcomCredentials.swift; sourceTree = ""; }; + E9DD3001208B28CF3DE59FB6 /* G7SensorIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SensorIdentity.swift; sourceTree = ""; }; 324F8C967DDB24EB158A1A45 /* G7PCert.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PCert.swift; sourceTree = ""; }; 3B0FD2A42D803BF000E5E921 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 43AE2890ABEAE0FB78B276F1 /* G7SensorPackageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SensorPackageTests.swift; sourceTree = ""; }; @@ -185,6 +188,7 @@ 64134474341A32F8DFDA266F /* G7P256Tests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7P256Tests.swift; sourceTree = ""; }; 641A515C9511A961C9C88318 /* G7ShareUploader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7ShareUploader.swift; sourceTree = ""; }; 6888359A74F9F636FBDCA8BB /* G7JPAKETests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7JPAKETests.swift; sourceTree = ""; }; + 051E43BB8C8DAC1C57549FF0 /* G7SensorIdentityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SensorIdentityTests.swift; sourceTree = ""; }; 6C50AD3678079467EE110CD1 /* G7AES.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7AES.swift; sourceTree = ""; }; 7028829033065F083529D6A8 /* G7PairingPlanner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PairingPlanner.swift; sourceTree = ""; }; 724358361262BEA3A8F375AA /* G7LifecycleAlertTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7LifecycleAlertTests.swift; sourceTree = ""; }; @@ -324,6 +328,7 @@ 6C50AD3678079467EE110CD1 /* G7AES.swift */, B1A771C4DCFFA924E278E352 /* G7ChallengeSigner.swift */, 31D1424E75ED6303B966EF89 /* G7DexcomCredentials.swift */, + E9DD3001208B28CF3DE59FB6 /* G7SensorIdentity.swift */, 63E50F6710E2494A0E14A6D8 /* G7JPAKE.swift */, 324F8C967DDB24EB158A1A45 /* G7PCert.swift */, ); @@ -423,6 +428,7 @@ 64134474341A32F8DFDA266F /* G7P256Tests.swift */, 94A7A4E8FF66BE03B02B2E01 /* G7AuthCryptoTests.swift */, 6888359A74F9F636FBDCA8BB /* G7JPAKETests.swift */, + 051E43BB8C8DAC1C57549FF0 /* G7SensorIdentityTests.swift */, 588CB2A449236A6A47F4579B /* G7SessionModeMigrationTests.swift */, 742542EDF25C573BA2E8B09A /* G7AdvertisementTests.swift */, 21F6EDAE3E7F2B5FF3E87922 /* G7PairingPlannerTests.swift */, @@ -818,6 +824,7 @@ 2ED1C8E18DD717A8FB98CD3C /* G7AES.swift in Sources */, EE80FBB62E9FF65D5946BBD7 /* G7ChallengeSigner.swift in Sources */, 70DD3285A1241531F66439E3 /* G7DexcomCredentials.swift in Sources */, + FB2B6214AAB554E3EE99A456 /* G7SensorIdentity.swift in Sources */, 8BB9F821830FA00C1939FFE3 /* G7JPAKE.swift in Sources */, E18DCCF3754672CBF129B139 /* G7PCert.swift in Sources */, 576A7AA8FC9457FE0F540A86 /* G7Authenticator.swift in Sources */, @@ -852,6 +859,7 @@ 8C80F065A36E33C97219C570 /* G7P256Tests.swift in Sources */, F998BB980898C160A2440473 /* G7AuthCryptoTests.swift in Sources */, F5F8174E48028D9B111B8C59 /* G7JPAKETests.swift in Sources */, + CE1322361390E1AAD4E94A46 /* G7SensorIdentityTests.swift in Sources */, D3DA1DF230DBADA4CB37F401 /* G7SessionModeMigrationTests.swift in Sources */, D2F6C2D8999636C3D4F31DB1 /* G7AdvertisementTests.swift in Sources */, 97F1FB097E42B4F376FA206F /* G7PairingPlannerTests.swift in Sources */, diff --git a/G7SensorKit/Crypto/G7SensorIdentity.swift b/G7SensorKit/Crypto/G7SensorIdentity.swift new file mode 100644 index 0000000..bce4402 --- /dev/null +++ b/G7SensorKit/Crypto/G7SensorIdentity.swift @@ -0,0 +1,115 @@ +// +// G7SensorIdentity.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import CryptoKit +import Foundation +import Security + +/// Whether the sensor on the other end of a direct connection is a genuine +/// Dexcom sensor, the g7sim development emulator, or could not be verified. +/// +/// A real G7 proves itself during pairing: it streams an X.509 P-256 +/// certificate chain whose leaf is signed by Dexcom's sensor intermediate +/// `DEX01PG2`, and signs the display's key-challenge nonce with that leaf key +/// (ECDSA-P256 over SHA256(nonce)). The g7sim emulator mirrors this with its +/// own published CA, so the two are distinguishable. This is a safety marker — +/// it never blocks a connection, and `.unverified` is treated like a real +/// sensor (older sensors, or a capture we cannot parse, simply are not +/// flagged). The emulator's key is public, so `.simulated` only ever errs +/// toward "this is simulated data", never the reverse for a real sensor. +public enum G7SensorAuthenticity: String, Equatable { + case genuine + case simulated + case unverified +} + +enum G7SensorIdentity { + + /// Dexcom's sensor intermediate (`CN=DEX01PG2`), captured from a real G7 on + /// 2026-10-10. A genuine sensor's leaf is signed by this. + static let dexcomSensorAnchor = Data(hexadecimalString: [ + "308201e93082018fa00302010202145bb833870d8ff2cb8bfb33c39c1ba24f96", + "c8b9cd300a06082a8648ce3d04030230133111300f06035504030c0844455830", + "30504731301e170d3233303931333137323830355a170d333830393039313732", + "3830355a30133111300f06035504030c0844455830315047323059301306072a", + "8648ce3d020106082a8648ce3d0301070342000432959b9acf8602090669f3be", + "8834790b9635ccf48bcc92e072c0bad63ba823cd5a84d4e9ea415d3ed64efabd", + "b3a04f9c4ba723af4a751d020ce745700ad6c4a8a381c03081bd300f0603551d", + "130101ff040530030101ff301f0603551d230418301680149e0f1e36f3f276a7", + "01fe8e883a6e26a635bd6afc305a0603551d1f04533051304fa034a032863068", + "7474703a2f2f63726c2e64702e736161732e7072696d656b65792e636f6d2f63", + "726c2f44455830305047312e63726ca217a41530133111300f06035504030c08", + "4445583030504731301d0603551d0e04160414d0d56feb7d94f4535aa0cadd41", + "283536cb3aa2bf300e0603551d0f0101ff040403020186300a06082a8648ce3d", + "0403020348003045022100d023f2223574705f40ab3d04a658a367392167c35c", + "c22be4e7788c1de0c370350220532ef909c220944665bd9ac13cf0f4980d7b1f", + "5bea1f2ca0de1c63be876ae566" + ].joined())! + + /// The g7sim emulator's CA. Its signing key is public (a development + /// marker), so this proves "simulated", not authenticity. + static let emulatorAnchor = Data(hexadecimalString: [ + "308201573081fda003020102020101300a06082a8648ce3d0403023013311130", + "0f06035504031308473753494d2d4341301e170d323630313031303030303030", + "5a170d3436303130313030303030305a30133111300f06035504031308473753", + "494d2d43413059301306072a8648ce3d020106082a8648ce3d03010703420004", + "4ebb9d03cece8ddd5d96d6a350615ea4b6e4ca6817bee3bac0938fb9045da5a9", + "cfda605269304703d2ebc1658d791be44831a3691dc27b3e83af202ed94ac673", + "a3423040300e0603551d0f0101ff040403020284300f0603551d130101ff0405", + "30030101ff301d0603551d0e0416041461e808e54c75334680ca4d6a2642a4c1", + "3ed34aed300a06082a8648ce3d0403020349003046022100d432d52df4713636", + "ecff04699d54cdce48946a0ed30dec1ab65dea4e22317b2c022100aecf28201b", + "e53e58221a1623de554be8a2b5c71e9497a6177fb8c3edf4871027" + ].joined())! + + /// Classifies a sensor from its leaf certificate, its 64-byte key-challenge + /// signature (raw r‖s), and the nonce the display sent in the `0C` request. + /// Never throws; anything it cannot verify is `.unverified`. + static func authenticity(leafCertificate: Data, challengeSignature: Data, nonce: Data) -> G7SensorAuthenticity { + guard challengeSignature.count == 64, + let leaf = SecCertificateCreateWithData(nil, leafCertificate as CFData), + let leafKey = SecCertificateCopyKey(leaf), + let leafPublicKey = SecKeyCopyExternalRepresentation(leafKey, nil) as Data?, + let publicKey = try? P256.Signing.PublicKey(x963Representation: leafPublicKey), + let signature = try? P256.Signing.ECDSASignature(rawRepresentation: challengeSignature) + else { + return .unverified + } + + // The sensor must have signed our nonce with the leaf key: proof it + // holds the private key of the certificate it presented. + guard publicKey.isValidSignature(signature, for: nonce) else { + return .unverified + } + + if chains(leaf, toAnchor: dexcomSensorAnchor) { + return .genuine + } + if chains(leaf, toAnchor: emulatorAnchor) { + return .simulated + } + return .unverified + } + + /// Whether `leaf` is signed by the single pinned `anchor` (used as the only + /// trust anchor, so neither system roots nor the sensor's own streamed + /// intermediate can substitute for it). + private static func chains(_ leaf: SecCertificate, toAnchor anchorDER: Data) -> Bool { + guard let anchor = SecCertificateCreateWithData(nil, anchorDER as CFData) else { + return false + } + var trust: SecTrust? + guard SecTrustCreateWithCertificates([leaf, anchor] as CFArray, SecPolicyCreateBasicX509(), &trust) == errSecSuccess, + let trust = trust + else { + return false + } + SecTrustSetAnchorCertificates(trust, [anchor] as CFArray) + SecTrustSetAnchorCertificatesOnly(trust, true) + return SecTrustEvaluateWithError(trust, nil) + } +} diff --git a/G7SensorKit/G7CGMManager/G7Authenticator.swift b/G7SensorKit/G7CGMManager/G7Authenticator.swift index ae11aca..e256465 100644 --- a/G7SensorKit/G7CGMManager/G7Authenticator.swift +++ b/G7SensorKit/G7CGMManager/G7Authenticator.swift @@ -111,6 +111,10 @@ final class G7Authenticator { /// Whether this run performed the EC-JPAKE exchange (as opposed to /// reusing a stored key). let didExchangeKeys: Bool + /// Whether the sensor proved itself genuine Dexcom, the g7sim emulator, + /// or could not be verified. Only meaningful after a fresh pairing (a + /// reconnect does not re-exchange certificates); `.unverified` then. + let authenticity: G7SensorAuthenticity } /// Per-step response deadline for a reconnect. Short: the link is up and @@ -134,6 +138,13 @@ final class G7Authenticator { /// Which of the sensor's display slots this client takes. private let displayType: G7DisplayType + /// Captured during a fresh pairing to classify the sensor afterwards: its + /// leaf certificate, the nonce we sent in the key challenge, and the + /// signature it returned. + private var sensorLeafCertificate: Data? + private var keyChallengeNonce: Data? + private var keyChallengeSignature: Data? + /// Receives a short description of each step as it happens, for the /// in-app device communication log. A tester reporting "it got stuck" /// is only diagnosable if these are visible outside Xcode. Never carries @@ -226,7 +237,7 @@ final class G7Authenticator { // pairing; so do we. if !didExchangeKeys, status.isAuthenticated, status.isBonded { report("Already authenticated and bonded") - return Result(sharedKey: sharedKey, deviceName: peripheral.peripheral.name, didExchangeKeys: didExchangeKeys) + return Result(sharedKey: sharedKey, deviceName: peripheral.peripheral.name, didExchangeKeys: didExchangeKeys, authenticity: .unverified) } if !didExchangeKeys { @@ -244,13 +255,27 @@ final class G7Authenticator { try answerKeyChallenge(peripheral, authentication: authentication) try finalizeAndBond(peripheral, authentication: authentication) + let authenticity = evaluateAuthenticity() + report("Sensor authenticity: \(authenticity.rawValue)") return Result( sharedKey: sharedKey, deviceName: peripheral.peripheral.name, - didExchangeKeys: didExchangeKeys + didExchangeKeys: didExchangeKeys, + authenticity: authenticity ) } + /// Classifies the sensor from what it presented during this fresh pairing. + private func evaluateAuthenticity() -> G7SensorAuthenticity { + guard let leaf = sensorLeafCertificate, + let nonce = keyChallengeNonce, + let signature = keyChallengeSignature + else { + return .unverified + } + return G7SensorIdentity.authenticity(leafCertificate: leaf, challengeSignature: signature, nonce: nonce) + } + // MARK: - EC-JPAKE private func exchangeKeys(_ peripheral: G7PeripheralManager) throws -> Data { @@ -389,6 +414,11 @@ final class G7Authenticator { } let theirs = try waitForCertificateBytes(peripheral, buffer: buffer, count: expectedLength, step: step) + // Index 1 is the sensor's leaf certificate (index 0 is the issuing + // intermediate), which classifies the sensor after the handshake. + if index == 1 { + sensorLeafCertificate = theirs + } report("\(step): received \(theirs.count) bytes, sending ours (\(certificate.count) bytes)") try peripheral.writeCertificateBytes(certificate) } @@ -400,8 +430,10 @@ final class G7Authenticator { report("\(step): sending a nonce") let buffer = installCertificateBuffer(peripheral) + let nonce = G7JPAKE.secureRandomBytes(16) + keyChallengeNonce = nonce try peripheral.writeValue( - Data([0x0C]) + G7JPAKE.secureRandomBytes(16), + Data([0x0C]) + nonce, for: .authentication, type: .withResponse ) @@ -412,10 +444,10 @@ final class G7Authenticator { throw G7AuthenticatorError.unexpectedResponse(step: step, response: acknowledgement) } - // The sensor's own 64-byte block arrives alongside. We do not verify - // it: the sensor is checking us, not the other way round, and the - // signing key it would be checked against is the one we just sent. - _ = try waitForCertificateBytes(peripheral, buffer: buffer, count: 64, step: step) + // The sensor's own 64-byte block arrives alongside: an ECDSA-P256 + // signature over SHA256(our nonce) with its leaf key, which classifies + // the sensor (G7SensorIdentity). It is not needed to complete pairing. + keyChallengeSignature = try waitForCertificateBytes(peripheral, buffer: buffer, count: 64, step: step) let signature = try G7ChallengeSigner.sign(challengeAcknowledgement: acknowledgement) report("\(step): sending our signature") diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index d7947cf..375cf65 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -324,7 +324,7 @@ public class G7CGMManager: CGMManager { /// keeps its identity, readings and alerts and only the mode changes. /// Otherwise the current sensor is closed out and kept as the previous /// one, and the new sensor's identity is learned from its first reading. - public func applyPairingResult(pairingCode: String, peripheralIdentifier: UUID?, sharedKey: Data?, handoff: G7PairingHandoff? = nil, sensorName: String? = nil) { + public func applyPairingResult(pairingCode: String, peripheralIdentifier: UUID?, sharedKey: Data?, authenticity: G7SensorAuthenticity = .unverified, handoff: G7PairingHandoff? = nil, sensorName: String? = nil) { let pairedName = sensorName ?? handoff?.peripheralManager.peripheral.name let isSameSensor: Bool if let pairedName = pairedName, let currentID = state.sensorID, G7Sensor.isSensorName(pairedName) { @@ -352,6 +352,8 @@ public class G7CGMManager: CGMManager { state.pairingCode = pairingCode state.peripheralIdentifier = peripheralIdentifier state.sharedKey = sharedKey + state.sensorAuthenticity = authenticity + state.simulatedSensorWarningSuppressed = false state.lastAuthenticationFailure = nil state.lastAuthenticationFailureDate = nil if !isSameSensor { @@ -488,18 +490,29 @@ public class G7CGMManager: CGMManager { } private var device: HKDevice? { + // A simulated sensor must be unmistakable wherever a reading lands — + // HealthKit and anything syncing from it, like Nightscout — so the + // emulator is never taken for a real Dexcom. We mark the manufacturer + // and model, and drop Dexcom's real UDI rather than claim it. + let simulated = state.sensorAuthenticity == .simulated return HKDevice( name: state.sensorID ?? "Unknown", - manufacturer: "Dexcom", - model: sensorModel.displayName, + manufacturer: simulated ? "g7sim (Simulator)" : "Dexcom", + model: simulated ? "\(sensorModel.displayName) Simulator" : sensorModel.displayName, hardwareVersion: nil, firmwareVersion: state.transmitterVersion?.firmwareVersion, softwareVersion: "CGMBLEKit" + String(G7SensorKitVersionNumber), localIdentifier: nil, - udiDeviceIdentifier: "00386270001863" + udiDeviceIdentifier: simulated ? nil : "00386270001863" ) } + /// Hide the simulated-sensor settings warning. For development + /// screenshots; the per-reading HealthKit metadata stays marked. + public func suppressSimulatedSensorWarning() { + mutateState { $0.simulatedSensorWarningSuppressed = true } + } + public func logDeviceCommunication(_ message: String, type: DeviceLogEntryType = .send) { self.cgmManagerDelegate?.deviceManager(self, logEventForDeviceIdentifier: state.sensorID, type: type, message: message, completion: nil) } @@ -696,10 +709,11 @@ extension G7CGMManager: G7SensorDelegate { return shouldSwitchToNewSensor } - public func sensor(_ sensor: G7Sensor, didAuthenticateWith sharedKey: Data, deviceName: String?) { - logDeviceCommunication("Authenticated with the sensor directly.", type: .connection) + public func sensor(_ sensor: G7Sensor, didAuthenticateWith sharedKey: Data, deviceName: String?, authenticity: G7SensorAuthenticity) { + logDeviceCommunication("Authenticated with the sensor directly (authenticity: \(authenticity.rawValue)).", type: .connection) mutateState { state in state.sharedKey = sharedKey + state.sensorAuthenticity = authenticity state.peripheralIdentifier = sensor.credentials.peripheralIdentifier state.lastAuthenticationFailure = nil state.lastAuthenticationFailureDate = nil diff --git a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift index 9caf70b..8477faa 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift @@ -47,6 +47,17 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { /// that sensor's own readings. public var sharedKey: Data? + /// Whether the paired sensor proved itself genuine Dexcom, the g7sim + /// emulator, or could not be verified — determined at pairing from its + /// identity certificate and retained across reconnects. + public var sensorAuthenticity: G7SensorAuthenticity = .unverified + + /// Whether the user has hidden the simulated-sensor settings warning. + /// Reset when a new sensor is paired so a freshly paired emulator always + /// warns once; hiding it afterwards is for development screenshots. The + /// per-reading HealthKit metadata still marks the data simulated. + public var simulatedSensorWarningSuppressed: Bool = false + /// The paired sensor's CoreBluetooth identifier, so a relaunch can /// retrieve it directly instead of waiting for its next advertisement. public var peripheralIdentifier: UUID? @@ -132,6 +143,8 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { self.sessionMode = (rawValue["sessionMode"] as? String).flatMap(G7SessionMode.init(rawValue:)) ?? .eavesdropping self.pairingCode = rawValue["pairingCode"] as? String self.sharedKey = rawValue["sharedKey"] as? Data + self.sensorAuthenticity = (rawValue["sensorAuthenticity"] as? String).flatMap(G7SensorAuthenticity.init(rawValue:)) ?? .unverified + self.simulatedSensorWarningSuppressed = rawValue["simulatedSensorWarningSuppressed"] as? Bool ?? false self.peripheralIdentifier = (rawValue["peripheralIdentifier"] as? String).flatMap(UUID.init(uuidString:)) self.lastAuthenticationFailure = rawValue["lastAuthenticationFailure"] as? String self.lastAuthenticationFailureDate = rawValue["lastAuthenticationFailureDate"] as? Date @@ -166,6 +179,8 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { rawValue["sessionMode"] = sessionMode.rawValue rawValue["pairingCode"] = pairingCode rawValue["sharedKey"] = sharedKey + rawValue["sensorAuthenticity"] = sensorAuthenticity.rawValue + rawValue["simulatedSensorWarningSuppressed"] = simulatedSensorWarningSuppressed rawValue["peripheralIdentifier"] = peripheralIdentifier?.uuidString rawValue["lastAuthenticationFailure"] = lastAuthenticationFailure rawValue["lastAuthenticationFailureDate"] = lastAuthenticationFailureDate diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index feeb383..ec6f299 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -48,7 +48,7 @@ public protocol G7SensorDelegate: AnyObject { /// Direct mode only: a handshake produced a new shared key, which the /// caller must persist so later reconnects can skip the key exchange. - func sensor(_ sensor: G7Sensor, didAuthenticateWith sharedKey: Data, deviceName: String?) + func sensor(_ sensor: G7Sensor, didAuthenticateWith sharedKey: Data, deviceName: String?, authenticity: G7SensorAuthenticity) /// Direct mode only: the stored shared key is no longer accepted by the /// sensor. It has been discarded; if a pairing code is still held, the @@ -510,7 +510,8 @@ public final class G7Sensor: G7BluetoothManagerDelegate { self.delegate?.sensor( self, didAuthenticateWith: authResult.sharedKey, - deviceName: authResult.deviceName + deviceName: authResult.deviceName, + authenticity: authResult.authenticity ) } } diff --git a/G7SensorKit/Pairing/G7PairingService.swift b/G7SensorKit/Pairing/G7PairingService.swift index e40648f..41ad676 100644 --- a/G7SensorKit/Pairing/G7PairingService.swift +++ b/G7SensorKit/Pairing/G7PairingService.swift @@ -20,7 +20,7 @@ public enum G7PairingState: Equatable { case authenticating(candidate: String, attempt: Int) /// Paired. `sharedKey` must be persisted: it is what lets reconnects skip /// the key exchange. - case succeeded(peripheralIdentifier: UUID, sharedKey: Data, deviceName: String?) + case succeeded(peripheralIdentifier: UUID, sharedKey: Data, deviceName: String?, authenticity: G7SensorAuthenticity) case failed(reason: String) public var isFinished: Bool { @@ -335,7 +335,8 @@ public final class G7PairingService { self.setState(.succeeded( peripheralIdentifier: UUID(), sharedKey: G7JPAKE.secureRandomBytes(16), - deviceName: "Dexcom" + self.pairingCode.suffix(2) + deviceName: "Dexcom" + self.pairingCode.suffix(2), + authenticity: .unverified )) } self.scanWatchdog = succeed @@ -447,7 +448,8 @@ public final class G7PairingService { self.setState(.succeeded( peripheralIdentifier: candidate.id, sharedKey: authResult.sharedKey, - deviceName: authResult.deviceName + deviceName: authResult.deviceName, + authenticity: authResult.authenticity )) self.bluetoothManager?.adoptAsActive(peripheralManager) case .failure(let error): diff --git a/G7SensorKitTests/G7SensorIdentityTests.swift b/G7SensorKitTests/G7SensorIdentityTests.swift new file mode 100644 index 0000000..126b02d --- /dev/null +++ b/G7SensorKitTests/G7SensorIdentityTests.swift @@ -0,0 +1,77 @@ +// +// G7SensorIdentityTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import G7SensorKit + +final class G7SensorIdentityTests: XCTestCase { + + // The real DXCMTR capture (2026-10-10): leaf cert, the 64-byte 0C block, + // and the nonce the display sent. + private let genuineLeaf = Data(hexadecimalString: [ + "308201a73082014da003020102021452c51b268669e8262757b9e482e226a2d5", + "5903ff300a06082a8648ce3d04030230133111300f06035504030c0844455830", + "31504732301e170d3235313031343036313133325a170d323731303134303631", + "3133325a30323130302e06035504030c2730312c303030302c30303030363030", + "302c687243587974716c6b337675306d6b6b5a4d2d56506f3059301306072a86", + "48ce3d020106082a8648ce3d03010703420004994f712b57b48a9b6116bd8064", + "dd5b2eb3d61ed36e5a208d558b77a0f87f3875de8c6dc1bfbf02019ff7e3728b", + "7451f157cfb4e5acc7bead6ab48f20ad502795a360305e300c0603551d130101", + "ff04023000301d0603551d0e041604145c1672eb2654e9fdbec4d4b48be3e4ac", + "03781842301f0603551d23041830168014d0d56feb7d94f4535aa0cadd412835", + "36cb3aa2bf300e0603551d0f0101ff040403020388300a06082a8648ce3d0403", + "020348003045022100cdeb833d130860b97abac19dd3dde82b98ca0ffcf2525e", + "fd81360c546260d3060220693a5433486c316a26b57dd1e23ee0b9206ba8805f", + "3630f82b27a86106e0aefe" + ].joined())! + private let genuineBlock = Data(hexadecimalString: "f0daaad65799d7954ae377f96623187b6c0a614793834993b54a6333530e38f97dc72132c6b127cdc07d7a662602a0a00fc28aa6f059638588f0c4263a8d1a49")! + private let genuineNonce = Data(hexadecimalString: "5d8ea3df8aec32584507dd4b23919df6")! + + // An emulator leaf signed by the g7sim dummy CA, with its own 0C signature. + private let simLeaf = Data(hexadecimalString: [ + "308201513081f8a003020102020501cf977871300a06082a8648ce3d04030230", + "133111300f06035504031308473753494d2d4341301e170d3236313031303232", + "303831315a170d3237313131343233303831315a301b31193017060355040313", + "10473753494d2d373737373737373737373059301306072a8648ce3d02010608", + "2a8648ce3d03010703420004f0838b8b57389af16435f046520cb40b2d367980", + "e18f8e7ed99f9bb6046da55a1445babc6e54ce74de8afef0fa1dfe86cc1a863b", + "86b25b98d805e6aea7d7da98a331302f300c0603551d130101ff04023000301f", + "0603551d2304183016801461e808e54c75334680ca4d6a2642a4c13ed34aed30", + "0a06082a8648ce3d040302034800304502200ece748c0b18187a659f0f1d653c", + "f862e1598be8d39d7c4446aca57fdb7b1a100221008176f475ea37c690984791", + "4452023e071ad5f490b322ddef69cbd1fe08bb15d2" + ].joined())! + private let simBlock = Data(hexadecimalString: "08fba6f52ecdf3a67a24a12d8fac09b751eb440c269f4e7d1a75ba4292acd0a7b4aade034b332e9d954e2fa4b2fe5fde377fd52403ea068e39ca42f23f41d57a")! + private let simNonce = Data(hexadecimalString: "000102030405060708090a0b0c0d0e0f")! + + func testGenuineSensorIsGenuine() { + XCTAssertEqual(G7SensorIdentity.authenticity(leafCertificate: genuineLeaf, challengeSignature: genuineBlock, nonce: genuineNonce), .genuine) + } + + func testEmulatorIsSimulated() { + XCTAssertEqual(G7SensorIdentity.authenticity(leafCertificate: simLeaf, challengeSignature: simBlock, nonce: simNonce), .simulated) + } + + func testTamperedSignatureIsUnverified() { + var bad = genuineBlock + bad[bad.startIndex] ^= 0xFF + XCTAssertEqual(G7SensorIdentity.authenticity(leafCertificate: genuineLeaf, challengeSignature: bad, nonce: genuineNonce), .unverified) + } + + func testWrongNonceIsUnverified() { + let otherNonce = Data(repeating: 0x5A, count: 16) + XCTAssertEqual(G7SensorIdentity.authenticity(leafCertificate: genuineLeaf, challengeSignature: genuineBlock, nonce: otherNonce), .unverified) + } + + func testGarbageCertificateIsUnverified() { + XCTAssertEqual(G7SensorIdentity.authenticity(leafCertificate: Data(repeating: 0, count: 100), challengeSignature: genuineBlock, nonce: genuineNonce), .unverified) + } + + func testWrongLengthSignatureIsUnverified() { + XCTAssertEqual(G7SensorIdentity.authenticity(leafCertificate: genuineLeaf, challengeSignature: Data(repeating: 0, count: 32), nonce: genuineNonce), .unverified) + } +} diff --git a/G7SensorKitTests/G7SessionModeMigrationTests.swift b/G7SensorKitTests/G7SessionModeMigrationTests.swift index d7bba4b..8d19544 100644 --- a/G7SensorKitTests/G7SessionModeMigrationTests.swift +++ b/G7SensorKitTests/G7SessionModeMigrationTests.swift @@ -285,7 +285,7 @@ final class G7SessionModeMigrationTests: XCTestCase { XCTAssertNil(cleared.state.lastAuthenticationFailure) // A successful handshake clears the record. - manager.sensor(manager.sensor, didAuthenticateWith: Data(repeating: 0x77, count: 16), deviceName: nil) + manager.sensor(manager.sensor, didAuthenticateWith: Data(repeating: 0x77, count: 16), deviceName: nil, authenticity: .unverified) XCTAssertNil(manager.state.lastAuthenticationFailure) } @@ -484,7 +484,7 @@ final class G7SessionModeMigrationTests: XCTestCase { let manager = makeManager(state: pairedState(pairingCode: "1155", peripheralIdentifier: UUID(), sharedKey: nil)) let key = Data(repeating: 0x55, count: 16) - manager.sensor(manager.sensor, didAuthenticateWith: key, deviceName: "Dexcom99") + manager.sensor(manager.sensor, didAuthenticateWith: key, deviceName: "Dexcom99", authenticity: .unverified) XCTAssertEqual(manager.state.sharedKey, key) } @@ -510,6 +510,6 @@ private final class BackfillRecordingDelegate: G7SensorDelegate { func sensor(_ sensor: G7Sensor, didReceiveCalibrationResponse response: G7CalibrateRxMessage) {} func sensor(_ sensor: G7Sensor, didReadCalibrationBounds bounds: G7CalibrationBoundsMessage) {} func sensorConnectionStatusDidUpdate(_ sensor: G7Sensor) {} - func sensor(_ sensor: G7Sensor, didAuthenticateWith sharedKey: Data, deviceName: String?) {} + func sensor(_ sensor: G7Sensor, didAuthenticateWith sharedKey: Data, deviceName: String?, authenticity: G7SensorAuthenticity) {} func sensorDidInvalidateSharedKey(_ sensor: G7Sensor) {} } diff --git a/G7SensorKitUI/G7CGMManager/G7UICoordinator.swift b/G7SensorKitUI/G7CGMManager/G7UICoordinator.swift index 1d56ac2..b5fe484 100644 --- a/G7SensorKitUI/G7CGMManager/G7UICoordinator.swift +++ b/G7SensorKitUI/G7CGMManager/G7UICoordinator.swift @@ -188,12 +188,13 @@ class G7UICoordinator: UINavigationController, CGMManagerOnboarding, CompletionN onLog: { [weak self] message in self?.recordPairingLog(message) }, - onSuccess: { [weak self] peripheralIdentifier, sharedKey, deviceName, handoff in + onSuccess: { [weak self] peripheralIdentifier, sharedKey, deviceName, authenticity, handoff in self?.pairingSucceeded( code: code, peripheralIdentifier: peripheralIdentifier, sharedKey: sharedKey, deviceName: deviceName, + authenticity: authenticity, handoff: handoff ) } @@ -313,8 +314,8 @@ class G7UICoordinator: UINavigationController, CGMManagerOnboarding, CompletionN cgmManager?.logDeviceCommunication("[pairing] " + message, type: .connection) } - private func pairingSucceeded(code: String, peripheralIdentifier: UUID, sharedKey: Data, deviceName: String?, handoff: G7PairingHandoff?) { - cgmManager?.applyPairingResult(pairingCode: code, peripheralIdentifier: peripheralIdentifier, sharedKey: sharedKey, handoff: handoff) + private func pairingSucceeded(code: String, peripheralIdentifier: UUID, sharedKey: Data, deviceName: String?, authenticity: G7SensorAuthenticity, handoff: G7PairingHandoff?) { + cgmManager?.applyPairingResult(pairingCode: code, peripheralIdentifier: peripheralIdentifier, sharedKey: sharedKey, authenticity: authenticity, handoff: handoff) navigate(to: .pairingSuccess(deviceName: deviceName)) } diff --git a/G7SensorKitUI/ViewModels/G7PairingViewModel.swift b/G7SensorKitUI/ViewModels/G7PairingViewModel.swift index acaeaf5..761ba5e 100644 --- a/G7SensorKitUI/ViewModels/G7PairingViewModel.swift +++ b/G7SensorKitUI/ViewModels/G7PairingViewModel.swift @@ -23,7 +23,7 @@ final class G7PairingViewModel: ObservableObject { private let excludedPeripheral: UUID? private let service: G7PairingService - private let onSuccess: (_ peripheralIdentifier: UUID, _ sharedKey: Data, _ deviceName: String?, _ handoff: G7PairingHandoff?) -> Void + private let onSuccess: (_ peripheralIdentifier: UUID, _ sharedKey: Data, _ deviceName: String?, _ authenticity: G7SensorAuthenticity, _ handoff: G7PairingHandoff?) -> Void private let onLog: ((String) -> Void)? /// - Parameter cgmManager: the manager being re-paired, if any. Its @@ -33,7 +33,7 @@ final class G7PairingViewModel: ObservableObject { serial: String?, cgmManager: G7CGMManager?, onLog: ((String) -> Void)? = nil, - onSuccess: @escaping (_ peripheralIdentifier: UUID, _ sharedKey: Data, _ deviceName: String?, _ handoff: G7PairingHandoff?) -> Void + onSuccess: @escaping (_ peripheralIdentifier: UUID, _ sharedKey: Data, _ deviceName: String?, _ authenticity: G7SensorAuthenticity, _ handoff: G7PairingHandoff?) -> Void ) { self.pairingCode = pairingCode // The sensor a session already holds is not the one being replaced; @@ -54,8 +54,8 @@ final class G7PairingViewModel: ObservableObject { service.onStateChange = { [weak self] state in guard let self = self else { return } self.state = state - if case .succeeded(let peripheralIdentifier, let sharedKey, let deviceName) = state { - self.onSuccess(peripheralIdentifier, sharedKey, deviceName, self.service.handOff()) + if case .succeeded(let peripheralIdentifier, let sharedKey, let deviceName, let authenticity) = state { + self.onSuccess(peripheralIdentifier, sharedKey, deviceName, authenticity, self.service.handOff()) } } } @@ -85,7 +85,7 @@ final class G7PairingViewModel: ObservableObject { return G7SensorModel(advertisedName: candidate) ?? .g7 case .scanning(let candidates): return candidates.last.flatMap(G7SensorModel.init(advertisedName:)) ?? .g7 - case .succeeded(_, _, let deviceName): + case .succeeded(_, _, let deviceName, _): return deviceName.flatMap(G7SensorModel.init(advertisedName:)) ?? .g7 case .idle, .failed: return .g7 @@ -182,7 +182,7 @@ final class G7PairingViewModel: ObservableObject { "Checking the sensors in range. This can take a few minutes.", comment: "Pairing guidance while working through the sensors that have been found" ) - case .succeeded(_, _, let deviceName): + case .succeeded(_, _, let deviceName, _): return deviceName case .failed(let reason): return reason diff --git a/G7SensorKitUI/Views/G7SettingsView.swift b/G7SensorKitUI/Views/G7SettingsView.swift index 2ad73e4..27ff58d 100644 --- a/G7SensorKitUI/Views/G7SettingsView.swift +++ b/G7SensorKitUI/Views/G7SettingsView.swift @@ -41,6 +41,7 @@ struct G7SettingsView: View { @State private var showingShareSignIn = false @State private var showingDexcomAppModeConfirmation = false @State private var showingDexcomAppModeInstructions = false + @State private var showingHideSimulatedWarningSheet = false init(didFinish: @escaping () -> Void, deleteCGM: @escaping () -> Void, pairNewSensor: @escaping () -> Void, pairCurrentSensor: @escaping () -> Void, viewModel: G7SettingsViewModel) { self.didFinish = didFinish @@ -206,6 +207,27 @@ if viewModel.sessionMode == .eavesdropping { /// and the pairing actions live here too. private var sensorSection: some View { Section(header: Text(LocalizedString("Sensor", comment: "Section header for sensor details"))) { + if viewModel.sensorAuthenticity == .simulated && !viewModel.simulatedSensorWarningSuppressed { + warning( + title: LocalizedString("Simulated Sensor", comment: "Settings warning title when connected to the g7sim emulator"), + message: LocalizedString("This is the g7sim development emulator, not a genuine Dexcom sensor. Its readings are simulated and must not be used for therapy.", comment: "Settings warning body when connected to the g7sim emulator"), + style: .critical + ) + .contentShape(Rectangle()) + .onTapGesture { showingHideSimulatedWarningSheet = true } + .actionSheet(isPresented: $showingHideSimulatedWarningSheet) { + ActionSheet( + title: Text(LocalizedString("Simulated Sensor", comment: "Title of the sheet to hide the simulated-sensor warning")), + message: Text(LocalizedString("Hide this warning? Readings uploaded from this sensor stay marked as simulated. Hiding it is for development screenshots only.", comment: "Message of the sheet to hide the simulated-sensor warning")), + buttons: [ + .destructive(Text(LocalizedString("Hide Warning", comment: "Button to hide the simulated-sensor warning"))) { + viewModel.hideSimulatedSensorWarning() + }, + .cancel(), + ] + ) + } + } // Eavesdropping sessions lead with the way out of them. if viewModel.sessionMode == .eavesdropping { if viewModel.isDexcomAppInstalled { diff --git a/G7SensorKitUI/Views/G7SettingsViewModel.swift b/G7SensorKitUI/Views/G7SettingsViewModel.swift index b2c3086..7764d6a 100644 --- a/G7SensorKitUI/Views/G7SettingsViewModel.swift +++ b/G7SensorKitUI/Views/G7SettingsViewModel.swift @@ -43,6 +43,8 @@ class G7SettingsViewModel: ObservableObject { @Published private(set) var siliconVersion: String? @Published private(set) var hardwareVersion: String? @Published private(set) var algorithmVersion: String? + @Published private(set) var sensorAuthenticity: G7SensorAuthenticity = .unverified + @Published private(set) var simulatedSensorWarningSuppressed: Bool = false /// Whether the sensor has reported its lifetime; until then the defaults /// are in use and not worth presenting as the sensor's own. @Published private(set) var hasReportedLifetime: Bool = false @@ -122,6 +124,8 @@ class G7SettingsViewModel: ObservableObject { sensorModelName = cgmManager.sensorModel.displayName(sessionLength: cgmManager.state.extendedVersion?.sessionLength) pairingCode = cgmManager.state.pairingCode serialNumber = cgmManager.state.transmitterVersion?.serialNumberString + sensorAuthenticity = cgmManager.state.sensorAuthenticity + simulatedSensorWarningSuppressed = cgmManager.state.simulatedSensorWarningSuppressed firmwareVersion = cgmManager.state.transmitterVersion?.firmwareVersion softwareNumber = cgmManager.state.transmitterVersion.map { String($0.softwareNumber) } siliconVersion = cgmManager.state.transmitterVersion.map { String($0.siliconVersion) } @@ -313,6 +317,11 @@ class G7SettingsViewModel: ObservableObject { cgmManager.scanForNewSensor() } + func hideSimulatedSensorWarning() { + cgmManager.suppressSimulatedSensorWarning() + updateValues() + } + /// Whether the last reading carries a glucose value worth showing. var hasLastGlucose: Bool { guard let lastReading = lastReading, lastReading.hasReliableGlucose else {