From 9756f47722b2f0ab0c9064932b1865643b89ead9 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Thu, 16 Oct 2025 13:40:25 +0100 Subject: [PATCH 01/17] Update Gradle so project can be build in latest Android Studio --- .gitignore | 4 ++++ android/build.gradle | 15 +++++++-------- android/gradle/wrapper/gradle-wrapper.properties | 3 ++- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 2870ebc..bb984be 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ .gradle .dart_tool pubspec.lock +.idea +android/local.properties +android/build/reports/problems/problems-report.html +AGENTS.md diff --git a/android/build.gradle b/android/build.gradle index 4927bae..15b7744 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -4,18 +4,17 @@ version '1.0' buildscript { repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'com.android.tools.build:gradle:8.5.0' } } rootProject.allprojects { repositories { google() - jcenter() mavenCentral() } } @@ -25,18 +24,18 @@ apply plugin: 'com.android.library' android { namespace 'com.criticalblue.approov_service_flutter_httpclient' - compileSdkVersion 29 + compileSdk 34 defaultConfig { - minSdkVersion 19 + minSdk 21 } - lintOptions { + lint { disable 'InvalidPackage' } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 01a286e..b423410 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Thu Oct 16 13:35:42 BST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip From 30906b11f76a591db80f8c8e84b52957d02ce2c5 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Thu, 16 Oct 2025 14:29:47 +0100 Subject: [PATCH 02/17] Implement message signing functionality and add tests for HTTP message signatures - untested --- .../ApproovHttpClientPlugin.java | 7 + ios/Classes/ApproovHttpClientPlugin.m | 18 +- lib/approov_service_flutter_httpclient.dart | 198 +++++++- lib/src/message_signing.dart | 435 ++++++++++++++++++ test/approov_http_client_test.dart | 48 ++ 5 files changed, 703 insertions(+), 3 deletions(-) create mode 100644 lib/src/message_signing.dart diff --git a/android/src/main/java/com/criticalblue/approov_service_flutter_httpclient/ApproovHttpClientPlugin.java b/android/src/main/java/com/criticalblue/approov_service_flutter_httpclient/ApproovHttpClientPlugin.java index a70f87c..7726900 100644 --- a/android/src/main/java/com/criticalblue/approov_service_flutter_httpclient/ApproovHttpClientPlugin.java +++ b/android/src/main/java/com/criticalblue/approov_service_flutter_httpclient/ApproovHttpClientPlugin.java @@ -341,6 +341,13 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { } catch(Exception e) { result.error("Approov.getMessageSignature", e.getLocalizedMessage(), null); } + } else if (call.method.equals("getInstallMessageSignature")) { + try { + String messageSignature = Approov.getInstallMessageSignature((String) call.argument("message")); + result.success(messageSignature); + } catch(Exception e) { + result.error("Approov.getInstallMessageSignature", e.getLocalizedMessage(), null); + } } else if (call.method.equals("setUserProperty")) { try { Approov.setUserProperty(call.argument("property")); diff --git a/ios/Classes/ApproovHttpClientPlugin.m b/ios/Classes/ApproovHttpClientPlugin.m index 8444c34..735ae09 100644 --- a/ios/Classes/ApproovHttpClientPlugin.m +++ b/ios/Classes/ApproovHttpClientPlugin.m @@ -472,7 +472,23 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result [Approov setDevKey:call.arguments[@"devKey"]]; result(nil); } else if ([@"getMessageSignature" isEqualToString:call.method]) { - result([Approov getMessageSignature:call.arguments[@"message"]]); + @try { + result([Approov getMessageSignature:call.arguments[@"message"]]); + } + @catch (NSException *exception) { + result([FlutterError errorWithCode:@"Approov.getMessageSignature" + message:exception.reason + details:nil]); + } + } else if ([@"getInstallMessageSignature" isEqualToString:call.method]) { + @try { + result([Approov getInstallMessageSignature:call.arguments[@"message"]]); + } + @catch (NSException *exception) { + result([FlutterError errorWithCode:@"Approov.getInstallMessageSignature" + message:exception.reason + details:nil]); + } } else if ([@"setUserProperty" isEqualToString:call.method]) { [Approov setUserProperty:call.arguments[@"property"]]; result(nil); diff --git a/lib/approov_service_flutter_httpclient.dart b/lib/approov_service_flutter_httpclient.dart index 2051862..7ed326f 100644 --- a/lib/approov_service_flutter_httpclient.dart +++ b/lib/approov_service_flutter_httpclient.dart @@ -21,6 +21,7 @@ import 'dart:convert'; import 'dart:core'; import 'dart:io'; import 'dart:math'; +import 'dart:typed_data'; import 'package:asn1lib/asn1lib.dart'; import 'package:crypto/crypto.dart'; @@ -33,6 +34,16 @@ import 'package:http/io_client.dart' as httpio; import 'package:logger/logger.dart'; import 'package:pem/pem.dart'; import 'package:mutex/mutex.dart'; +import 'src/message_signing.dart'; +export 'src/message_signing.dart' + show + ApproovMessageSigning, + ApproovSigningContext, + SignatureAlgorithm, + SignatureBaseBuilder, + SignatureDigest, + SignatureParameters, + SignatureParametersFactory; // Logger final Logger Log = Logger(); @@ -227,6 +238,10 @@ class ApproovService { // map of URL regexs that should be excluded from any Approov protection, mapped to the regular expressions static Map _exclusionURLRegexs = {}; + // configuration for automatically signing outbound requests using Approov + static ApproovMessageSigning? _messageSigning; + static bool _installMessageSigningAvailable = true; + // cached host certificates obtaining from probing the relevant host domains static Map?> _hostCertificates = Map?>(); @@ -254,6 +269,14 @@ class ApproovService { } } + static Map> _snapshotHeaders(HttpHeaders headers) { + final snapshot = >{}; + headers.forEach((name, values) { + snapshot[name] = List.from(values); + }); + return snapshot; + } + /// Initialize the Approov SDK. This must be called prior to any other methods on the ApproovService. This does not /// actually initialize the SDK at this point, but sets up the intialization which can then be awaited on by other /// methods which need it to be initialized. @@ -386,6 +409,30 @@ class ApproovService { _approovTokenPrefix = prefix; } + /// Enables automatic message signing for outgoing requests. The Approov SDK provides the signing key after a + /// successful attestation and the resulting signature is attached to each protected request via the standard + /// `Signature` and `Signature-Input` headers as defined by the HTTP Message Signatures specification. Provide + /// a [defaultFactory] to control which components are included in the canonical representation, or optionally + /// override the configuration for specific hosts via [hostFactories]. + static void enableMessageSigning({ + SignatureParametersFactory? defaultFactory, + Map? hostFactories, + }) { + final messageSigning = ApproovMessageSigning(); + messageSigning.setDefaultFactory(defaultFactory ?? SignatureParametersFactory.generateDefaultFactory()); + if (hostFactories != null) { + hostFactories.forEach((host, factory) => messageSigning.putHostFactory(host, factory)); + } + _messageSigning = messageSigning; + Log.d("$TAG: enableMessageSigning configured"); + } + + /// Disables automatic Approov message signing for subsequent requests. + static void disableMessageSigning() { + if (_messageSigning != null) Log.d("$TAG: disableMessageSigning"); + _messageSigning = null; + } + /// Sets a binding header that must be present on all requests using the Approov service. A /// header should be chosen whose value is unchanging for most requests (such as an /// Authorization header). A hash of the header value is included in the issued Approov tokens @@ -1004,8 +1051,9 @@ class ApproovService { /// information about the reason for the rejection. /// /// @param request is the HttpClientRequest to which Approov is being added + /// @param pendingBodyBytes holds any buffered body bytes available before the request is sent, or null for streaming /// @throws ApproovException if it is not possible to obtain an Approov token or perform required header substitutions - static Future _updateRequest(HttpClientRequest request) async { + static Future _updateRequest(HttpClientRequest request, Uint8List? pendingBodyBytes) async { // check if the URL matches one of the exclusion regexs and just return if so await _requireInitialized(); String url = request.uri.toString(); @@ -1137,6 +1185,100 @@ class ApproovService { throw new ApproovException("Header substitution for $header: ${fetchResult.tokenFetchStatus.name}"); } } + + if (_messageSigning != null && fetchResult.tokenFetchStatus == _TokenFetchStatus.SUCCESS) { + try { + await _applyMessageSigning(request, pendingBodyBytes); + } on ApproovException { + rethrow; + } catch (err) { + throw ApproovException("Message signing failed: $err"); + } + } + } + + static Future _applyMessageSigning(HttpClientRequest request, Uint8List? pendingBodyBytes) async { + final messageSigning = _messageSigning; + if (messageSigning == null) return; + + final context = ApproovSigningContext( + requestMethod: request.method, + uri: request.uri, + headers: _snapshotHeaders(request.headers), + bodyBytes: pendingBodyBytes, + tokenHeaderName: _approovTokenHeader.isEmpty ? null : _approovTokenHeader, + onSetHeader: (name, value) => request.headers.set(name, value, preserveHeaderCase: true), + onAddHeader: (name, value) => request.headers.add(name, value, preserveHeaderCase: true), + ); + + final params = messageSigning.buildParametersFor(request.uri, context); + if (params == null) { + Log.d("$TAG: no message signing parameters for ${request.uri}"); + return; + } + + final signatureBase = SignatureBaseBuilder(params, context).createSignatureBase(); + String signature; + try { + signature = await _signCanonicalMessage(signatureBase, params.algorithm); + } on StateError { + if (params.algorithm == SignatureAlgorithm.ecdsaP256Sha256) { + Log.w("$TAG: install message signing unavailable, falling back to account signing"); + params.algorithm = SignatureAlgorithm.hmacSha256; + params.setAlg('hmac-sha256'); + signature = await _signCanonicalMessage(signatureBase, params.algorithm); + } else { + rethrow; + } + } + if (signature.isEmpty) { + Log.d("$TAG: message signing returned empty signature for ${request.uri}"); + return; + } + + final signatureLabel = params.signatureLabel(); + final signatureHeader = '$signatureLabel=:${signature}:'; + context.setHeader('Signature', signatureHeader); + + final signatureInput = '$signatureLabel=${params.serializeComponentValue()}'; + context.setHeader('Signature-Input', signatureInput); + + if (params.debugMode) { + final digest = sha256.convert(utf8.encode(signatureBase)).bytes; + final baseDigestHeader = 'sha-256=:${base64Encode(digest)}:'; + context.setHeader('Signature-Base-Digest', baseDigestHeader); + } + } + + static Future _signCanonicalMessage(String message, SignatureAlgorithm algorithm) async { + switch (algorithm) { + case SignatureAlgorithm.ecdsaP256Sha256: + return await _getInstallMessageSignature(message); + case SignatureAlgorithm.hmacSha256: + default: + return await getMessageSignature(message); + } + } + + static Future _getInstallMessageSignature(String message) async { + if (!_installMessageSigningAvailable) { + throw StateError('install message signing not supported'); + } + try { + final result = await _fgChannel.invokeMethod('getInstallMessageSignature', { + "message": message, + }); + if (result != null && result.isNotEmpty) return result; + throw StateError('install message signature empty'); + } on MissingPluginException { + _installMessageSigningAvailable = false; + Log.w("$TAG: getInstallMessageSignature not available on this platform"); + throw StateError('install message signing not supported'); + } catch (err) { + _installMessageSigningAvailable = false; + Log.w("$TAG: getInstallMessageSignature error: $err"); + throw StateError('install message signing not supported'); + } } /// Retrieves the certificates in the chain for the specified URL. These may be cached based on the host @@ -1398,6 +1540,9 @@ class _ApproovHttpClientRequest implements HttpClientRequest { // true if the request has been updated with Approov related headers bool _requestUpdated = false; + // true if the body will be provided through a stream, meaning we cannot cache the payload bytes + bool _hasStreamBody = false; + // Construct a new _ApproovHttpClientRequest that delegates to the given request. This adds Approov as late as possible while // the headers are still mutable. // @@ -1411,8 +1556,9 @@ class _ApproovHttpClientRequest implements HttpClientRequest { // Thus pending write operations are held and issue after the header updates. Future _updateRequestIfRequired() async { if (!_requestUpdated) { + final Uint8List? pendingBodyBytes = _snapshotPendingBodyBytes(); // update the request while the headers can still be mutated - await ApproovService._updateRequest(_delegateRequest); + await ApproovService._updateRequest(_delegateRequest, pendingBodyBytes); _requestUpdated = true; // now perform any pending write operations @@ -1423,6 +1569,53 @@ class _ApproovHttpClientRequest implements HttpClientRequest { } } + Uint8List? _snapshotPendingBodyBytes() { + if (_hasStreamBody) { + return null; + } + if (_pendingWriteOps.isEmpty) { + return Uint8List(0); + } + final encoding = _delegateRequest.encoding ?? utf8; + final builder = BytesBuilder(copy: false); + for (final pending in _pendingWriteOps) { + switch (pending.type) { + case _WriteOpType.add: + if (pending.data != null) builder.add(pending.data!); + break; + case _WriteOpType.write: + final str = pending.object?.toString() ?? ""; + builder.add(encoding.encode(str)); + break; + case _WriteOpType.writeAll: + if (pending.objects != null) { + final sep = pending.separator ?? ""; + var isFirst = true; + for (final element in pending.objects!) { + if (!isFirst && sep.isNotEmpty) { + builder.add(encoding.encode(sep)); + } + final str = element?.toString() ?? ""; + builder.add(encoding.encode(str)); + isFirst = false; + } + } + break; + case _WriteOpType.writeCharCode: + builder.add(encoding.encode(String.fromCharCode(pending.charCode))); + break; + case _WriteOpType.writeln: + final str = pending.object?.toString() ?? ""; + builder.add(encoding.encode(str)); + builder.add(encoding.encode("\n")); + break; + default: + break; + } + } + return builder.toBytes(); + } + @override set bufferOutput(bool _bufferOutput) => _delegateRequest.bufferOutput = _bufferOutput; @override @@ -1500,6 +1693,7 @@ class _ApproovHttpClientRequest implements HttpClientRequest { @override Future addStream(Stream> stream) async { + _hasStreamBody = true; await _updateRequestIfRequired(); return _delegateRequest.addStream(stream); } diff --git a/lib/src/message_signing.dart b/lib/src/message_signing.dart new file mode 100644 index 0000000..29f4693 --- /dev/null +++ b/lib/src/message_signing.dart @@ -0,0 +1,435 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Signature algorithms supported by the Approov message signing flow. +enum SignatureAlgorithm { + hmacSha256, + ecdsaP256Sha256, +} + +/// Represents a HTTP Structured Field string item with optional parameters. +class SfStringItem { + SfStringItem(this.value, [Map? parameters]) + : parameters = LinkedHashMap.of(parameters ?? const {}); + + final String value; + final LinkedHashMap parameters; + + String serialize() { + final buffer = StringBuffer(); + buffer.write(_serializeSfString(value)); + parameters.forEach((key, v) { + buffer.write(';'); + buffer.write(key); + buffer.write('='); + buffer.write(_serializeSfString(v)); + }); + return buffer.toString(); + } +} + +/// Holds configuration for message signature parameters, mirroring the Swift implementation. +class SignatureParameters { + SignatureParameters(); + + SignatureParameters.copy(SignatureParameters other) + : _componentIdentifiers = List.from(other._componentIdentifiers), + _parameters = LinkedHashMap.of(other._parameters), + debugMode = other.debugMode, + algorithm = other.algorithm; + + final List _componentIdentifiers = []; + final LinkedHashMap _parameters = LinkedHashMap(); + + bool debugMode = false; + SignatureAlgorithm algorithm = SignatureAlgorithm.hmacSha256; + + List get componentIdentifiers => List.unmodifiable(_componentIdentifiers); + + void addComponentIdentifier(String identifier, {Map? parameters}) { + final normalized = identifier.startsWith('@') ? identifier : identifier.toLowerCase(); + if (_componentIdentifiers.any((item) => item.value == normalized && _parametersMatch(item.parameters, parameters))) { + return; + } + _componentIdentifiers.add(SfStringItem(normalized, parameters)); + } + + bool _parametersMatch(Map existing, Map? candidate) { + if (candidate == null || candidate.isEmpty) return existing.isEmpty; + if (existing.length != candidate.length) return false; + for (final entry in candidate.entries) { + if (existing[entry.key] != entry.value) return false; + } + return true; + } + + void setAlg(String value) { + _parameters['alg'] = value; + } + + void setCreated(int timestampSeconds) { + _parameters['created'] = timestampSeconds; + } + + void setExpires(int timestampSeconds) { + _parameters['expires'] = timestampSeconds; + } + + void setKeyId(String keyId) { + _parameters['keyid'] = keyId; + } + + void setNonce(String nonce) { + _parameters['nonce'] = nonce; + } + + void setTag(String tag) { + _parameters['tag'] = tag; + } + + String signatureLabel() { + switch (algorithm) { + case SignatureAlgorithm.ecdsaP256Sha256: + return 'install'; + case SignatureAlgorithm.hmacSha256: + default: + return 'account'; + } + } + + SfStringItem signatureParamsIdentifier() => SfStringItem('@signature-params'); + + String serializeComponentValue() { + final buffer = StringBuffer(); + buffer.write('('); + for (var i = 0; i < _componentIdentifiers.length; i++) { + if (i > 0) buffer.write(' '); + buffer.write(_componentIdentifiers[i].serialize()); + } + buffer.write(')'); + _parameters.forEach((key, value) { + buffer.write(';'); + buffer.write(key); + buffer.write('='); + buffer.write(_serializeParameter(value)); + }); + return buffer.toString(); + } +} + +class SignatureParametersFactory { + SignatureParametersFactory(); + + SignatureParameters? _baseParameters; + String? _bodyDigestAlgorithm; + bool _bodyDigestRequired = false; + bool _useAccountMessageSigning = true; + bool _addCreated = false; + int _expiresLifetimeSeconds = 0; + bool _addApproovTokenHeader = false; + final List _optionalHeaders = []; + bool _debugMode = false; + + SignatureParametersFactory setBaseParameters(SignatureParameters base) { + _baseParameters = SignatureParameters.copy(base); + return this; + } + + SignatureParametersFactory setBodyDigestConfig(String? algorithm, {required bool required}) { + if (algorithm != null && + algorithm != SignatureDigest.sha256.identifier && + algorithm != SignatureDigest.sha512.identifier) { + throw ArgumentError('Unsupported body digest algorithm: $algorithm'); + } + _bodyDigestAlgorithm = algorithm; + _bodyDigestRequired = required; + return this; + } + + SignatureParametersFactory setUseInstallMessageSigning() { + _useAccountMessageSigning = false; + return this; + } + + SignatureParametersFactory setUseAccountMessageSigning() { + _useAccountMessageSigning = true; + return this; + } + + SignatureParametersFactory setAddCreated(bool addCreated) { + _addCreated = addCreated; + return this; + } + + SignatureParametersFactory setExpiresLifetime(int seconds) { + _expiresLifetimeSeconds = seconds; + return this; + } + + SignatureParametersFactory setAddApproovTokenHeader(bool add) { + _addApproovTokenHeader = add; + return this; + } + + SignatureParametersFactory addOptionalHeaders(List headers) { + for (final header in headers) { + final normalized = header.toLowerCase(); + if (!_optionalHeaders.contains(normalized)) { + _optionalHeaders.add(normalized); + } + } + return this; + } + + SignatureParametersFactory setDebugMode(bool debugMode) { + _debugMode = debugMode; + return this; + } + + SignatureParameters build(ApproovSigningContext context) { + final params = _baseParameters != null ? SignatureParameters.copy(_baseParameters!) : SignatureParameters(); + params.debugMode = _debugMode; + params.algorithm = _useAccountMessageSigning ? SignatureAlgorithm.hmacSha256 : SignatureAlgorithm.ecdsaP256Sha256; + params.setAlg(_useAccountMessageSigning ? 'hmac-sha256' : 'ecdsa-p256-sha256'); + + final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + if (_addCreated) params.setCreated(now); + if (_expiresLifetimeSeconds > 0) params.setExpires(now + _expiresLifetimeSeconds); + + if (_addApproovTokenHeader) { + final tokenHeader = context.tokenHeaderName; + if (tokenHeader != null && context.hasField(tokenHeader)) { + params.addComponentIdentifier(tokenHeader); + } + } + + for (final header in _optionalHeaders) { + if (context.hasField(header)) { + params.addComponentIdentifier(header); + } + } + + if (_bodyDigestAlgorithm != null) { + final digestHeader = + context.ensureContentDigest(SignatureDigest.fromIdentifier(_bodyDigestAlgorithm!), required: _bodyDigestRequired); + if (digestHeader != null) { + params.addComponentIdentifier('content-digest'); + } + } + + return params; + } + + static SignatureParametersFactory generateDefaultFactory({SignatureParameters? overrideBase}) { + final base = overrideBase ?? + (SignatureParameters() + ..addComponentIdentifier('@method') + ..addComponentIdentifier('@target-uri')); + return SignatureParametersFactory() + .setBaseParameters(base) + .setUseInstallMessageSigning() + .setAddCreated(true) + .setExpiresLifetime(15) + .setAddApproovTokenHeader(true) + .addOptionalHeaders(const ['authorization', 'content-length', 'content-type']) + .setBodyDigestConfig(SignatureDigest.sha256.identifier, required: false); + } +} + +class SignatureBaseBuilder { + SignatureBaseBuilder(this.params, this.context); + + final SignatureParameters params; + final ApproovSigningContext context; + + String createSignatureBase() { + final buffer = StringBuffer(); + for (final component in params.componentIdentifiers) { + final value = context.getComponentValue(component); + if (value == null) { + throw StateError('Missing component value for ${component.value}'); + } + buffer.write(component.serialize()); + buffer.write(': '); + buffer.writeln(value); + } + final signatureParamsItem = params.signatureParamsIdentifier(); + buffer.write(signatureParamsItem.serialize()); + buffer.write(': '); + buffer.write(params.serializeComponentValue()); + return buffer.toString(); + } +} + +enum SignatureDigest { + sha256('sha-256'), + sha512('sha-512'); + + const SignatureDigest(this.identifier); + final String identifier; + + static SignatureDigest fromIdentifier(String id) { + return SignatureDigest.values.firstWhere( + (value) => value.identifier == id, + orElse: () => throw ArgumentError('Unsupported digest identifier: $id'), + ); + } +} + +class ApproovSigningContext { + ApproovSigningContext({ + required this.requestMethod, + required this.uri, + required Map> headers, + required this.bodyBytes, + required this.tokenHeaderName, + this.onSetHeader, + this.onAddHeader, + }) : _headers = LinkedHashMap>.fromEntries( + headers.entries.map((entry) => MapEntry(entry.key.toLowerCase(), List.from(entry.value)))); + + final String requestMethod; + final Uri uri; + final Uint8List? bodyBytes; + final String? tokenHeaderName; + final LinkedHashMap> _headers; + + final void Function(String name, String value)? onSetHeader; + final void Function(String name, String value)? onAddHeader; + + bool hasField(String name) => _headers.containsKey(name.toLowerCase()); + + void setHeader(String name, String value) { + _headers[name.toLowerCase()] = [value]; + onSetHeader?.call(name, value); + } + + void addHeader(String name, String value) { + _headers.putIfAbsent(name.toLowerCase(), () => []).add(value); + onAddHeader?.call(name, value); + } + + String? getComponentValue(SfStringItem component) { + final identifier = component.value; + if (identifier.startsWith('@')) { + switch (identifier) { + case '@method': + return requestMethod.toUpperCase(); + case '@authority': + return _authority(); + case '@scheme': + return uri.scheme; + case '@target-uri': + return uri.toString(); + case '@request-target': + return _requestTarget(); + case '@path': + return uri.path.isEmpty ? '/' : uri.path; + case '@query': + return uri.hasQuery ? uri.query : ''; + case '@query-param': + final name = component.parameters['name']; + if (name == null) { + throw StateError('Missing name parameter for @query-param'); + } + return _queryParameterValue(name); + default: + throw StateError('Unknown derived component: $identifier'); + } + } else { + final values = _headers[identifier.toLowerCase()]; + if (values == null || values.isEmpty) return null; + return _combineFieldValues(values); + } + } + + String? ensureContentDigest(SignatureDigest digest, {required bool required}) { + if (bodyBytes == null) { + return required ? throw StateError('Body digest required but body is not available') : null; + } + final bytes = switch (digest) { + SignatureDigest.sha256 => sha256.convert(bodyBytes!).bytes, + SignatureDigest.sha512 => sha512.convert(bodyBytes!).bytes, + }; + final headerValue = '${digest.identifier}=:${base64Encode(bytes)}:'; + setHeader('Content-Digest', headerValue); + return headerValue; + } + + String _authority() { + if ((uri.scheme == 'http' && uri.port == 80) || (uri.scheme == 'https' && uri.port == 443) || (uri.port == 0)) { + return uri.host; + } + return '${uri.host}:${uri.port}'; + } + + String _requestTarget() { + final path = uri.path.isEmpty ? '/' : uri.path; + if (!uri.hasQuery) return path; + return '$path?${uri.query}'; + } + + String? _queryParameterValue(String name) { + final values = uri.queryParametersAll[name]; + if (values == null) return null; + if (values.length > 1) return null; + return values.isEmpty ? '' : values.first; + } + + String _combineFieldValues(List values) { + final cleaned = values.map((value) { + final trimmed = value.trim(); + return trimmed.replaceAll(RegExp(r'\s*\r\n\s*'), ' '); + }).toList(); + return cleaned.join(', '); + } + + Map> snapshotHeaders() => LinkedHashMap.of(_headers); +} + +class ApproovMessageSigning { + SignatureParametersFactory? _defaultFactory; + final Map _hostFactories = {}; + + ApproovMessageSigning setDefaultFactory(SignatureParametersFactory factory) { + _defaultFactory = factory; + return this; + } + + ApproovMessageSigning putHostFactory(String host, SignatureParametersFactory factory) { + _hostFactories[host] = factory; + return this; + } + + SignatureParametersFactory? _factoryForHost(String host) { + return _hostFactories[host] ?? _defaultFactory; + } + + SignatureParameters? buildParametersFor(Uri uri, ApproovSigningContext context) { + final factory = _factoryForHost(uri.host); + if (factory == null) return null; + return factory.build(context); + } +} + +String _serializeParameter(dynamic value) { + if (value is String) { + return _serializeSfString(value); + } else if (value is int) { + return value.toString(); + } else if (value is bool) { + return value ? '?1' : '?0'; + } else if (value is Uint8List) { + return ':${base64Encode(value)}:'; + } else { + throw ArgumentError('Unsupported parameter type: ${value.runtimeType}'); + } +} + +String _serializeSfString(String value) { + final escaped = value.replaceAll('\\', r'\\').replaceAll('"', r'\"'); + return '"$escaped"'; +} diff --git a/test/approov_http_client_test.dart b/test/approov_http_client_test.dart index 32c7b69..d952f79 100644 --- a/test/approov_http_client_test.dart +++ b/test/approov_http_client_test.dart @@ -1,3 +1,8 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:approov_service_flutter_httpclient/approov_service_flutter_httpclient.dart'; +import 'package:crypto/crypto.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -17,4 +22,47 @@ void main() { }); test('getPlatformVersion', () async {}); + + test('signature base matches HTTP message signatures format', () { + final bodyBytes = Uint8List.fromList(utf8.encode('{"hello":"world"}')); + final headers = >{ + 'host': ['api.example.com'], + 'content-type': ['application/json'], + 'approov-token': ['Bearer token'], + }; + final context = ApproovSigningContext( + requestMethod: 'post', + uri: Uri.parse('https://api.example.com/v1/resource?b=2&a=1&b=1'), + headers: headers, + bodyBytes: bodyBytes, + tokenHeaderName: 'Approov-Token', + onSetHeader: (name, value) => headers[name.toLowerCase()] = [value], + onAddHeader: (name, value) => headers.putIfAbsent(name.toLowerCase(), () => []).add(value), + ); + + final factory = SignatureParametersFactory() + .setBaseParameters(SignatureParameters() + ..addComponentIdentifier('@method') + ..addComponentIdentifier('@target-uri')) + .setUseAccountMessageSigning() + .setAddApproovTokenHeader(true) + .addOptionalHeaders(const ['content-type']) + .setBodyDigestConfig(SignatureDigest.sha256.identifier, required: false); + + final params = factory.build(context); + final signatureBase = SignatureBaseBuilder(params, context).createSignatureBase(); + + final digestHeader = 'sha-256=:${base64Encode(sha256.convert(bodyBytes).bytes)}:'; + expect(headers['content-digest'], [digestHeader]); + final expectedString = [ + '"@method": POST', + '"@target-uri": https://api.example.com/v1/resource?b=2&a=1&b=1', + '"approov-token": Bearer token', + '"content-type": application/json', + '"content-digest": $digestHeader', + '"@signature-params": ("@method" "@target-uri" "approov-token" "content-type" "content-digest");alg="hmac-sha256"' + ].join('\n'); + + expect(signatureBase, expectedString); + }); } From a9d10f0e93aab08baf3dfd4f1429f00511b70ae0 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Thu, 16 Oct 2025 14:37:59 +0100 Subject: [PATCH 03/17] Build fix --- .gitignore | 1 + lib/src/message_signing.dart | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index bb984be..48b3991 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ pubspec.lock android/local.properties android/build/reports/problems/problems-report.html AGENTS.md +build/ \ No newline at end of file diff --git a/lib/src/message_signing.dart b/lib/src/message_signing.dart index 29f4693..7d9508a 100644 --- a/lib/src/message_signing.dart +++ b/lib/src/message_signing.dart @@ -33,7 +33,9 @@ class SfStringItem { /// Holds configuration for message signature parameters, mirroring the Swift implementation. class SignatureParameters { - SignatureParameters(); + SignatureParameters() + : _componentIdentifiers = [], + _parameters = LinkedHashMap(); SignatureParameters.copy(SignatureParameters other) : _componentIdentifiers = List.from(other._componentIdentifiers), @@ -41,8 +43,8 @@ class SignatureParameters { debugMode = other.debugMode, algorithm = other.algorithm; - final List _componentIdentifiers = []; - final LinkedHashMap _parameters = LinkedHashMap(); + final List _componentIdentifiers; + final LinkedHashMap _parameters; bool debugMode = false; SignatureAlgorithm algorithm = SignatureAlgorithm.hmacSha256; From 23aaae71b37f5a1a60b1975e67a9f903a1fc1285 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Fri, 17 Oct 2025 09:38:38 +0100 Subject: [PATCH 04/17] Add debug logging for message signing process. Currently message signature is malformed - work put on hold --- lib/approov_service_flutter_httpclient.dart | 115 +++++++++++++++++++- 1 file changed, 110 insertions(+), 5 deletions(-) diff --git a/lib/approov_service_flutter_httpclient.dart b/lib/approov_service_flutter_httpclient.dart index 7ed326f..04826fd 100644 --- a/lib/approov_service_flutter_httpclient.dart +++ b/lib/approov_service_flutter_httpclient.dart @@ -241,6 +241,7 @@ class ApproovService { // configuration for automatically signing outbound requests using Approov static ApproovMessageSigning? _messageSigning; static bool _installMessageSigningAvailable = true; + static bool _messageSigningDebugLogging = false; // cached host certificates obtaining from probing the relevant host domains static Map?> _hostCertificates = Map?>(); @@ -433,6 +434,13 @@ class ApproovService { _messageSigning = null; } + /// Enables verbose diagnostic logging for the message signing flow. This will log canonical signature bases, + /// signature headers, and request header snapshots. Do not enable this in production as it may expose sensitive + /// material such as Approov tokens or API keys in logs. + static void setMessageSigningDebugLogging(bool enabled) { + _messageSigningDebugLogging = enabled; + Log.d("$TAG: message signing debug logging ${enabled ? "enabled" : "disabled"}"); + } /// Sets a binding header that must be present on all requests using the Approov service. A /// header should be chosen whose value is unchanging for most requests (such as an /// Authorization header). A hash of the header value is included in the issued Approov tokens @@ -1164,10 +1172,14 @@ class ApproovService { } // process the returned Approov status - if (fetchResult.tokenFetchStatus == _TokenFetchStatus.SUCCESS) + if (fetchResult.tokenFetchStatus == _TokenFetchStatus.SUCCESS) { // substitute the header value - request.headers.set(header, prefix + fetchResult.secureString!, preserveHeaderCase: true); - else if (fetchResult.tokenFetchStatus == _TokenFetchStatus.REJECTED) + final substitutedValue = prefix + fetchResult.secureString!; + request.headers.set(header, substitutedValue, preserveHeaderCase: true); + if (_messageSigningDebugLogging) { + Log.d("$TAG: substituted header $header with value $substitutedValue on ${request.uri}"); + } + } else if (fetchResult.tokenFetchStatus == _TokenFetchStatus.REJECTED) // if the request is rejected then we provide a special exception with additional information throw new ApproovRejectionException( "Header substitution for $header: ${fetchResult.tokenFetchStatus.name}: ${fetchResult.ARC} ${fetchResult.rejectionReasons}", @@ -1218,6 +1230,17 @@ class ApproovService { } final signatureBase = SignatureBaseBuilder(params, context).createSignatureBase(); + if (_messageSigningDebugLogging) { + Log.d( + "$TAG: message signing request ${request.method} ${request.uri}\nHeaders before signing: ${context.snapshotHeaders()}"); + Log.d("$TAG: message signing canonical base:\n$signatureBase"); + if (pendingBodyBytes != null) { + Log.d( + "$TAG: message signing body bytes length=${pendingBodyBytes.length} base64=${base64Encode(pendingBodyBytes)}"); + } else { + Log.d("$TAG: message signing body bytes unavailable (streaming)"); + } + } String signature; try { signature = await _signCanonicalMessage(signatureBase, params.algorithm); @@ -1248,6 +1271,11 @@ class ApproovService { final baseDigestHeader = 'sha-256=:${base64Encode(digest)}:'; context.setHeader('Signature-Base-Digest', baseDigestHeader); } + if (_messageSigningDebugLogging) { + Log.d("$TAG: message signing headers applied Signature=$signatureHeader"); + Log.d("$TAG: message signing headers applied Signature-Input=$signatureInput"); + Log.d("$TAG: message signing resulting headers: ${context.snapshotHeaders()}"); + } } static Future _signCanonicalMessage(String message, SignatureAlgorithm algorithm) async { @@ -1268,8 +1296,12 @@ class ApproovService { final result = await _fgChannel.invokeMethod('getInstallMessageSignature', { "message": message, }); - if (result != null && result.isNotEmpty) return result; - throw StateError('install message signature empty'); + if (result == null || result.isEmpty) { + throw StateError('install message signature empty'); + } + final derSignature = base64Decode(result); + final rawSignature = _decodeDerEcdsaSignature(derSignature); + return base64Encode(rawSignature); } on MissingPluginException { _installMessageSigningAvailable = false; Log.w("$TAG: getInstallMessageSignature not available on this platform"); @@ -1281,6 +1313,79 @@ class ApproovService { } } + static Uint8List _decodeDerEcdsaSignature(Uint8List der) { + int offset = 0; + if (der.isEmpty || der[offset] != 0x30) { + throw StateError('Invalid DER signature: missing sequence'); + } + offset++; + + int sequenceLength = _readDerLength(der, offset); + offset += _encodedLengthByteCount(der, offset); + if (sequenceLength != der.length - offset) { + throw StateError('Invalid DER signature: incorrect sequence length'); + } + + if (der[offset] != 0x02) { + throw StateError('Invalid DER signature: expected integer for r'); + } + offset++; + int rLength = _readDerLength(der, offset); + offset += _encodedLengthByteCount(der, offset); + final rBytes = Uint8List.sublistView(der, offset, offset + rLength); + offset += rLength; + + if (der[offset] != 0x02) { + throw StateError('Invalid DER signature: expected integer for s'); + } + offset++; + int sLength = _readDerLength(der, offset); + offset += _encodedLengthByteCount(der, offset); + final sBytes = Uint8List.sublistView(der, offset, offset + sLength); + + final rFixed = _toFixedLength(rBytes); + final sFixed = _toFixedLength(sBytes); + return Uint8List.fromList([...rFixed, ...sFixed]); + } + + static int _readDerLength(Uint8List data, int offset) { + int lengthByte = data[offset]; + if (lengthByte < 0x80) { + return lengthByte; + } + final numBytes = lengthByte & 0x7F; + if (numBytes == 0 || numBytes > 2) { + throw StateError('Unsupported DER length encoding'); + } + int value = 0; + for (int i = 0; i < numBytes; i++) { + value = (value << 8) | data[offset + 1 + i]; + } + return value; + } + + static int _encodedLengthByteCount(Uint8List data, int offset) { + final lengthByte = data[offset]; + if (lengthByte < 0x80) return 1; + return 1 + (lengthByte & 0x7F); + } + + static Uint8List _toFixedLength(Uint8List value) { + const targetLength = 32; + int offset = 0; + while (offset < value.length && value[offset] == 0x00) { + offset++; + } + final stripped = Uint8List.sublistView(value, offset); + if (stripped.length > targetLength) { + throw StateError('DER integer longer than $targetLength bytes'); + } + if (stripped.length == targetLength) return stripped; + final result = Uint8List(targetLength); + result.setRange(targetLength - stripped.length, targetLength, stripped); + return result; + } + /// Retrieves the certificates in the chain for the specified URL. These may be cached based on the host /// used in the URL (since the certificates are host rather than URL specific). If the certificates are /// not cached then they are obtained at the platform level and we cache them so subsequent requests don't From 0e7083f17248272498169a63e1f1e5420e34801a Mon Sep 17 00:00:00 2001 From: adriantuk Date: Mon, 20 Oct 2025 16:27:32 +0100 Subject: [PATCH 05/17] Exclude 'content-length' header from signing when body is empty, as it turns out Dart's HttpClient drops an automatic "Content-Length: 0" --- lib/src/message_signing.dart | 15 +++++++++++++-- test/approov_http_client_test.dart | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/src/message_signing.dart b/lib/src/message_signing.dart index 7d9508a..e8d5b88 100644 --- a/lib/src/message_signing.dart +++ b/lib/src/message_signing.dart @@ -209,9 +209,20 @@ class SignatureParametersFactory { } for (final header in _optionalHeaders) { - if (context.hasField(header)) { - params.addComponentIdentifier(header); + if (!context.hasField(header)) continue; + if (header == 'content-length') { + final hasBodyBytes = context.bodyBytes != null && context.bodyBytes!.isNotEmpty; + final contentLengthValue = context.getComponentValue(SfStringItem('content-length')); + final shouldIncludeContentLength = + hasBodyBytes || (contentLengthValue != null && contentLengthValue.trim() != '0'); + if (!shouldIncludeContentLength) { + // Dart's HttpClient drops an automatic "Content-Length: 0" header for GETs, + // so skip signing it to keep the canonical representation aligned with the + // transmitted request. + continue; + } } + params.addComponentIdentifier(header); } if (_bodyDigestAlgorithm != null) { diff --git a/test/approov_http_client_test.dart b/test/approov_http_client_test.dart index d952f79..1e6b495 100644 --- a/test/approov_http_client_test.dart +++ b/test/approov_http_client_test.dart @@ -65,4 +65,27 @@ void main() { expect(signatureBase, expectedString); }); + + test('content-length header with zero body is not signed', () { + final headers = >{ + 'content-length': ['0'], + 'approov-token': ['Bearer token'], + }; + final context = ApproovSigningContext( + requestMethod: 'get', + uri: Uri.parse('https://api.example.com/v1/resource'), + headers: headers, + bodyBytes: Uint8List(0), + tokenHeaderName: 'Approov-Token', + onSetHeader: (name, value) => headers[name.toLowerCase()] = [value], + onAddHeader: (name, value) => headers.putIfAbsent(name.toLowerCase(), () => []).add(value), + ); + + final factory = SignatureParametersFactory.generateDefaultFactory(); + final params = factory.build(context); + + final componentNames = params.componentIdentifiers.map((item) => item.value).toList(); + expect(componentNames.contains('content-length'), isFalse); + expect(params.serializeComponentValue().contains('"content-length"'), isFalse); + }); } From 666c4ff87e74b0aa59e1751ec623af9f39de8d50 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Wed, 22 Oct 2025 11:26:50 +0100 Subject: [PATCH 06/17] enhance DER signature validation, add defensive checks to ensure we never go out of bounds; Remove debug logging in prep for release; --- lib/approov_service_flutter_httpclient.dart | 91 +++++++++++++-------- lib/src/message_signing.dart | 5 +- 2 files changed, 61 insertions(+), 35 deletions(-) diff --git a/lib/approov_service_flutter_httpclient.dart b/lib/approov_service_flutter_httpclient.dart index 04826fd..23320aa 100644 --- a/lib/approov_service_flutter_httpclient.dart +++ b/lib/approov_service_flutter_httpclient.dart @@ -241,8 +241,7 @@ class ApproovService { // configuration for automatically signing outbound requests using Approov static ApproovMessageSigning? _messageSigning; static bool _installMessageSigningAvailable = true; - static bool _messageSigningDebugLogging = false; - + // cached host certificates obtaining from probing the relevant host domains static Map?> _hostCertificates = Map?>(); @@ -434,13 +433,6 @@ class ApproovService { _messageSigning = null; } - /// Enables verbose diagnostic logging for the message signing flow. This will log canonical signature bases, - /// signature headers, and request header snapshots. Do not enable this in production as it may expose sensitive - /// material such as Approov tokens or API keys in logs. - static void setMessageSigningDebugLogging(bool enabled) { - _messageSigningDebugLogging = enabled; - Log.d("$TAG: message signing debug logging ${enabled ? "enabled" : "disabled"}"); - } /// Sets a binding header that must be present on all requests using the Approov service. A /// header should be chosen whose value is unchanging for most requests (such as an /// Authorization header). A hash of the header value is included in the issued Approov tokens @@ -1176,9 +1168,6 @@ class ApproovService { // substitute the header value final substitutedValue = prefix + fetchResult.secureString!; request.headers.set(header, substitutedValue, preserveHeaderCase: true); - if (_messageSigningDebugLogging) { - Log.d("$TAG: substituted header $header with value $substitutedValue on ${request.uri}"); - } } else if (fetchResult.tokenFetchStatus == _TokenFetchStatus.REJECTED) // if the request is rejected then we provide a special exception with additional information throw new ApproovRejectionException( @@ -1230,26 +1219,17 @@ class ApproovService { } final signatureBase = SignatureBaseBuilder(params, context).createSignatureBase(); - if (_messageSigningDebugLogging) { - Log.d( - "$TAG: message signing request ${request.method} ${request.uri}\nHeaders before signing: ${context.snapshotHeaders()}"); - Log.d("$TAG: message signing canonical base:\n$signatureBase"); - if (pendingBodyBytes != null) { - Log.d( - "$TAG: message signing body bytes length=${pendingBodyBytes.length} base64=${base64Encode(pendingBodyBytes)}"); - } else { - Log.d("$TAG: message signing body bytes unavailable (streaming)"); - } - } String signature; - try { + try { // If we fail to sign with install signing, we fall back to account signing (install signing is safer but not always available) signature = await _signCanonicalMessage(signatureBase, params.algorithm); } on StateError { if (params.algorithm == SignatureAlgorithm.ecdsaP256Sha256) { Log.w("$TAG: install message signing unavailable, falling back to account signing"); params.algorithm = SignatureAlgorithm.hmacSha256; params.setAlg('hmac-sha256'); - signature = await _signCanonicalMessage(signatureBase, params.algorithm); + // Regenerate the signature base with the updated algorithm + final updatedSignatureBase = SignatureBaseBuilder(params, context).createSignatureBase(); + signature = await _signCanonicalMessage(updatedSignatureBase, params.algorithm); } else { rethrow; } @@ -1271,11 +1251,7 @@ class ApproovService { final baseDigestHeader = 'sha-256=:${base64Encode(digest)}:'; context.setHeader('Signature-Base-Digest', baseDigestHeader); } - if (_messageSigningDebugLogging) { - Log.d("$TAG: message signing headers applied Signature=$signatureHeader"); - Log.d("$TAG: message signing headers applied Signature-Input=$signatureInput"); - Log.d("$TAG: message signing resulting headers: ${context.snapshotHeaders()}"); - } + } static Future _signCanonicalMessage(String message, SignatureAlgorithm algorithm) async { @@ -1315,32 +1291,73 @@ class ApproovService { static Uint8List _decodeDerEcdsaSignature(Uint8List der) { int offset = 0; - if (der.isEmpty || der[offset] != 0x30) { + if (der.isEmpty) { + throw StateError('Invalid DER signature: buffer is empty'); + } + if (offset >= der.length) { + throw StateError('Invalid DER signature: unexpected end of DER buffer at sequence'); + } + if (der[offset] != 0x30) { throw StateError('Invalid DER signature: missing sequence'); } offset++; + if (offset >= der.length) { + throw StateError('Invalid DER signature: unexpected end of DER buffer after sequence tag'); + } int sequenceLength = _readDerLength(der, offset); - offset += _encodedLengthByteCount(der, offset); + int seqLenBytes = _encodedLengthByteCount(der, offset); + if (offset + seqLenBytes > der.length) { + throw StateError('Invalid DER signature: sequence length encoding exceeds buffer'); + } + offset += seqLenBytes; if (sequenceLength != der.length - offset) { throw StateError('Invalid DER signature: incorrect sequence length'); } + if (offset >= der.length) { + throw StateError('Invalid DER signature: unexpected end of DER buffer at r integer tag'); + } if (der[offset] != 0x02) { throw StateError('Invalid DER signature: expected integer for r'); } offset++; + + if (offset >= der.length) { + throw StateError('Invalid DER signature: unexpected end of DER buffer at r length'); + } int rLength = _readDerLength(der, offset); - offset += _encodedLengthByteCount(der, offset); + int rLenBytes = _encodedLengthByteCount(der, offset); + if (offset + rLenBytes > der.length) { + throw StateError('Invalid DER signature: r length encoding exceeds buffer'); + } + offset += rLenBytes; + if (offset + rLength > der.length) { + throw StateError('r length exceeds buffer'); + } final rBytes = Uint8List.sublistView(der, offset, offset + rLength); offset += rLength; + if (offset >= der.length) { + throw StateError('Invalid DER signature: unexpected end of DER buffer at s integer tag'); + } if (der[offset] != 0x02) { throw StateError('Invalid DER signature: expected integer for s'); } offset++; + + if (offset >= der.length) { + throw StateError('Invalid DER signature: unexpected end of DER buffer at s length'); + } int sLength = _readDerLength(der, offset); - offset += _encodedLengthByteCount(der, offset); + int sLenBytes = _encodedLengthByteCount(der, offset); + if (offset + sLenBytes > der.length) { + throw StateError('Invalid DER signature: s length encoding exceeds buffer'); + } + offset += sLenBytes; + if (offset + sLength > der.length) { + throw StateError('s length exceeds buffer'); + } final sBytes = Uint8List.sublistView(der, offset, offset + sLength); final rFixed = _toFixedLength(rBytes); @@ -1349,6 +1366,9 @@ class ApproovService { } static int _readDerLength(Uint8List data, int offset) { + if (offset >= data.length) { + throw StateError('Truncated DER length: offset exceeds buffer'); + } int lengthByte = data[offset]; if (lengthByte < 0x80) { return lengthByte; @@ -1357,6 +1377,9 @@ class ApproovService { if (numBytes == 0 || numBytes > 2) { throw StateError('Unsupported DER length encoding'); } + if (offset + 1 + numBytes > data.length) { + throw StateError('Truncated DER length: not enough bytes for length'); + } int value = 0; for (int i = 0; i < numBytes; i++) { value = (value << 8) | data[offset + 1 + i]; diff --git a/lib/src/message_signing.dart b/lib/src/message_signing.dart index e8d5b88..580c741 100644 --- a/lib/src/message_signing.dart +++ b/lib/src/message_signing.dart @@ -361,7 +361,10 @@ class ApproovSigningContext { String? ensureContentDigest(SignatureDigest digest, {required bool required}) { if (bodyBytes == null) { - return required ? throw StateError('Body digest required but body is not available') : null; + if (required) { + throw StateError('Body digest required but body is not available'); + } + return null; } final bytes = switch (digest) { SignatureDigest.sha256 => sha256.convert(bodyBytes!).bytes, From 8a847344006274a7a028f5ff15405d963b26af86 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Mon, 27 Oct 2025 12:40:23 +0000 Subject: [PATCH 07/17] Implement HTTP Structured Fields Value syntax defined in IETF RFC 9651 --- lib/src/message_signing.dart | 135 +++---- lib/src/structured_fields.dart | 550 +++++++++++++++++++++++++++++ test/approov_http_client_test.dart | 4 +- test/structured_fields_test.dart | 103 ++++++ 4 files changed, 712 insertions(+), 80 deletions(-) create mode 100644 lib/src/structured_fields.dart create mode 100644 test/structured_fields_test.dart diff --git a/lib/src/message_signing.dart b/lib/src/message_signing.dart index 580c741..d93011b 100644 --- a/lib/src/message_signing.dart +++ b/lib/src/message_signing.dart @@ -4,92 +4,97 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart'; +import 'structured_fields.dart'; + /// Signature algorithms supported by the Approov message signing flow. enum SignatureAlgorithm { hmacSha256, ecdsaP256Sha256, } -/// Represents a HTTP Structured Field string item with optional parameters. -class SfStringItem { - SfStringItem(this.value, [Map? parameters]) - : parameters = LinkedHashMap.of(parameters ?? const {}); - - final String value; - final LinkedHashMap parameters; +SfItem _buildComponentIdentifier(String value, Map? parameters) { + return SfItem.string(value, parameters); +} - String serialize() { - final buffer = StringBuffer(); - buffer.write(_serializeSfString(value)); - parameters.forEach((key, v) { - buffer.write(';'); - buffer.write(key); - buffer.write('='); - buffer.write(_serializeSfString(v)); - }); - return buffer.toString(); +String _componentIdentifierValue(SfItem item) { + final bareItem = item.bareItem; + if (bareItem.type != SfBareItemType.string) { + throw StateError('Component identifiers must be sf-string values'); } + return bareItem.value as String; } /// Holds configuration for message signature parameters, mirroring the Swift implementation. class SignatureParameters { SignatureParameters() - : _componentIdentifiers = [], - _parameters = LinkedHashMap(); + : _componentIdentifiers = [], + _parameters = LinkedHashMap(); SignatureParameters.copy(SignatureParameters other) - : _componentIdentifiers = List.from(other._componentIdentifiers), - _parameters = LinkedHashMap.of(other._parameters), + : _componentIdentifiers = List.from(other._componentIdentifiers), + _parameters = LinkedHashMap.from(other._parameters), debugMode = other.debugMode, algorithm = other.algorithm; - final List _componentIdentifiers; - final LinkedHashMap _parameters; + final List _componentIdentifiers; + final LinkedHashMap _parameters; bool debugMode = false; SignatureAlgorithm algorithm = SignatureAlgorithm.hmacSha256; - List get componentIdentifiers => List.unmodifiable(_componentIdentifiers); + List get componentIdentifiers => List.unmodifiable(_componentIdentifiers); - void addComponentIdentifier(String identifier, {Map? parameters}) { + void addComponentIdentifier(String identifier, {Map? parameters}) { final normalized = identifier.startsWith('@') ? identifier : identifier.toLowerCase(); - if (_componentIdentifiers.any((item) => item.value == normalized && _parametersMatch(item.parameters, parameters))) { + final candidateParameters = SfParameters(parameters); + if (_componentIdentifiers.any( + (item) => + _componentIdentifierMatches(item, normalized, candidateParameters), + )) { return; } - _componentIdentifiers.add(SfStringItem(normalized, parameters)); + _componentIdentifiers.add(_buildComponentIdentifier(normalized, parameters)); + } + + bool _componentIdentifierMatches(SfItem item, String value, SfParameters candidate) { + if (_componentIdentifierValue(item) != value) return false; + return _parametersMatch(item.parameters, candidate); } - bool _parametersMatch(Map existing, Map? candidate) { - if (candidate == null || candidate.isEmpty) return existing.isEmpty; - if (existing.length != candidate.length) return false; - for (final entry in candidate.entries) { - if (existing[entry.key] != entry.value) return false; + bool _parametersMatch(SfParameters existing, SfParameters candidate) { + final existingMap = existing.asMap(); + final candidateMap = candidate.asMap(); + if (existingMap.length != candidateMap.length) return false; + for (final entry in candidateMap.entries) { + final existingValue = existingMap[entry.key]; + if (existingValue == null) return false; + if (existingValue.serialize() != entry.value.serialize()) return false; } return true; } void setAlg(String value) { - _parameters['alg'] = value; + _parameters['alg'] = SfBareItem.string(value); } void setCreated(int timestampSeconds) { - _parameters['created'] = timestampSeconds; + _parameters['created'] = SfBareItem.integer(timestampSeconds); } void setExpires(int timestampSeconds) { - _parameters['expires'] = timestampSeconds; + _parameters['expires'] = SfBareItem.integer(timestampSeconds); } void setKeyId(String keyId) { - _parameters['keyid'] = keyId; + _parameters['keyid'] = SfBareItem.string(keyId); } void setNonce(String nonce) { - _parameters['nonce'] = nonce; + _parameters['nonce'] = SfBareItem.string(nonce); } void setTag(String tag) { - _parameters['tag'] = tag; + _parameters['tag'] = SfBareItem.string(tag); } String signatureLabel() { @@ -102,23 +107,11 @@ class SignatureParameters { } } - SfStringItem signatureParamsIdentifier() => SfStringItem('@signature-params'); + SfItem signatureParamsIdentifier() => _buildComponentIdentifier('@signature-params', null); String serializeComponentValue() { - final buffer = StringBuffer(); - buffer.write('('); - for (var i = 0; i < _componentIdentifiers.length; i++) { - if (i > 0) buffer.write(' '); - buffer.write(_componentIdentifiers[i].serialize()); - } - buffer.write(')'); - _parameters.forEach((key, value) { - buffer.write(';'); - buffer.write(key); - buffer.write('='); - buffer.write(_serializeParameter(value)); - }); - return buffer.toString(); + final parameters = _parameters.isEmpty ? null : _parameters; + return SfInnerList(_componentIdentifiers, parameters).serialize(); } } @@ -212,7 +205,7 @@ class SignatureParametersFactory { if (!context.hasField(header)) continue; if (header == 'content-length') { final hasBodyBytes = context.bodyBytes != null && context.bodyBytes!.isNotEmpty; - final contentLengthValue = context.getComponentValue(SfStringItem('content-length')); + final contentLengthValue = context.getComponentValue(SfItem.string('content-length')); final shouldIncludeContentLength = hasBodyBytes || (contentLengthValue != null && contentLengthValue.trim() != '0'); if (!shouldIncludeContentLength) { @@ -263,7 +256,7 @@ class SignatureBaseBuilder { for (final component in params.componentIdentifiers) { final value = context.getComponentValue(component); if (value == null) { - throw StateError('Missing component value for ${component.value}'); + throw StateError('Missing component value for ${_componentIdentifierValue(component)}'); } buffer.write(component.serialize()); buffer.write(': '); @@ -325,8 +318,8 @@ class ApproovSigningContext { onAddHeader?.call(name, value); } - String? getComponentValue(SfStringItem component) { - final identifier = component.value; + String? getComponentValue(SfItem component) { + final identifier = _componentIdentifierValue(component); if (identifier.startsWith('@')) { switch (identifier) { case '@method': @@ -344,11 +337,14 @@ class ApproovSigningContext { case '@query': return uri.hasQuery ? uri.query : ''; case '@query-param': - final name = component.parameters['name']; - if (name == null) { + final paramValue = component.parameters.asMap()['name']; + if (paramValue == null) { throw StateError('Missing name parameter for @query-param'); } - return _queryParameterValue(name); + if (paramValue.type != SfBareItemType.string) { + throw StateError('name parameter for @query-param must be an sf-string'); + } + return _queryParameterValue(paramValue.value as String); default: throw StateError('Unknown derived component: $identifier'); } @@ -430,22 +426,3 @@ class ApproovMessageSigning { return factory.build(context); } } - -String _serializeParameter(dynamic value) { - if (value is String) { - return _serializeSfString(value); - } else if (value is int) { - return value.toString(); - } else if (value is bool) { - return value ? '?1' : '?0'; - } else if (value is Uint8List) { - return ':${base64Encode(value)}:'; - } else { - throw ArgumentError('Unsupported parameter type: ${value.runtimeType}'); - } -} - -String _serializeSfString(String value) { - final escaped = value.replaceAll('\\', r'\\').replaceAll('"', r'\"'); - return '"$escaped"'; -} diff --git a/lib/src/structured_fields.dart b/lib/src/structured_fields.dart new file mode 100644 index 0000000..cb1ed7b --- /dev/null +++ b/lib/src/structured_fields.dart @@ -0,0 +1,550 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:typed_data'; + +/// Exception thrown when Structured Field values fail validation. +class SfFormatException extends FormatException { + SfFormatException(String message, [dynamic source]) + : super(message, source); +} + +enum _CharType { alphaLower, alphaUpper, digit } + +bool _isLowerAlpha(int codeUnit) => + codeUnit >= 0x61 && codeUnit <= 0x7a; // a-z + +bool _isUpperAlpha(int codeUnit) => + codeUnit >= 0x41 && codeUnit <= 0x5a; // A-Z + +bool _isAlpha(int codeUnit) => _isLowerAlpha(codeUnit) || _isUpperAlpha(codeUnit); + +bool _isDigit(int codeUnit) => codeUnit >= 0x30 && codeUnit <= 0x39; + +bool _isTchar(int codeUnit) { + if (_isAlpha(codeUnit) || _isDigit(codeUnit)) return true; + const allowed = { + 0x21, // ! + 0x23, // # + 0x24, // $ + 0x25, // % + 0x26, // & + 0x27, // ' + 0x2a, // * + 0x2b, // + + 0x2d, // - + 0x2e, // . + 0x5e, // ^ + 0x5f, // _ + 0x60, // ` + 0x7c, // | + 0x7e, // ~ + }; + return allowed.contains(codeUnit); +} + +void _validateKey(String key) { + if (key.isEmpty) { + throw SfFormatException('Structured Field parameter and dictionary keys must not be empty'); + } + final codeUnits = key.codeUnits; + for (var index = 0; index < codeUnits.length; index++) { + final unit = codeUnits[index]; + final isValid = index == 0 + ? (unit == 0x2a /* * */ || _isLowerAlpha(unit)) + : (_isLowerAlpha(unit) || _isDigit(unit) || unit == 0x5f /* _ */ || unit == 0x2d /* - */ || unit == 0x2e /* . */ || unit == 0x2a /* * */); + if (!isValid) { + throw SfFormatException('Invalid character "${String.fromCharCode(unit)}" in key "$key" at position $index'); + } + } +} + +void _validateString(String value) { + for (var index = 0; index < value.length; index++) { + final unit = value.codeUnitAt(index); + if (unit < 0x20 || unit == 0x7f || unit > 0x7f) { + throw SfFormatException( + 'Invalid character 0x${unit.toRadixString(16).padLeft(2, '0')} in sf-string at position $index', + ); + } + } +} + +void _validateToken(String value) { + if (value.isEmpty) { + throw SfFormatException('sf-token must not be empty'); + } + final codeUnits = value.codeUnits; + for (var index = 0; index < codeUnits.length; index++) { + final unit = codeUnits[index]; + final isValid = index == 0 + ? (_isAlpha(unit) || unit == 0x2a /* * */) + : (_isTchar(unit) || unit == 0x3a /* : */ || unit == 0x2f /* / */); + if (!isValid) { + throw SfFormatException( + 'Invalid character "${String.fromCharCode(unit)}" in sf-token "$value" at position $index', + ); + } + } +} + +void _validateDisplayString(String value) { + for (final rune in value.runes) { + if (rune >= 0xd800 && rune <= 0xdfff) { + throw SfFormatException('Display strings must not contain surrogate code points'); + } + if (rune < 0x0 || rune > 0x10ffff) { + throw SfFormatException('Invalid Unicode scalar value 0x${rune.toRadixString(16)} in display string'); + } + } +} + +/// Represents an sf-token value. +class SfToken { + SfToken(String value) : value = value { + _validateToken(value); + } + + final String value; +} + +/// Represents a display string bare item. +class SfDisplayString { + SfDisplayString(String value) : value = value { + _validateDisplayString(value); + } + + final String value; +} + +/// Represents a decimal bare item using a fixed three-digit scale. +class SfDecimal { + SfDecimal._(this._scaledValue); + + factory SfDecimal.fromNum(num value) { + final scaled = value * 1000; + final rounded = scaled.round(); + if ((scaled - rounded).abs() > 1e-9) { + throw SfFormatException('Decimals must have at most three fractional digits: $value'); + } + return SfDecimal._checked(rounded); + } + + factory SfDecimal.parse(String value) { + if (!RegExp(r'^-?[0-9]{1,12}\.[0-9]{1,3}$').hasMatch(value)) { + throw SfFormatException('Invalid decimal format: $value'); + } + final negative = value.startsWith('-'); + final parts = value.substring(negative ? 1 : 0).split('.'); + final integral = int.parse(parts[0]); + final fractional = int.parse(parts[1].padRight(3, '0')); + final scaled = (integral * 1000 + fractional) * (negative ? -1 : 1); + return SfDecimal._checked(scaled); + } + + static SfDecimal _checked(int scaled) { + const max = 999999999999999; + if (scaled.abs() > max) { + throw SfFormatException('Decimal magnitude exceeds allowed range'); + } + return SfDecimal._(scaled); + } + + final int _scaledValue; + + int get scaledValue => _scaledValue; + + double toDouble() => _scaledValue / 1000.0; + + @override + String toString() { + final sign = _scaledValue < 0 ? '-' : ''; + final absValue = _scaledValue.abs(); + final integral = absValue ~/ 1000; + var fractional = (absValue % 1000).toString().padLeft(3, '0'); + while (fractional.length > 1 && fractional.endsWith('0')) { + fractional = fractional.substring(0, fractional.length - 1); + } + return '$sign$integral.$fractional'; + } +} + +/// Represents a Date bare item storing seconds since Unix epoch. +class SfDate { + SfDate.fromSeconds(int seconds) : seconds = seconds { + _validateRange(seconds); + } + + factory SfDate.fromDateTime(DateTime dateTime) { + final utc = dateTime.toUtc(); + final seconds = utc.millisecondsSinceEpoch ~/ 1000; + return SfDate.fromSeconds(seconds); + } + + final int seconds; + + DateTime toUtcDateTime() => DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); + + static void _validateRange(int seconds) { + const min = -62135596800; // year 0001 + const max = 253402214400; // year 9999 + if (seconds < min || seconds > max) { + throw SfFormatException('Date value $seconds is outside the supported range'); + } + } +} + +/// Enumeration of bare item types. +enum SfBareItemType { + integer, + decimal, + string, + token, + byteSequence, + boolean, + date, + displayString, +} + +/// Represents a bare item per RFC 9651. +class SfBareItem { + const SfBareItem._(this.type, this.value); + + factory SfBareItem.integer(int value) { + const min = -999999999999999; + const max = 999999999999999; + if (value < min || value > max) { + throw SfFormatException('Integer magnitude exceeds allowed range: $value'); + } + return SfBareItem._(SfBareItemType.integer, value); + } + + factory SfBareItem.decimal(dynamic value) { + if (value is SfDecimal) { + return SfBareItem._(SfBareItemType.decimal, value); + } else if (value is num) { + return SfBareItem._(SfBareItemType.decimal, SfDecimal.fromNum(value)); + } else if (value is String) { + return SfBareItem._(SfBareItemType.decimal, SfDecimal.parse(value)); + } + throw SfFormatException('Unsupported value for decimal bare item: ${value.runtimeType}'); + } + + factory SfBareItem.string(String value) { + _validateString(value); + return SfBareItem._(SfBareItemType.string, value); + } + + factory SfBareItem.token(SfToken token) => + SfBareItem._(SfBareItemType.token, token.value); + + factory SfBareItem.byteSequence(Uint8List value) => + SfBareItem._(SfBareItemType.byteSequence, Uint8List.fromList(value)); + + factory SfBareItem.boolean(bool value) => + SfBareItem._(SfBareItemType.boolean, value); + + factory SfBareItem.date(dynamic value) { + if (value is SfDate) { + return SfBareItem._(SfBareItemType.date, value); + } else if (value is DateTime) { + return SfBareItem._(SfBareItemType.date, SfDate.fromDateTime(value)); + } else if (value is int) { + return SfBareItem._(SfBareItemType.date, SfDate.fromSeconds(value)); + } + throw SfFormatException('Unsupported value for date bare item: ${value.runtimeType}'); + } + + factory SfBareItem.displayString(SfDisplayString value) => + SfBareItem._(SfBareItemType.displayString, value); + + factory SfBareItem.fromDynamic(dynamic value) { + if (value is SfBareItem) return value; + if (value is bool) return SfBareItem.boolean(value); + if (value is int) return SfBareItem.integer(value); + if (value is SfDecimal || value is num || value is String && value.contains('.')) { + try { + return SfBareItem.decimal(value); + } on SfFormatException { + if (value is String) { + return SfBareItem.string(value); + } + rethrow; + } + } + if (value is SfToken) return SfBareItem.token(value); + if (value is SfDisplayString) return SfBareItem.displayString(value); + if (value is Uint8List) return SfBareItem.byteSequence(value); + if (value is List) return SfBareItem.byteSequence(Uint8List.fromList(value)); + if (value is DateTime || value is SfDate || value is int) { + return SfBareItem.date(value); + } + if (value is String) return SfBareItem.string(value); + throw SfFormatException('Unsupported value for bare item: ${value.runtimeType}'); + } + + final SfBareItemType type; + final Object value; + + bool get isBooleanTrue => type == SfBareItemType.boolean && value == true; + + void serializeTo(StringBuffer buffer) { + switch (type) { + case SfBareItemType.integer: + buffer.write(value as int); + case SfBareItemType.decimal: + buffer.write((value as SfDecimal).toString()); + case SfBareItemType.string: + buffer.write('"'); + final stringValue = value as String; + for (var index = 0; index < stringValue.length; index++) { + final char = stringValue[index]; + if (char == '\\' || char == '"') { + buffer.write('\\'); + } + buffer.write(char); + } + buffer.write('"'); + case SfBareItemType.token: + buffer.write(value as String); + case SfBareItemType.byteSequence: + buffer + ..write(':') + ..write(base64Encode(value as Uint8List)) + ..write(':'); + case SfBareItemType.boolean: + buffer.write((value as bool) ? '?1' : '?0'); + case SfBareItemType.date: + buffer + ..write('@') + ..write((value as SfDate).seconds.toString()); + case SfBareItemType.displayString: + buffer.write(_encodeDisplayString(value as SfDisplayString)); + } + } + + String serialize() { + final buffer = StringBuffer(); + serializeTo(buffer); + return buffer.toString(); + } + + static String _encodeDisplayString(SfDisplayString display) { + final buffer = StringBuffer()..write('%"'); + final bytes = utf8.encode(display.value); + for (final byte in bytes) { + if (byte == 0x25 || byte == 0x22 || byte < 0x20 || byte > 0x7e) { + buffer + ..write('%') + ..write(byte.toRadixString(16).padLeft(2, '0')); + } else { + buffer.write(String.fromCharCode(byte)); + } + } + buffer.write('"'); + return buffer.toString(); + } +} + +/// Represents the parameters attached to an Item or Inner List. +class SfParameters { + SfParameters._(this._entries); + + factory SfParameters([Map? entries]) { + if (entries == null || entries.isEmpty) { + return SfParameters._(UnmodifiableMapView(LinkedHashMap())); + } + final map = LinkedHashMap(); + entries.forEach((key, value) { + _validateKey(key); + map[key] = SfBareItem.fromDynamic(value); + }); + return SfParameters._(UnmodifiableMapView(map)); + } + + final Map _entries; + + bool get isEmpty => _entries.isEmpty; + + Map asMap() => _entries; + + void serializeTo(StringBuffer buffer) { + _entries.forEach((key, value) { + buffer + ..write(';') + ..write(key); + if (!value.isBooleanTrue) { + buffer.write('='); + value.serializeTo(buffer); + } + }); + } +} + +/// Represents an sf-item. +class SfItem { + SfItem(this.bareItem, [Map? parameters]) + : parameters = SfParameters(parameters); + + factory SfItem.string(String value, [Map? parameters]) => + SfItem(SfBareItem.string(value), parameters); + + factory SfItem.token(String value, [Map? parameters]) => + SfItem(SfBareItem.token(SfToken(value)), parameters); + + factory SfItem.boolean(bool value, [Map? parameters]) => + SfItem(SfBareItem.boolean(value), parameters); + + factory SfItem.integer(int value, [Map? parameters]) => + SfItem(SfBareItem.integer(value), parameters); + + factory SfItem.decimal(dynamic value, [Map? parameters]) => + SfItem(SfBareItem.decimal(value), parameters); + + factory SfItem.byteSequence(Uint8List value, [Map? parameters]) => + SfItem(SfBareItem.byteSequence(value), parameters); + + factory SfItem.date(dynamic value, [Map? parameters]) => + SfItem(SfBareItem.date(value), parameters); + + factory SfItem.displayString(String value, [Map? parameters]) => + SfItem(SfBareItem.displayString(SfDisplayString(value)), parameters); + + final SfBareItem bareItem; + final SfParameters parameters; + + void serializeTo(StringBuffer buffer) { + bareItem.serializeTo(buffer); + parameters.serializeTo(buffer); + } + + String serialize() { + final buffer = StringBuffer(); + serializeTo(buffer); + return buffer.toString(); + } +} + +/// Represents an inner list per RFC 9651. +class SfInnerList { + SfInnerList(List items, [Map? parameters]) + : items = List.unmodifiable(items), + parameters = SfParameters(parameters); + + final List items; + final SfParameters parameters; + + void serializeTo(StringBuffer buffer) { + buffer.write('('); + for (var index = 0; index < items.length; index++) { + if (index > 0) buffer.write(' '); + items[index].serializeTo(buffer); + } + buffer.write(')'); + parameters.serializeTo(buffer); + } + + String serialize() { + final buffer = StringBuffer(); + serializeTo(buffer); + return buffer.toString(); + } +} + +/// Represents a list member (either an Item or inner list). +class SfListMember { + SfListMember.item(SfItem item) + : item = item, + innerList = null; + + SfListMember.innerList(SfInnerList innerList) + : item = null, + innerList = innerList; + + final SfItem? item; + final SfInnerList? innerList; + + void serializeTo(StringBuffer buffer) { + if (item != null) { + item!.serializeTo(buffer); + } else { + innerList!.serializeTo(buffer); + } + } +} + +/// Represents an sf-list. +class SfList { + SfList(List members) + : members = List.unmodifiable(members); + + final List members; + + String serialize() { + final buffer = StringBuffer(); + for (var index = 0; index < members.length; index++) { + if (index > 0) buffer.write(', '); + members[index].serializeTo(buffer); + } + return buffer.toString(); + } +} + +/// Represents a dictionary member that can be either a value or boolean true with parameters. +class SfDictionaryMember { + SfDictionaryMember.booleanTrue([Map? parameters]) + : item = null, + innerList = null, + parameters = SfParameters(parameters); + + SfDictionaryMember.item(SfItem item) + : item = item, + innerList = null, + parameters = null; + + SfDictionaryMember.innerList(SfInnerList innerList) + : item = null, + innerList = innerList, + parameters = null; + + final SfItem? item; + final SfInnerList? innerList; + final SfParameters? parameters; + + void serializeTo(StringBuffer buffer) { + if (item != null) { + buffer.write('='); + item!.serializeTo(buffer); + } else if (innerList != null) { + buffer.write('='); + innerList!.serializeTo(buffer); + } else if (parameters != null && !parameters!.isEmpty) { + parameters!.serializeTo(buffer); + } + } +} + +/// Represents an sf-dictionary. +class SfDictionary { + SfDictionary(Map entries) + : _entries = UnmodifiableMapView( + LinkedHashMap.fromEntries(entries.entries.map((entry) { + _validateKey(entry.key); + return MapEntry(entry.key, entry.value); + }))); + + final Map _entries; + + Map asMap() => _entries; + + String serialize() { + final buffer = StringBuffer(); + var index = 0; + _entries.forEach((key, member) { + if (index > 0) buffer.write(', '); + buffer.write(key); + member.serializeTo(buffer); + index++; + }); + return buffer.toString(); + } +} diff --git a/test/approov_http_client_test.dart b/test/approov_http_client_test.dart index 1e6b495..0851d6b 100644 --- a/test/approov_http_client_test.dart +++ b/test/approov_http_client_test.dart @@ -84,7 +84,9 @@ void main() { final factory = SignatureParametersFactory.generateDefaultFactory(); final params = factory.build(context); - final componentNames = params.componentIdentifiers.map((item) => item.value).toList(); + final componentNames = params.componentIdentifiers + .map((item) => item.bareItem.value as String) + .toList(); expect(componentNames.contains('content-length'), isFalse); expect(params.serializeComponentValue().contains('"content-length"'), isFalse); }); diff --git a/test/structured_fields_test.dart b/test/structured_fields_test.dart new file mode 100644 index 0000000..81d21d3 --- /dev/null +++ b/test/structured_fields_test.dart @@ -0,0 +1,103 @@ +import 'dart:typed_data'; + +import 'package:approov_service_flutter_httpclient/src/structured_fields.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SfBareItem serialization', () { + test('integer encodes without modification', () { + expect(SfBareItem.integer(42).serialize(), '42'); + expect(SfBareItem.integer(-7).serialize(), '-7'); + }); + + test('decimal encodes canonical representation', () { + expect(SfBareItem.decimal(1.25).serialize(), '1.25'); + expect(SfBareItem.decimal(SfDecimal.parse('-12.340')).serialize(), '-12.34'); + }); + + test('string escapes quotes and backslashes', () { + expect(SfBareItem.string('say "hi" \\ wave').serialize(), '"say \\"hi\\" \\\\ wave"'); + }); + + test('token enforces allowed syntax', () { + expect(SfBareItem.token(SfToken('Foo/Bar')).serialize(), 'Foo/Bar'); + expect(() => SfToken('1abc'), throwsA(isA())); + }); + + test('byte sequence base64 encodes content', () { + final bytes = Uint8List.fromList([0, 1, 2, 3]); + expect(SfBareItem.byteSequence(bytes).serialize(), ':AAECAw==:'); + }); + + test('boolean serializes using ?0/?1', () { + expect(SfBareItem.boolean(true).serialize(), '?1'); + expect(SfBareItem.boolean(false).serialize(), '?0'); + }); + + test('date serializes with @ prefix', () { + expect(SfBareItem.date(SfDate.fromSeconds(1659578233)).serialize(), '@1659578233'); + }); + + test('display string percent encodes non-ascii', () { + final display = SfBareItem.displayString(SfDisplayString('über % test')); + expect(display.serialize(), '%"%c3%bcber %25 test"'); + }); + }); + + group('Structured collections', () { + test('parameters omit explicit true values', () { + final item = SfItem.string('example', {'flag': true, 'mode': 'test'}); + expect(item.serialize(), '"example";flag;mode="test"'); + }); + + test('parameters retain false boolean', () { + final item = SfItem.string('example', {'flag': false}); + expect(item.serialize(), '"example";flag=?0'); + }); + + test('inner list serializes members and parameters', () { + final inner = SfInnerList( + [ + SfItem.token('foo'), + SfItem.integer(10, {'v': 1}), + ], + {'tag': 'alpha'}, + ); + expect(inner.serialize(), '(foo 10;v=1);tag="alpha"'); + }); + + test('list supports mixed members', () { + final inner = SfInnerList([SfItem.string('bar')]); + final list = SfList([ + SfListMember.item(SfItem.integer(1)), + SfListMember.innerList(inner), + SfListMember.item(SfItem.boolean(true)), + ]); + expect(list.serialize(), '1, ("bar"), ?1'); + }); + + test('dictionary serializes values and parameters', () { + final dictionary = SfDictionary({ + 'flag': SfDictionaryMember.booleanTrue({'v': 1}), + 'count': SfDictionaryMember.item(SfItem.integer(4)), + 'list': SfDictionaryMember.innerList(SfInnerList([SfItem.string('x')])), + }); + expect(dictionary.serialize(), 'flag;v=1, count=4, list=("x")'); + }); + }); + + group('Validation', () { + test('rejects invalid keys in parameters', () { + expect(() => SfParameters({'Invalid': 'x'}), throwsA(isA())); + }); + + test('rejects strings with control characters', () { + expect(() => SfBareItem.string('hi\n'), throwsA(isA())); + }); + + test('rejects display strings with unpaired surrogate', () { + final highSurrogate = String.fromCharCode(0xD800); + expect(() => SfDisplayString(highSurrogate), throwsA(isA())); + }); + }); +} From 7b9e2675c62034101f313f34409185993516a864 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Mon, 27 Oct 2025 12:44:26 +0000 Subject: [PATCH 08/17] adding more tests with focus on sfv addition --- test/approov_http_client_test.dart | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/approov_http_client_test.dart b/test/approov_http_client_test.dart index 0851d6b..2b5bf1f 100644 --- a/test/approov_http_client_test.dart +++ b/test/approov_http_client_test.dart @@ -90,4 +90,49 @@ void main() { expect(componentNames.contains('content-length'), isFalse); expect(params.serializeComponentValue().contains('"content-length"'), isFalse); }); + + test('signature parameters serialize using structured fields', () { + final params = SignatureParameters() + ..addComponentIdentifier('@method') + ..addComponentIdentifier('content-type', parameters: {'charset': 'utf-8'}) + ..setAlg('hmac-sha256') + ..setNonce('nonce123') + ..setTag('tagged'); + + // Duplicate component with identical parameters should be ignored. + params.addComponentIdentifier('content-type', parameters: {'charset': 'utf-8'}); + expect(params.componentIdentifiers.length, 2); + + final serialized = params.serializeComponentValue(); + expect( + serialized, + '("@method" "content-type";charset="utf-8");alg="hmac-sha256";nonce="nonce123";tag="tagged"', + ); + }); + + test('signature base builder includes derived query-param component', () { + final params = SignatureParameters() + ..addComponentIdentifier('@method') + ..addComponentIdentifier('@query-param', parameters: {'name': 'foo'}) + ..setAlg('ecdsa-p256-sha256'); + + final context = ApproovSigningContext( + requestMethod: 'get', + uri: Uri.parse('https://api.example.com/search?foo=bar&baz=1'), + headers: >{}, + bodyBytes: null, + tokenHeaderName: null, + onSetHeader: (_, __) {}, + onAddHeader: (_, __) {}, + ); + + final base = SignatureBaseBuilder(params, context).createSignatureBase(); + final expected = [ + '"@method": GET', + '"@query-param";name="foo": bar', + '"@signature-params": ("@method" "@query-param";name="foo");alg="ecdsa-p256-sha256"', + ].join('\n'); + + expect(base, expected); + }); } From 4972ffc45d22d4880e00aabcb160d6b3e7c98325 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Mon, 27 Oct 2025 13:03:09 +0000 Subject: [PATCH 09/17] Refactor enableMessageSigning to include mutex protection and validate host keys; Update SfBareItem.fromDynamic to remove dead code for int check; Clean up test cases by removing unused test --- .gitignore | 2 +- lib/approov_service_flutter_httpclient.dart | 25 +++++++++++++++------ lib/src/structured_fields.dart | 2 +- test/approov_http_client_test.dart | 1 - 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 48b3991..8468b5f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,6 @@ pubspec.lock .idea android/local.properties -android/build/reports/problems/problems-report.html +android/build/ AGENTS.md build/ \ No newline at end of file diff --git a/lib/approov_service_flutter_httpclient.dart b/lib/approov_service_flutter_httpclient.dart index 23320aa..ba35c41 100644 --- a/lib/approov_service_flutter_httpclient.dart +++ b/lib/approov_service_flutter_httpclient.dart @@ -417,13 +417,24 @@ class ApproovService { static void enableMessageSigning({ SignatureParametersFactory? defaultFactory, Map? hostFactories, - }) { - final messageSigning = ApproovMessageSigning(); - messageSigning.setDefaultFactory(defaultFactory ?? SignatureParametersFactory.generateDefaultFactory()); - if (hostFactories != null) { - hostFactories.forEach((host, factory) => messageSigning.putHostFactory(host, factory)); - } - _messageSigning = messageSigning; + }) async { + await _initMutex.protect(() async { + final effectiveDefaultFactory = defaultFactory ?? SignatureParametersFactory.generateDefaultFactory(); + if (hostFactories != null) { + for (final entry in hostFactories.entries) { + final host = entry.key; + if (host.isEmpty) { + throw ArgumentError('Each host key must be a non-empty string'); + } + } + } + final messageSigning = ApproovMessageSigning(); + messageSigning.setDefaultFactory(effectiveDefaultFactory); + if (hostFactories != null) { + hostFactories.forEach((host, factory) => messageSigning.putHostFactory(host, factory)); + } + _messageSigning = messageSigning; + }); Log.d("$TAG: enableMessageSigning configured"); } diff --git a/lib/src/structured_fields.dart b/lib/src/structured_fields.dart index cb1ed7b..857c6a9 100644 --- a/lib/src/structured_fields.dart +++ b/lib/src/structured_fields.dart @@ -275,7 +275,7 @@ class SfBareItem { if (value is SfDisplayString) return SfBareItem.displayString(value); if (value is Uint8List) return SfBareItem.byteSequence(value); if (value is List) return SfBareItem.byteSequence(Uint8List.fromList(value)); - if (value is DateTime || value is SfDate || value is int) { + if (value is DateTime || value is SfDate) { return SfBareItem.date(value); } if (value is String) return SfBareItem.string(value); diff --git a/test/approov_http_client_test.dart b/test/approov_http_client_test.dart index 2b5bf1f..83a361e 100644 --- a/test/approov_http_client_test.dart +++ b/test/approov_http_client_test.dart @@ -21,7 +21,6 @@ void main() { channel.setMockMethodCallHandler(null); }); - test('getPlatformVersion', () async {}); test('signature base matches HTTP message signatures format', () { final bodyBytes = Uint8List.fromList(utf8.encode('{"hello":"world"}')); From 966089862f92481118563c4ce4ea8b5f65a467b8 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Mon, 27 Oct 2025 13:22:31 +0000 Subject: [PATCH 10/17] add more edge cases tests for structured fields; make enableMessageSigning synchronus (as it was) as we want it to be returning void not Future --- lib/approov_service_flutter_httpclient.dart | 30 ++++++------ test/approov_http_client_test.dart | 51 +++++++++++++++++++++ test/structured_fields_test.dart | 43 +++++++++++++++++ 3 files changed, 108 insertions(+), 16 deletions(-) diff --git a/lib/approov_service_flutter_httpclient.dart b/lib/approov_service_flutter_httpclient.dart index ba35c41..662185c 100644 --- a/lib/approov_service_flutter_httpclient.dart +++ b/lib/approov_service_flutter_httpclient.dart @@ -34,6 +34,7 @@ import 'package:http/io_client.dart' as httpio; import 'package:logger/logger.dart'; import 'package:pem/pem.dart'; import 'package:mutex/mutex.dart'; +import 'package:meta/meta.dart'; import 'src/message_signing.dart'; export 'src/message_signing.dart' show @@ -417,24 +418,18 @@ class ApproovService { static void enableMessageSigning({ SignatureParametersFactory? defaultFactory, Map? hostFactories, - }) async { - await _initMutex.protect(() async { - final effectiveDefaultFactory = defaultFactory ?? SignatureParametersFactory.generateDefaultFactory(); - if (hostFactories != null) { - for (final entry in hostFactories.entries) { - final host = entry.key; - if (host.isEmpty) { - throw ArgumentError('Each host key must be a non-empty string'); - } + }) { + final effectiveDefaultFactory = defaultFactory ?? SignatureParametersFactory.generateDefaultFactory(); + if (hostFactories != null) { + for (final entry in hostFactories.entries) { + if (entry.key.isEmpty) { + throw ArgumentError('Each host key must be a non-empty string'); } } - final messageSigning = ApproovMessageSigning(); - messageSigning.setDefaultFactory(effectiveDefaultFactory); - if (hostFactories != null) { - hostFactories.forEach((host, factory) => messageSigning.putHostFactory(host, factory)); - } - _messageSigning = messageSigning; - }); + } + final messageSigning = ApproovMessageSigning()..setDefaultFactory(effectiveDefaultFactory); + hostFactories?.forEach(messageSigning.putHostFactory); + _messageSigning = messageSigning; Log.d("$TAG: enableMessageSigning configured"); } @@ -444,6 +439,9 @@ class ApproovService { _messageSigning = null; } + @visibleForTesting + static ApproovMessageSigning? messageSigningForTesting() => _messageSigning; + /// Sets a binding header that must be present on all requests using the Approov service. A /// header should be chosen whose value is unchanging for most requests (such as an /// Authorization header). A hash of the header value is included in the issued Approov tokens diff --git a/test/approov_http_client_test.dart b/test/approov_http_client_test.dart index 83a361e..0b1d0e0 100644 --- a/test/approov_http_client_test.dart +++ b/test/approov_http_client_test.dart @@ -19,6 +19,7 @@ void main() { tearDown(() { channel.setMockMethodCallHandler(null); + ApproovService.disableMessageSigning(); }); @@ -134,4 +135,54 @@ void main() { expect(base, expected); }); + + test('enableMessageSigning configures default and host factories', () { + final defaultFactory = SignatureParametersFactory() + .setBaseParameters(SignatureParameters()..addComponentIdentifier('@method')) + .setUseAccountMessageSigning(); + final hostFactory = SignatureParametersFactory() + .setBaseParameters(SignatureParameters()..addComponentIdentifier('@path')) + .setUseInstallMessageSigning(); + + ApproovService.enableMessageSigning( + defaultFactory: defaultFactory, + hostFactories: {'api.example.com': hostFactory}, + ); + + final messageSigning = ApproovService.messageSigningForTesting(); + expect(messageSigning, isNotNull); + + final defaultContext = _buildSigningContext(Uri.parse('https://example.org/resource')); + final defaultParams = messageSigning!.buildParametersFor(defaultContext.uri, defaultContext); + expect(defaultParams, isNotNull); + final defaultComponents = defaultParams!.componentIdentifiers + .map((item) => item.bareItem.value as String) + .toList(); + expect(defaultComponents, contains('@method')); + expect(defaultParams.algorithm, SignatureAlgorithm.hmacSha256); + + final hostContext = _buildSigningContext(Uri.parse('https://api.example.com/resource')); + final hostParams = messageSigning.buildParametersFor(hostContext.uri, hostContext); + expect(hostParams, isNotNull); + final hostComponents = hostParams!.componentIdentifiers + .map((item) => item.bareItem.value as String) + .toList(); + expect(hostComponents, contains('@path')); + expect(hostParams.algorithm, SignatureAlgorithm.ecdsaP256Sha256); + }); +} + +ApproovSigningContext _buildSigningContext(Uri uri) { + final headers = >{ + 'host': [uri.host], + }; + return ApproovSigningContext( + requestMethod: 'get', + uri: uri, + headers: headers, + bodyBytes: null, + tokenHeaderName: null, + onSetHeader: (name, value) => headers[name.toLowerCase()] = [value], + onAddHeader: (name, value) => headers.putIfAbsent(name.toLowerCase(), () => []).add(value), + ); } diff --git a/test/structured_fields_test.dart b/test/structured_fields_test.dart index 81d21d3..4016a0d 100644 --- a/test/structured_fields_test.dart +++ b/test/structured_fields_test.dart @@ -10,11 +10,30 @@ void main() { expect(SfBareItem.integer(-7).serialize(), '-7'); }); + test('integer boundary values are accepted', () { + const max = 999999999999999; + const min = -999999999999999; + expect(SfBareItem.integer(max).serialize(), '$max'); + expect(SfBareItem.integer(min).serialize(), '$min'); + }); + + test('integer beyond boundary throws', () { + const tooLarge = 1000000000000000; + const tooSmall = -1000000000000000; + expect(() => SfBareItem.integer(tooLarge), throwsA(isA())); + expect(() => SfBareItem.integer(tooSmall), throwsA(isA())); + }); + test('decimal encodes canonical representation', () { expect(SfBareItem.decimal(1.25).serialize(), '1.25'); expect(SfBareItem.decimal(SfDecimal.parse('-12.340')).serialize(), '-12.34'); }); + test('decimal enforces precision limits', () { + expect(SfBareItem.decimal(123456789012.123).serialize(), '123456789012.123'); + expect(() => SfBareItem.decimal(1.2345), throwsA(isA())); + }); + test('string escapes quotes and backslashes', () { expect(SfBareItem.string('say "hi" \\ wave').serialize(), '"say \\"hi\\" \\\\ wave"'); }); @@ -42,6 +61,24 @@ void main() { final display = SfBareItem.displayString(SfDisplayString('über % test')); expect(display.serialize(), '%"%c3%bcber %25 test"'); }); + + test('empty string and byte sequence serialize correctly', () { + expect(SfBareItem.string('').serialize(), '""'); + expect(SfBareItem.byteSequence(Uint8List(0)).serialize(), '::'); + }); + + test('long string and token serialize without truncation', () { + final longString = 'x' * 2048; + final longToken = 'a' * 1024; + expect(SfBareItem.string(longString).serialize().length, longString.length + 2); + expect(SfBareItem.token(SfToken(longToken)).serialize(), longToken); + }); + + test('decimal parse round-trips to canonical string', () { + final decimal = SfDecimal.parse('42.500'); + expect(decimal.toString(), '42.5'); + expect(SfBareItem.decimal(decimal).serialize(), '42.5'); + }); }); group('Structured collections', () { @@ -84,6 +121,12 @@ void main() { }); expect(dictionary.serialize(), 'flag;v=1, count=4, list=("x")'); }); + + test('empty collections serialize to empty string', () { + expect(SfInnerList([]).serialize(), '()'); + expect(SfList([]).serialize(), ''); + expect(SfDictionary({}).serialize(), ''); + }); }); group('Validation', () { From f99ee0a00530bf6960f0a6df16bb915ccd8ef1a0 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Mon, 3 Nov 2025 14:42:47 +0000 Subject: [PATCH 11/17] Add comments to clarify validation logic and serialization rules in structured fields/message signing --- lib/src/message_signing.dart | 7 +++++++ lib/src/structured_fields.dart | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/src/message_signing.dart b/lib/src/message_signing.dart index d93011b..0d4c53f 100644 --- a/lib/src/message_signing.dart +++ b/lib/src/message_signing.dart @@ -47,6 +47,7 @@ class SignatureParameters { void addComponentIdentifier(String identifier, {Map? parameters}) { final normalized = identifier.startsWith('@') ? identifier : identifier.toLowerCase(); final candidateParameters = SfParameters(parameters); + // Skip adding duplicate component identifiers that only differ in letter case or parameter identity. if (_componentIdentifiers.any( (item) => _componentIdentifierMatches(item, normalized, candidateParameters), @@ -64,6 +65,7 @@ class SignatureParameters { bool _parametersMatch(SfParameters existing, SfParameters candidate) { final existingMap = existing.asMap(); final candidateMap = candidate.asMap(); + // Structured Field parameters are only equal when both name and serialized value match. if (existingMap.length != candidateMap.length) return false; for (final entry in candidateMap.entries) { final existingValue = existingMap[entry.key]; @@ -187,6 +189,7 @@ class SignatureParametersFactory { SignatureParameters build(ApproovSigningContext context) { final params = _baseParameters != null ? SignatureParameters.copy(_baseParameters!) : SignatureParameters(); params.debugMode = _debugMode; + // Message signing algorithm is selected by swapping between account (HMAC) and install (ECDSA) modes. params.algorithm = _useAccountMessageSigning ? SignatureAlgorithm.hmacSha256 : SignatureAlgorithm.ecdsaP256Sha256; params.setAlg(_useAccountMessageSigning ? 'hmac-sha256' : 'ecdsa-p256-sha256'); @@ -206,6 +209,7 @@ class SignatureParametersFactory { if (header == 'content-length') { final hasBodyBytes = context.bodyBytes != null && context.bodyBytes!.isNotEmpty; final contentLengthValue = context.getComponentValue(SfItem.string('content-length')); + // Avoid signing Content-Length: 0 to mirror how Dart's HttpClient elides that header on the wire. final shouldIncludeContentLength = hasBodyBytes || (contentLengthValue != null && contentLengthValue.trim() != '0'); if (!shouldIncludeContentLength) { @@ -252,6 +256,7 @@ class SignatureBaseBuilder { final ApproovSigningContext context; String createSignatureBase() { + // Serialize each signed component and the signature parameters into the canonical signature base string. final buffer = StringBuffer(); for (final component in params.componentIdentifiers) { final value = context.getComponentValue(component); @@ -362,6 +367,7 @@ class ApproovSigningContext { } return null; } + // RFC-compliant digest header uses base64-encoded hash surrounded by colons, e.g. sha-256=:...: final bytes = switch (digest) { SignatureDigest.sha256 => sha256.convert(bodyBytes!).bytes, SignatureDigest.sha512 => sha512.convert(bodyBytes!).bytes, @@ -394,6 +400,7 @@ class ApproovSigningContext { String _combineFieldValues(List values) { final cleaned = values.map((value) { final trimmed = value.trim(); + // Collapse line folding and excess whitespace to keep a stable canonical field value. return trimmed.replaceAll(RegExp(r'\s*\r\n\s*'), ' '); }).toList(); return cleaned.join(', '); diff --git a/lib/src/structured_fields.dart b/lib/src/structured_fields.dart index 857c6a9..7b2d515 100644 --- a/lib/src/structured_fields.dart +++ b/lib/src/structured_fields.dart @@ -47,6 +47,7 @@ void _validateKey(String key) { throw SfFormatException('Structured Field parameter and dictionary keys must not be empty'); } final codeUnits = key.codeUnits; + // RFC 9651 restricts the first character and allows a limited token charset for the rest. for (var index = 0; index < codeUnits.length; index++) { final unit = codeUnits[index]; final isValid = index == 0 @@ -62,6 +63,7 @@ void _validateString(String value) { for (var index = 0; index < value.length; index++) { final unit = value.codeUnitAt(index); if (unit < 0x20 || unit == 0x7f || unit > 0x7f) { + // Printable ASCII only; Structured Fields treat anything else as invalid input. throw SfFormatException( 'Invalid character 0x${unit.toRadixString(16).padLeft(2, '0')} in sf-string at position $index', ); @@ -74,6 +76,7 @@ void _validateToken(String value) { throw SfFormatException('sf-token must not be empty'); } final codeUnits = value.codeUnits; + // Tokens use the HTTP tchar set and allow ":" and "/" past the first position. for (var index = 0; index < codeUnits.length; index++) { final unit = codeUnits[index]; final isValid = index == 0 @@ -92,6 +95,7 @@ void _validateDisplayString(String value) { if (rune >= 0xd800 && rune <= 0xdfff) { throw SfFormatException('Display strings must not contain surrogate code points'); } + // Reject values outside the valid Unicode scalar range. if (rune < 0x0 || rune > 0x10ffff) { throw SfFormatException('Invalid Unicode scalar value 0x${rune.toRadixString(16)} in display string'); } @@ -124,6 +128,7 @@ class SfDecimal { final scaled = value * 1000; final rounded = scaled.round(); if ((scaled - rounded).abs() > 1e-9) { + // Enforce the three-decimal fixed scale defined by Structured Fields decimals. throw SfFormatException('Decimals must have at most three fractional digits: $value'); } return SfDecimal._checked(rounded); @@ -262,6 +267,7 @@ class SfBareItem { if (value is bool) return SfBareItem.boolean(value); if (value is int) return SfBareItem.integer(value); if (value is SfDecimal || value is num || value is String && value.contains('.')) { + // Interpret numeric-looking inputs as decimals first, falling back to strings when invalid. try { return SfBareItem.decimal(value); } on SfFormatException { @@ -290,6 +296,7 @@ class SfBareItem { void serializeTo(StringBuffer buffer) { switch (type) { case SfBareItemType.integer: + // Integers serialize as plain decimal digits. buffer.write(value as int); case SfBareItemType.decimal: buffer.write((value as SfDecimal).toString()); @@ -333,6 +340,7 @@ class SfBareItem { final bytes = utf8.encode(display.value); for (final byte in bytes) { if (byte == 0x25 || byte == 0x22 || byte < 0x20 || byte > 0x7e) { + // Percent-encode reserved characters and non-printable bytes per RFC guidance. buffer ..write('%') ..write(byte.toRadixString(16).padLeft(2, '0')); @@ -373,6 +381,7 @@ class SfParameters { ..write(';') ..write(key); if (!value.isBooleanTrue) { + // Boolean true omits "=value"; all other entries include the serialized bare item. buffer.write('='); value.serializeTo(buffer); } @@ -518,6 +527,7 @@ class SfDictionaryMember { buffer.write('='); innerList!.serializeTo(buffer); } else if (parameters != null && !parameters!.isEmpty) { + // Bare dictionary boolean members serialize only their attached parameters. parameters!.serializeTo(buffer); } } @@ -540,6 +550,7 @@ class SfDictionary { final buffer = StringBuffer(); var index = 0; _entries.forEach((key, member) { + // Preserve insertion order so signature bases remain stable. if (index > 0) buffer.write(', '); buffer.write(key); member.serializeTo(buffer); From cc1ea7def33d2ba54050cfc64f34f45cde1fac6a Mon Sep 17 00:00:00 2001 From: adriantuk Date: Mon, 3 Nov 2025 15:38:24 +0000 Subject: [PATCH 12/17] Enhance documentation with detailed comments for Structured Fields and message signing components, for each method in the code. --- lib/src/message_signing.dart | 51 +++++++++++++++++++++++++ lib/src/structured_fields.dart | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/lib/src/message_signing.dart b/lib/src/message_signing.dart index 0d4c53f..3194ad0 100644 --- a/lib/src/message_signing.dart +++ b/lib/src/message_signing.dart @@ -12,10 +12,12 @@ enum SignatureAlgorithm { ecdsaP256Sha256, } +/// Builds a component identifier item with optional Structured Fields parameters. SfItem _buildComponentIdentifier(String value, Map? parameters) { return SfItem.string(value, parameters); } +/// Extracts the string value from a Structured Field component identifier. String _componentIdentifierValue(SfItem item) { final bareItem = item.bareItem; if (bareItem.type != SfBareItemType.string) { @@ -26,10 +28,12 @@ String _componentIdentifierValue(SfItem item) { /// Holds configuration for message signature parameters, mirroring the Swift implementation. class SignatureParameters { + /// Creates an empty set of signature parameters. SignatureParameters() : _componentIdentifiers = [], _parameters = LinkedHashMap(); + /// Creates a deep copy of another `SignatureParameters` instance. SignatureParameters.copy(SignatureParameters other) : _componentIdentifiers = List.from(other._componentIdentifiers), _parameters = LinkedHashMap.from(other._parameters), @@ -42,8 +46,10 @@ class SignatureParameters { bool debugMode = false; SignatureAlgorithm algorithm = SignatureAlgorithm.hmacSha256; + /// The ordered list of Structured Field components that will be signed. List get componentIdentifiers => List.unmodifiable(_componentIdentifiers); + /// Adds a component identifier to the signature, avoiding duplicates. void addComponentIdentifier(String identifier, {Map? parameters}) { final normalized = identifier.startsWith('@') ? identifier : identifier.toLowerCase(); final candidateParameters = SfParameters(parameters); @@ -57,11 +63,13 @@ class SignatureParameters { _componentIdentifiers.add(_buildComponentIdentifier(normalized, parameters)); } + /// Returns whether the candidate `SfItem` matches an existing component. bool _componentIdentifierMatches(SfItem item, String value, SfParameters candidate) { if (_componentIdentifierValue(item) != value) return false; return _parametersMatch(item.parameters, candidate); } + /// Compares two Structured Field parameter sets for equality. bool _parametersMatch(SfParameters existing, SfParameters candidate) { final existingMap = existing.asMap(); final candidateMap = candidate.asMap(); @@ -75,30 +83,37 @@ class SignatureParameters { return true; } + /// Sets the `alg` parameter that advertises the signing algorithm. void setAlg(String value) { _parameters['alg'] = SfBareItem.string(value); } + /// Records the `created` timestamp parameter in seconds. void setCreated(int timestampSeconds) { _parameters['created'] = SfBareItem.integer(timestampSeconds); } + /// Records the `expires` timestamp parameter in seconds. void setExpires(int timestampSeconds) { _parameters['expires'] = SfBareItem.integer(timestampSeconds); } + /// Sets the `keyid` parameter to identify the signing key. void setKeyId(String keyId) { _parameters['keyid'] = SfBareItem.string(keyId); } + /// Sets the `nonce` parameter used for replay protection. void setNonce(String nonce) { _parameters['nonce'] = SfBareItem.string(nonce); } + /// Sets the optional `tag` parameter carried with the signature. void setTag(String tag) { _parameters['tag'] = SfBareItem.string(tag); } + /// Derives the Approov signature label for the configured algorithm. String signatureLabel() { switch (algorithm) { case SignatureAlgorithm.ecdsaP256Sha256: @@ -109,15 +124,19 @@ class SignatureParameters { } } + /// Returns the Structured Field identifier used for the `Signature-Params` entry. SfItem signatureParamsIdentifier() => _buildComponentIdentifier('@signature-params', null); + /// Serializes the signature parameters into the canonical inner list representation. String serializeComponentValue() { final parameters = _parameters.isEmpty ? null : _parameters; return SfInnerList(_componentIdentifiers, parameters).serialize(); } } +/// Configures how signature parameters are generated for requests. class SignatureParametersFactory { + /// Creates a factory for building `SignatureParameters` instances. SignatureParametersFactory(); SignatureParameters? _baseParameters; @@ -130,11 +149,13 @@ class SignatureParametersFactory { final List _optionalHeaders = []; bool _debugMode = false; + /// Seeds the factory with base parameters that are cloned per build. SignatureParametersFactory setBaseParameters(SignatureParameters base) { _baseParameters = SignatureParameters.copy(base); return this; } + /// Configures body digest requirements and the hashing algorithm. SignatureParametersFactory setBodyDigestConfig(String? algorithm, {required bool required}) { if (algorithm != null && algorithm != SignatureDigest.sha256.identifier && @@ -146,31 +167,37 @@ class SignatureParametersFactory { return this; } + /// Switches signing to the install (ECDSA) key path. SignatureParametersFactory setUseInstallMessageSigning() { _useAccountMessageSigning = false; return this; } + /// Switches signing to the account (HMAC) key path. SignatureParametersFactory setUseAccountMessageSigning() { _useAccountMessageSigning = true; return this; } + /// Enables or disables emitting the `created` parameter. SignatureParametersFactory setAddCreated(bool addCreated) { _addCreated = addCreated; return this; } + /// Sets the validity window for the `expires` parameter. SignatureParametersFactory setExpiresLifetime(int seconds) { _expiresLifetimeSeconds = seconds; return this; } + /// Controls whether the Approov token header is added to the component list. SignatureParametersFactory setAddApproovTokenHeader(bool add) { _addApproovTokenHeader = add; return this; } + /// Adds additional headers to sign when present on the request. SignatureParametersFactory addOptionalHeaders(List headers) { for (final header in headers) { final normalized = header.toLowerCase(); @@ -181,11 +208,13 @@ class SignatureParametersFactory { return this; } + /// Enables or disables debug mode on the produced parameters. SignatureParametersFactory setDebugMode(bool debugMode) { _debugMode = debugMode; return this; } + /// Builds a concrete parameter set for the supplied signing context. SignatureParameters build(ApproovSigningContext context) { final params = _baseParameters != null ? SignatureParameters.copy(_baseParameters!) : SignatureParameters(); params.debugMode = _debugMode; @@ -233,6 +262,7 @@ class SignatureParametersFactory { return params; } + /// Generates the default Approov configuration, optionally layering on an override base. static SignatureParametersFactory generateDefaultFactory({SignatureParameters? overrideBase}) { final base = overrideBase ?? (SignatureParameters() @@ -249,12 +279,15 @@ class SignatureParametersFactory { } } +/// Builds canonical signature base strings from parameters and request context. class SignatureBaseBuilder { + /// Creates a builder that canonicalizes the parameters for signing. SignatureBaseBuilder(this.params, this.context); final SignatureParameters params; final ApproovSigningContext context; + /// Produces the canonical signature base string for the configured context. String createSignatureBase() { // Serialize each signed component and the signature parameters into the canonical signature base string. final buffer = StringBuffer(); @@ -282,6 +315,7 @@ enum SignatureDigest { const SignatureDigest(this.identifier); final String identifier; + /// Looks up a digest configuration by its HTTP identifier. static SignatureDigest fromIdentifier(String id) { return SignatureDigest.values.firstWhere( (value) => value.identifier == id, @@ -290,7 +324,9 @@ enum SignatureDigest { } } +/// Holds the HTTP request data required for canonical signing. class ApproovSigningContext { + /// Captures the request metadata and header snapshot for signing. ApproovSigningContext({ required this.requestMethod, required this.uri, @@ -311,18 +347,22 @@ class ApproovSigningContext { final void Function(String name, String value)? onSetHeader; final void Function(String name, String value)? onAddHeader; + /// Returns true when a header with the provided name is present. bool hasField(String name) => _headers.containsKey(name.toLowerCase()); + /// Sets a header to a single canonical value, replacing any previous entry. void setHeader(String name, String value) { _headers[name.toLowerCase()] = [value]; onSetHeader?.call(name, value); } + /// Adds an additional header value while keeping existing ones intact. void addHeader(String name, String value) { _headers.putIfAbsent(name.toLowerCase(), () => []).add(value); onAddHeader?.call(name, value); } + /// Resolves the canonical value for a Structured Field component. String? getComponentValue(SfItem component) { final identifier = _componentIdentifierValue(component); if (identifier.startsWith('@')) { @@ -360,6 +400,7 @@ class ApproovSigningContext { } } + /// Ensures the `Content-Digest` header exists by hashing the request body. String? ensureContentDigest(SignatureDigest digest, {required bool required}) { if (bodyBytes == null) { if (required) { @@ -377,6 +418,7 @@ class ApproovSigningContext { return headerValue; } + /// Returns the authority component normalized per HTTP request rules. String _authority() { if ((uri.scheme == 'http' && uri.port == 80) || (uri.scheme == 'https' && uri.port == 443) || (uri.port == 0)) { return uri.host; @@ -384,12 +426,14 @@ class ApproovSigningContext { return '${uri.host}:${uri.port}'; } + /// Builds the request-target pseudo-component used by HTTP signatures. String _requestTarget() { final path = uri.path.isEmpty ? '/' : uri.path; if (!uri.hasQuery) return path; return '$path?${uri.query}'; } + /// Extracts a single query parameter value, returning null when ambiguous. String? _queryParameterValue(String name) { final values = uri.queryParametersAll[name]; if (values == null) return null; @@ -397,6 +441,7 @@ class ApproovSigningContext { return values.isEmpty ? '' : values.first; } + /// Collapses folded header lines into a single comma-separated value. String _combineFieldValues(List values) { final cleaned = values.map((value) { final trimmed = value.trim(); @@ -406,27 +451,33 @@ class ApproovSigningContext { return cleaned.join(', '); } + /// Returns a copy of the tracked headers map for inspection or replay. Map> snapshotHeaders() => LinkedHashMap.of(_headers); } +/// Coordinates signature parameter factories across different hosts. class ApproovMessageSigning { SignatureParametersFactory? _defaultFactory; final Map _hostFactories = {}; + /// Sets the fallback factory used when a host-specific one is absent. ApproovMessageSigning setDefaultFactory(SignatureParametersFactory factory) { _defaultFactory = factory; return this; } + /// Registers a signature parameters factory for a specific host. ApproovMessageSigning putHostFactory(String host, SignatureParametersFactory factory) { _hostFactories[host] = factory; return this; } + /// Looks up the factory to use for the provided host. SignatureParametersFactory? _factoryForHost(String host) { return _hostFactories[host] ?? _defaultFactory; } + /// Builds signature parameters for the supplied URI if a factory is configured. SignatureParameters? buildParametersFor(Uri uri, ApproovSigningContext context) { final factory = _factoryForHost(uri.host); if (factory == null) return null; diff --git a/lib/src/structured_fields.dart b/lib/src/structured_fields.dart index 7b2d515..130207b 100644 --- a/lib/src/structured_fields.dart +++ b/lib/src/structured_fields.dart @@ -4,22 +4,28 @@ import 'dart:typed_data'; /// Exception thrown when Structured Field values fail validation. class SfFormatException extends FormatException { + /// Creates a format exception referencing the offending source. SfFormatException(String message, [dynamic source]) : super(message, source); } enum _CharType { alphaLower, alphaUpper, digit } +/// Returns true when the code unit represents a lowercase ASCII letter. bool _isLowerAlpha(int codeUnit) => codeUnit >= 0x61 && codeUnit <= 0x7a; // a-z +/// Returns true when the code unit represents an uppercase ASCII letter. bool _isUpperAlpha(int codeUnit) => codeUnit >= 0x41 && codeUnit <= 0x5a; // A-Z +/// Returns true when the code unit represents any ASCII letter. bool _isAlpha(int codeUnit) => _isLowerAlpha(codeUnit) || _isUpperAlpha(codeUnit); +/// Returns true when the code unit is an ASCII digit. bool _isDigit(int codeUnit) => codeUnit >= 0x30 && codeUnit <= 0x39; +/// Returns true when the code unit falls within the HTTP `tchar` token range. bool _isTchar(int codeUnit) { if (_isAlpha(codeUnit) || _isDigit(codeUnit)) return true; const allowed = { @@ -42,6 +48,7 @@ bool _isTchar(int codeUnit) { return allowed.contains(codeUnit); } +/// Validates that a Structured Field key adheres to RFC token rules. void _validateKey(String key) { if (key.isEmpty) { throw SfFormatException('Structured Field parameter and dictionary keys must not be empty'); @@ -59,6 +66,7 @@ void _validateKey(String key) { } } +/// Validates that an sf-string contains only printable ASCII characters. void _validateString(String value) { for (var index = 0; index < value.length; index++) { final unit = value.codeUnitAt(index); @@ -71,6 +79,7 @@ void _validateString(String value) { } } +/// Validates the character set of an sf-token. void _validateToken(String value) { if (value.isEmpty) { throw SfFormatException('sf-token must not be empty'); @@ -90,6 +99,7 @@ void _validateToken(String value) { } } +/// Validates that a display string uses legal Unicode scalar values. void _validateDisplayString(String value) { for (final rune in value.runes) { if (rune >= 0xd800 && rune <= 0xdfff) { @@ -104,6 +114,7 @@ void _validateDisplayString(String value) { /// Represents an sf-token value. class SfToken { + /// Validates and stores the Structured Field token value. SfToken(String value) : value = value { _validateToken(value); } @@ -113,6 +124,7 @@ class SfToken { /// Represents a display string bare item. class SfDisplayString { + /// Validates and stores the Structured Field display string. SfDisplayString(String value) : value = value { _validateDisplayString(value); } @@ -122,8 +134,10 @@ class SfDisplayString { /// Represents a decimal bare item using a fixed three-digit scale. class SfDecimal { + /// Stores the decimal using its scaled integer representation. SfDecimal._(this._scaledValue); + /// Creates a Structured Field decimal from a numeric value. factory SfDecimal.fromNum(num value) { final scaled = value * 1000; final rounded = scaled.round(); @@ -134,6 +148,7 @@ class SfDecimal { return SfDecimal._checked(rounded); } + /// Parses a Structured Field decimal from its textual form. factory SfDecimal.parse(String value) { if (!RegExp(r'^-?[0-9]{1,12}\.[0-9]{1,3}$').hasMatch(value)) { throw SfFormatException('Invalid decimal format: $value'); @@ -146,6 +161,7 @@ class SfDecimal { return SfDecimal._checked(scaled); } + /// Ensures the scaled value falls within the allowed magnitude. static SfDecimal _checked(int scaled) { const max = 999999999999999; if (scaled.abs() > max) { @@ -156,11 +172,14 @@ class SfDecimal { final int _scaledValue; + /// Returns the scaled integer representation (value * 1000). int get scaledValue => _scaledValue; + /// Converts the decimal into a floating point number. double toDouble() => _scaledValue / 1000.0; @override + /// Serializes the decimal back into its canonical textual representation. String toString() { final sign = _scaledValue < 0 ? '-' : ''; final absValue = _scaledValue.abs(); @@ -175,10 +194,12 @@ class SfDecimal { /// Represents a Date bare item storing seconds since Unix epoch. class SfDate { + /// Creates a date from seconds since the Unix epoch. SfDate.fromSeconds(int seconds) : seconds = seconds { _validateRange(seconds); } + /// Creates an `SfDate` from a `DateTime`, normalizing to UTC seconds. factory SfDate.fromDateTime(DateTime dateTime) { final utc = dateTime.toUtc(); final seconds = utc.millisecondsSinceEpoch ~/ 1000; @@ -187,8 +208,10 @@ class SfDate { final int seconds; + /// Converts the stored seconds back into a UTC `DateTime`. DateTime toUtcDateTime() => DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); + /// Validates that the seconds value lies within the allowed range. static void _validateRange(int seconds) { const min = -62135596800; // year 0001 const max = 253402214400; // year 9999 @@ -212,8 +235,10 @@ enum SfBareItemType { /// Represents a bare item per RFC 9651. class SfBareItem { + /// Internal constructor storing both the type and underlying value. const SfBareItem._(this.type, this.value); + /// Creates an integer bare item after validating its range. factory SfBareItem.integer(int value) { const min = -999999999999999; const max = 999999999999999; @@ -223,6 +248,7 @@ class SfBareItem { return SfBareItem._(SfBareItemType.integer, value); } + /// Creates a decimal bare item from supported numeric inputs. factory SfBareItem.decimal(dynamic value) { if (value is SfDecimal) { return SfBareItem._(SfBareItemType.decimal, value); @@ -234,20 +260,25 @@ class SfBareItem { throw SfFormatException('Unsupported value for decimal bare item: ${value.runtimeType}'); } + /// Creates a string bare item, validating the character set. factory SfBareItem.string(String value) { _validateString(value); return SfBareItem._(SfBareItemType.string, value); } + /// Creates a token bare item. factory SfBareItem.token(SfToken token) => SfBareItem._(SfBareItemType.token, token.value); + /// Creates a byte sequence bare item with a defensive copy. factory SfBareItem.byteSequence(Uint8List value) => SfBareItem._(SfBareItemType.byteSequence, Uint8List.fromList(value)); + /// Creates a boolean bare item. factory SfBareItem.boolean(bool value) => SfBareItem._(SfBareItemType.boolean, value); + /// Creates a date bare item from several supported temporal types. factory SfBareItem.date(dynamic value) { if (value is SfDate) { return SfBareItem._(SfBareItemType.date, value); @@ -259,9 +290,11 @@ class SfBareItem { throw SfFormatException('Unsupported value for date bare item: ${value.runtimeType}'); } + /// Creates a display string bare item. factory SfBareItem.displayString(SfDisplayString value) => SfBareItem._(SfBareItemType.displayString, value); + /// Coerces dynamic input into the appropriate bare item type. factory SfBareItem.fromDynamic(dynamic value) { if (value is SfBareItem) return value; if (value is bool) return SfBareItem.boolean(value); @@ -291,8 +324,10 @@ class SfBareItem { final SfBareItemType type; final Object value; + /// Returns `true` when the bare item represents boolean true. bool get isBooleanTrue => type == SfBareItemType.boolean && value == true; + /// Writes the bare item serialization into the provided buffer. void serializeTo(StringBuffer buffer) { switch (type) { case SfBareItemType.integer: @@ -329,12 +364,14 @@ class SfBareItem { } } + /// Serializes the bare item into a string. String serialize() { final buffer = StringBuffer(); serializeTo(buffer); return buffer.toString(); } + /// Percent-encodes a display string according to Structured Fields rules. static String _encodeDisplayString(SfDisplayString display) { final buffer = StringBuffer()..write('%"'); final bytes = utf8.encode(display.value); @@ -355,8 +392,10 @@ class SfBareItem { /// Represents the parameters attached to an Item or Inner List. class SfParameters { + /// Stores the parameters as an unmodifiable map view. SfParameters._(this._entries); + /// Builds an `SfParameters` instance from a map of raw values. factory SfParameters([Map? entries]) { if (entries == null || entries.isEmpty) { return SfParameters._(UnmodifiableMapView(LinkedHashMap())); @@ -371,10 +410,13 @@ class SfParameters { final Map _entries; + /// Returns true when no parameters are present. bool get isEmpty => _entries.isEmpty; + /// Exposes the underlying parameter map. Map asMap() => _entries; + /// Serializes parameters into the Structured Fields `;key=value` form. void serializeTo(StringBuffer buffer) { _entries.forEach((key, value) { buffer @@ -391,41 +433,52 @@ class SfParameters { /// Represents an sf-item. class SfItem { + /// Creates an item from a bare value and optional parameters. SfItem(this.bareItem, [Map? parameters]) : parameters = SfParameters(parameters); + /// Creates a string item. factory SfItem.string(String value, [Map? parameters]) => SfItem(SfBareItem.string(value), parameters); + /// Creates a token item. factory SfItem.token(String value, [Map? parameters]) => SfItem(SfBareItem.token(SfToken(value)), parameters); + /// Creates a boolean item. factory SfItem.boolean(bool value, [Map? parameters]) => SfItem(SfBareItem.boolean(value), parameters); + /// Creates an integer item. factory SfItem.integer(int value, [Map? parameters]) => SfItem(SfBareItem.integer(value), parameters); + /// Creates a decimal item. factory SfItem.decimal(dynamic value, [Map? parameters]) => SfItem(SfBareItem.decimal(value), parameters); + /// Creates a byte sequence item. factory SfItem.byteSequence(Uint8List value, [Map? parameters]) => SfItem(SfBareItem.byteSequence(value), parameters); + /// Creates a date item. factory SfItem.date(dynamic value, [Map? parameters]) => SfItem(SfBareItem.date(value), parameters); + /// Creates a display string item. factory SfItem.displayString(String value, [Map? parameters]) => SfItem(SfBareItem.displayString(SfDisplayString(value)), parameters); final SfBareItem bareItem; final SfParameters parameters; + /// Serializes the item and its parameters into the provided buffer. void serializeTo(StringBuffer buffer) { bareItem.serializeTo(buffer); parameters.serializeTo(buffer); } + /// Serializes the item into a string. String serialize() { final buffer = StringBuffer(); serializeTo(buffer); @@ -435,6 +488,7 @@ class SfItem { /// Represents an inner list per RFC 9651. class SfInnerList { + /// Creates an inner list with optional parameters. SfInnerList(List items, [Map? parameters]) : items = List.unmodifiable(items), parameters = SfParameters(parameters); @@ -442,6 +496,7 @@ class SfInnerList { final List items; final SfParameters parameters; + /// Serializes the inner list into the provided buffer. void serializeTo(StringBuffer buffer) { buffer.write('('); for (var index = 0; index < items.length; index++) { @@ -452,6 +507,7 @@ class SfInnerList { parameters.serializeTo(buffer); } + /// Serializes the inner list into a string. String serialize() { final buffer = StringBuffer(); serializeTo(buffer); @@ -461,10 +517,12 @@ class SfInnerList { /// Represents a list member (either an Item or inner list). class SfListMember { + /// Creates a list member wrapping an item. SfListMember.item(SfItem item) : item = item, innerList = null; + /// Creates a list member wrapping an inner list. SfListMember.innerList(SfInnerList innerList) : item = null, innerList = innerList; @@ -472,6 +530,7 @@ class SfListMember { final SfItem? item; final SfInnerList? innerList; + /// Serializes either the item or inner list into the buffer. void serializeTo(StringBuffer buffer) { if (item != null) { item!.serializeTo(buffer); @@ -483,11 +542,13 @@ class SfListMember { /// Represents an sf-list. class SfList { + /// Creates a list from ordered members. SfList(List members) : members = List.unmodifiable(members); final List members; + /// Serializes the list into a comma-separated string. String serialize() { final buffer = StringBuffer(); for (var index = 0; index < members.length; index++) { @@ -500,16 +561,19 @@ class SfList { /// Represents a dictionary member that can be either a value or boolean true with parameters. class SfDictionaryMember { + /// Creates a boolean-true dictionary member with optional parameters. SfDictionaryMember.booleanTrue([Map? parameters]) : item = null, innerList = null, parameters = SfParameters(parameters); + /// Creates a dictionary member that stores a single item. SfDictionaryMember.item(SfItem item) : item = item, innerList = null, parameters = null; + /// Creates a dictionary member that stores an inner list. SfDictionaryMember.innerList(SfInnerList innerList) : item = null, innerList = innerList, @@ -519,6 +583,7 @@ class SfDictionaryMember { final SfInnerList? innerList; final SfParameters? parameters; + /// Serializes the dictionary member according to its stored variant. void serializeTo(StringBuffer buffer) { if (item != null) { buffer.write('='); @@ -535,6 +600,7 @@ class SfDictionaryMember { /// Represents an sf-dictionary. class SfDictionary { + /// Creates a dictionary while validating the member keys. SfDictionary(Map entries) : _entries = UnmodifiableMapView( LinkedHashMap.fromEntries(entries.entries.map((entry) { @@ -544,8 +610,10 @@ class SfDictionary { final Map _entries; + /// Provides access to the underlying entries. Map asMap() => _entries; + /// Serializes the dictionary into a comma-separated string. String serialize() { final buffer = StringBuffer(); var index = 0; From ec316217dc2a302e96dfe6c0d12cddea71c19dd7 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Mon, 3 Nov 2025 17:24:42 +0000 Subject: [PATCH 13/17] Remove the fallback between algorithms; the user should explicitly specify whether Installation or Account message signing is used. --- devtools_options.yaml | 3 +++ lib/approov_service_flutter_httpclient.dart | 17 ++--------------- 2 files changed, 5 insertions(+), 15 deletions(-) create mode 100644 devtools_options.yaml diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/approov_service_flutter_httpclient.dart b/lib/approov_service_flutter_httpclient.dart index 662185c..fda8a19 100644 --- a/lib/approov_service_flutter_httpclient.dart +++ b/lib/approov_service_flutter_httpclient.dart @@ -1228,21 +1228,8 @@ class ApproovService { } final signatureBase = SignatureBaseBuilder(params, context).createSignatureBase(); - String signature; - try { // If we fail to sign with install signing, we fall back to account signing (install signing is safer but not always available) - signature = await _signCanonicalMessage(signatureBase, params.algorithm); - } on StateError { - if (params.algorithm == SignatureAlgorithm.ecdsaP256Sha256) { - Log.w("$TAG: install message signing unavailable, falling back to account signing"); - params.algorithm = SignatureAlgorithm.hmacSha256; - params.setAlg('hmac-sha256'); - // Regenerate the signature base with the updated algorithm - final updatedSignatureBase = SignatureBaseBuilder(params, context).createSignatureBase(); - signature = await _signCanonicalMessage(updatedSignatureBase, params.algorithm); - } else { - rethrow; - } - } + // Allow the configured algorithm to fail fast so callers can decide how to handle it. + final signature = await _signCanonicalMessage(signatureBase, params.algorithm); if (signature.isEmpty) { Log.d("$TAG: message signing returned empty signature for ${request.uri}"); return; From 722a3fa3d52f506c04620bda38f66e1793127eb5 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Tue, 4 Nov 2025 11:43:45 +0000 Subject: [PATCH 14/17] Message signing now derives the signer and header label directly from the "alg" parameter so the Dart layer mirrors the OkHttp/URLSession behaviour and the parameter object stays algorithm-agnostic. Improved formatting of the code to be easier to read. --- lib/approov_service_flutter_httpclient.dart | 371 +++++++++++++------- lib/src/message_signing.dart | 114 +++--- test/approov_http_client_test.dart | 49 ++- 3 files changed, 348 insertions(+), 186 deletions(-) diff --git a/lib/approov_service_flutter_httpclient.dart b/lib/approov_service_flutter_httpclient.dart index fda8a19..6d7e9b2 100644 --- a/lib/approov_service_flutter_httpclient.dart +++ b/lib/approov_service_flutter_httpclient.dart @@ -40,7 +40,6 @@ export 'src/message_signing.dart' show ApproovMessageSigning, ApproovSigningContext, - SignatureAlgorithm, SignatureBaseBuilder, SignatureDigest, SignatureParameters, @@ -105,8 +104,8 @@ class _TokenFetchResult { /// /// @param tokenFetchResultMap holds the results of the fetch _TokenFetchResult.fromTokenFetchResultMap(Map tokenFetchResultMap) { - _TokenFetchStatus? newTokenFetchStatus = - EnumToString.fromString(_TokenFetchStatus.values, tokenFetchResultMap["TokenFetchStatus"]); + _TokenFetchStatus? newTokenFetchStatus = EnumToString.fromString( + _TokenFetchStatus.values, tokenFetchResultMap["TokenFetchStatus"]); if (newTokenFetchStatus != null) tokenFetchStatus = newTokenFetchStatus; token = tokenFetchResultMap["Token"]; String? newSecureString = tokenFetchResultMap["SecureString"]; @@ -166,7 +165,8 @@ class ApproovRejectionException extends ApproovException { /// @param cause is a message giving the cause of the exception /// @param arc is the code that can be used for support purposes /// @param rejectionReasons may provide a comma separated list of rejection reasons - ApproovRejectionException(String cause, String arc, String rejectionReasons) : super(cause) { + ApproovRejectionException(String cause, String arc, String rejectionReasons) + : super(cause) { this.arc = arc; this.rejectionReasons = rejectionReasons; } @@ -186,12 +186,14 @@ class ApproovService { // foreground channel for communicating with the platform specific layers (used by the root isolate) - this is // used in all cases where the operation is not expected to block for an extended period and also from the root // isolate where a callback may be received - static const MethodChannel _fgChannel = const MethodChannel('approov_service_flutter_httpclient_fg'); + static const MethodChannel _fgChannel = + const MethodChannel('approov_service_flutter_httpclient_fg'); // background channel for communicating with the platform specific layers (used by background isolates) - this is // used in cases where the operation may block for an extended period and it is necessary to use this in that // case to avoid the main isolate thread being blocked by the operation - static const MethodChannel _bgChannel = const MethodChannel('approov_service_flutter_httpclient_bg'); + static const MethodChannel _bgChannel = + const MethodChannel('approov_service_flutter_httpclient_bg'); // header that will be added to Approov enabled requests static const String APPROOV_HEADER = "Approov-Token"; @@ -242,16 +244,18 @@ class ApproovService { // configuration for automatically signing outbound requests using Approov static ApproovMessageSigning? _messageSigning; static bool _installMessageSigningAvailable = true; - + // cached host certificates obtaining from probing the relevant host domains - static Map?> _hostCertificates = Map?>(); + static Map?> _hostCertificates = + Map?>(); // next transaction ID to be used for the next asynchronous transaction - we choose this randomly for // each isolate to avoid collisions between transactions in isolates since they share a common native plugin static int transactionID = Random().nextInt(1000000); // map of transactions that are being performed asynchronously in the platform layer - static Map> _platformTransactions = Map>(); + static Map> _platformTransactions = + Map>(); /** * Handles a response from the platform layer for an asynchronous transaction. This @@ -263,7 +267,8 @@ class ApproovService { */ static void _handleResponse(dynamic arguments) { final String transactionID = arguments["TransactionID"] as String; - final Completer? transaction = _platformTransactions[transactionID]; + final Completer? transaction = + _platformTransactions[transactionID]; if (transaction != null) { transaction.complete(arguments); _platformTransactions.remove(transactionID); @@ -314,12 +319,15 @@ class ApproovService { await _initMutex.protect(() async { bool isRootIsolate = (RootIsolateToken.instance != null); String isolate = isRootIsolate ? "root" : "background"; - if (_isInitialized && ((comment == null) || !comment.startsWith("reinit"))) { + if (_isInitialized && + ((comment == null) || !comment.startsWith("reinit"))) { // this is a reinitialization attempt and we need to check if the config is the same if (_initialConfig != config) { - throw ApproovException("Attempt to reinitialize the Approov SDK with a different configuration $config"); + throw ApproovException( + "Attempt to reinitialize the Approov SDK with a different configuration $config"); } - Log.d("$TAG: $isolate initialization ignoring attempt with the same config"); + Log.d( + "$TAG: $isolate initialization ignoring attempt with the same config"); } else { // perform the actual initialization try { @@ -419,7 +427,8 @@ class ApproovService { SignatureParametersFactory? defaultFactory, Map? hostFactories, }) { - final effectiveDefaultFactory = defaultFactory ?? SignatureParametersFactory.generateDefaultFactory(); + final effectiveDefaultFactory = + defaultFactory ?? SignatureParametersFactory.generateDefaultFactory(); if (hostFactories != null) { for (final entry in hostFactories.entries) { if (entry.key.isEmpty) { @@ -427,7 +436,8 @@ class ApproovService { } } } - final messageSigning = ApproovMessageSigning()..setDefaultFactory(effectiveDefaultFactory); + final messageSigning = ApproovMessageSigning() + ..setDefaultFactory(effectiveDefaultFactory); hostFactories?.forEach(messageSigning.putHostFactory); _messageSigning = messageSigning; Log.d("$TAG: enableMessageSigning configured"); @@ -562,7 +572,8 @@ class ApproovService { final Map waitArgs = { "transactionID": transactionID, }; - final results = await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); + final results = + await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); completer.complete(results); _platformTransactions.remove(transactionID); } @@ -590,11 +601,13 @@ class ApproovService { (fetchResult.tokenFetchStatus == _TokenFetchStatus.MITM_DETECTED)) // we are unable to get the secure string due to network conditions so the request can // be retried by the user later - throw new ApproovNetworkException("precheck: ${fetchResult.tokenFetchStatus.name}"); + throw new ApproovNetworkException( + "precheck: ${fetchResult.tokenFetchStatus.name}"); else if ((fetchResult.tokenFetchStatus != _TokenFetchStatus.SUCCESS) && (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNKNOWN_KEY)) // we are unable to get the secure string due to a more permanent error - throw new ApproovException("precheck: ${fetchResult.tokenFetchStatus.name}"); + throw new ApproovException( + "precheck: ${fetchResult.tokenFetchStatus.name}"); } /// Gets the device ID used by Approov to identify the particular device that the SDK is running on. Note that @@ -663,7 +676,8 @@ class ApproovService { // check the status of Approov token fetch if ((fetchResult.tokenFetchStatus == _TokenFetchStatus.SUCCESS) || - (fetchResult.tokenFetchStatus == _TokenFetchStatus.NO_APPROOV_SERVICE)) { + (fetchResult.tokenFetchStatus == + _TokenFetchStatus.NO_APPROOV_SERVICE)) { // we successfully obtained a token so provide it, or provide an empty one on complete Approov service failure return fetchResult.token; } else if ((fetchResult.tokenFetchStatus == _TokenFetchStatus.NO_NETWORK) || @@ -671,10 +685,12 @@ class ApproovService { (fetchResult.tokenFetchStatus == _TokenFetchStatus.MITM_DETECTED)) { // we are unable to get an Approov token due to network conditions so the request can // be retried by the user later - throw new ApproovNetworkException("fetchToken for $url: ${fetchResult.tokenFetchStatus.name}"); + throw new ApproovNetworkException( + "fetchToken for $url: ${fetchResult.tokenFetchStatus.name}"); } else { // we have failed to get an Approov token with a more serious permanent error - throw ApproovException("fetchToken for $url: ${fetchResult.tokenFetchStatus.name}"); + throw ApproovException( + "fetchToken for $url: ${fetchResult.tokenFetchStatus.name}"); } } @@ -695,7 +711,8 @@ class ApproovService { "message": message, }; try { - String messageSignature = await _fgChannel.invokeMethod('getMessageSignature', arguments); + String messageSignature = + await _fgChannel.invokeMethod('getMessageSignature', arguments); return messageSignature; } catch (err) { throw ApproovException('$err'); @@ -752,7 +769,8 @@ class ApproovService { final Map waitArgs = { "transactionID": transactionID, }; - final results = await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); + final results = + await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); completer.complete(results); _platformTransactions.remove(transactionID); } @@ -763,7 +781,8 @@ class ApproovService { Map fetchResultMap = await completer.future; fetchResult = _TokenFetchResult.fromTokenFetchResultMap(fetchResultMap); String isolate = _isRootIsolate ? "root" : "background"; - Log.d("$TAG: $isolate fetchSecureString $type: $key, ${fetchResult.tokenFetchStatus.name}"); + Log.d( + "$TAG: $isolate fetchSecureString $type: $key, ${fetchResult.tokenFetchStatus.name}"); } catch (err) { throw ApproovException('$err'); } @@ -780,11 +799,13 @@ class ApproovService { (fetchResult.tokenFetchStatus == _TokenFetchStatus.MITM_DETECTED)) // we are unable to get the secure string due to network conditions so the request can // be retried by the user later - throw new ApproovNetworkException("fetchSecureString $type for $key: ${fetchResult.tokenFetchStatus.name}"); + throw new ApproovNetworkException( + "fetchSecureString $type for $key: ${fetchResult.tokenFetchStatus.name}"); else if ((fetchResult.tokenFetchStatus != _TokenFetchStatus.SUCCESS) && (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNKNOWN_KEY)) // we are unable to get the secure string due to a more permanent error - throw new ApproovException("fetchSecureString $type for $key: ${fetchResult.tokenFetchStatus.name}"); + throw new ApproovException( + "fetchSecureString $type for $key: ${fetchResult.tokenFetchStatus.name}"); return fetchResult.secureString; } @@ -829,7 +850,8 @@ class ApproovService { final Map waitArgs = { "transactionID": transactionID, }; - final results = await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); + final results = + await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); completer.complete(results); _platformTransactions.remove(transactionID); } @@ -840,7 +862,8 @@ class ApproovService { Map fetchResultMap = await completer.future; fetchResult = _TokenFetchResult.fromTokenFetchResultMap(fetchResultMap); String isolate = _isRootIsolate ? "root" : "background"; - Log.d("$TAG: $isolate fetchCustomJWT: ${fetchResult.tokenFetchStatus.name}"); + Log.d( + "$TAG: $isolate fetchCustomJWT: ${fetchResult.tokenFetchStatus.name}"); } catch (err) { throw ApproovException('$err'); } @@ -857,10 +880,12 @@ class ApproovService { (fetchResult.tokenFetchStatus == _TokenFetchStatus.MITM_DETECTED)) // we are unable to get the custom JWT due to network conditions so the request can // be retried by the user later - throw new ApproovNetworkException("fetchCustomJWT: ${fetchResult.tokenFetchStatus.name}"); + throw new ApproovNetworkException( + "fetchCustomJWT: ${fetchResult.tokenFetchStatus.name}"); else if (fetchResult.tokenFetchStatus != _TokenFetchStatus.SUCCESS) // we are unable to get the custom JWT due to a more permanent error - throw new ApproovException("fetchCustomJWT: ${fetchResult.tokenFetchStatus.name}"); + throw new ApproovException( + "fetchCustomJWT: ${fetchResult.tokenFetchStatus.name}"); // provide the custom JWT return fetchResult.token; @@ -940,7 +965,8 @@ class ApproovService { final Map waitArgs = { "transactionID": transactionID, }; - final results = await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); + final results = + await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); completer.complete(results); _platformTransactions.remove(transactionID); } @@ -948,7 +974,8 @@ class ApproovService { // wait for the transaction to complete final results = await completer.future; _configEpoch = results['ConfigEpoch']; - _TokenFetchResult tokenFetchResult = _TokenFetchResult.fromTokenFetchResultMap(results); + _TokenFetchResult tokenFetchResult = + _TokenFetchResult.fromTokenFetchResultMap(results); return tokenFetchResult; } catch (err) { throw ApproovException('$err'); @@ -968,7 +995,8 @@ class ApproovService { /// @param queryParameter is the parameter to be potentially substituted /// @return Uri passed in, or modified with a new Uri if required /// @throws ApproovException if it is not possible to obtain secure strings for substitution - static Future substituteQueryParam(Uri uri, String queryParameter) async { + static Future substituteQueryParam( + Uri uri, String queryParameter) async { await _requireInitialized(); String? queryValue = uri.queryParameters[queryParameter]; if (queryValue != null) { @@ -1007,7 +1035,8 @@ class ApproovService { final Map waitArgs = { "transactionID": transactionID, }; - final results = await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); + final results = + await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); completer.complete(results); _platformTransactions.remove(transactionID); } @@ -1018,7 +1047,8 @@ class ApproovService { Map fetchResultMap = await completer.future; fetchResult = _TokenFetchResult.fromTokenFetchResultMap(fetchResultMap); String isolate = _isRootIsolate ? "root" : "background"; - Log.d("$TAG: $isolate substituting query parameter $queryParameter: ${fetchResult.tokenFetchStatus.name}"); + Log.d( + "$TAG: $isolate substituting query parameter $queryParameter: ${fetchResult.tokenFetchStatus.name}"); } catch (err) { throw ApproovException('$err'); } @@ -1026,7 +1056,8 @@ class ApproovService { // process the returned Approov status if (fetchResult.tokenFetchStatus == _TokenFetchStatus.SUCCESS) { // perform a query substitution - Map updatedParams = Map.from(uri.queryParameters); + Map updatedParams = + Map.from(uri.queryParameters); updatedParams[queryParameter] = fetchResult.secureString!; return uri.replace(queryParameters: updatedParams); } else if (fetchResult.tokenFetchStatus == _TokenFetchStatus.REJECTED) @@ -1062,7 +1093,8 @@ class ApproovService { /// @param request is the HttpClientRequest to which Approov is being added /// @param pendingBodyBytes holds any buffered body bytes available before the request is sent, or null for streaming /// @throws ApproovException if it is not possible to obtain an Approov token or perform required header substitutions - static Future _updateRequest(HttpClientRequest request, Uint8List? pendingBodyBytes) async { + static Future _updateRequest( + HttpClientRequest request, Uint8List? pendingBodyBytes) async { // check if the URL matches one of the exclusion regexs and just return if so await _requireInitialized(); String url = request.uri.toString(); @@ -1087,7 +1119,8 @@ class ApproovService { // be used to check the validity of the token and if you use token annotations they // will appear here to determine why a request is being rejected) String isolate = _isRootIsolate ? "root" : "background"; - Log.d("$TAG: $isolate updateRequest for $host: ${fetchResult.loggableToken}, ${stopWatch.elapsedMilliseconds}ms"); + Log.d( + "$TAG: $isolate updateRequest for $host: ${fetchResult.loggableToken}, ${stopWatch.elapsedMilliseconds}ms"); // if there was a configuration change we fetch a new configuration, which will update // the configuration epoch across all isolates and cause all delegate HttpClient caches to be @@ -1100,26 +1133,32 @@ class ApproovService { // check the status of Approov token fetch if (fetchResult.tokenFetchStatus == _TokenFetchStatus.SUCCESS) { // we successfully obtained a token so add it to the header for the request - request.headers.set(_approovTokenHeader, _approovTokenPrefix + fetchResult.token, preserveHeaderCase: true); + request.headers.set( + _approovTokenHeader, _approovTokenPrefix + fetchResult.token, + preserveHeaderCase: true); } else if ((fetchResult.tokenFetchStatus == _TokenFetchStatus.NO_NETWORK) || (fetchResult.tokenFetchStatus == _TokenFetchStatus.POOR_NETWORK) || (fetchResult.tokenFetchStatus == _TokenFetchStatus.MITM_DETECTED)) { // we are unable to get an Approov token due to network conditions so the request can // be retried by the user later - unless overridden if (!_proceedOnNetworkFail) - throw new ApproovNetworkException("Approov token fetch for $host: ${fetchResult.tokenFetchStatus.name}"); - } else if ((fetchResult.tokenFetchStatus != _TokenFetchStatus.NO_APPROOV_SERVICE) && + throw new ApproovNetworkException( + "Approov token fetch for $host: ${fetchResult.tokenFetchStatus.name}"); + } else if ((fetchResult.tokenFetchStatus != + _TokenFetchStatus.NO_APPROOV_SERVICE) && (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNKNOWN_URL) && (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNPROTECTED_URL)) { // we have failed to get an Approov token with a more serious permanent error - throw ApproovException("Approov token fetch for $host: ${fetchResult.tokenFetchStatus.name}"); + throw ApproovException( + "Approov token fetch for $host: ${fetchResult.tokenFetchStatus.name}"); } // we only continue additional processing if we had a valid status from Approov, to prevent additional delays // by trying to fetch from Approov again and this also protects against header substiutions in domains not // protected by Approov and therefore potentially subject to a MitM if ((fetchResult.tokenFetchStatus != _TokenFetchStatus.SUCCESS) && - (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNPROTECTED_URL)) return; + (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNPROTECTED_URL)) + return; // we now deal with any header substitutions, which may require further fetches but these // should be using cached results @@ -1127,7 +1166,9 @@ class ApproovService { String header = entry.key; String prefix = entry.value; String? value = request.headers.value(header); - if ((value != null) && value.startsWith(prefix) && (value.length > prefix.length)) { + if ((value != null) && + value.startsWith(prefix) && + (value.length > prefix.length)) { // setup a Completer for the transaction ID we are going to use Completer completer = new Completer(); String transactionID = ApproovService.transactionID.toString(); @@ -1156,7 +1197,8 @@ class ApproovService { final Map waitArgs = { "transactionID": transactionID, }; - final results = await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); + final results = + await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); completer.complete(results); _platformTransactions.remove(transactionID); } @@ -1165,9 +1207,11 @@ class ApproovService { _TokenFetchResult fetchResult; try { Map fetchResultMap = await completer.future; - fetchResult = _TokenFetchResult.fromTokenFetchResultMap(fetchResultMap); + fetchResult = + _TokenFetchResult.fromTokenFetchResultMap(fetchResultMap); String isolate = _isRootIsolate ? "root" : "background"; - Log.d("$TAG: $isolate updateRequest substituting header $header: ${fetchResult.tokenFetchStatus.name}"); + Log.d( + "$TAG: $isolate updateRequest substituting header $header: ${fetchResult.tokenFetchStatus.name}"); } catch (err) { throw ApproovException('$err'); } @@ -1176,27 +1220,33 @@ class ApproovService { if (fetchResult.tokenFetchStatus == _TokenFetchStatus.SUCCESS) { // substitute the header value final substitutedValue = prefix + fetchResult.secureString!; - request.headers.set(header, substitutedValue, preserveHeaderCase: true); + request.headers + .set(header, substitutedValue, preserveHeaderCase: true); } else if (fetchResult.tokenFetchStatus == _TokenFetchStatus.REJECTED) // if the request is rejected then we provide a special exception with additional information throw new ApproovRejectionException( "Header substitution for $header: ${fetchResult.tokenFetchStatus.name}: ${fetchResult.ARC} ${fetchResult.rejectionReasons}", fetchResult.ARC, fetchResult.rejectionReasons); - else if ((fetchResult.tokenFetchStatus == _TokenFetchStatus.NO_NETWORK) || + else if ((fetchResult.tokenFetchStatus == + _TokenFetchStatus.NO_NETWORK) || (fetchResult.tokenFetchStatus == _TokenFetchStatus.POOR_NETWORK) || (fetchResult.tokenFetchStatus == _TokenFetchStatus.MITM_DETECTED)) { // we are unable to get the secure string due to network conditions so the request can // be retried by the user later - unless overridden if (!_proceedOnNetworkFail) - throw new ApproovNetworkException("Header substitution for $header: ${fetchResult.tokenFetchStatus.name}"); - } else if (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNKNOWN_KEY) + throw new ApproovNetworkException( + "Header substitution for $header: ${fetchResult.tokenFetchStatus.name}"); + } else if (fetchResult.tokenFetchStatus != + _TokenFetchStatus.UNKNOWN_KEY) // we are unable to get the secure string due to a more permanent error - throw new ApproovException("Header substitution for $header: ${fetchResult.tokenFetchStatus.name}"); + throw new ApproovException( + "Header substitution for $header: ${fetchResult.tokenFetchStatus.name}"); } } - if (_messageSigning != null && fetchResult.tokenFetchStatus == _TokenFetchStatus.SUCCESS) { + if (_messageSigning != null && + fetchResult.tokenFetchStatus == _TokenFetchStatus.SUCCESS) { try { await _applyMessageSigning(request, pendingBodyBytes); } on ApproovException { @@ -1207,7 +1257,8 @@ class ApproovService { } } - static Future _applyMessageSigning(HttpClientRequest request, Uint8List? pendingBodyBytes) async { + static Future _applyMessageSigning( + HttpClientRequest request, Uint8List? pendingBodyBytes) async { final messageSigning = _messageSigning; if (messageSigning == null) return; @@ -1217,8 +1268,10 @@ class ApproovService { headers: _snapshotHeaders(request.headers), bodyBytes: pendingBodyBytes, tokenHeaderName: _approovTokenHeader.isEmpty ? null : _approovTokenHeader, - onSetHeader: (name, value) => request.headers.set(name, value, preserveHeaderCase: true), - onAddHeader: (name, value) => request.headers.add(name, value, preserveHeaderCase: true), + onSetHeader: (name, value) => + request.headers.set(name, value, preserveHeaderCase: true), + onAddHeader: (name, value) => + request.headers.add(name, value, preserveHeaderCase: true), ); final params = messageSigning.buildParametersFor(request.uri, context); @@ -1227,19 +1280,25 @@ class ApproovService { return; } - final signatureBase = SignatureBaseBuilder(params, context).createSignatureBase(); - // Allow the configured algorithm to fail fast so callers can decide how to handle it. - final signature = await _signCanonicalMessage(signatureBase, params.algorithm); + final signatureBase = + SignatureBaseBuilder(params, context).createSignatureBase(); + final alg = params.algorithmIdentifier; + if (alg == null) { + throw StateError('Signature parameters missing alg identifier'); + } + final signature = await _signCanonicalMessage(signatureBase, alg); if (signature.isEmpty) { - Log.d("$TAG: message signing returned empty signature for ${request.uri}"); + Log.d( + "$TAG: message signing returned empty signature for ${request.uri}"); return; } - final signatureLabel = params.signatureLabel(); + final signatureLabel = _signatureLabelForAlg(alg); final signatureHeader = '$signatureLabel=:${signature}:'; context.setHeader('Signature', signatureHeader); - final signatureInput = '$signatureLabel=${params.serializeComponentValue()}'; + final signatureInput = + '$signatureLabel=${params.serializeComponentValue()}'; context.setHeader('Signature-Input', signatureInput); if (params.debugMode) { @@ -1247,16 +1306,28 @@ class ApproovService { final baseDigestHeader = 'sha-256=:${base64Encode(digest)}:'; context.setHeader('Signature-Base-Digest', baseDigestHeader); } - } - static Future _signCanonicalMessage(String message, SignatureAlgorithm algorithm) async { - switch (algorithm) { - case SignatureAlgorithm.ecdsaP256Sha256: + static Future _signCanonicalMessage( + String message, String algorithmIdentifier) async { + switch (algorithmIdentifier) { + case 'ecdsa-p256-sha256': return await _getInstallMessageSignature(message); - case SignatureAlgorithm.hmacSha256: - default: + case 'hmac-sha256': return await getMessageSignature(message); + default: + throw StateError('Unsupported signature alg: $algorithmIdentifier'); + } + } + + static String _signatureLabelForAlg(String algorithmIdentifier) { + switch (algorithmIdentifier) { + case 'ecdsa-p256-sha256': + return 'install'; + case 'hmac-sha256': + return 'account'; + default: + throw StateError('Unsupported signature alg: $algorithmIdentifier'); } } @@ -1265,7 +1336,8 @@ class ApproovService { throw StateError('install message signing not supported'); } try { - final result = await _fgChannel.invokeMethod('getInstallMessageSignature', { + final result = await _fgChannel + .invokeMethod('getInstallMessageSignature', { "message": message, }); if (result == null || result.isEmpty) { @@ -1291,7 +1363,8 @@ class ApproovService { throw StateError('Invalid DER signature: buffer is empty'); } if (offset >= der.length) { - throw StateError('Invalid DER signature: unexpected end of DER buffer at sequence'); + throw StateError( + 'Invalid DER signature: unexpected end of DER buffer at sequence'); } if (der[offset] != 0x30) { throw StateError('Invalid DER signature: missing sequence'); @@ -1299,12 +1372,14 @@ class ApproovService { offset++; if (offset >= der.length) { - throw StateError('Invalid DER signature: unexpected end of DER buffer after sequence tag'); + throw StateError( + 'Invalid DER signature: unexpected end of DER buffer after sequence tag'); } int sequenceLength = _readDerLength(der, offset); int seqLenBytes = _encodedLengthByteCount(der, offset); if (offset + seqLenBytes > der.length) { - throw StateError('Invalid DER signature: sequence length encoding exceeds buffer'); + throw StateError( + 'Invalid DER signature: sequence length encoding exceeds buffer'); } offset += seqLenBytes; if (sequenceLength != der.length - offset) { @@ -1312,7 +1387,8 @@ class ApproovService { } if (offset >= der.length) { - throw StateError('Invalid DER signature: unexpected end of DER buffer at r integer tag'); + throw StateError( + 'Invalid DER signature: unexpected end of DER buffer at r integer tag'); } if (der[offset] != 0x02) { throw StateError('Invalid DER signature: expected integer for r'); @@ -1320,12 +1396,14 @@ class ApproovService { offset++; if (offset >= der.length) { - throw StateError('Invalid DER signature: unexpected end of DER buffer at r length'); + throw StateError( + 'Invalid DER signature: unexpected end of DER buffer at r length'); } int rLength = _readDerLength(der, offset); int rLenBytes = _encodedLengthByteCount(der, offset); if (offset + rLenBytes > der.length) { - throw StateError('Invalid DER signature: r length encoding exceeds buffer'); + throw StateError( + 'Invalid DER signature: r length encoding exceeds buffer'); } offset += rLenBytes; if (offset + rLength > der.length) { @@ -1335,7 +1413,8 @@ class ApproovService { offset += rLength; if (offset >= der.length) { - throw StateError('Invalid DER signature: unexpected end of DER buffer at s integer tag'); + throw StateError( + 'Invalid DER signature: unexpected end of DER buffer at s integer tag'); } if (der[offset] != 0x02) { throw StateError('Invalid DER signature: expected integer for s'); @@ -1343,12 +1422,14 @@ class ApproovService { offset++; if (offset >= der.length) { - throw StateError('Invalid DER signature: unexpected end of DER buffer at s length'); + throw StateError( + 'Invalid DER signature: unexpected end of DER buffer at s length'); } int sLength = _readDerLength(der, offset); int sLenBytes = _encodedLengthByteCount(der, offset); if (offset + sLenBytes > der.length) { - throw StateError('Invalid DER signature: s length encoding exceeds buffer'); + throw StateError( + 'Invalid DER signature: s length encoding exceeds buffer'); } offset += sLenBytes; if (offset + sLength > der.length) { @@ -1447,7 +1528,8 @@ class ApproovService { final Map waitArgs = { "transactionID": transactionID, }; - final results = await channel.invokeMethod('waitForHostCertificates', waitArgs); + final results = + await channel.invokeMethod('waitForHostCertificates', waitArgs); completer.complete(results); _platformTransactions.remove(transactionID); } @@ -1457,13 +1539,15 @@ class ApproovService { if (results is Map) { if (results.containsKey("Certificates")) { // certificate were fetched so we cache them - List fetchedHostCertificates = results["Certificates"] as List; + List fetchedHostCertificates = + results["Certificates"] as List; hostCertificates = []; for (final cert in fetchedHostCertificates) { hostCertificates.add(cert as Uint8List); } _hostCertificates[url.host] = hostCertificates; - Log.d("$TAG: $isolate fetchHostCertificates ${url.host} obtained ${hostCertificates.length} certificates"); + Log.d( + "$TAG: $isolate fetchHostCertificates ${url.host} obtained ${hostCertificates.length} certificates"); } else if (results.containsKey("Error")) { // there was a specific error fetching the certificates String error = results["Error"] as String; @@ -1476,7 +1560,8 @@ class ApproovService { } } else { // there was an unknown return format fetching the certificates - Log.d("$TAG: $isolate fetchHostCertificates ${url.host} bad response"); + Log.d( + "$TAG: $isolate fetchHostCertificates ${url.host} bad response"); return null; } } catch (err) { @@ -1522,7 +1607,8 @@ class ApproovService { bool isFirst = true; List hostPinCerts = []; for (final cert in hostCerts) { - Uint8List serverSpkiSha256Digest = Uint8List.fromList(_spkiSha256Digest(cert).bytes); + Uint8List serverSpkiSha256Digest = + Uint8List.fromList(_spkiSha256Digest(cert).bytes); if (!isFirst) info += ", "; isFirst = false; info += base64.encode(serverSpkiSha256Digest); @@ -1553,7 +1639,8 @@ class ApproovService { ) async { // determine the list of X.509 ASN.1 DER host certificates that match any Approov pins for the host - if this // returns an empty list then nothing will be trusted - List pinCerts = await ApproovService._hostPinCertificates(host, approovPins, hostCerts); + List pinCerts = + await ApproovService._hostPinCertificates(host, approovPins, hostCerts); // add the certificates to create the security context of trusted certs SecurityContext securityContext = SecurityContext(withTrustedRoots: false); @@ -1570,7 +1657,15 @@ class ApproovService { } /// Possible write operations that may need to be placed in the pending list -enum _WriteOpType { unknown, add, addError, write, writeAll, writeCharCode, writeln } +enum _WriteOpType { + unknown, + add, + addError, + write, + writeAll, + writeCharCode, + writeln +} /// Holds a pending write operation that must be delayed because issuing it immediately /// would cause the headers to become immutable, but it is not possible to update the headers @@ -1741,7 +1836,8 @@ class _ApproovHttpClientRequest implements HttpClientRequest { } @override - set bufferOutput(bool _bufferOutput) => _delegateRequest.bufferOutput = _bufferOutput; + set bufferOutput(bool _bufferOutput) => + _delegateRequest.bufferOutput = _bufferOutput; @override bool get bufferOutput => _delegateRequest.bufferOutput; @@ -1749,7 +1845,8 @@ class _ApproovHttpClientRequest implements HttpClientRequest { HttpConnectionInfo? get connectionInfo => _delegateRequest.connectionInfo; @override - set contentLength(int _contentLength) => _delegateRequest.contentLength = _contentLength; + set contentLength(int _contentLength) => + _delegateRequest.contentLength = _contentLength; @override int get contentLength => _delegateRequest.contentLength; @@ -1765,7 +1862,8 @@ class _ApproovHttpClientRequest implements HttpClientRequest { Encoding get encoding => _delegateRequest.encoding; @override - set followRedirects(bool _followRedirects) => _delegateRequest.followRedirects = _followRedirects; + set followRedirects(bool _followRedirects) => + _delegateRequest.followRedirects = _followRedirects; @override bool get followRedirects => _delegateRequest.followRedirects; @@ -1773,7 +1871,8 @@ class _ApproovHttpClientRequest implements HttpClientRequest { HttpHeaders get headers => _delegateRequest.headers; @override - set maxRedirects(int _maxRedirects) => _delegateRequest.maxRedirects = _maxRedirects; + set maxRedirects(int _maxRedirects) => + _delegateRequest.maxRedirects = _maxRedirects; @override int get maxRedirects => _delegateRequest.maxRedirects; @@ -1781,7 +1880,8 @@ class _ApproovHttpClientRequest implements HttpClientRequest { String get method => _delegateRequest.method; @override - set persistentConnection(bool _persistentConnection) => _delegateRequest.persistentConnection = _persistentConnection; + set persistentConnection(bool _persistentConnection) => + _delegateRequest.persistentConnection = _persistentConnection; @override bool get persistentConnection => _delegateRequest.persistentConnection; @@ -1905,20 +2005,24 @@ class ApproovHttpClient implements HttpClient { // a special client that is used when we want to force a no connection, such as when there is a forced pin // update required or if we have failed to fetch the certificates for a host. Note we don't update its // attribute state since it is not relevant given it will never actually connect. - HttpClient _noConnectionClient = HttpClient(context: SecurityContext(withTrustedRoots: false)); + HttpClient _noConnectionClient = + HttpClient(context: SecurityContext(withTrustedRoots: false)); // indicates whether the ApproovHttpClient has been closed by calling close() bool _isClosed = false; // state required to implement getters and setters required by the HttpClient interface Future Function(Uri url, String scheme, String? realm)? _authenticate; - Future> Function(Uri url, String? proxyHost, int? proxyPort)? _connectionFactory; + Future> Function( + Uri url, String? proxyHost, int? proxyPort)? _connectionFactory; void Function(String line)? _keyLog; final List _credentials = []; String Function(Uri url)? _findProxy; - Future Function(String host, int port, String scheme, String? realm)? _authenticateProxy; + Future Function(String host, int port, String scheme, String? realm)? + _authenticateProxy; final List _proxyCredentials = []; - bool Function(X509Certificate cert, String host, int port)? _badCertificateCallback; + bool Function(X509Certificate cert, String host, int port)? + _badCertificateCallback; Duration _idleTimeout = const Duration(seconds: 15); Duration? _connectionTimeout; int? _maxConnectionsPerHost; @@ -1943,7 +2047,8 @@ class ApproovHttpClient implements HttpClient { _delegatePinnedHttpClients.remove(host); // call any user defined function for its side effects only (as we are going to reject anyway) - Function(X509Certificate cert, String host, int port)? badCertificateCallback = _badCertificateCallback; + Function(X509Certificate cert, String host, int port)? + badCertificateCallback = _badCertificateCallback; if (badCertificateCallback != null) { badCertificateCallback(cert, host, port); } @@ -1989,7 +2094,8 @@ class ApproovHttpClient implements HttpClient { // wait on the Approov token fetching to complete - but note we do not fail if a token fetch was not possible _TokenFetchResult fetchResult = await futureApproovToken; - tokenFinishTime = stopWatch.elapsedMilliseconds - tokenStartTime - certStartTime; + tokenFinishTime = + stopWatch.elapsedMilliseconds - tokenStartTime - certStartTime; Log.d( "$TAG: $isolate pinning setup fetch token for ${url.host}: ${fetchResult.tokenFetchStatus.name}, certStart ${certStartTime}ms, tokenStart ${tokenStartTime}ms, tokenFinish ${tokenFinishTime}ms"); @@ -1997,7 +2103,8 @@ class ApproovHttpClient implements HttpClient { // across all isolates, which will cause pinned delegate clients to be cleared since the pins may have changed if (fetchResult.isConfigChanged) { await ApproovService._fetchConfig(); - Log.d("$TAG: $isolate creating pinning delegate client, dynamic configuration update"); + Log.d( + "$TAG: $isolate creating pinning delegate client, dynamic configuration update"); } // get pins from Approov - note that it is still possible at this point if the token fetch failed that no pins @@ -2010,7 +2117,8 @@ class ApproovHttpClient implements HttpClient { (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNKNOWN_URL)) { // perform another attempted token fetch fetchResult = await ApproovService._fetchApproovToken(url.host); - Log.d("$TAG: $isolate pinning setup retry fetch token for ${url.host}: ${fetchResult.tokenFetchStatus.name}"); + Log.d( + "$TAG: $isolate pinning setup retry fetch token for ${url.host}: ${fetchResult.tokenFetchStatus.name}"); // if we are forced to update pins then this likely means that no pins were ever fetched and in this // case we must force a no connection so that another fetch can be tried again. This is because @@ -2018,7 +2126,8 @@ class ApproovHttpClient implements HttpClient { // to retry and get the pins without restarting the app. We just return the no connection client // in this case. if (fetchResult.isForceApplyPins) { - Log.d("$TAG: $isolate force apply pins asserted so forcing no connection"); + Log.d( + "$TAG: $isolate force apply pins asserted so forcing no connection"); return null; } } @@ -2047,17 +2156,24 @@ class ApproovHttpClient implements HttpClient { if (pins.isEmpty) { // if there are no pins then we can just use a standard http client newHttpClient = HttpClient(); - Log.d("$TAG: $isolate client ready for ${url.host}, without pinning restriction"); + Log.d( + "$TAG: $isolate client ready for ${url.host}, without pinning restriction"); } else { // create HttpClient with pinning enabled by determining the particular certificates we should trust Set approovPins = HashSet(); for (final pin in pins) { approovPins.add(pin); } - SecurityContext securityContext = await ApproovService._pinnedSecurityContext(url.host, approovPins, hostCerts); + SecurityContext securityContext = + await ApproovService._pinnedSecurityContext( + url.host, approovPins, hostCerts); newHttpClient = HttpClient(context: securityContext); - final pinningFinishTime = stopWatch.elapsedMilliseconds - tokenFinishTime - tokenStartTime - certStartTime; - Log.d("$TAG: $isolate client ready for ${url.host}, pinningFinish ${pinningFinishTime}ms"); + final pinningFinishTime = stopWatch.elapsedMilliseconds - + tokenFinishTime - + tokenStartTime - + certStartTime; + Log.d( + "$TAG: $isolate client ready for ${url.host}, pinningFinish ${pinningFinishTime}ms"); } // copy state to the new delegate HttpClient @@ -2079,7 +2195,8 @@ class ApproovHttpClient implements HttpClient { newHttpClient.findProxy = _findProxy; newHttpClient.authenticateProxy = _authenticateProxy; for (var proxyCredential in _proxyCredentials) { - newHttpClient.addProxyCredentials(proxyCredential[0], proxyCredential[1], proxyCredential[2], proxyCredential[3]); + newHttpClient.addProxyCredentials(proxyCredential[0], proxyCredential[1], + proxyCredential[2], proxyCredential[3]); } newHttpClient.badCertificateCallback = _pinningFailureCallback; @@ -2123,7 +2240,8 @@ class ApproovHttpClient implements HttpClient { _delegatePinnedHttpClients.clear(); _cachedConfigEpoch = ApproovService._configEpoch; String isolate = ApproovService._isRootIsolate ? "root" : "background"; - Log.d("$TAG: $isolate configuration epoch changed, clearing delegate cache"); + Log.d( + "$TAG: $isolate configuration epoch changed, clearing delegate cache"); } // lookup the cache and see if a new delegate is required - note that we @@ -2152,7 +2270,8 @@ class ApproovHttpClient implements HttpClient { // create a new delegate client and add its future to the cache String isolate = ApproovService._isRootIsolate ? "root" : "background"; - Log.d("$TAG: $isolate creating pinned delegate creation for $url.host:$url.port"); + Log.d( + "$TAG: $isolate creating pinned delegate creation for $url.host:$url.port"); futureDelegateClient = _createPinnedHttpClient(url); _createdPinnedHttpClients.add(futureDelegateClient); _delegatePinnedHttpClients[url.host] = futureDelegateClient; @@ -2163,7 +2282,8 @@ class ApproovHttpClient implements HttpClient { } @override - Future open(String method, String host, int port, String path) async { + Future open( + String method, String host, int port, String path) async { // obtain the delegate HttpClient to be used (with null meaning no connection should // be forced) and then wrap the provided HttpClientRequest Uri url = Uri(scheme: "https", host: host, port: port, path: path); @@ -2186,37 +2306,43 @@ class ApproovHttpClient implements HttpClient { } @override - Future get(String host, int port, String path) => open("get", host, port, path); + Future get(String host, int port, String path) => + open("get", host, port, path); @override Future getUrl(Uri url) => openUrl("get", url); @override - Future post(String host, int port, String path) => open("post", host, port, path); + Future post(String host, int port, String path) => + open("post", host, port, path); @override Future postUrl(Uri url) => openUrl("post", url); @override - Future put(String host, int port, String path) => open("put", host, port, path); + Future put(String host, int port, String path) => + open("put", host, port, path); @override Future putUrl(Uri url) => openUrl("put", url); @override - Future delete(String host, int port, String path) => open("delete", host, port, path); + Future delete(String host, int port, String path) => + open("delete", host, port, path); @override Future deleteUrl(Uri url) => openUrl("delete", url); @override - Future head(String host, int port, String path) => open("head", host, port, path); + Future head(String host, int port, String path) => + open("head", host, port, path); @override Future headUrl(Uri url) => openUrl("head", url); @override - Future patch(String host, int port, String path) => open("patch", host, port, path); + Future patch(String host, int port, String path) => + open("patch", host, port, path); @override Future patchUrl(Uri url) => openUrl("patch", url); @@ -2273,7 +2399,9 @@ class ApproovHttpClient implements HttpClient { } @override - set connectionFactory(Future> f(Uri url, String? proxyHost, int? proxyPort)?) { + set connectionFactory( + Future> f( + Uri url, String? proxyHost, int? proxyPort)?) { _connectionFactory = f; _delegatePinnedHttpClients.clear(); } @@ -2285,7 +2413,8 @@ class ApproovHttpClient implements HttpClient { } @override - void addCredentials(Uri url, String realm, HttpClientCredentials credentials) { + void addCredentials( + Uri url, String realm, HttpClientCredentials credentials) { _credentials.add({url, realm, credentials}); _delegatePinnedHttpClients.clear(); } @@ -2297,19 +2426,22 @@ class ApproovHttpClient implements HttpClient { } @override - set authenticateProxy(Future f(String host, int port, String scheme, String? realm)?) { + set authenticateProxy( + Future f(String host, int port, String scheme, String? realm)?) { _authenticateProxy = f; _delegatePinnedHttpClients.clear(); } @override - void addProxyCredentials(String host, int port, String realm, HttpClientCredentials credentials) { + void addProxyCredentials( + String host, int port, String realm, HttpClientCredentials credentials) { _proxyCredentials.add({host, port, realm, credentials}); _delegatePinnedHttpClients.clear(); } @override - set badCertificateCallback(bool callback(X509Certificate cert, String host, int port)?) { + set badCertificateCallback( + bool callback(X509Certificate cert, String host, int port)?) { _badCertificateCallback = callback; } @@ -2353,7 +2485,8 @@ class ApproovClient extends http.BaseClient { // initialization. If no config is provided the comment string is // ignored. ApproovClient([String? initialConfig, String? initialComment]) - : _delegateClient = httpio.IOClient(ApproovHttpClient(initialConfig, initialComment)), + : _delegateClient = + httpio.IOClient(ApproovHttpClient(initialConfig, initialComment)), super() {} @override diff --git a/lib/src/message_signing.dart b/lib/src/message_signing.dart index 3194ad0..5ca492d 100644 --- a/lib/src/message_signing.dart +++ b/lib/src/message_signing.dart @@ -6,14 +6,9 @@ import 'package:crypto/crypto.dart'; import 'structured_fields.dart'; -/// Signature algorithms supported by the Approov message signing flow. -enum SignatureAlgorithm { - hmacSha256, - ecdsaP256Sha256, -} - /// Builds a component identifier item with optional Structured Fields parameters. -SfItem _buildComponentIdentifier(String value, Map? parameters) { +SfItem _buildComponentIdentifier( + String value, Map? parameters) { return SfItem.string(value, parameters); } @@ -37,21 +32,32 @@ class SignatureParameters { SignatureParameters.copy(SignatureParameters other) : _componentIdentifiers = List.from(other._componentIdentifiers), _parameters = LinkedHashMap.from(other._parameters), - debugMode = other.debugMode, - algorithm = other.algorithm; + debugMode = other.debugMode; final List _componentIdentifiers; final LinkedHashMap _parameters; bool debugMode = false; - SignatureAlgorithm algorithm = SignatureAlgorithm.hmacSha256; + + /// Returns the configured signing algorithm identifier (`alg` parameter), if any. + String? get algorithmIdentifier { + final algItem = _parameters['alg']; + if (algItem == null) return null; + if (algItem.type != SfBareItemType.string) { + throw StateError('alg parameter must be an sf-string'); + } + return algItem.value as String; + } /// The ordered list of Structured Field components that will be signed. - List get componentIdentifiers => List.unmodifiable(_componentIdentifiers); + List get componentIdentifiers => + List.unmodifiable(_componentIdentifiers); /// Adds a component identifier to the signature, avoiding duplicates. - void addComponentIdentifier(String identifier, {Map? parameters}) { - final normalized = identifier.startsWith('@') ? identifier : identifier.toLowerCase(); + void addComponentIdentifier(String identifier, + {Map? parameters}) { + final normalized = + identifier.startsWith('@') ? identifier : identifier.toLowerCase(); final candidateParameters = SfParameters(parameters); // Skip adding duplicate component identifiers that only differ in letter case or parameter identity. if (_componentIdentifiers.any( @@ -60,11 +66,13 @@ class SignatureParameters { )) { return; } - _componentIdentifiers.add(_buildComponentIdentifier(normalized, parameters)); + _componentIdentifiers + .add(_buildComponentIdentifier(normalized, parameters)); } /// Returns whether the candidate `SfItem` matches an existing component. - bool _componentIdentifierMatches(SfItem item, String value, SfParameters candidate) { + bool _componentIdentifierMatches( + SfItem item, String value, SfParameters candidate) { if (_componentIdentifierValue(item) != value) return false; return _parametersMatch(item.parameters, candidate); } @@ -113,19 +121,9 @@ class SignatureParameters { _parameters['tag'] = SfBareItem.string(tag); } - /// Derives the Approov signature label for the configured algorithm. - String signatureLabel() { - switch (algorithm) { - case SignatureAlgorithm.ecdsaP256Sha256: - return 'install'; - case SignatureAlgorithm.hmacSha256: - default: - return 'account'; - } - } - /// Returns the Structured Field identifier used for the `Signature-Params` entry. - SfItem signatureParamsIdentifier() => _buildComponentIdentifier('@signature-params', null); + SfItem signatureParamsIdentifier() => + _buildComponentIdentifier('@signature-params', null); /// Serializes the signature parameters into the canonical inner list representation. String serializeComponentValue() { @@ -156,7 +154,8 @@ class SignatureParametersFactory { } /// Configures body digest requirements and the hashing algorithm. - SignatureParametersFactory setBodyDigestConfig(String? algorithm, {required bool required}) { + SignatureParametersFactory setBodyDigestConfig(String? algorithm, + {required bool required}) { if (algorithm != null && algorithm != SignatureDigest.sha256.identifier && algorithm != SignatureDigest.sha512.identifier) { @@ -216,15 +215,17 @@ class SignatureParametersFactory { /// Builds a concrete parameter set for the supplied signing context. SignatureParameters build(ApproovSigningContext context) { - final params = _baseParameters != null ? SignatureParameters.copy(_baseParameters!) : SignatureParameters(); + final params = _baseParameters != null + ? SignatureParameters.copy(_baseParameters!) + : SignatureParameters(); params.debugMode = _debugMode; - // Message signing algorithm is selected by swapping between account (HMAC) and install (ECDSA) modes. - params.algorithm = _useAccountMessageSigning ? SignatureAlgorithm.hmacSha256 : SignatureAlgorithm.ecdsaP256Sha256; - params.setAlg(_useAccountMessageSigning ? 'hmac-sha256' : 'ecdsa-p256-sha256'); + params.setAlg( + _useAccountMessageSigning ? 'hmac-sha256' : 'ecdsa-p256-sha256'); final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; if (_addCreated) params.setCreated(now); - if (_expiresLifetimeSeconds > 0) params.setExpires(now + _expiresLifetimeSeconds); + if (_expiresLifetimeSeconds > 0) + params.setExpires(now + _expiresLifetimeSeconds); if (_addApproovTokenHeader) { final tokenHeader = context.tokenHeaderName; @@ -236,11 +237,13 @@ class SignatureParametersFactory { for (final header in _optionalHeaders) { if (!context.hasField(header)) continue; if (header == 'content-length') { - final hasBodyBytes = context.bodyBytes != null && context.bodyBytes!.isNotEmpty; - final contentLengthValue = context.getComponentValue(SfItem.string('content-length')); + final hasBodyBytes = + context.bodyBytes != null && context.bodyBytes!.isNotEmpty; + final contentLengthValue = + context.getComponentValue(SfItem.string('content-length')); // Avoid signing Content-Length: 0 to mirror how Dart's HttpClient elides that header on the wire. - final shouldIncludeContentLength = - hasBodyBytes || (contentLengthValue != null && contentLengthValue.trim() != '0'); + final shouldIncludeContentLength = hasBodyBytes || + (contentLengthValue != null && contentLengthValue.trim() != '0'); if (!shouldIncludeContentLength) { // Dart's HttpClient drops an automatic "Content-Length: 0" header for GETs, // so skip signing it to keep the canonical representation aligned with the @@ -252,8 +255,9 @@ class SignatureParametersFactory { } if (_bodyDigestAlgorithm != null) { - final digestHeader = - context.ensureContentDigest(SignatureDigest.fromIdentifier(_bodyDigestAlgorithm!), required: _bodyDigestRequired); + final digestHeader = context.ensureContentDigest( + SignatureDigest.fromIdentifier(_bodyDigestAlgorithm!), + required: _bodyDigestRequired); if (digestHeader != null) { params.addComponentIdentifier('content-digest'); } @@ -263,7 +267,8 @@ class SignatureParametersFactory { } /// Generates the default Approov configuration, optionally layering on an override base. - static SignatureParametersFactory generateDefaultFactory({SignatureParameters? overrideBase}) { + static SignatureParametersFactory generateDefaultFactory( + {SignatureParameters? overrideBase}) { final base = overrideBase ?? (SignatureParameters() ..addComponentIdentifier('@method') @@ -274,8 +279,11 @@ class SignatureParametersFactory { .setAddCreated(true) .setExpiresLifetime(15) .setAddApproovTokenHeader(true) - .addOptionalHeaders(const ['authorization', 'content-length', 'content-type']) - .setBodyDigestConfig(SignatureDigest.sha256.identifier, required: false); + .addOptionalHeaders(const [ + 'authorization', + 'content-length', + 'content-type' + ]).setBodyDigestConfig(SignatureDigest.sha256.identifier, required: false); } } @@ -294,7 +302,8 @@ class SignatureBaseBuilder { for (final component in params.componentIdentifiers) { final value = context.getComponentValue(component); if (value == null) { - throw StateError('Missing component value for ${_componentIdentifierValue(component)}'); + throw StateError( + 'Missing component value for ${_componentIdentifierValue(component)}'); } buffer.write(component.serialize()); buffer.write(': '); @@ -336,7 +345,8 @@ class ApproovSigningContext { this.onSetHeader, this.onAddHeader, }) : _headers = LinkedHashMap>.fromEntries( - headers.entries.map((entry) => MapEntry(entry.key.toLowerCase(), List.from(entry.value)))); + headers.entries.map((entry) => MapEntry( + entry.key.toLowerCase(), List.from(entry.value)))); final String requestMethod; final Uri uri; @@ -387,7 +397,8 @@ class ApproovSigningContext { throw StateError('Missing name parameter for @query-param'); } if (paramValue.type != SfBareItemType.string) { - throw StateError('name parameter for @query-param must be an sf-string'); + throw StateError( + 'name parameter for @query-param must be an sf-string'); } return _queryParameterValue(paramValue.value as String); default: @@ -401,7 +412,8 @@ class ApproovSigningContext { } /// Ensures the `Content-Digest` header exists by hashing the request body. - String? ensureContentDigest(SignatureDigest digest, {required bool required}) { + String? ensureContentDigest(SignatureDigest digest, + {required bool required}) { if (bodyBytes == null) { if (required) { throw StateError('Body digest required but body is not available'); @@ -420,7 +432,9 @@ class ApproovSigningContext { /// Returns the authority component normalized per HTTP request rules. String _authority() { - if ((uri.scheme == 'http' && uri.port == 80) || (uri.scheme == 'https' && uri.port == 443) || (uri.port == 0)) { + if ((uri.scheme == 'http' && uri.port == 80) || + (uri.scheme == 'https' && uri.port == 443) || + (uri.port == 0)) { return uri.host; } return '${uri.host}:${uri.port}'; @@ -467,7 +481,8 @@ class ApproovMessageSigning { } /// Registers a signature parameters factory for a specific host. - ApproovMessageSigning putHostFactory(String host, SignatureParametersFactory factory) { + ApproovMessageSigning putHostFactory( + String host, SignatureParametersFactory factory) { _hostFactories[host] = factory; return this; } @@ -478,7 +493,8 @@ class ApproovMessageSigning { } /// Builds signature parameters for the supplied URI if a factory is configured. - SignatureParameters? buildParametersFor(Uri uri, ApproovSigningContext context) { + SignatureParameters? buildParametersFor( + Uri uri, ApproovSigningContext context) { final factory = _factoryForHost(uri.host); if (factory == null) return null; return factory.build(context); diff --git a/test/approov_http_client_test.dart b/test/approov_http_client_test.dart index 0b1d0e0..2f8df16 100644 --- a/test/approov_http_client_test.dart +++ b/test/approov_http_client_test.dart @@ -22,7 +22,6 @@ void main() { ApproovService.disableMessageSigning(); }); - test('signature base matches HTTP message signatures format', () { final bodyBytes = Uint8List.fromList(utf8.encode('{"hello":"world"}')); final headers = >{ @@ -37,7 +36,8 @@ void main() { bodyBytes: bodyBytes, tokenHeaderName: 'Approov-Token', onSetHeader: (name, value) => headers[name.toLowerCase()] = [value], - onAddHeader: (name, value) => headers.putIfAbsent(name.toLowerCase(), () => []).add(value), + onAddHeader: (name, value) => + headers.putIfAbsent(name.toLowerCase(), () => []).add(value), ); final factory = SignatureParametersFactory() @@ -46,13 +46,16 @@ void main() { ..addComponentIdentifier('@target-uri')) .setUseAccountMessageSigning() .setAddApproovTokenHeader(true) - .addOptionalHeaders(const ['content-type']) - .setBodyDigestConfig(SignatureDigest.sha256.identifier, required: false); + .addOptionalHeaders(const ['content-type']).setBodyDigestConfig( + SignatureDigest.sha256.identifier, + required: false); final params = factory.build(context); - final signatureBase = SignatureBaseBuilder(params, context).createSignatureBase(); + final signatureBase = + SignatureBaseBuilder(params, context).createSignatureBase(); - final digestHeader = 'sha-256=:${base64Encode(sha256.convert(bodyBytes).bytes)}:'; + final digestHeader = + 'sha-256=:${base64Encode(sha256.convert(bodyBytes).bytes)}:'; expect(headers['content-digest'], [digestHeader]); final expectedString = [ '"@method": POST', @@ -78,7 +81,8 @@ void main() { bodyBytes: Uint8List(0), tokenHeaderName: 'Approov-Token', onSetHeader: (name, value) => headers[name.toLowerCase()] = [value], - onAddHeader: (name, value) => headers.putIfAbsent(name.toLowerCase(), () => []).add(value), + onAddHeader: (name, value) => + headers.putIfAbsent(name.toLowerCase(), () => []).add(value), ); final factory = SignatureParametersFactory.generateDefaultFactory(); @@ -88,7 +92,8 @@ void main() { .map((item) => item.bareItem.value as String) .toList(); expect(componentNames.contains('content-length'), isFalse); - expect(params.serializeComponentValue().contains('"content-length"'), isFalse); + expect( + params.serializeComponentValue().contains('"content-length"'), isFalse); }); test('signature parameters serialize using structured fields', () { @@ -100,7 +105,8 @@ void main() { ..setTag('tagged'); // Duplicate component with identical parameters should be ignored. - params.addComponentIdentifier('content-type', parameters: {'charset': 'utf-8'}); + params.addComponentIdentifier('content-type', + parameters: {'charset': 'utf-8'}); expect(params.componentIdentifiers.length, 2); final serialized = params.serializeComponentValue(); @@ -138,10 +144,12 @@ void main() { test('enableMessageSigning configures default and host factories', () { final defaultFactory = SignatureParametersFactory() - .setBaseParameters(SignatureParameters()..addComponentIdentifier('@method')) + .setBaseParameters( + SignatureParameters()..addComponentIdentifier('@method')) .setUseAccountMessageSigning(); final hostFactory = SignatureParametersFactory() - .setBaseParameters(SignatureParameters()..addComponentIdentifier('@path')) + .setBaseParameters( + SignatureParameters()..addComponentIdentifier('@path')) .setUseInstallMessageSigning(); ApproovService.enableMessageSigning( @@ -152,23 +160,27 @@ void main() { final messageSigning = ApproovService.messageSigningForTesting(); expect(messageSigning, isNotNull); - final defaultContext = _buildSigningContext(Uri.parse('https://example.org/resource')); - final defaultParams = messageSigning!.buildParametersFor(defaultContext.uri, defaultContext); + final defaultContext = + _buildSigningContext(Uri.parse('https://example.org/resource')); + final defaultParams = + messageSigning!.buildParametersFor(defaultContext.uri, defaultContext); expect(defaultParams, isNotNull); final defaultComponents = defaultParams!.componentIdentifiers .map((item) => item.bareItem.value as String) .toList(); expect(defaultComponents, contains('@method')); - expect(defaultParams.algorithm, SignatureAlgorithm.hmacSha256); + expect(defaultParams.algorithmIdentifier, 'hmac-sha256'); - final hostContext = _buildSigningContext(Uri.parse('https://api.example.com/resource')); - final hostParams = messageSigning.buildParametersFor(hostContext.uri, hostContext); + final hostContext = + _buildSigningContext(Uri.parse('https://api.example.com/resource')); + final hostParams = + messageSigning.buildParametersFor(hostContext.uri, hostContext); expect(hostParams, isNotNull); final hostComponents = hostParams!.componentIdentifiers .map((item) => item.bareItem.value as String) .toList(); expect(hostComponents, contains('@path')); - expect(hostParams.algorithm, SignatureAlgorithm.ecdsaP256Sha256); + expect(hostParams.algorithmIdentifier, 'ecdsa-p256-sha256'); }); } @@ -183,6 +195,7 @@ ApproovSigningContext _buildSigningContext(Uri uri) { bodyBytes: null, tokenHeaderName: null, onSetHeader: (name, value) => headers[name.toLowerCase()] = [value], - onAddHeader: (name, value) => headers.putIfAbsent(name.toLowerCase(), () => []).add(value), + onAddHeader: (name, value) => + headers.putIfAbsent(name.toLowerCase(), () => []).add(value), ); } From 67eb970d06f595af60c8f5869450311c526a0430 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Tue, 4 Nov 2025 11:45:48 +0000 Subject: [PATCH 15/17] comment improvement --- lib/src/message_signing.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/message_signing.dart b/lib/src/message_signing.dart index 5ca492d..3d9cdf7 100644 --- a/lib/src/message_signing.dart +++ b/lib/src/message_signing.dart @@ -91,7 +91,7 @@ class SignatureParameters { return true; } - /// Sets the `alg` parameter that advertises the signing algorithm. + /// Sets the `alg` parameter that advertises the signing algorithm. hmac-sha256 / ecdsa-p256-sha256 void setAlg(String value) { _parameters['alg'] = SfBareItem.string(value); } From 4e030598aa3e2446228575ac65b0bb9eefe957d7 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Tue, 4 Nov 2025 12:21:51 +0000 Subject: [PATCH 16/17] Implemented `getAccountMessageSignature` method in Dart and platform layers to provide a clearer intent for account message signing. Now there is a clear distinction between account and installation message signing. Added revelant tests --- .../ApproovHttpClientPlugin.java | 14 +++++++ ios/Classes/ApproovHttpClientPlugin.m | 41 ++++++++++++------- lib/approov_service_flutter_httpclient.dart | 21 +++++++++- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/android/src/main/java/com/criticalblue/approov_service_flutter_httpclient/ApproovHttpClientPlugin.java b/android/src/main/java/com/criticalblue/approov_service_flutter_httpclient/ApproovHttpClientPlugin.java index 7726900..77471c8 100644 --- a/android/src/main/java/com/criticalblue/approov_service_flutter_httpclient/ApproovHttpClientPlugin.java +++ b/android/src/main/java/com/criticalblue/approov_service_flutter_httpclient/ApproovHttpClientPlugin.java @@ -341,6 +341,20 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { } catch(Exception e) { result.error("Approov.getMessageSignature", e.getLocalizedMessage(), null); } + } else if (call.method.equals("getAccountMessageSignature")) { + try { + String messageSignature = Approov.getAccountMessageSignature((String) call.argument("message")); + result.success(messageSignature); + } catch (NoSuchMethodError e) { + try { + String messageSignature = Approov.getMessageSignature((String) call.argument("message")); + result.success(messageSignature); + } catch(Exception inner) { + result.error("Approov.getAccountMessageSignature", inner.getLocalizedMessage(), null); + } + } catch(Exception e) { + result.error("Approov.getAccountMessageSignature", e.getLocalizedMessage(), null); + } } else if (call.method.equals("getInstallMessageSignature")) { try { String messageSignature = Approov.getInstallMessageSignature((String) call.argument("message")); diff --git a/ios/Classes/ApproovHttpClientPlugin.m b/ios/Classes/ApproovHttpClientPlugin.m index 735ae09..8acea6d 100644 --- a/ios/Classes/ApproovHttpClientPlugin.m +++ b/ios/Classes/ApproovHttpClientPlugin.m @@ -471,20 +471,33 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } else if ([@"setDevKey" isEqualToString:call.method]) { [Approov setDevKey:call.arguments[@"devKey"]]; result(nil); - } else if ([@"getMessageSignature" isEqualToString:call.method]) { - @try { - result([Approov getMessageSignature:call.arguments[@"message"]]); - } - @catch (NSException *exception) { - result([FlutterError errorWithCode:@"Approov.getMessageSignature" - message:exception.reason - details:nil]); - } - } else if ([@"getInstallMessageSignature" isEqualToString:call.method]) { - @try { - result([Approov getInstallMessageSignature:call.arguments[@"message"]]); - } - @catch (NSException *exception) { + } else if ([@"getMessageSignature" isEqualToString:call.method]) { + @try { + result([Approov getMessageSignature:call.arguments[@"message"]]); + } + @catch (NSException *exception) { + result([FlutterError errorWithCode:@"Approov.getMessageSignature" + message:exception.reason + details:nil]); + } + } else if ([@"getAccountMessageSignature" isEqualToString:call.method]) { + @try { + if ([Approov respondsToSelector:@selector(getAccountMessageSignature:)]) { + result([Approov getAccountMessageSignature:call.arguments[@"message"]]); + } else { + result([Approov getMessageSignature:call.arguments[@"message"]]); + } + } + @catch (NSException *exception) { + result([FlutterError errorWithCode:@"Approov.getAccountMessageSignature" + message:exception.reason + details:nil]); + } + } else if ([@"getInstallMessageSignature" isEqualToString:call.method]) { + @try { + result([Approov getInstallMessageSignature:call.arguments[@"message"]]); + } + @catch (NSException *exception) { result([FlutterError errorWithCode:@"Approov.getInstallMessageSignature" message:exception.reason details:nil]); diff --git a/lib/approov_service_flutter_httpclient.dart b/lib/approov_service_flutter_httpclient.dart index 6d7e9b2..b9976e1 100644 --- a/lib/approov_service_flutter_httpclient.dart +++ b/lib/approov_service_flutter_httpclient.dart @@ -719,6 +719,25 @@ class ApproovService { } } + /// Gets the signature for the given message using the account message signing key. This is a + /// convenience alias for [getMessageSignature] that mirrors the native SDK naming and may provide + /// clearer intent when working alongside install message signing. + static Future getAccountMessageSignature(String message) async { + Log.d("$TAG: getAccountMessageSignature"); + await _requireInitialized(); + final Map arguments = { + "message": message, + }; + try { + return await _fgChannel.invokeMethod( + 'getAccountMessageSignature', arguments); + } on MissingPluginException { + return await getMessageSignature(message); + } catch (err) { + throw ApproovException('$err'); + } + } + /// Fetches a secure string with the given key. If newDef is not null then a /// secure string for the particular app instance may be defined. In this case the /// new value is returned as the secure string. Use of an empty string for newDef removes @@ -1314,7 +1333,7 @@ class ApproovService { case 'ecdsa-p256-sha256': return await _getInstallMessageSignature(message); case 'hmac-sha256': - return await getMessageSignature(message); + return await getAccountMessageSignature(message); default: throw StateError('Unsupported signature alg: $algorithmIdentifier'); } From 151ee50d86c24efc3e1f522465537a69f23c2179 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Tue, 4 Nov 2025 12:24:49 +0000 Subject: [PATCH 17/17] Add tests for getAccountMessageSignature method --- test/approov_http_client_test.dart | 76 +++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/test/approov_http_client_test.dart b/test/approov_http_client_test.dart index 2f8df16..5984d05 100644 --- a/test/approov_http_client_test.dart +++ b/test/approov_http_client_test.dart @@ -7,18 +7,21 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - const MethodChannel channel = MethodChannel('approov_http_client'); - TestWidgetsFlutterBinding.ensureInitialized(); + const MethodChannel fgChannel = + MethodChannel('approov_service_flutter_httpclient_fg'); + late Future Function(MethodCall call) channelHandler; + setUp(() { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - return '42'; - }); + channelHandler = (MethodCall methodCall) async => '42'; + fgChannel.setMockMethodCallHandler( + (MethodCall call) => channelHandler(call), + ); }); tearDown(() { - channel.setMockMethodCallHandler(null); + fgChannel.setMockMethodCallHandler(null); ApproovService.disableMessageSigning(); }); @@ -182,6 +185,67 @@ void main() { expect(hostComponents, contains('@path')); expect(hostParams.algorithmIdentifier, 'ecdsa-p256-sha256'); }); + + test('getAccountMessageSignature invokes account-specific channel', () async { + final calls = []; + const message = 'payload'; + channelHandler = (MethodCall call) async { + calls.add(call); + switch (call.method) { + case 'initialize': + case 'setUserProperty': + return null; + case 'getAccountMessageSignature': + expect(call.arguments, {'message': message}); + return 'account-signature'; + default: + fail('Unexpected method ${call.method}'); + } + }; + + await ApproovService.initialize('test-config', 'reinit-account'); + final signature = await ApproovService.getAccountMessageSignature(message); + + expect(signature, 'account-signature'); + expect( + calls.map((call) => call.method), + ['initialize', 'setUserProperty', 'getAccountMessageSignature'], + ); + }); + + test('getAccountMessageSignature falls back when channel missing', () async { + final calls = []; + const message = 'payload'; + channelHandler = (MethodCall call) async { + calls.add(call); + switch (call.method) { + case 'initialize': + case 'setUserProperty': + return null; + case 'getAccountMessageSignature': + throw MissingPluginException('getAccountMessageSignature'); + case 'getMessageSignature': + expect(call.arguments, {'message': message}); + return 'legacy-signature'; + default: + fail('Unexpected method ${call.method}'); + } + }; + + await ApproovService.initialize('test-config', 'reinit-fallback'); + final signature = await ApproovService.getAccountMessageSignature(message); + + expect(signature, 'legacy-signature'); + expect( + calls.map((call) => call.method), + [ + 'initialize', + 'setUserProperty', + 'getAccountMessageSignature', + 'getMessageSignature' + ], + ); + }); } ApproovSigningContext _buildSigningContext(Uri uri) {