8000 feat(cbio): record a durable redacted GS1 evidence artifact (#156) by shroominic · Pull Request #189 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

Skip to content
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
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ SHELL := /bin/sh

.PHONY: help bootstrap tooling-bootstrap tooling-check hooks format \
format-check lint typecheck test-unit test-integration test-e2e test \
cbio-gs1-evidence \
build build-android build-web build-ios build-macos test-ios-native \
test-macos-native \
verify-android-release-signing check
Expand Down Expand Up @@ -50,6 +51,9 @@ test-integration: ## Run every tagged or directory-based integration test.
test-e2e: ## Report the explicitly deferred device end-to-end lane.
@./scripts/flutter-workspace.sh test-e2e

cbio-gs1-evidence: ## Run one GS1 session on a device and record a redacted artifact.
@./scripts/cbio-gs1-evidence.sh

test: test-unit test-integration ## Run all locally configured automated tests.

build: build-android build-web ## Build the portable Android and web targets.
Expand Down
73 changes: 73 additions & 0 deletions docs/testing/cbio-gs1-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# CBIO GS1 device evidence

A GS1 session used to leave nothing behind: the harnesses printed lines, the
app kept the last 250 log entries in memory, and the only durable record of a
hardware run was whatever a human had saved by hand. This lane turns one device
run into one reviewable artifact.

## Command

```sh
make cbio-gs1-evidence DEVICE_ID=<adb serial>
```

Optional:

- `HARNESS=<openhealth/integration_test/..._test.dart>` selects the harness
(default: the authenticated session).
- `EVIDENCE_DIR=<dir>` selects the output directory
(default: `../evidence` next to this repository).
- `CBIO_STREAM_SECONDS=<n>` bounds the post-read streaming window.
8000 - `CBIO_EVIDENCE_LOG=<file>` re-records an artifact from an already captured
device log instead of running a device. This is how an artifact is reproduced
without hardware.

The command runs the assertion-bearing harness on the device, keeps the device
log, and writes one artifact into the evidence directory. It exits non-zero when
the harness fails, after writing the artifact, so a failed session still leaves
evidence and still fails loudly.

## Artifact

One file per run: `gs1-session-<utc>-<harness>.json`, plus an `INDEX.md` row.
The file is the harness's `CBIO-EVIDENCE` record, validated before it is
written:

| Key | Content |
|---|---|
| `schema` | `cbio.session-evidence/1` |
| `identity` | harness path, harness revision, app package, app revision, platform string |
| `outcome` | `completed`, `aborted_no_target`, `aborted_missing_characteristics`, `aborted_authentication_failed`, `failed` |
| `writes` | command frames actually sent, per classified kind |
| `records` | glucose and raw record counts, first/last index, raw value range |
| `notifications` | FF31 notifications observed |
| `startedAtUtc`, `endedAtUtc`, `durationMilliseconds` | bounded session timing |
| `errors` | counts per closed reason from a fixed vocabulary |
| `unitStatus` | always `unverified` |

The raw glucose field has no verified scale, so no artifact, log or document may
present it as a physical glucose value.

## Redaction

`cbioSessionEvidenceArtifactViolations` rejects an artifact that:

- is missing schema, identity, outcome, records, writes or errors;
- carries an outcome, write kind, or error reason outside the closed set;
- carries a record range or raw value outside the observed envelope;
- carries a non-monotonic clock or a duration that disagrees with it;
- claims a verified unit;
- contains a Bluetooth address in colon or dash form;
- contains a long hex run that could carry credential or vendor material.

The model itself has no field for a sensor address or credential bytes, and the
harness never logs the authentication plaintext, so a rejected artifact means a
new leak path was introduced, not a formatting accident.

## Safety envelope

The allowed writes for this lane are device information, authentication, one
clock set per session, glucose reads and raw history reads. The harness
classifies every frame before transmission and fails before sending anything
outside its allow-list, so activation, reset, threshold, calibration,
key-registration and firmware frames are unreachable from this lane.
151 changes: 151 additions & 0 deletions packages/cgm_cbio/lib/src/cbio_session_evidence.dart
< 8BD0 td class="blob-num blob-num-addition empty-cell">
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
/// `unverified`.
library;

import 'dart:convert';

/// Terminal state of one harness run.
enum CbioSessionOutcome {
/// The session ran every required step and produced its required evidence.
Expand Down Expand Up @@ -115,6 +117,155 @@ const Set<String> cbioSessionErrorReasons = <String>{
/// Highest raw value the 10-bit packed glucose field can hold.
const int cbioRawGlucoseMaximum = 0x3ff;

/// Identity strings that must never appear in an evidence artifact: a Bluetooth
/// address in colon or dash form, or a long hex run that could carry credential
/// or vendor material.
final List<RegExp> _forbiddenEvidencePatterns = <RegExp>[
RegExp(r'([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}'),
RegExp(r'[0-9A-Fa-f]{32,}'),
];

/// Validates one decoded evidence artifact as it is read back from a device log.
///
/// Pure and host-testable. An empty result means the artifact can be trusted as
/// hardware evidence: schema and closed vocabularies hold, ranges are inside the
/// observed envelope, timing is forward-moving, and no identity leaks.
List<String> cbioSessionEvidenceA 8000 rtifactViolations(Object? decoded) {
if (decoded is! Map<String, Object?>) return const ['artifact_not_an_object'];
final violations = <String>[];
if (decoded['schema'] != 'cbio.session-evidence/1') {
violations.add('unknown_schema');
}
final identity = decoded['identity'];
if (identity is! Map) {
violations.add('identity_missing');
} else {
for (final key in const [
'harness',
'harnessRevision',
'appPackage',
'appRevision',
'platform',
]) {
final value = identity[key];
if (value is! String || value.trim().isEmpty) {
violations.add('identity_missing:$key');
}
}
}
final outcome = decoded['outcome'];
if (outcome is! String) {
violations.add('outcome_missing');
} else if (!CbioSessionOutcome.values.any((value) => value.wire == outcome)) {
violations.add('outcome_unknown');
}
if (decoded['unitStatus'] != 'unverified') {
violations.add('unit_status_claimed');
}
for (final key in const ['targetAcquired', 'gattReleased']) {
if (decoded[key] is! bool) violations.add('${key}_not_boolean');
}
final notifications = decoded['notifications'];
if (notifications is! int || notifications < 0) {
violations.add('notifications_not_a_count');
}
final writes = decoded['writes'];
if (writes is! Map) {
violations.add('writes_missing');
} else {
final known = <String>{for (final kind in CbioWriteKind.values) kind.wire};
for (final entry in writes.entries) {
if (!known.contains(entry.key)) {
violations.add('write_kind_unknown:${entry.key}');
}
if (entry.value is! int || (entry.value as int) < 0) {
violations.add('write_count_invalid:${entry.key}');
}
}
}
final errors = decoded['errors'];
if (errors is! Map) {
violations.add('errors_missing');
} else {
for (final entry in errors.entries) {
if (!cbioSessionErrorReasons.contains(entry.key)) {
violations.add('error_reason_unknown:${entry.key}');
}
if (entry.value is! int || (entry.value as int) < 0) {
violations.add('error_count_invalid:${entry.key}');
}
}
}
violations.addAll(_recordViolations(decoded['records']));
violations.addAll(_timingViolations(decoded));
final encoded = jsonEncode(decoded);
for (final pattern in _forbiddenEvidencePatterns) {
if (pattern.hasMatch(encoded)) violations.add('identity_leak');
}
return violations;
}

List<String> _recordViolations(Object? records) {
if (records is! Map) return const ['records_missing'];
final violations = <String>[];
for (final key in const ['glucose', 'raw']) {
final block = records[key];
if (block is! Map) {
violations.add('records_missing:$key');
continue;
}
final count = block['count'];
final first = block['firstIndex'];
final last = block['lastIndex'];
if (count is! int || count < 0) {
violations.add('record_count_invalid:$key');
continue;
}
if (count == 0) {
if (first != null || last != null) violations.add('empty_range:$key');
continue;
}
if (first is! int || last is! int || first < 0 || last < first) {
violations.add('record_range_invalid:$key');
}
if (last is int && last > 0xffff) {
violations.add('record_index_out_of_bounds:$key');
}
}
for (final key in const ['rawGlucoseMinimum', 'rawGlucoseMaximum']) {
final value = records[key];
if (value == null) continue;
if (value is! int || value < 0 || value > cbioRawGlucoseMaximum) {
violations.add('raw_glucose_outside_envelope:$key');
}
}
final minimum = records['rawGlucoseMinimum'];
final maximum = records['rawGlucoseMaximum'];
if (minimum is int && maximum is int && minimum > maximum) {
violations.add('raw_glucose_range_inverted');
}
return violations;
}

List<String> _timingViolations(Map<String, Object?> decoded) {
final violations = <String>[];
final started = DateTime.tryParse(decoded['startedAtUtc'] as String? ?? '');
final ended = DateTime.tryParse(decoded['endedAtUtc'] as String? ?? '');
if (started == null || ended == null) {
violations.add('timing_not_parsable');
return violations;
}
if (ended.isBefore(started)) violations.add('clock_moved_backwards');
final duration = decoded['durationMilliseconds'];
if (duration is! int || duration < 0) {
violations.add('duration_invalid');
} else if ((ended.difference(started).inMilliseconds - duration).abs() >
1000) {
violations.add('duration_disagrees_with_timestamps');
}
return violations;
}

/// Evidence for one device-backed GS1 session.
///
/// Every field is either a count, a range, a bounded duration, a closed reason,
Expand Down
84 changes: 84 additions & 0 deletions packages/cgm_cbio/test/cbio_session_evidence_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,88 @@ void main() {
);
expect(RegExp(r'[0-9a-f]{32,}').hasMatch(encoded), isFalse);
});

test('a recorded artifact validates and leak paths are caught', () {
final artifact =
jsonDecode(jsonEncode(evidence().toJson())) as Map<String, Object?>;
FCBC expect(cbioSessionEvidenceArtifactViolations(artifact), isEmpty);

Map<String, Object?> mutated(void Function(Map<String, Object?>) change) {
final copy =
jsonDecode(jsonEncode(evidence().toJson())) as Map<String, Object?>;
change(copy);
return copy;
}

expect(
cbioSessionEvidenceArtifactViolations(artifact['outcome']),
contains('artifact_not_an_object'),
);
expect(
cbioSessionEvidenceArtifactViolations(
mutated((copy) {
copy['schema'] = 'cbio.session-evidence/2';
}),
),
contains('unknown_schema'),
);
expect(
cbioSessionEvidenceArtifactViolations(
mutated((copy) {
copy['unitStatus'] = 'mg/dL';
}),
),
contains('unit_status_claimed'),
);
expect(
cbioSessionEvidenceArtifactViolations(
mutated((copy) {
(copy['errors']! as Map<String, Object?>)['made_up'] = 1;
}),
),
contains('error_reason_unknown:made_up'),
);
expect(
cbioSessionEvidenceArtifactViolations(
mutated((copy) {
(copy['writes']! as Map<String, Object?>)['activation'] = 1;
}),
),
contains('write_kind_unknown:activation'),
);
expect(
cbioSessionEvidenceArtifactViolations(
mutated((copy) {
final records = copy['records']! as Map<String, Object?>;
records['rawGlucoseMaximum'] = cbioRawGlucoseMaximum + 1;
}),
),
contains('raw_glucose_outside_envelope:rawGlucoseMaximum'),
);
expect(
cbioSessionEvidenceArtifactViolations(
mutated((copy) {
copy['endedAtUtc'] = '2025-01-01T00:00:00.000Z';
}),
),
contains('clock_moved_backwards'),
);
expect(
cbioSessionEvidenceArtifactViolations(
mutated((copy) {
(copy['identity']! as Map<String, Object?>)['platform'] =
'android AA:BB:CC:DD:EE:FF';
}),
),
contains('identity_leak'),
);
expect(
cbioSessionEvidenceArtifactViolations(
mutated((copy) {
(copy['identity']! as Map<String, Object?>)['platform'] = 'a' * 40;
}),
),
contains('identity_leak'),
);
});
}
Loading
Loading
0