8000 cbio: classify the live GS1 FF31 reply as unresolved by shroominic · Pull Request #132 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

Skip to content
< 8000 /react-partial>
Closed
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
149 changes: 149 additions & 0 deletions docs/testing/cbio-gs1-reply-decode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Cbio GS1 FF31 reply: what the five bytes are not

This record closes the question "what is the FF31 reply?" as far as the bytes
themselves can take it. The answer is negative and exact: the payload is not a
complete plaintext V120 frame under any plaintext reading tested here, and the
only surviving explanation that the repository already has evidence for is a
keyed stream-cipher mask that cannot be removed without the vendor's registered
key. No glucose value, unit, or timestamp is claimed anywhere in this file.

## Observed payload

The driver's single bounded read `06 08 01 00 00 00 F1` (opcode `08`, index 1)
on `FF32` produced exactly one notification on `FF31`, five bytes long, and
`parseCbioPlaintextFrame` refused it with reason `length`.

| Field | Value |
| --- | --- |
| Notification bytes | `23 F7 6F D9 F4` |
| Length | 5 |
| Additive sum modulo 256 | `0x56` (contract requires `0x00`) |
| Byte zero read as the vendor length `L`, total `L + 1` | `0x24` = 36 |
| Bytes outstanding under that reading | 31 |
| Byte one read as an opcode | `0xF7`, outside the mapped opcode set |
| Five-byte acknowledgement marker `0x04` at byte zero | absent |

The payload is identifier-free: it contains no address, name, key, nonce, or
time. It was byte-identical in every recorded session of 2026-09-17, including
the four sessions pulled from the device under `/tmp/cbio_pull*` and the
confirmation capture recorded from this lane.

Timing from the capture segments is part of the evidence. In the session
pulled at 20:27 the write-with-response completed at `13:27:39.060929Z` and the
notification arrived at `13:27:39.078912Z`, 18 ms later. In the confirmation
capture recorded for this lane the write completed at `14:40:52.657538Z` and
the notification arrived at `14:40:52.666907Z`, 9 ms later, and the notification
stream then stayed silent until it was cancelled 6.7 s afterwards. Passive
windows of 12 s (plugin) and 20 s (bench) also observed nothing on `FF31`. The
payload is therefore a response to the read, not a periodic or unsolicited
broadcast.

## Plaintext contract used for every test

`packages/cgm_cbio/lib/src/cbio_frames.dart` accepts a complete frame when the
length is 5 to 256 bytes, byte zero is `length - 1`, the additive sum of all
bytes is zero modulo 256, and byte one is a mapped opcode. Five-byte control
replies are additionally restricted to the observed set `00/01/02/0A/F0`.

`packages/cgm_cbio/lib/src/cbio_reply_inspection.dart` applies that contract to
a raw notification and reports the findings without transforming the bytes. It
never returns a decoded value for an unresolved payload.

## Hypotheses and outcomes

| # | Hypothesis | Outcome | Evidence |
| --- | --- | --- | --- |
| 1 | Complete plaintext frame | Refuted | Byte zero declares 36 bytes, not 5; sum is `0x56`, not 0 |
| 2 | Plaintext five-byte ACK `04 opcode result status C` | Refuted | Byte zero is `0x23`, not the `0x04` marker |
| 3 | Frame at another offset, rotation, or reversed order | Refuted | No rotation and neither orientation satisfies length, checksum, and opcode together |
| 4 | Constant single-byte XOR or additive mask | Refuted | All 512 candidate transforms were searched; none exposes a valid frame |
| 5 | CRC-8 trailer over the first four bytes | Refuted | Polynomials `07/31/9B/1D/8F/39/D5` with init `00` and `FF` reproduce `0xF4` in no case |
| 6 | CRC-16 or longer trailer | Refuted by length | A trailer cannot fit in a five-byte payload that also carries a frame header |
| 7 | Length-prefixed frame whose remainder is still in flight | Not sustained | 31 bytes are outstanding if byte zero is `L`, and byte one would be the unmapped opcode `0xF7`; the 6 s settle window after the first packet and the passive windows produced no further `FF31` notifications. The vendor's `V120SpiltData` entry point keeps split handling an open possibility, so this is the only plaintext reading left standing, and it is unevidenced |
| 8 | Wrong characteristic | Refuted | The payload arrived on `FF31` of service `FF30`, the sensor-to-app side of the vendor mapping; `FF32` is the write side |
| 9 | Spontaneous or periodic sensor broadcast | Refuted by timing | 18 ms after the read completed, and nothing at all in 12 s and 20 s passive windows |
| 10 | Unmasked undocumented vendor opcode | Refuted | Any unmasked reading still has to balance the additive checksum, which `0x23 F7 6F D9 F4` does not |
| 11 | Keyed stream-cipher mask (RC4 with the vendor key) | Plausible, not decidable here | See below |

## Why hypothesis 11 is the surviving one

The earlier bench on this same sensor model sent plaintext `F0` and `0A`
queries and received one five-byte notification per query. Those notifications
were not valid plaintext frames either; they became length-valid and
sum-to-zero only after the native RC4 transform with the vendor's 16-byte
stream key at stream offset zero. The reply bytes now captured have the same
shape: five bytes, deterministic across sessions, and refused by every
plaintext invariant.

Decoding by that route needs material this repository does not have. The key is
not a static constant: the native `register_key` path hex-decodes a token,
applies RC4, checks the package binding, and copies the clear bytes into a BSS
slot that the request and reply paths then use. The token, key, and the
decompiled vendor artifacts lived in a private scratch directory that no longer
exists, the vendor application is not installed on the paired phone, and no
vendor APK remains on this host. A five-byte constant-mask search cannot stand
in for the key: RC4 produces a per-byte keystream, so the payload stays
unresolved rather than "probably" a status code.

If the key were recovered, the test would be exact: a correct keystream must
turn `23 F7 6F D9 F4` into a frame whose byte zero is 4, whose sum is zero, and
whose opcode is a mapped one - for example the `08` acknowledgement
`04 08 result status C`. That result is a hypothesis about the plaintext, not a
decode, and must not be published as a reading.

## Proven here

- The reply is a deterministic response to the bounded `08` read, not a
broadcast, and it is byte-identical across every recorded session.
- It is not a complete plaintext V120 frame, in any orientation, under any
constant mask, and its trailer is not a CRC-8 of its prefix.
- No decoding exists for it in this repository, and none was fabricated. The
inspection added by this lane returns `unresolved` with no frame for these
bytes.

## Not proven

- What the five bytes mean, whether they carry a result code, and whether the
sensor considers the read authenticated, authorized, or malformed.
- Whether the sensor expects more traffic (a fragmented remainder, an
authentication write, or a different command) before it answers in plaintext.
- Any glucose value, unit, epoch, or sensor state.
- Firmware or model coverage: one sensor, one firmware revision, one app build.

## Update: the payload is not command-specific

The vendor's own read queries were then sent to the same live sensor from the
phone: the storage read `03 F0 04 09`, the time/last-index read `03 F0 03 0A`,
and the glucose reads `06 0A 00 00 00 00 F0` and `06 0A 01 00 00 00 EF`. All
four returned the identical five bytes `23 F7 6F D9 F4`, one notification each,
with nothing further arriving across 19 s.

That result strengthens two of the conclusions above and retires a third. The
payload is not a per-command reply, since a storage read, a time read, and two
different glucose reads produce the same bytes; it is not the first fragment of
a longer plaintext frame; and hypothesis 7 above, a length-prefixed remainder
still in flight, is now refuted for this path rather than merely unsustained.
The surviving explanation is unchanged: one fixed response, almost certainly
keyed, that the sensor emits until the vendor's authentication material is
present. See `cbio-gs1-glucose-live.md` for the full query, reply, and timing
record.

## Exact next evidence needed

1. The vendor's 16-byte stream key, recovered the same way the earlier bench
obtained it (`register_key` token for `com.sisensing.sijoy`, followed by the
package-binding check), or an HCI snoop of the vendor application completing
its own `08` read. Either gives a keystream/plaintext pair and turns the
analysis into a bounded offline test.
2. A second sensor, ideally a different production lot, to show whether the
payload is firmware-wide or unit-specific.
3. The vendor's own response to an authenticated session, which requires an
authentication write. That write is out of scope for this lane and was not
attempted.

## Safety record

Every session in this lane sent exactly one write, the bounded `06 08 01 00 00 00 F1`
read, and nothing else. No activation (`07`), clock (`03`), reset, interval,
threshold, key, or calibration write was sent. Radio time stayed inside the
bounded capture windows, and no sensor state change was requested.
7 changes: 7 additions & 0 deletions packages/cgm_cbio/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- Add `inspectCbioReply`, a read-only structural inspection of one raw `FF31`
notification. It reports the plaintext invariants, orientation and constant-mask
searches, and returns `unresolved` with a null frame for the captured five-byte
payload. It does not decrypt or reassemble.

## 0.0.1

- Reserve the `cbio` driver contract for offline GS1 research.
Expand Down
9 changes: 9 additions & 0 deletions packages/cgm_cbio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ and preserves its raw byte without an active/inactive enum.
clock-update ACK and retains unknown result/status values. These inspect bytes
only. The package contains no activation/clock builder or live write path.

`inspectCbioReply` reports what a raw `FF31` notification proves under the
plaintext contract: declared length, additive checksum, acknowledgement marker,
mapped opcode, whether any rotation or reversal fits, and whether one constant
byte mask would expose a frame. It returns `unresolved` with a null frame for
the live five-byte payload `23 F7 6F D9 F4`, which no plaintext reading fits.
The inspection does not decrypt, reassemble, or hold a key; a keyed stream
cipher stays untestable here. See the
[reply framing record](../../docs/testing/cbio-gs1-reply-decode.md).

Run package checks from this directory with the pinned Dart SDK:

```sh
Expand Down
1 change: 1 addition & 0 deletions packages/cgm_cbio/lib/cgm_cbio.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ library;

export 'src/cbio_driver.dart';
export 'src/cbio_frames.dart';
export 'src/cbio_reply_inspection.dart';
213 changes: 213 additions & 0 deletions packages/cgm_cbio/lib/src/cbio_reply_inspection.dart
8443
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/// Structural inspection of one raw FF31 notification payload.
///
/// The GS1 sensor answered the single bounded `06 08 01 00 00 00 F1` read with
/// five byte-identical notification bytes that the plaintext length invariant
/// rejects. This module reports which plaintext hypotheses those bytes survive
/// so a capture can be classified without inventing a decode. It does not
/// decrypt, reassemble fragments, hold a key, or produce a `CgmReading`.
library;

import 'cbio_frames.dart';

/// The identifier-free FF31 payload recovered from the live GS1 capture.
///
/// Byte-identical across the recorded sessions of 2026-09-17. It carries no
/// address, name, key, nonce, or timestamp, so it is safe to publish and test.
const List<int> cbioLiveUnresolvedReply = <int>[0x23, 0xf7, 0x6f, 0xd9, 0xf4];

/// The opcodes the plaintext contract can attribute to a complete frame.
const Set<int> cbioKnownOpcodes = <int>{
0x00,
0x01,
0x02,
0x03,
0x04,
0x07,
0x08,
0x0a,
0xf0,
};

/// What the plaintext contract alone can say about a captured payload.
enum CbioReplyVerdict {
/// The bytes satisfy the contract: declared length, checksum, known opcode.
plaintextFrame,

/// The bytes satisfy none of the plaintext hypotheses.
///
/// A payload may still declare a longer frame through [declaredTotalLength];
/// that reading is a hypothesis about framing, not a decoded result.
unresolved,
}

/// Read-only findings for one notification payload.
///
/// Every field is derived from the bytes themselves. [frame] stays null unless
/// the existing plaintext parser accepted them, so an unresolved reply can
/// never be mistaken for a decoded one.
final class CbioReplyInspection {
const CbioReplyInspection({
required this.verdict,
required this.byteLength,
required this.sumModulo256,
required this.declaredTotalLength,
required this.missingTrailingBytes,
required this.checksumBalances,
required this.acknowledgementMarkerPresent,
required this.declaredOpcodeKnown,
required this.plaintextFrameValid,
required this.plaintextFailure,
required this.plaintextFrameInAnyOrientation,
required this.plaintextFrameUnderSingleByteMask,
this.frame,
});

final CbioReplyVerdict verdict;

/// Number of bytes in the payload, verbatim from the capture.
final int byteLength;

/// Additive sum of every byte, modulo 256. Zero is the contract's check.
final int sumModulo256;

/// Byte zero read as the vendor's checksum offset, `L + 1`, or null if empty.
///
/// This is only a hypothesis about the framing. It is not a header that the
/// capture proved the sensor sends.
final int? declaredTotalLength;

/// Bytes still missing if [declaredTotalLength] framed this payload.
final int missingTrailingBytes;

/// Whether byte zero declares a frame longer than the captured payload.
bool get declaresLongerFrame => missingTrailingBytes > 0;

final bool checksumBalances;

/// Whether the five-byte acknowledgement marker `0x04` leads the payload.
final bool acknowledgementMarkerPresent;

/// Whether byte one is an opcode the plaintext contract can attribute.
final bool declaredOpcodeKnown;

/// Whether the payload satisfies length, checksum, and opcode together.
final bool plaintextFrameValid;

/// Why the existing plaintext parser refused the bytes, if it did.
final CbioFrameFailure? plaintextFailure;

/// Whether any rotation or the reverse of the payload satisfies the contract.
final bool plaintextFrameInAnyOrientation;

/// Whether one constant XOR or additive mask exposes a valid plaintext frame.
///
/// A constant mask is the weakest reversible transform hypothesis. A false
/// result rules it out; it says nothing about a keyed stream cipher, which
/// this module cannot test without the vendor key.
final bool plaintextFrameUnderSingleByteMask;

/// The parsed frame, only when the plaintext parser accepted the payload.
final CbioFrame? frame;
}

/// Inspects one raw notification payload without transforming it.
CbioReplyInspection inspectCbioReply(List<int> bytes) {
final sum = bytes.fold<int>(0, (acc, byte) => acc + byte) & 255;
final declaredTotalLength = bytes.isEmpty ? null : bytes[0] + 1;
final missingTrailingBytes = declaredTotalLength == null
? 0
: declaredTotalLength - bytes.length > 0
? declaredTotalLength - bytes.length
: 0;
final checksumBalances = sum == 0;
final acknowledgementMarkerPresent = bytes.isNotEmpty && bytes[0] == 0x04;
final declaredOpcodeKnown =
bytes.length > 1 && cbioKnownOpcodes.contains(bytes[1]);
final plaintextFrameValid =
declaredTotalLength == bytes.length &&
checksumBalances &&
declaredOpcodeKnown;

CbioFrameFailure? plaintextFailure;
CbioFrame? frame;
try {
frame = parseCbioPlaintextFrame(bytes);
} on CbioFrameException catch (exception) {
plaintextFailure = exception.reason;
}

final verdict = plaintextFrameValid
? CbioReplyVerdict.plaintextFrame
: CbioReplyVerdict.unresolved;

return CbioReplyInspection(
verdict: verdict,
byteLength: bytes.length,
sumModulo256: sum,
declaredTotalLength: declaredTotalLength,
missingTrailingBytes: missingTrailingBytes,
checksumBalances: checksumBalances,
acknowledgementMarkerPresent: acknowledgementMarkerPresent,
declaredOpcodeKnown: declaredOpcodeKnown,
plaintextFrameValid: plaintextFrameValid,
plaintextFailure: plaintextFailure,
plaintextFrameInAnyOrientation: fitsCbioPlaintextFrameInAnyOrientation(
bytes,
),
plaintextFrameUnderSingleByteMask:
fitsCbioPlaintextFrameUnderSingleByteMask(bytes),
frame: frame,
);
}

/// Whether the payload satisfies the complete plaintext contract.
///
/// The generic entry point models five-byte control replies and `0A` batches
/// only, so this checks the framing invariants and the known opcode set.
bool fitsCbioPlaintextFrame(List<int> bytes) {
if (bytes.length < 5 || bytes.length > 256) {
return false;
}
if (bytes.any((byte) => byte < 0 || byte > 255)) {
return false;
}
if (bytes[0] + 1 != bytes.length) {
return false;
}
if ((bytes.fold<int>(0, (total, byte) => total + byte) & 255) != 0) {
return false;
}
return cbioKnownOpcodes.contains(bytes[1]);
}

/// Whether any rotation or the reversed payload satisfies the contract.
///
/// Byte-order mistakes and a leaked fragment tail are both worth ruling out
/// before a payload is reported as unexplained.
bool fitsCbioPlaintextFrameInAnyOrientation(List<int> bytes) {
final candidates = <List<int>>[
for (var offset = 0; offset < bytes.length; offset++)
[...bytes.sublist(offset), ...bytes.sublist(0, offset)],
bytes.reversed.toList(),
];
return candidates.any(fitsCbioPlaintextFrame);
}

/// Whether one constant XOR or additive mask exposes a valid plaintext frame.
///
/// Only 512 candidate transforms are tested, so this is a bounded refutation:
/// it can rule out a constant mask and can confirm one, but it cannot rule out
/// a keyed per-byte keystream such as RC4.
bool fitsCbioPlaintextFrameUnderSingleByteMask(List<int> bytes) {
for (var mask = 0; mask < 256; mask++) {
if (fitsCbioPlaintextFrame([for (final byte in bytes) byte ^ mask])) {
return true;
}
if (fitsCbioPlaintextFrame([
for (final byte in bytes) (byte + mask) & 255,
])) {
return true;
}
}
return false;
}
Loading
Loading
0