From ce9126fbc6ab9da28a249f0c4a5bf5d90c1bc2c1 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 22 Jun 2026 02:16:01 -0400 Subject: [PATCH] test(#68): adversarial codec test suite (A1, TDD-RED) 11 tests for ObserverConfigClient / BrokerListDecoder. 9 green (builders, response parsing, happy-path decode, malformed-NUL, garbage-numeric, stray-KV). 2 RED BY DESIGN, documenting the Gemini findings to be fixed by C1 (#72): - DoS: BrokerListDecoder retains 256 slots from a hostile BROKER_KV flood (must bound to brokerSlotCount=10). - out-of-range slot 200 is stored (must be dropped). Co-Authored-By: Claude Opus 4.8 --- .../observer_config_client_test.dart | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 test/connector/observer_config_client_test.dart diff --git a/test/connector/observer_config_client_test.dart b/test/connector/observer_config_client_test.dart new file mode 100644 index 0000000..f94fdf9 --- /dev/null +++ b/test/connector/observer_config_client_test.dart @@ -0,0 +1,211 @@ +// Adversarial + correctness tests for the Offband config codec (#64 A1). +// +// TDD: the "hostile device" group is written to FAIL on the un-hardened codec — +// it documents the Gemini BLOCKER (unbounded BrokerListDecoder / DoS) and MINOR +// (out-of-range slot accepted). C1 (#72) hardens the codec until these pass. +// The builder / parsing / happy-path groups assert the contract and should be +// green already. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/observer_config_client.dart'; +import 'package:meshcore_open/models/observer_config.dart'; + +void main() { + Uint8List resp(int sub, [List? payload]) { + final b = BytesBuilder(); + b.addByte(ObserverConfigClient.respConfig); + b.addByte(sub); + if (payload != null) b.add(payload); + return b.toBytes(); + } + + Uint8List respText(int sub, String text) => + resp(sub, [...utf8.encode(text), 0]); + + Uint8List brokerKv(int slot, String kv) => Uint8List.fromList([ + ObserverConfigClient.respConfig, + ObserverConfigClient.rBrokerKv, + slot, + ...utf8.encode(kv), + 0, + ]); + + group('frame builders', () { + test('buildSet = [0xC0, OCFG_SET, "key value", NUL]', () { + final f = ObserverConfigClient.buildSet('mqtt.iata', 'HAO'); + expect(f[0], ObserverConfigClient.cmdConfig); + expect(f[1], ObserverConfigClient.opSet); + expect(utf8.decode(f.sublist(2, f.length - 1)), 'mqtt.iata HAO'); + expect(f.last, 0); + }); + + test('buildGet / buildBrokers sub-types', () { + expect( + ObserverConfigClient.buildGet('wifi.ssid')[1], + ObserverConfigClient.opGet, + ); + final br = ObserverConfigClient.buildBrokers(); + expect(br.length, 2); + expect(br[1], ObserverConfigClient.opBrokers); + }); + + test( + 'SET value with spaces is verbatim (first-space split is firmware-side)', + () { + final f = ObserverConfigClient.buildSet('wifi.ssid', 'My Home Net'); + expect( + utf8.decode(f.sublist(2, f.length - 1)), + 'wifi.ssid My Home Net', + ); + }, + ); + }); + + group('response parsing', () { + test('ACK / ERR / VALUE', () { + expect( + ObserverConfigClient.parse(respText(ObserverConfigClient.rAck, 'ok')), + isA(), + ); + expect( + ObserverConfigClient.parse( + respText(ObserverConfigClient.rErr, 'ERROR boom'), + ), + isA(), + ); + final v = ObserverConfigClient.parse( + respText(ObserverConfigClient.rValue, 'wifi.ssid = MyNet'), + ); + expect(v, isA()); + expect((v as ConfigValue).value, 'MyNet'); + }); + + test('wrong code -> ConfigUnknown', () { + expect( + ObserverConfigClient.parse(Uint8List.fromList([0x42, 0x00])), + isA(), + ); + }); + + test('too-short frame -> ConfigUnknown', () { + expect( + ObserverConfigClient.parse(Uint8List.fromList([0xC0])), + isA(), + ); + }); + + test('BROKER_KV splits on the FIRST = only (value may contain =)', () { + final r = + ObserverConfigClient.parse(brokerKv(0, 'url=mqtt://h/?a=b')) + as ConfigBrokerKv; + expect(r.slot, 0); + expect(r.key, 'url'); + expect(r.value, 'mqtt://h/?a=b'); + }); + }); + + group('broker decode — happy path', () { + test('START -> KV -> END assembles a broker', () { + final d = BrokerListDecoder(); + expect( + d.add( + ObserverConfigClient.parse( + resp(ObserverConfigClient.rBrokersStart, [1]), + ), + ), + isNull, + ); + d.add(ObserverConfigClient.parse(brokerKv(0, 'url=mqtt://h'))); + d.add(ObserverConfigClient.parse(brokerKv(0, 'port=8883'))); + final list = d.add( + ObserverConfigClient.parse(resp(ObserverConfigClient.rBrokersEnd)), + ); + expect(list, isNotNull); + expect(list!.length, 1); + expect(list.first.url, 'mqtt://h'); + expect(list.first.port, 8883); + }); + }); + + // ---- ADVERSARIAL: assume a malicious/compromised device controls the wire ---- + group('adversarial: hostile device', () { + test( + 'DoS — flood of BROKER_KV must not retain slots past brokerSlotCount', + () { + final d = BrokerListDecoder(); + d.add( + ObserverConfigClient.parse( + resp(ObserverConfigClient.rBrokersStart, [0]), + ), + ); + // Malicious device floods 1000 frames across 256 slots, far over the + // 10-slot contract, then (eventually) END. + for (var i = 0; i < 1000; i++) { + d.add( + ObserverConfigClient.parse(brokerKv(i % 256, 'url=mqtt://flood$i')), + ); + } + final list = d.add( + ObserverConfigClient.parse(resp(ObserverConfigClient.rBrokersEnd)), + ); + expect( + list!.length, + lessThanOrEqualTo(ObserverConfigClient.brokerSlotCount), + reason: + 'BrokerListDecoder must bound retained slots to brokerSlotCount (DoS guard)', + ); + }, + ); + + test('out-of-range slot index is dropped, never surfaced', () { + final d = BrokerListDecoder(); + d.add( + ObserverConfigClient.parse( + resp(ObserverConfigClient.rBrokersStart, [1]), + ), + ); + d.add(ObserverConfigClient.parse(brokerKv(200, 'url=mqtt://evil'))); + final list = d.add( + ObserverConfigClient.parse(resp(ObserverConfigClient.rBrokersEnd)), + ); + expect( + list!.any((b) => b.slot >= ObserverConfigClient.brokerSlotCount), + isFalse, + reason: 'slots >= brokerSlotCount must be dropped, not stored', + ); + }); + + test('malformed: missing NUL terminator parses without throwing', () { + final f = Uint8List.fromList([ + ObserverConfigClient.respConfig, + ObserverConfigClient.rValue, + ...utf8.encode('wifi.ssid = y'), + ]); + expect(() => ObserverConfigClient.parse(f), returnsNormally); + }); + + test('garbage numeric fields fall back to defaults, never throw', () { + final b = BrokerConfig.fromWireFields(0, { + 'url': 'h', + 'port': 'NaN', + 'jwt_refresh': '', + }); + expect(b.port, 0); + expect(b.jwtRefresh, 0); + }); + + test('KV before START is ignored (no implicit accumulation)', () { + final d = BrokerListDecoder(); + // No START — a stray KV must not be retained. + d.add(ObserverConfigClient.parse(brokerKv(0, 'url=mqtt://stray'))); + final list = d.add( + ObserverConfigClient.parse(resp(ObserverConfigClient.rBrokersEnd)), + ); + // END with no open stream returns null (nothing to assemble). + expect(list, isNull); + }); + }); +}