Skip to content

Latest commit

 

History

History
625 lines (469 loc) · 22.8 KB

File metadata and controls

625 lines (469 loc) · 22.8 KB

Libre 3 Protocol Notes

This document summarizes the Libre 3 NFC, BLE, authorization, and data-plane behavior modeled by LibreCRKit. It intentionally focuses on protocol structure and integration knowledge.

The names here are library-facing names. Some fields are still labeled as raw or provisional where their clinical meaning is not fully grounded.

Integration Model

A complete app-level session has four stages:

  1. NFC scan the sensor and read patch metadata.
  2. Send an NFC activation or switch-receiver command to obtain BLE connection metadata.
  3. Connect over BLE and complete the security handshake.
  4. Subscribe to encrypted data-plane characteristics and decode realtime, status, and backfill frames.

Apps should persist enough state to reconnect without repeating onboarding:

  • Receiver ID, stored and shown as 4-byte little-endian hex.
  • Sensor serial number.
  • BLE address returned by NFC.
  • 4-byte BLE PIN returned by NFC.
  • Last accepted realtime glucose life count and value.
  • Lifecycle metadata as more fields become named.

The receiver ID is the protocol identity value used in the NFC activation or switch command. A LibreView account is not required by the protocol paths currently modeled by this package.

NFC Layer

Libre 3 sensors are scanned as ISO 15693 NFC tags.

Patch Info

Patch info is read with the Abbott manufacturer code 0x7a and command 0xa1. CoreNFC supplies the manufacturer code separately for customCommand, so the app-level request parameters for this read are empty.

The normalized patch-info response starts with:

00 a5 ...

Known fields in the normalized response:

Offset Size Meaning
9 2 Wear duration / life count in minutes, little-endian
12 1 Raw status byte
13 4 Firmware version bytes, displayed as b13.b14.b15.b16
17 1 Sensor state byte
18 9 ASCII sensor serial number

The current command choice rule is:

  • State 0x01: send activation command 0xa0.
  • Other expected active states: send switch-receiver command 0xa8.

Activation And Switch-Receiver Commands

The activation and switch-receiver commands carry the same 10-byte body:

timeSeconds_LE4 || receiverID_LE4 || abbottCRC16_LE2

Command codes:

Code Name Use
0xa0 Activate Initial activation/pairing of a new sensor
0xa8 Switch receiver Recovery or pairing against an already active sensor

The CRC is a CRC-CCITT style 16-bit checksum with initial value 0xffff, byte bit reversal before each byte enters the CRC, polynomial 0x1021, and little-endian output.

The normalized activation/switch response is 19 bytes:

00 a5 00 || bleAddress_LE6 || blePIN4 || activationTime4 || crc2

Known response fields:

Offset Size Meaning
3 6 BLE address in little-endian byte order
9 4 BLE PIN used in the BLE authorization tail
13 4 Activation time / sensor time raw field
17 2 Trailing CRC/raw checksum

The BLE address is displayed by reversing the six address bytes.

BLE GATT

Libre 3 uses UUIDs under the base:

0898xxxx-EF89-11E9-81B4-2A2AE2DBCCE4

Services:

UUID prefix Library name Role
089810cc serviceUUID Primary Libre 3 data service, used for scanning
0898203a securityServiceUUID Security handshake service
08982400 debugServiceUUID Debug/service-adjacent UUID exposed by the sensor

Data characteristics:

UUID prefix Library name Direction Role
0898177a glucoseData Notify Realtime glucose data
08981482 patchStatus Notify Patch status and lifecycle/disconnect data
08981338 patchControl Write Data-plane control commands
0898195a historicData Notify Historical backfill pages
08981bee eventLog Notify Event-log records
08981ab8 clinicalData Notify Clinical/backfill stream
08981d24 factoryData Notify Factory data stream

Security characteristics:

UUID prefix Library name Direction Role
089823fa secCertData Write/notify Phone certificate, sensor certificate, ephemeral public keys
089822ce secChallengeData Write/notify Phase 5 challenge and Phase 6 response
08982198 secCommandResponse Write/notify Single-byte security command and response clock

BLE Fragmentation

Logical messages are fragmented at the GATT layer.

Phone-to-sensor writes:

offset_LE2 || payload_chunk

The default payload chunk size is 18 bytes.

Sensor-to-phone notifications:

sequence1 || payload_chunk

The default payload chunk size is 19 bytes. The sequence byte increments by one for each fragment in a logical message.

The realtime glucoseData notification is additionally split at the data-plane level in the current implementation: a 15-byte prefix followed by a 20-byte suffix. Concatenate those two notifications before parsing the encrypted data-frame trailer and verifying the CCM tag.

BLE Authorization

The authorization flow uses a phone certificate, sensor certificate, phone and sensor ephemeral P-256 public keys, a challenge/response exchange, and a final session-material response.

Certificate And Ephemeral Exchange

The basic exchange is:

  1. Phone writes its 162-byte certificate to secCertData.
  2. Sensor notifies a 140-byte sensor certificate on secCertData.
  3. Phone writes a 65-byte uncompressed P-256 ephemeral public key padded to 72 bytes.
  4. Sensor notifies a 65-byte uncompressed P-256 ephemeral public key.

The sensor certificate layout is:

header11 || sensor_static_public_key65 || signature64

The signature is standard ECDSA-P256(SHA-256), raw r || s, over header || sensor_static_public_key. LibreCRKit verifies it with the bundled Abbott patch-signing public keys before accepting the static public key.

The implementation derives:

  • ECDH(phone_ephemeral_private, sensor_static_public)
  • ECDH(phone_ephemeral_private, sensor_ephemeral_public)

Those shared secrets feed the later key material.

Command-Gated Sequence

The security command characteristic clocks the certificate and challenge flow. The modeled full command sequence is:

Step Phone command Expected sensor response Data side effect
1 0x01 StartAuthentication
2 0x02 LoadCertificate Phone writes certificate
3 0x03 SendCertificateLoadDone Prefix 0x04 CertificateAccepted
4 0x09 GetCertificate Prefix 0x0a CertificateReady Sensor notifies certificate
5 0x0d ValidateCertificate Phone writes ephemeral public key
6 0x0e SendEphemeralDone Prefix 0x0f EphemeralReady Sensor notifies ephemeral public key
7 0x11 StartAuthorization Prefix 0x08 ChallengeLoadDone Sensor notifies R1 challenge
8 0x08 SendChallengeLoadDone Prefix 0x08 PatchChallengeLoadDone Sensor notifies Phase 6

First-Pair Certificate And Phase 5 Source

Initial (fresh) pairing has two extra constraints beyond the command sequence above, both confirmed against live sensors:

  • Certificate family. The phone certificate must be the 03 03 family (phone_cert_162b.bin), loadable via PhoneCert.bundled162b(). The 03 00 candidate (phone_cert_firstpair.bin, PhoneCert.bundledFirstPair()) is rejected by live fresh sensors and is kept only for tests. The 03 03 prefix selects the index-1 native static-scalar window for Phase 5. The 03 03 cert is byte-exact Juggluco's public LIBRE3_APP_CERTIFICATES_B[1] — a universal static artifact, not per-user material — so the package bundles it directly and integrations no longer need to vendor their own copy.
  • Native-ephemeral coupling. The phone ephemeral public key and the Phase 5 key source must derive from the same native entropy. Generate them together with SessionKey.makeFirstPairNativeEphemeral(entropySource:), pass the returned keypair as the flow's phoneEph, and feed the returned entropy back into PairingFlow.runCommandGatedFirstPairHandshake(...). A random ephemeral does not reach a verified Phase 6.

This is the path used by shipping integrations for live first-pair; cached reconnect (below) does not send a certificate or ephemeral at all.

Cached Direct Reconnect

Pristine post-pairing reconnect captures also show a shorter cached/direct path when the app already has accepted receiver/session state. It skips certificate and ephemeral exchange and starts directly at authorization:

Step Phone command Expected sensor response Data side effect
1 0x11 StartAuthorization Prefix 0x08 ChallengeLoadDone Sensor notifies R1 challenge
2 0x08 SendChallengeLoadDone Prefix 0x08 PatchChallengeLoadDone Sensor notifies Phase 6

LibreCRKit exposes this as PairingFlow.runCachedReconnectPreamble(...) and PairingFlow.runCachedReconnectHandshake(...). If the cached/direct path is rejected before Phase 6, integrations should fall back to the full command-gated authorization sequence above. For saved kAuth state, the cached Phase 5 raw key is exposed by Child23KAuthImport.phase5RawKey(forKAuthBlob:).

Phase 5 Phone Challenge

The sensor emits a 23-byte authorization challenge:

R1_16 || nonce7

The phone generates R2_16 and sends a Phase 5 message to secChallengeData. The logical plaintext is:

R1_16 || R2_16 || tail4

For the supported pairing/recovery flows, tail4 is the 4-byte BLE PIN from the NFC activation/switch response or persisted sensor state.

The wire form is:

ccm_ciphertext36 || ccm_tag4 || zero_pad14

The CCM tag length is 4 bytes. The 14-byte zero pad brings the message to 54 bytes so it fragments into three 18-byte write chunks. The CCM nonce is the 7-byte nonce from the sensor's R1 challenge.

Phase 6 Sensor Response

The sensor Phase 6 response on secChallengeData is 67 bytes:

ccm_ciphertext56 || ccm_tag4 || nonce7

After CCM decrypt and tag verification, the plaintext is:

R2_16 || R1_16 || kEnc16 || ivEnc8

The phone must verify that Phase 6 echoes the phone's R2 and the sensor's R1. kEnc and ivEnc are then used for the encrypted post-authorization data plane.

Observed reconnect/recovery can use either the cached/direct sequence or the full command-gated authorization sequence. In both cases, StartAuthorization, Phase 5, and Phase 6 are still required before subscribing to post-auth data-plane traffic.

Data Plane

After authorization, enable notifications on the data characteristics. The current PoC refreshes CCCDs for patchControl, eventLog, historicData, clinicalData, factoryData, glucoseData, and patchStatus.

Frame Trailer

Encrypted notification bodies use a two-byte little-endian sequence trailer:

encrypted_payload || sequence_lo || sequence_hi

LibreCRKit exposes those bytes as seq, type, and sequenceNumber.

Data-Plane Encryption

Data-plane payloads use AES-CCM-128 with a 4-byte tag. The CCM key is Phase 6 kEnc. The 13-byte nonce is:

sequence_LE2 || packetDescriptor3 || ivEnc8

Known packet descriptors:

Kind Descriptor
kind0 00 00 00
handshake 00 00 0f
kind2 00 00 f0
kind3 00 0f 00
kind4 00 f0 00
kind5 0f 00 00
kind6 f0 00 00
patchData 44 00 00

Preferred inbound descriptors currently used by the decoder:

Channel Preferred kind
patchStatus kind2
glucoseData kind3
historicData kind4

If the preferred descriptor fails CCM verification, LibreCRKit can try all known descriptors and accept the first one that validates.

Patch Status Plaintext

patchStatus plaintext is 12 bytes:

Offset Size Field
0 2 lifeCount
2 2 errorData
4 2 eventDataRaw; app display value is currently 4000 + eventDataRaw
6 1 Event index
7 1 Patch state
8 2 Current life count in minutes
10 1 Stack disconnect reason
11 1 App disconnect reason

The disconnect-reason bytes are diagnostic signal/reconnect evidence. They do not by themselves indicate sensor shutdown, and LibreCRKit keeps them separate from patch-state and errorData terminal-state decoding.

Known patch state:

Value Meaning / handling
3 Expired/error handling state; clients may request backfill before shutdown
4 Active
5 Expired/error handling state; distinct from the terminated state
6 Terminated / shut down
7 Expired/error handling state
8 Terminated / shut down

LibreCRKit names only state 4 as .active and exposes other patch-state values raw. Convenience helpers group states 3, 5, and 7 as expired/error handling states, and states 6 and 8 as already terminated. Integrations should keep unknown patch-state values visible in logs rather than coercing them into a terminal state.

The errorData field carries the sensor error/condition code. The patch-status record shares the lifeCount / errorData / eventData / index shape of the sensor's event-log record, and errorData is the error field of that record. Current compatibility mapping for errorData:

errorData Meaning BLE advertising / session note
0 No error Active/normal
3 Insertion failure (sensor failed to start) Check-sensor / replacement condition
5 Expired (normal end-of-wear) Sensor-ended notification; can still advertise over BLE after wear duration
6 Terminated / shut down Sensor-ended/terminated condition; stops advertising after shutdown
7 Transmission error vocabulary, but replace-sensor UI handling Replace-sensor notification condition in observed Android app response
8 Terminated / shut down Replace-sensor / termination condition; stops advertising after shutdown

5 and 6 are distinct states. Code 5 is the normal end-of-wear condition at the end of wearDuration; Libre 3 sensors can still advertise over BLE after that transition. Codes 6 and 8 are terminated/shutdown conditions. The matching patch-control shutdown command is 05 00 00 00 00 00 00, and that command is reachable from the app's shutdown/end-session handling. However, the live ordering of any outbound shutdown write versus the sensor's own terminate has not been captured, so integrations should not assume that every physical failure or removal trigger is preceded by an outbound shutdown write. LibreCRKit exposes the decode as PatchStatus.sensorError (Libre3SensorError) plus PatchStatus.isShutdownTerminated and PatchStatus.isInsertionFailure convenience flags.

For product notifications, LibreCRKit also exposes PatchStatus.sensorAttention and PatchStatus.shouldNotifyReplaceSensor. This keeps the raw protocol code visible while matching the current Abbott-app response layer: 3 -> checkSensor, 5/6 -> sensorEnded, and 7/8 -> replaceSensor. When errorData == 0, patch-state fallback treats state 3 as check-sensor, states 5/6 as sensor-ended, and states 7/8 as replace-sensor. The recovered Android alarm alert record does not expose the underlying Abbott replaceSensorError bit directly, so this mapping is the stable LibreCRKit surface for prompt client notifications.

Lifecycle helpers currently model a 60-minute warmup and optional total wear duration supplied by the app.

Realtime Glucose Plaintext

glucoseData plaintext is 29 bytes.

Known fields:

Offset Size Field
0 2 Realtime life count
2 2 Current packed glucose/status word
4 2 Signed rate of change, hundredths of mg/dL/min
6 2 Temperature/status raw field
8 2 Projected glucose raw field
10 2 Historical life count
12 2 Historical packed glucose/status word
14 1 Trend and actionability bits
15 2 Uncapped current glucose raw value
17 2 Uncapped historical glucose raw value
19 2 Temperature raw field
21 8 Fast/raw data bytes

Packed current glucose word:

  • Bits 0...12: displayed glucose value candidate.
  • Bits 13...14: sensor condition/status bits.
  • Bit 15 set: non-displayable data-quality status.

Packed historical glucose word:

  • Bits 0...12: historical glucose value candidate.
  • Bits 13...14: historical result range status.
  • Bit 15 set: non-displayable data-quality status.

Trend/actionability byte:

  • Bits 0...2: trend.
  • Bit 3: actionable flag.
  • Bits 4...7: additional status bits.

Known trend values:

Value Meaning
0 Not determined
1 Falling quickly
2 Falling
3 Stable
4 Rising
5 Rising quickly

Known sensor condition values:

Value Meaning
0 OK
1 Invalid
2 ESA

Known historical result-range values:

Value Meaning
0 In range
1 Below range
2 Above range

Current usability rule in LibreCRKit:

current glucose is displayable
AND current data-quality status is good
AND sensor condition is OK
AND no lifecycle block such as warmup or expiry is active

When a SensorLifecycle is available, the framework's high-level quality assessment also blocks current glucose during warmup and after expiry. This is intentionally separate from the raw glucoseData quality bits: warmup comes from lifecycle timing, typically the patchStatus.currentLifeCount field and a 60-minute default warmup window.

RealtimeGlucoseReading.currentGlucoseQualityAssessment(lifecycle:) returns a structured Libre3GlucoseQualityAssessment with isUsable, raw evidence, one or more blocking issues, and advisory issues:

Issue Meaning
sensorWarmup(remainingMinutes:) Lifecycle is still inside the warmup window
sensorExpired Lifecycle is past the supplied wear duration
currentGlucoseUnavailable Raw current glucose is outside displayable handling
currentDataQuality Packed current word has a non-good data-quality state
sensorCondition Packed current word reports a non-OK sensor condition
notActionable Advisory: trend/status byte says current glucose is not actionable

The non-good data-quality and sensor-condition values are deliberately exposed as raw/provisional enums where their precise clinical labels are not yet grounded. Apps should treat blocking issues as suppressing automated use unless they have stronger product-specific evidence. Actionability is advisory in LibreCRKit: an otherwise clean non-actionable reading remains usable, while the notActionable issue is exposed through advisories for callers that want a stricter policy.

Apps that process decoded data-plane packets can use Libre3DataPlaneState to remember the latest patchStatus lifecycle and apply it automatically to each subsequent realtime glucose reading. This avoids treating a technically well-formed glucose frame as usable while the sensor is still in warmup.

Display glucose range handling:

Raw value Display behavior
1...38 Below display range, display as 39 mg/dL
39...501 Valid displayed mg/dL
502...999 Above display range, display as 501 mg/dL
Other values Unavailable

The temperature/status and fast-data fields are exposed as raw evidence until their units and clinical semantics are fully named.

Historical Backfill

Historical and clinical backfill are requested by encrypted patchControl writes. Patch-control plaintext commands are 7 bytes before data-plane encryption.

Known command builders:

Command Plaintext shape
Historical backfill greater/equal 01 00 selector lifeCount_LE2 00 00
Clinical backfill greater/equal 01 01 selector lifeCount_LE2 00 00
Event log from index 04 index 00 00 00 00 00
Factory data 06 00 00 00 00 00 00
Shutdown patch 05 00 00 00 00 00 00

Known client builders encode shutdown as command byte 0x05 with six zero payload bytes. That identifies the terminal command format; it does not imply that every terminal status was caused by an outbound write.

The shutdown command is terminal session control. It is not part of routine disconnect, reconnect, or bounded backfill.

Historical reading pages are 14-byte plaintext records:

startLifeCount_LE2 || value0_LE2 || value1_LE2 || ... || value5_LE2

Samples are spaced by 5 life-count minutes:

startLifeCount, startLifeCount + 5, ..., startLifeCount + 25

Factory data is a separate stream on factoryData; do not treat the first-pair factory-data push as a historical burst. Its decrypted payload appears to carry factory calibration parameters plus a trailing CRC16, but individual fields are not yet part of the public model.

LibreCRKit can summarize ordered backfill samples and identify gaps larger than the 5-minute sample spacing.

Libre3DataPlaneState tracks the last usable realtime glucose life count and can produce bounded historical/clinical backfill commands for reconnect. The commands intentionally request >= lastAcceptedGlucoseLifeCount, accepting one possible duplicate sample so an integration does not accidentally skip the handoff boundary. Apps that persist Libre3SensorState can seed Libre3DataPlaneState from the saved last glucose point on launch, then write the newest accepted point back before suspension or after each emitted sample.

Background Operation On iOS

Libre 3 appears to remain connected after authorization and emit minute-spaced glucoseData notifications. For iOS apps, the expected background strategy is:

  • Enable bluetooth-central.
  • Use a stable CBCentralManagerOptionRestoreIdentifierKey.
  • Create the central manager early during app launch.
  • Restore connected or pending peripherals from CoreBluetooth state restoration.
  • Keep notifications subscribed after authorization.
  • On disconnect, register for CoreBluetooth connection events for the known peripheral and/or Libre 3 service UUID, then issue a bounded reconnect.
  • Track the last accepted glucose life count so reconnect backfill can request a bounded range rather than draining all available history.

Open Protocol Questions

Known areas that need more grounding before app integrations should present a fully polished clinical model:

  • Exact clinical semantics for every non-zero data-quality and sensor-condition value.
  • Units/calibration for temperature and temperature-status fields.
  • Complete patch-state and error-state enum coverage, especially for uncommon physical failure and removal scenarios.
  • Live ordering between patch-status failure states, optional backfill, shutdown patch-control writes, patch-control responses, and BLE disconnect.
  • Completion markers and all record variants for event, clinical, and factory data streams.