8000 fix(cbio): resolve the live lane's vendor material from an injected source by shroominic · Pull Request #192 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// Masked bytes are logged; the authentication frame's plaintext is not, because
// it carries the link credential.
import 'dart:async';
import 'dart:io' show Platform;

import 'package:cgm_cbio/cgm_cbio.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
Expand All @@ -40,6 +41,23 @@ const int _maxWrites = 9;

const String _targetDeviceId = String.fromEnvironment('CBIO_TARGET_DEVICE_ID');

/// Vendor material for this run.
///
/// Nothing in the repository carries the stream key or the link credential, so
/// the harness reads them from the process environment or from `--dart-define`
/// and aborts before touching the radio when they are absent. Provide them
/// without writing them to a committed file, for example with
/// `--dart-define-from-file` against a git-ignored local file.
final CbioMapCredentialSource _credentials =
CbioMapCredentialSource(<String, String>{
...Platform.environment,
if (cbioStreamKeyHex.isNotEmpty) cbioStreamKeyDefine: cbioStreamKeyHex,
if (cbioAuthMaterialHex.isNotEmpty)
cbioAuthMaterialDefine: cbioAuthMaterialHex,
if (cbioAuthTriggerHex.isNotEmpty)
cbioAuthTriggerDefine: cbioAuthTriggerHex,
});

/// First raw (`08`) index to request. Zero starts a full history replay; a
/// higher value resumes partway so a bounded window can reach the newest
/// stored record instead of re-reading the whole archive.
Expand Down Expand Up @@ -67,6 +85,15 @@ void main() {
}

Future<void> _runSession() async {
if (!_credentials.isConfigured) {
_emit(
'CBIO-A abort=vendor-material-missing '
'missing=${_credentials.missing.join(",")}',
);
return;
}
final vendor = _credentials.read();

final targetId = _targetDeviceId.isNotEmpty
? _targetDeviceId
: await _acquireTargetId();
Expand Down Expand Up @@ -125,7 +152,7 @@ Future<void> _runSession() async {

subscription = notify.onValueReceived.listen((bytes) {
masked.add((elapsed(), List<int>.from(bytes)));
final plaintext = unmaskCbioFrame(bytes);
final plaintext = unmaskCbioFrame(bytes, key: vendor.streamKey);
_emit(
'CBIO-A notify t=${elapsed()} masked=${_hex(bytes)} '
'plaintext=${_hex(plaintext)}',
Expand Down Expand Up @@ -193,7 +220,7 @@ Future<void> _runSession() async {
/// Returns true when the newest unmasked reply is an auth success ACK.
bool authSucceeded() {
for (final (_, bytes) in masked.reversed) {
final plaintext = unmaskCbioFrame(bytes);
final plaintext = unmaskCbioFrame(bytes, key: vendor.streamKey);
if (plaintext.length != 5) continue;
if (plaintext.fold<int>(0, (a, b) => a + b) & 255 != 0) continue;
if (plaintext[1] == 0x01) {
Expand All @@ -215,7 +242,11 @@ Future<void> _runSession() async {
var authenticated = false;
for (final (source, octets) in addressCandidates) {
_emit('CBIO-A auth-attempt source=$source');
final authFrame = buildMaskedCbioAuthentication(octets);
final authFrame = buildMaskedCbioAuthentication(
octets,
key: vendor.streamKey,
material: vendor.authMaterial,
);
await send(authFrame, 'auth-$source');
final deadline = DateTime.now().add(_authWindow);
while (DateTime.now().isBefore(deadline)) {
Expand All @@ -240,14 +271,18 @@ Future<void> _runSession() async {
await send(
buildMaskedCbioClock(
DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000,
key: vendor.streamKey,
),
'clock',
);

final beforeReads = masked.length;
await send(buildMaskedCbioGlucoseQuery(0), 'glucose-0a-index0');
await send(
buildMaskedCbioRawQuery(_rawStartIndex),
buildMaskedCbioGlucoseQuery(0, key: vendor.streamKey),
'glucose-0a-index0',
);
await send(
buildMaskedCbioRawQuery(_rawStartIndex, key: vendor.streamKey),
'raw-08-index$_rawStartIndex',
);

Expand All @@ -261,7 +296,7 @@ Future<void> _runSession() async {
final rawRecords = <CbioRawRecord>[];
final plaintextFrames = <List<int>>[];
for (final (_, bytes) in masked.sublist(beforeReads)) {
final plaintext = unmaskCbioFrame(bytes);
final plaintext = unmaskCbioFrame(bytes, key: vendor.streamKey);
plaintextFrames.add(plaintext);
try {
final batch = parseCbioGlucoseBatch(plaintext);
8000 Expand Down
48 changes: 44 additions & 4 deletions openhealth/integration_test/cbio_live_capture_test.dart
8000
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
// run is active. This file prints only redacted identifiers and never prints
// payload bytes.
import 'dart:async';
import 'dart:io' show Platform;

import 'package:cgm_ble/cgm_ble.dart';
import 'package:cgm_ble_flutter/cgm_ble_flutter.dart';
Expand All @@ -59,8 +60,8 @@ const Set<String> _allowedCommandKeys = <String>{
};

/// One captured write that the session was authorized to send.
bool _isAllowedWrite(List<int> masked) {
final plaintext = unmaskCbioFrame(masked);
bool _isAllowedWrite(List<int> masked, List<int> key) {
final plaintext = unmaskCbioFrame(masked, key: key);
if (plaintext.length < 2) {
return false;
}
Expand All @@ -70,6 +71,23 @@ bool _isAllowedWrite(List<int> masked) {
);
}

/// Vendor material for this run.
///
/// Nothing in the repository carries the stream key or the link credential, so
/// the harness reads them from the process environment or from `--dart-define`
/// and aborts before touching the radio when they are absent. Provide them
/// without writing them to a committed file, for example with
/// `--dart-define-from-file` against a git-ignored local file.
final CbioMapCredentialSource _credentials =
CbioMapCredentialSource(<String, String>{
...Platform.environment,
if (cbioStreamKeyHex.isNotEmpty) cbioStreamKeyDefine: cbioStreamKeyHex,
if (cbioAuthMaterialHex.isNotEmpty)
cbioAuthMaterialDefine: cbioAuthMaterialHex,
if (cbioAuthTriggerHex.isNotEmpty)
cbioAuthTriggerDefine: cbioAuthTriggerHex,
});

/// Every phase is bounded so a silent or unresponsive radio cannot hang.
const Duration _filteredScanWindow = Duration(seconds: 8);
const Duration _unfilteredScanWindow = Duration(seconds: 12);
Expand Down Expand Up @@ -114,6 +132,16 @@ void main() {
testWidgets(
'Cbio GS1 live capture: FF30 discovery, connect, FF31 notification bytes',
(tester) async {
if (!_credentials.isConfigured) {
// The authenticated link cannot be driven without the injected
// material, and a missing material is a configuration problem rather
// than a finding about the sensor.
_emit(
'CBIO-PROGRESS phase=abort abort=vendor-material-missing '
'missing=${_credentials.missing.join(",")}',
);
return;
}
final summary = await tester.runAsync(_runCapture);
_report(summary!);
_assertCapture(summary);
Expand All @@ -123,6 +151,7 @@ void main() {
}

Future<_CaptureSummary> _runCapture() async {
final vendor = _credentials.read();
final token = 'cbio-${DateTime.now().toUtc().millisecondsSinceEpoch}';
final sink 8BD0 = LocalBleTraceSink(sessionToken: token);
final events = <BleTraceEvent>[];
Expand All @@ -131,7 +160,11 @@ Future<_CaptureSummary> _runCapture() async {
sink: _TeeTraceSink(sink, events),
);
const discovery = CbioDiscovery();
final driver = CbioSensorDriver(transport, discovery: discovery);
final driver = CbioSensorDriver(
transport,
discovery: discovery,
credentials: _credentials,
);

_emit('CBIO-PROGRESS phase=heartbeat');
await transport.recordCaptureHeartbeat().timeout(_discoveryOverhead);
Expand Down Expand Up @@ -354,6 +387,7 @@ Future<_CaptureSummary> _runCapture() async {

return _CaptureSummary(
sessionToken: token,
streamKey: vendor.streamKey,
traceFile: segmentFileName == null
? null
: '${await _traceDirectoryPath()}/$segmentFileName',
Expand Down Expand Up @@ -726,6 +760,7 @@ final class _LinkOutcome {
final class _CaptureSummary {
const _CaptureSummary({
required this.sessionToken,
required this.streamKey,
required this.traceFile,
required this.filteredCandidates,
required this.filteredError,
Expand Down Expand Up @@ -753,6 +788,11 @@ final class _CaptureSummary {
});

final String sessionToken;

/// The mask key the driver under test writes with, used to read back the
/// plaintext command of each captured write. It is never printed.
final List<int> streamKey;

final String? traceFile;
final List<DiscoveredSensor> filteredCandidates;
final Object? filteredError;
Expand Down Expand Up @@ -781,7 +821,7 @@ final class _CaptureSummary {
/// Writes outside the authorised vendor link set.
List<List<int>> get illegalWrites => <List<int>>[
for (final bytes in writeBytes)
if (!_isAllowedWrite(bytes)) bytes,
if (!_isAllowedWrite(bytes, streamKey)) bytes,
];
}

Expand Down
27 changes: 22 additions & 5 deletions openhealth/lib/src/driver_factory_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ import 'protocol_capture_profile.dart';
import 'protocol_capture_status.dart';
import 'yuwell_secure_session_store.dart';

/// Where the GS1 driver resolves the vendor material its link needs.
///
/// The values are never compiled into the repository, so a plain build reports
/// itself unconfigured and the platform registry leaves the driver out.
const CbioCredentialSource cbioCredentials = CbioDefineCredentialSource();

/// When built with `--dart-define=OG_DEMO=true`, native/simulator builds use the
/// in-memory [DemoCgmDriver] instead of the real BLE driver, so the app can be
/// exercised in the iOS simulator (which has no Bluetooth). Defaults to false,
Expand Down Expand Up @@ -205,11 +211,22 @@ CgmDriver _buildPlatformRegistry(BleTransport transport) {
return CgmDriverRegistry(
transport: transport,
registrations: <CgmDriverRegistration>[
CgmDriverRegistration(
driver: CbioSensorDriver(transport, discovery: cbioDiscovery),
scanServiceUuids: CbioDiscovery.scanServiceUuids,
discover: cbioDiscovery.mapScanResult,
),
// The GS1 link both authenticates and unmaskes its replies with vendor
// material this repository does not carry. A build that did not supply
// it cannot read the sensor at all, so registering the driver would put
// a candidate in the nearby-sensor list that can only fail. The driver is
// therefore offered only when its material is configured; supply it with
// `--dart-define-from-file` (see `packages/cgm_cbio/README.md`).
if (cbioCredentials.isConfigured)
CgmDriverRegistration(
driver: CbioSensorDriver(
transport,
discovery: cbioDiscovery,
credentials: cbioCredentials,
),
scanServiceUuids: CbioDiscovery.scanServiceUuids,
discover: cbioDiscovery.mapScanResult,
),
CgmDriverRegistration(
driver: AidexSensorDriver(transport, discovery: aidexDiscovery),
scanServiceUuids: AidexDiscovery.scanServiceUuids,
Expand Down
7 changes: 6 additions & 1 deletion openhealth/test/og_demo_flag_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ void main() {
expect(driver, isA<DemoCgmDriver>());
} else {
expect(driver, isA<CgmDriverRegistry>());
// The GS1 driver is registered only when the build supplied the
// vendor material its authenticated link needs, so a plain
// `flutter test` run expects the AiDEX driver alone.
expect(
(driver as CgmDriverRegistry).registeredDriverIds,
const <String>{'aidex', 'cbio'},
cbioCredentials.isConfigured
? const <String>{'aidex', 'cbio'}
: const <String>{'aidex'},
);
expect(driver, isNot(isA<DemoCgmDriver>()));
}
Expand Down
42 changes: 36 additions & 6 deletions packages/cgm_cbio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ of frames the session may ever put on the radio (`03 F0` device information,
`19 01` authentication, `06 03` clock, `06 0A` packed read, `06 08` raw read);
anything else is rejected before the transport sees it. Activation (`07`),
reset, thresholds, calibration, key registration, and firmware frames are never
built. The 16-byte link credential comes from a `CbioAuthMaterialProvider`
whose compiled default is the derivation-recorded constant; it is never logged,
published in a snapshot, or attached to an exception.
built. The vendor material the link authenticates with is resolved once per
session from an injected `CbioCredentialSource`; it is never logged, published
in a snapshot, or attached to an exception, and the app's platform registry
leaves the driver out entirely when a build did not supply it.

The sensor answers one `06 08` request with a stream of `08` batches pushed to
the same characteristic, so history is an ingest problem rather than a
Expand Down Expand Up @@ -64,9 +65,8 @@ only. The package contains no activation/clock builder or live write path.
`buildCbioGlucoseQuery` and `buildCbioInformationQuery` reproduce the vendor's
recovered V120 read frames (`06 0A LE16(index) 00 00 C` and `03 F0 selector C`).
`parseCbioGlucoseBatch` decodes only the plaintext `0A` batch layout and refuses
the `08` raw-data layout. `cbio_crypto.dart` carries the vendor's static
per-frame stream mask, `cbio_vendor_frames.dart` builds the masked link frames
the sensor actually accepts, and `cbio_history_archive.dart` assembles the `08`
the `08` raw-data layout. `cbio_vendor_frames.dart` builds the masked link
frames the sensor actually accepts, and `cbio_history_archive.dart` assembles the `08`
record stream into an ordered archive with gap and overlap detection.
`CbioGlucoseSyncSession` adds bounded live polling at the newest index and
bounded history paging from the oldest record, with explicit `noRecords`,
Expand All @@ -77,6 +77,36 @@ streams a contiguous raw archive up to the present; the `0A` packed field is
zero throughout, so only `08` carries usable content. See the
[live record](../../docs/testing/cbio-gs1-glucose-live.md).

## Vendor material is injected, never compiled

The vendor link needs three values that this package does **not** carry: the
16-byte RC4 stream key, the 16-byte link credential inside the authentication
frame, and the five-byte authentication prompt. They come from the vendor
artifact described in the evidence record, and storing them in this public
repository is not acceptable.

`cbio_credentials.dart` defines the boundary. `CbioCredentials` validates and
holds the three values and never renders them in `toString`; a
`CbioCredentialSource` supplies them and fails closed with
`CbioCredentialUnavailable` when they are absent or malformed.
`CbioMapCredentialSource` reads them from a supplied string map (a process
environment) and `CbioDefineCredentialSource` from `--dart-define` values.
There is no compiled default.

`cbioRc4Keystream`, `maskCbioFrame`, `unmaskCbioFrame`, and every builder in
`cbio_vendor_frames.dart` take the key and material as required arguments, so a
caller that has not resolved material cannot compile a call. Run a build or a
bench that needs the live link with a git-ignored define file:

```sh
flutter run --dart-define-from-file=cbio_vendor.local.json
```

where the file supplies `CBIO_VENDOR_STREAM_KEY_HEX`,
`CBIO_VENDOR_AUTH_MATERIAL_HEX`, and `CBIO_VENDOR_AUTH_TRIGGER_HEX`. Tests and
fixtures use synthetic material of the same shape; the real values are never
checked in.

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 @@ -2,6 +2,7 @@
library;

export 'src/cbio_driver.dart';
export 'src/cbio_credentials.dart';
export 'src/cbio_frames.dart';
export 'src/cbio_glucose_session.dart';
export 'src/cbio_history_archive.dart';
Expand Down
Loading
Loading
0