From 92d5cd212b7bc4afbbde31d1e8e8ea6f04a57b43 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:59:54 -0500 Subject: [PATCH] fix(#1065): stop three capability gates reading FIRMWARE_VER_CODE The block list, caplog and packet-hash gates each checked the correct caps bit and then ANDed a fork contract number on top (>=15, >=17, >=22). With the code rolling back to 14 to resolve the 1.18.0 collision, all three returned false on firmware that genuinely has the feature, while the capability bit advertised support the whole time. Silent capability loss. Owner ruling (AM 1401): no dependency rides on FIRMWARE_VER_CODE. The floors were never what protected stock radios, which is why removing them is safe: - the connector parses both caps bytes ONLY when the population classifier allows fork frames, and nulls them otherwise (meshcore_connector.dart :7924-7938), so a stock reply yields null caps and every caps-anchored gate is false regardless of any floor - the classifier keys on the device-info frame SHAPE plus a dual-form version string, never on the ver code (device_population.dart:107-129), so a fork device at 14 still classifies as offband and works - a caps bit is self-dating anyway: firmware predating a feature cannot set that feature's bit, and caps byte 2 only exists from the version that added offset 84 Drops the now-unused parameter from all three signatures rather than ignoring it, and rewrites the POPULATION INVARIANT note to say no caps-anchored gate may read the ver code. Left alone deliberately: supportsCompanionRadioStats >=8 and firmwareSupportsRegionScope >=13 have NO caps bit to fall back on and are pre-split codes meaning the same thing upstream and in the fork; observer config >=14 mirrors firmware's OFFBAND_CONFIG_MIN_VER and is the one floor doing real work. Removing those would be a behaviour change, not a fix. Tests: the three gates asserted capable at ver 14 (4 assertions failed before this change), the bit still the whole gate, and the classifier proven to carry the stock protection on its own. A structural signature assertion was tried and removed: an optional positional parameter still satisfies the function type, so it passed while a deliberately reintroduced >=22 floor was in place. The behavioural assertions were verified to fail against that floor. Also adds the connector-level stock case at ver 14 to #877's test, which previously only covered a hypothetical stock device at 22. Full suite: 1738 passed. --- lib/connector/meshcore_connector.dart | 9 +- lib/connector/meshcore_protocol.dart | 55 ++++---- test/connector/caplog_cap_test.dart | 30 ++--- test/connector/offband_caps2_test.dart | 2 +- test/connector/pkt_hash_protocol_test.dart | 20 +-- .../vercode_floor_regression_test.dart | 126 ++++++++++++++++++ test/device_population_test.dart | 18 +++ 7 files changed, 201 insertions(+), 59 deletions(-) create mode 100644 test/connector/vercode_floor_regression_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b46f7240..2ea031f6 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -974,8 +974,7 @@ class MeshCoreConnector extends ChangeNotifier { DeviceUiSurface? get deviceUiErrorSurface => _deviceUiErrorSurface; /// Whether this radio answers the 0xC6 packet-hash query (#524/#611). - bool get supportsPktHash => - firmwareSupportsPktHash(_offbandCaps2, _firmwareVerCode); + bool get supportsPktHash => firmwareSupportsPktHash(_offbandCaps2); /// Whether to fetch CoreScope observer counts for outgoing channel messages: /// firmware supports the hash query AND the owner enabled the feature. @@ -1771,8 +1770,7 @@ class MeshCoreConnector extends ChangeNotifier { /// Whether the connected radio persists a block list + drops blocked DMs /// (Offband firmware, capability-gated). Absent → app-local block only. - bool get supportsOffbandBlock => - firmwareSupportsOffbandBlock(_offbandCaps, _firmwareVerCode); + bool get supportsOffbandBlock => firmwareSupportsOffbandBlock(_offbandCaps); /// True when the radio's block store hit `MAX_BLOCKED_KEYS` (32) and rejected /// an ADD (ok=0). Local block still applies; those keys just aren't portable. @@ -1787,8 +1785,7 @@ class MeshCoreConnector extends ChangeNotifier { /// Never shortcut this to a model or version check. bool get supportsOffbandFemLna => firmwareSupportsOffbandFemLna(_offbandCaps); - bool get supportsOffbandCaplog => - firmwareSupportsOffbandCaplog(_offbandCaps, _firmwareVerCode); + bool get supportsOffbandCaplog => firmwareSupportsOffbandCaplog(_offbandCaps); /// Current FEM LNA state as last reported by the radio, or null if unknown /// (pre-v16 firmware). Always reflects hardware truth, never a local guess. diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 5b3c9ab8..37bfef11 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -539,9 +539,9 @@ bool firmwareSupportsOffbandGps(int? offbandCaps) => offbandCaps != null; /// `OFFBAND_CAP_BLOCK` bit (bit 1) in the `offband_caps` byte of the device-info /// reply: an Offband radio that persists a block list and drops blocked DMs at -/// receive (firmware PR #247, `FIRMWARE_VER_CODE 15`). Unlike the loose GPS -/// presence-gate above, block requires the **explicit bit** set AND -/// `FIRMWARE_VER_CODE >= 15`; absent → app-only mode (no sync, no firmware drop). +/// receive (firmware PR #247). Unlike the loose GPS presence-gate above, block +/// requires the **explicit bit** set; absent → app-only mode (no sync, no +/// firmware drop). The bit alone, never the version code (#1065). const int offbandCapBlock = 0x02; /// `OFFBAND_CAP_FEM_LNA` bit (bit 2) in the `offband_caps` byte: this radio can @@ -559,25 +559,33 @@ const int offbandCapFemLna = 0x04; // POPULATION INVARIANT (#875-#877): every `offbandCaps`-anchored gate below is // safe against stock 1.18+ devices ONLY because the connector nulls the caps // bytes unless the population classifier corroborates the fork layout -// (device_population.dart). The ver-code floors in these gates are FORK -// contract numbers; stock reuses 14+ with different meanings, so a bare -// `firmwareVerCode >=` comparison may only be added for codes BELOW 14 -// (pre-split, same meaning everywhere: see supportsCompanionRadioStats >= 8 -// and firmwareSupportsRegionScope >= 13) or behind a caps-anchored gate here. +// (device_population.dart). +// +// NO CAPS-ANCHORED GATE MAY READ FIRMWARE_VER_CODE (#1065, owner ruling). The +// code rolled back to 14 to resolve the 1.18.0 collision, so a fork contract +// number is not a capability signal: a floor of >= 15, >= 17 or >= 22 goes +// false on firmware that genuinely has the feature. Those three floors were +// removed here, and they were never what protected stock radios -- the +// classifier is, by nulling the caps bytes a stock reply never carries. +// Capability is the BIT, which is also self-dating, since firmware predating a +// feature cannot set that feature's bit. +// +// The only surviving `firmwareVerCode >=` comparisons are the BITLESS ones for +// pre-split codes, which mean the same thing upstream and in the fork and have +// no bit to fall back on: supportsCompanionRadioStats >= 8 and +// firmwareSupportsRegionScope >= 13. Do not add another. bool firmwareSupportsOffbandFemLna(int? offbandCaps) => offbandCaps != null && (offbandCaps & offbandCapFemLna) != 0; -bool firmwareSupportsOffbandBlock(int? offbandCaps, int? firmwareVerCode) => - offbandCaps != null && - (offbandCaps & offbandCapBlock) != 0 && - (firmwareVerCode ?? 0) >= 15; +bool firmwareSupportsOffbandBlock(int? offbandCaps) => + offbandCaps != null && (offbandCaps & offbandCapBlock) != 0; /// Caplog serial-capture capability (firmware #427). The bit is compile-time /// static in device-info, so it's reliable across reboots, the client re-reads /// it on reconnect and never has to race a STATUS probe. Bit 5 (0x08/0x10 are -/// reserved for WiFi-companion #365 / display-config); requires FIRMWARE_VER_CODE -/// >= 17, the version that introduced it. +/// reserved for WiFi-companion #365 / display-config). Gated on the bit alone, +/// never the version code (#1065). const int offbandCapCaplog = 0x20; /// `offband_caps` BYTE 2 bits (device-info frame offset 84, firmware #508). @@ -637,10 +645,8 @@ bool firmwareSupportsScreenSettings(int? offbandCaps2) => bool firmwareSupportsGpsMode(int? offbandCaps2) => offbandCaps2 != null && (offbandCaps2 & offbandCap2GpsMode) != 0; -bool firmwareSupportsOffbandCaplog(int? offbandCaps, int? firmwareVerCode) => - offbandCaps != null && - (offbandCaps & offbandCapCaplog) != 0 && - (firmwareVerCode ?? 0) >= 17; +bool firmwareSupportsOffbandCaplog(int? offbandCaps) => + offbandCaps != null && (offbandCaps & offbandCapCaplog) != 0; /// Packet-hash query capability (firmware #611, cap byte 2 bit 3). Lets the /// client ask firmware for the authoritative on-air hash of a channel message @@ -649,13 +655,12 @@ bool firmwareSupportsOffbandCaplog(int? offbandCaps, int? firmwareVerCode) => /// PR merges; confirm against it before release. const int offbandCap2PktHash = 0x08; -/// True iff this radio supports the 0xC6 packet-hash query. Requires both the -/// cap-byte-2 bit AND FIRMWARE_VER_CODE >= 22. The client must never emit 0xC6 -/// unless this is true. -bool firmwareSupportsPktHash(int? offbandCaps2, int? firmwareVerCode) => - offbandCaps2 != null && - (offbandCaps2 & offbandCap2PktHash) != 0 && - (firmwareVerCode ?? 0) >= 22; +/// True iff this radio supports the 0xC6 packet-hash query: the cap-byte-2 bit +/// and nothing else (#1065). The client must never emit 0xC6 unless this is +/// true. Caps byte 2 only exists from the version that added device-info offset +/// 84, so the bit's presence already dates the firmware. +bool firmwareSupportsPktHash(int? offbandCaps2) => + offbandCaps2 != null && (offbandCaps2 & offbandCap2PktHash) != 0; /// 0xC6 CMD_OFFBAND_PKT_HASH (firmware #611). Client-issued query, never a push; /// the stock channel-send OK reply is untouched. diff --git a/test/connector/caplog_cap_test.dart b/test/connector/caplog_cap_test.dart index b3faf6ee..eaad6c31 100644 --- a/test/connector/caplog_cap_test.dart +++ b/test/connector/caplog_cap_test.dart @@ -3,30 +3,24 @@ import 'package:meshcore_open/connector/meshcore_protocol.dart'; void main() { group('firmwareSupportsOffbandCaplog', () { - test('true when 0x20 bit set AND version >= 17', () { - expect(firmwareSupportsOffbandCaplog(0x20, 17), isTrue); - expect( - firmwareSupportsOffbandCaplog(0x26, 17), - isTrue, - ); // block|fem|caplog - expect(firmwareSupportsOffbandCaplog(0x20, 18), isTrue); + test('true when the 0x20 bit is set', () { + expect(firmwareSupportsOffbandCaplog(0x20), isTrue); + expect(firmwareSupportsOffbandCaplog(0x26), isTrue); // block|fem|caplog }); test('false when the caplog bit is missing', () { - expect( - firmwareSupportsOffbandCaplog(0x06, 17), - isFalse, - ); // block|fem only - expect(firmwareSupportsOffbandCaplog(0x00, 17), isFalse); - }); - - test('false when version is below 17', () { - expect(firmwareSupportsOffbandCaplog(0x20, 16), isFalse); - expect(firmwareSupportsOffbandCaplog(0x20, null), isFalse); + expect(firmwareSupportsOffbandCaplog(0x06), isFalse); // block|fem only + expect(firmwareSupportsOffbandCaplog(0x00), isFalse); }); test('false when caps are unknown (null)', () { - expect(firmwareSupportsOffbandCaplog(null, 17), isFalse); + expect(firmwareSupportsOffbandCaplog(null), isFalse); }); + + // #1065: the gate used to AND `FIRMWARE_VER_CODE >= 17` on top of the bit, + // which went false on the rollback to 14. The version code is no longer a + // capability signal; the bit is the whole gate and nothing else is read. + // Stock radios are kept out by the population classifier nulling the caps + // bytes, not by a floor. See vercode_floor_regression_test.dart. }); } diff --git a/test/connector/offband_caps2_test.dart b/test/connector/offband_caps2_test.dart index 5d56073a..95562612 100644 --- a/test/connector/offband_caps2_test.dart +++ b/test/connector/offband_caps2_test.dart @@ -91,7 +91,7 @@ void main() { test('byte-1 gate predicates are unaffected by byte 2', () { final caps1 = MeshCoreConnector.parseOffbandCaps(frame); expect(firmwareSupportsOffbandFemLna(caps1), isTrue); - expect(firmwareSupportsOffbandBlock(caps1, 15), isTrue); + expect(firmwareSupportsOffbandBlock(caps1), isTrue); }); test('all three tail fields are independent', () { diff --git a/test/connector/pkt_hash_protocol_test.dart b/test/connector/pkt_hash_protocol_test.dart index ce6b234a..76e28d30 100644 --- a/test/connector/pkt_hash_protocol_test.dart +++ b/test/connector/pkt_hash_protocol_test.dart @@ -59,20 +59,22 @@ void main() { }); group('firmwareSupportsPktHash gate', () { - test('requires both the cap bit and ver >= 22', () { - expect(firmwareSupportsPktHash(0x08, 22), isTrue); - expect(firmwareSupportsPktHash(0x08, 23), isTrue); - expect(firmwareSupportsPktHash(0x08, 21), isFalse); // version too low - expect(firmwareSupportsPktHash(0x00, 22), isFalse); // bit clear - expect(firmwareSupportsPktHash(null, 22), isFalse); // no byte 2 - expect(firmwareSupportsPktHash(0x08, null), isFalse); // no version + // #1065: this used to also require `FIRMWARE_VER_CODE >= 22`, which went + // false on the rollback to 14. The cap bit is the whole gate now; caps + // byte 2 only exists from the version that added offset 84, so the bit + // already dates the firmware, and stock radios are excluded by the + // population classifier rather than by a version floor. + test('requires the cap bit, and nothing else', () { + expect(firmwareSupportsPktHash(0x08), isTrue); + expect(firmwareSupportsPktHash(0x00), isFalse); // bit clear + expect(firmwareSupportsPktHash(null), isFalse); // no byte 2 }); test('ignores unrelated bits', () { // 0x0A = button-matrix (0x02) + pkt-hash (0x08) both set. - expect(firmwareSupportsPktHash(0x0A, 22), isTrue); + expect(firmwareSupportsPktHash(0x0A), isTrue); // Only notify-scope (0x01) set: pkt-hash bit clear. - expect(firmwareSupportsPktHash(0x01, 22), isFalse); + expect(firmwareSupportsPktHash(0x01), isFalse); }); }); } diff --git a/test/connector/vercode_floor_regression_test.dart b/test/connector/vercode_floor_regression_test.dart new file mode 100644 index 00000000..0d08e3b8 --- /dev/null +++ b/test/connector/vercode_floor_regression_test.dart @@ -0,0 +1,126 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/device_population.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; + +/// #1065: FIRMWARE_VER_CODE is rolling back to 14 and no capability decision +/// may ride on it (owner ruling, Agent Mail 1401). Three shipped gates ANDed a +/// fork contract number on top of an already-correct caps-bit check, so they +/// went false on firmware that genuinely supports the feature. +/// +/// The stock protection these floors appeared to provide is actually carried by +/// the population classifier, which nulls both caps bytes unless the device-info +/// frame's SHAPE corroborates the fork layout. The last group here proves that, +/// so removing the floors cannot leak a fork frame to a stock radio. +void main() { + /// Stock replies are exactly 82 bytes. The fork appends caps byte 1 at 82, + /// the FEM LNA state at 83 and caps byte 2 at 84. + Uint8List deviceInfo({ + required int length, + int verCode = 14, + int caps1 = 0, + int femByte = 0, + int caps2 = 0, + }) { + final frame = Uint8List(length); + if (length >= 1) frame[0] = respCodeDeviceInfo; + if (length >= 2) frame[1] = verCode; + if (length >= 83) frame[82] = caps1; + if (length >= 84) frame[83] = femByte; + if (length >= 85) frame[84] = caps2; + return frame; + } + + group('#1065 the three gates must not read FIRMWARE_VER_CODE', () { + // The signatures take the caps byte and nothing else, so a version floor + // cannot be reintroduced by accident: there is no parameter to read. These + // assertions would have failed at ver 14 before the fix. + test('block list is capable with its bit set', () { + expect(firmwareSupportsOffbandBlock(offbandCapBlock), isTrue); + }); + + test('caplog is capable with its bit set', () { + expect(firmwareSupportsOffbandCaplog(offbandCapCaplog), isTrue); + }); + + test('packet hash is capable with its bit set', () { + expect(firmwareSupportsPktHash(offbandCap2PktHash), isTrue); + }); + + // A structural `isA()` assertion was tried here and + // REMOVED: an optional positional `[int? firmwareVerCode]` still satisfies + // that type, so the check passed while the floor was back in place. The + // behavioural assertions above are the real guard -- they were verified to + // fail against a deliberately reintroduced `>= 22` floor. + }); + + group('#1065 the bit is still the whole gate', () { + test('a clear bit is false', () { + expect(firmwareSupportsOffbandBlock(0x00), isFalse); + expect(firmwareSupportsOffbandCaplog(0x00), isFalse); + expect(firmwareSupportsPktHash(0x00), isFalse); + }); + + test('another feature bit does not open the gate', () { + expect(firmwareSupportsOffbandBlock(offbandCapFemLna), isFalse); + expect(firmwareSupportsOffbandCaplog(offbandCapBlock), isFalse); + expect(firmwareSupportsPktHash(offbandCap2NotifyScope), isFalse); + }); + + test('absent caps bytes are false, never an error', () { + expect(firmwareSupportsOffbandBlock(null), isFalse); + expect(firmwareSupportsOffbandCaplog(null), isFalse); + expect(firmwareSupportsPktHash(null), isFalse); + }); + }); + + group('#1065 the population classifier carries the stock protection', () { + test('a stock-shape frame at ver 14 is stockModern, not offband', () { + final c = classifyDevicePopulation( + deviceInfo: deviceInfo(length: stockDeviceInfoLength, verCode: 14), + firmwareVersion: 'v1.18.0-81dbae5', + ); + expect(c.population, DevicePopulation.stockModern); + expect(c.allowsOffbandFrames, isFalse); + }); + + test('a fork-shape frame at ver 14 is still offband', () { + // The case the floors broke: the fork reporting the rolled-back code. + final c = classifyDevicePopulation( + deviceInfo: deviceInfo( + length: 85, + verCode: 14, + caps1: offbandCapBlock | offbandCapCaplog, + caps2: offbandCap2PktHash, + ), + firmwareVersion: '1.4.0-1.17.0', + ); + expect(c.population, DevicePopulation.offband); + expect(c.allowsOffbandFrames, isTrue); + }); + + test('a stock radio yields no caps, so every gate is false', () { + // What the connector does at meshcore_connector.dart:7924-7938: the caps + // bytes are only parsed when the classifier allows fork frames. + final frame = deviceInfo(length: stockDeviceInfoLength, verCode: 14); + final c = classifyDevicePopulation( + deviceInfo: frame, + firmwareVersion: 'v1.18.0-81dbae5', + ); + final caps1 = c.allowsOffbandFrames + ? MeshCoreConnector.parseOffbandCaps(frame) + : null; + final caps2 = c.allowsOffbandFrames + ? MeshCoreConnector.parseOffbandCaps2(frame) + : null; + + expect(caps1, isNull); + expect(caps2, isNull); + expect(firmwareSupportsOffbandBlock(caps1), isFalse); + expect(firmwareSupportsOffbandCaplog(caps1), isFalse); + expect(firmwareSupportsPktHash(caps2), isFalse); + }); + }); +} diff --git a/test/device_population_test.dart b/test/device_population_test.dart index 1767b6c1..139e9716 100644 --- a/test/device_population_test.dart +++ b/test/device_population_test.dart @@ -314,5 +314,23 @@ void main() { // Pre-split floors (same meaning on every population) stay available. expect(connector.supportsCompanionRadioStats, isTrue); }); + + test('stock at ver 14 lights nothing either (#1065): the real stock code,' + ' now that the fork rolls back onto the same number', () { + // The case above uses 22, which no stock device reports. 14 is what real + // stock 1.18 sends AND what the fork reports after the rollback, so this + // is the collision in its actual form. The three gates whose version + // floors were removed in #1065 must still be false here, and they are, + // because the 82-byte shape classifies stockModern and the connector + // nulls both caps bytes. + connector.handleFrameForTest( + deviceInfo(verCode: 14, version: 'v1.18.0-81dbae5'), + ); + expect(connector.offbandCaps, isNull); + expect(connector.offbandCaps2, isNull); + expect(connector.supportsOffbandBlock, isFalse); + expect(connector.supportsOffbandCaplog, isFalse); + expect(connector.supportsPktHash, isFalse); + }); }); }