From 9c1e37a395b5660f85c4319255e80495228d3954 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 27 Sep 2026 12:40:53 +0200 Subject: [PATCH] Verify local firmware against uploaded image metadata --- .../lib/models/firmware_image_version.dart | 42 +++++++++ .../lib/models/firmware_version_matcher.dart | 23 ++++- .../models/fota_post_update_verification.dart | 17 +--- .../models/firmware_image_version_test.dart | 86 +++++++++++++++++++ .../models/firmware_version_matcher_test.dart | 8 ++ 5 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 open_wearable/lib/models/firmware_image_version.dart create mode 100644 open_wearable/test/models/firmware_image_version_test.dart diff --git a/open_wearable/lib/models/firmware_image_version.dart b/open_wearable/lib/models/firmware_image_version.dart new file mode 100644 index 00000000..3a37ef92 --- /dev/null +++ b/open_wearable/lib/models/firmware_image_version.dart @@ -0,0 +1,42 @@ +import 'dart:typed_data'; + +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'firmware_version_matcher.dart'; + +/// Read local image metadata after unpacking; filenames are arbitrary labels. +String? expectedFirmwareVersionForRequest(FirmwareUpdateRequest request) { + final firmware = request.firmware; + if (firmware is RemoteFirmware) { + return normalizeFirmwareVersion(firmware.version); + } + if (firmware is! LocalFirmware) return null; + + if (request is MultiImageFirmwareUpdateRequest) { + final applicationImages = + request.firmwareImages?.where((image) => image.image == 0).toList(); + if (applicationImages == null || applicationImages.length != 1) return null; + return _mcubootVersion(applicationImages.single.data); + } + if (request is SingleImageFirmwareUpdateRequest) { + return _mcubootVersion(firmware.data); + } + return null; +} + +String? _mcubootVersion(Uint8List bytes) { + // MCUboot image_header: little-endian magic, sizes, then image_version at 20. + if (bytes.length < 32) return null; + final header = ByteData.sublistView(bytes); + if (header.getUint32(0, Endian.little) != 0x96f3b83d) return null; + final headerSize = header.getUint16(8, Endian.little); + final imageSize = header.getUint32(12, Endian.little); + if (headerSize < 32 || + imageSize == 0 || + headerSize + imageSize > bytes.length) { + return null; + } + final base = '${header.getUint8(20)}.${header.getUint8(21)}.' + '${header.getUint16(22, Endian.little)}'; + final tweak = header.getUint32(24, Endian.little); + return tweak == 0 ? base : '$base.$tweak'; +} diff --git a/open_wearable/lib/models/firmware_version_matcher.dart b/open_wearable/lib/models/firmware_version_matcher.dart index fdf6c6ad..fe81f463 100644 --- a/open_wearable/lib/models/firmware_version_matcher.dart +++ b/open_wearable/lib/models/firmware_version_matcher.dart @@ -25,12 +25,29 @@ bool firmwareVersionsMatch(String? expected, String? actual) { return expectedPrNumber == actualPrNumber; } - return actualComparison.contains(expectedComparison) || - expectedComparison.contains(actualComparison); + final corePattern = RegExp(r'^\d+\.\d+\.\d+(?:\.\d+)?'); + final expectedCore = corePattern.firstMatch(expectedComparison)?.group(0); + final actualCore = corePattern.firstMatch(actualComparison)?.group(0); + if (expectedCore != null && + actualCore != null && + expectedCore != actualCore) { + return false; + } + + // A release may match its development/build suffix, never a partial number + // such as 2.2.9 inside 2.2.90 or 12.2.9. + bool containsVersion(String value, String version) => RegExp( + '(^|[^0-9A-Za-z])${RegExp.escape(version)}(?=\$|[^0-9A-Za-z])', + ).hasMatch(value); + return containsVersion(actualComparison, expectedComparison) || + containsVersion(expectedComparison, actualComparison); } String _comparisonValue(String value) { - return value.toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + return value + .toLowerCase() + .replaceAll(RegExp(r'\s+'), ' ') + .replaceFirst(RegExp(r'^v(?=\d+\.)'), ''); } String? _extractPullRequestNumber(String value) { diff --git a/open_wearable/lib/models/fota_post_update_verification.dart b/open_wearable/lib/models/fota_post_update_verification.dart index 54dac842..14b262a4 100644 --- a/open_wearable/lib/models/fota_post_update_verification.dart +++ b/open_wearable/lib/models/fota_post_update_verification.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/device_name_formatter.dart'; import 'package:open_wearable/models/firmware_version_matcher.dart'; +import 'package:open_wearable/models/firmware_image_version.dart'; /// Metadata returned when a post-update verification check is armed. class ArmedFotaPostUpdateVerification { @@ -99,7 +100,7 @@ class FotaPostUpdateVerificationCoordinator { _resolveSideLabelFromName(request.peripheral?.name), ); final expectedFirmwareVersion = - _extractExpectedFirmwareVersion(request.firmware); + expectedFirmwareVersionForRequest(request); if (expectedName == null && expectedDeviceId == null) { return null; @@ -449,20 +450,6 @@ class FotaPostUpdateVerificationCoordinator { _pendingIdsController.add(Set.unmodifiable(_pendingById.keys)); } - String? _extractExpectedFirmwareVersion(SelectedFirmware? firmware) { - if (firmware is RemoteFirmware) { - return normalizeFirmwareVersion(firmware.version); - } - - if (firmware is LocalFirmware) { - final match = - RegExp(r'(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)').firstMatch(firmware.name); - return normalizeFirmwareVersion(match?.group(1)); - } - - return null; - } - bool _firmwareVersionsMatch(String expected, String actual) { return firmwareVersionsMatch(expected, actual); } diff --git a/open_wearable/test/models/firmware_image_version_test.dart b/open_wearable/test/models/firmware_image_version_test.dart new file mode 100644 index 00000000..634901ea --- /dev/null +++ b/open_wearable/test/models/firmware_image_version_test.dart @@ -0,0 +1,86 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart' as mcumgr; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/firmware_image_version.dart'; +import 'package:open_wearable/models/firmware_version_matcher.dart'; + +Uint8List image(int major, int minor, int patch, {int tweak = 0}) { + final bytes = Uint8List(64); + final data = ByteData.sublistView(bytes); + data.setUint32(0, 0x96f3b83d, Endian.little); + data.setUint16(8, 32, Endian.little); + data.setUint32(12, 32, Endian.little); + data.setUint8(20, major); + data.setUint8(21, minor); + data.setUint16(22, patch, Endian.little); + data.setUint32(24, tweak, Endian.little); + return bytes; +} + +void main() { + test('local ZIP uses application header, not filename or network version', + () { + final request = MultiImageFirmwareUpdateRequest( + firmware: LocalFirmware( + name: 'OpenEarable-2.2.9-validation-e22638ad.zip', + data: Uint8List(0), + type: FirmwareType.multiImage,), + firmwareImages: [ + mcumgr.Image(image: 1, data: image(1, 0, 0)), + mcumgr.Image(image: 0, data: image(2, 2, 9)), + ], + ); + final expected = expectedFirmwareVersionForRequest(request); + expect(expected, '2.2.9'); + expect(firmwareVersionsMatch(expected, '2.2.9-dev.104+ge22638ad'), isTrue); + expect(firmwareVersionsMatch(expected, '2.2.8'), isFalse); + expect(firmwareVersionsMatch(expected, '2.2.90'), isFalse); + expect(firmwareVersionsMatch(expected, '12.2.9'), isFalse); + }); + test('local signed binary reads little-endian revision and tweak', () { + final request = SingleImageFirmwareUpdateRequest( + firmware: LocalFirmware( + name: 'arbitrary.bin', + data: image(2, 3, 260, tweak: 7), + type: FirmwareType.singleImage,),); + expect(expectedFirmwareVersionForRequest(request), '2.3.260.7'); + }); + test('malformed images do not fall back to a version-looking filename', () { + final invalid = [ + Uint8List(0), + Uint8List(32), + image(2, 2, 9).sublist(0, 40), + ]; + for (final data in invalid) { + final request = SingleImageFirmwareUpdateRequest( + firmware: LocalFirmware( + name: '2.2.9.bin', data: data, type: FirmwareType.singleImage,),); + expect(expectedFirmwareVersionForRequest(request), isNull); + } + }); + test('missing or duplicate application images remain unverified', () { + final request = MultiImageFirmwareUpdateRequest( + firmware: LocalFirmware( + name: '2.2.9.zip', + data: Uint8List(0), + type: FirmwareType.multiImage,),); + expect(expectedFirmwareVersionForRequest(request), isNull); + request.firmwareImages = [mcumgr.Image(image: 1, data: image(1, 0, 0))]; + expect(expectedFirmwareVersionForRequest(request), isNull); + request.firmwareImages = [ + mcumgr.Image(image: 0, data: image(2, 2, 9)), + mcumgr.Image(image: 0, data: image(2, 2, 8)), + ]; + expect(expectedFirmwareVersionForRequest(request), isNull); + }); + test('remote release labels keep their existing verification behavior', () { + final request = FirmwareUpdateRequest( + firmware: RemoteFirmware( + name: 'release', + version: '2.2.9', + url: 'https://example.test/fw.zip', + type: FirmwareType.multiImage,),); + expect(expectedFirmwareVersionForRequest(request), '2.2.9'); + }); +} diff --git a/open_wearable/test/models/firmware_version_matcher_test.dart b/open_wearable/test/models/firmware_version_matcher_test.dart index 4550b26b..eb072b81 100644 --- a/open_wearable/test/models/firmware_version_matcher_test.dart +++ b/open_wearable/test/models/firmware_version_matcher_test.dart @@ -32,6 +32,14 @@ void main() { expect(firmwareVersionsMatch('PR #123', '2.2.7'), isFalse); }); + test('release tag prefixes match device labels without partial numbers', + () { + expect( + firmwareVersionsMatch('v2.2.9', '2.2.9-dev.104+ge22638ad'), isTrue,); + expect(firmwareVersionsMatch('v2.2.9', '2.2.90'), isFalse); + expect(firmwareVersionsMatch('2.2.9.1', '2.2.9'), isFalse); + }); + test('keeps existing stable version containment behavior', () { expect(firmwareVersionsMatch('2.2.7', '2.2.7+1'), isTrue); expect(firmwareVersionsMatch('2.2.7+1', '2.2.7'), isTrue);