diff --git a/.gitignore b/.gitignore index 2870ebc..3f78c91 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,10 @@ .gradle .dart_tool pubspec.lock +.idea +android/local.properties +android/build/ +AGENTS.md +build/ +# Exclude .json test files used for testing only. +test/third_party/structured_field_tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 974d432..50cf3b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [3.5.2] - (12-November-2025) +- Update platform SDK to version 3.5.2 +- HTTP Message Signing Support + ## [3.5.1] - (31-July-2025) - Update platform SDK to version 3.5.1 diff --git a/README.md b/README.md index 9ae4b8b..20afdc3 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,15 @@ A wrapper for the iOS [Approov SDK](https://github.com/approov/approov-ios-sdk) and Android [Approov SDK](https://github.com/approov/approov-android-sdk) to enable easy integration when using [`Flutter`](https://flutter.dev) for making the API calls that you wish to protect with Approov. In order to use this you will need a trial or paid [Approov](https://www.approov.io) account. See the [Quickstart](https://github.com/approov/quickstart-flutter-httpclient) for usage instructions. + +## Structured Field Compliance Tests + +The HTTP message signing implementation relies on Structured Field values, so we vendor the official [httpwg/structured-field-tests](https://github.com/httpwg/structured-field-tests) fixtures for full test coverage. + +Clone the [httpwg/structured-field-tests](https://github.com/httpwg/structured-field-tests), copy the `.json`, `README.md`, `LICENSE.md`, and `serialisation-tests/` assets into `test/third_party/structured_field_tests`, and then run the following to execute the conformance suite: + +``` +flutter test test/structured_fields_conformance_test.dart +``` + +The harness focuses on serialization/canonicalization (parsing is not implemented in this package). All JSON fixtures (including `can_fail` advisories and the serialisation edge cases) are exercised; multi-value field inputs are normalised using the HTTP list concatenation rules so they can be compared against the single-value serializer APIs in this package. diff --git a/android/build.gradle b/android/build.gradle index 20f5f8e..6edc01c 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 } } @@ -45,5 +44,5 @@ artifacts.add("default", file('approov-sdk.aar')) dependencies { implementation 'com.squareup.okhttp3:okhttp:4.12.0' - implementation 'io.approov:approov-android-sdk:3.5.1' + implementation 'io.approov:approov-android-sdk:3.5.2' } 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 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..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,27 @@ 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")); + 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/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/ios/Classes/ApproovHttpClientPlugin.m b/ios/Classes/ApproovHttpClientPlugin.m index 8444c34..2073eb5 100644 --- a/ios/Classes/ApproovHttpClientPlugin.m +++ b/ios/Classes/ApproovHttpClientPlugin.m @@ -472,7 +472,36 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result [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 ([@"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]); + } } else if ([@"setUserProperty" isEqualToString:call.method]) { [Approov setUserProperty:call.arguments[@"property"]]; result(nil); @@ -498,7 +527,7 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } result(nil); } - } else if ([@"waitForHostCertificates" isEqualToString:call.method]) { + } else if ([@"waitForHostCertificates" isEqualToString:call.method]) { NSString *transactionID = call.arguments[@"transactionID"]; CertificatesFetcher *certFetcher = nil; @synchronized(_activeCertFetches) { @@ -510,7 +539,7 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result details:@"No active certificate fetch for transaction ID"]); } else { @synchronized(_activeCertFetches) { - [_activeCertFetches removeObjectForKey:transactionID]; + [_activeCertFetches removeObjectForKey:transactionID]; } NSDictionary *certResults = [certFetcher getResult]; result(certResults); @@ -560,19 +589,19 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } } result(nil); - } else if ([@"waitForFetchValue" isEqualToString:call.method]) { + } else if ([@"waitForFetchValue" isEqualToString:call.method]) { NSString *transactionID = call.arguments[@"transactionID"]; InternalCallBackHandler *callBackHandler = nil; @synchronized(_activeCallBackHandlers) { callBackHandler = [_activeCallBackHandlers objectForKey:transactionID]; } if (callBackHandler == nil) { - result([FlutterError errorWithCode:[NSString stringWithFormat:@"%d", -1] - message:@"ApproovService has no active fetch" - details:@"No active fetch for transaction ID"]); + result([FlutterError errorWithCode:[NSString stringWithFormat:@"%d", -1] + message:@"ApproovService has no active fetch" + details:@"No active fetch for transaction ID"]); } else { @synchronized(_activeCallBackHandlers) { - [_activeCallBackHandlers removeObjectForKey:transactionID]; + [_activeCallBackHandlers removeObjectForKey:transactionID]; } result([callBackHandler getResult]); } diff --git a/ios/approov_service_flutter_httpclient.podspec b/ios/approov_service_flutter_httpclient.podspec index 9748382..5e4719f 100644 --- a/ios/approov_service_flutter_httpclient.podspec +++ b/ios/approov_service_flutter_httpclient.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'approov_service_flutter_httpclient' - s.version = '3.5.1' + s.version = '3.5.2' s.summary = 'Flutter plugin for accessing Approov SDK attestation services.' s.description = <<-DESC A Flutter plugin using mobile API protection provided by the Approov SDK. If the provided Approov SDK is configured to protect an API, then the plugin will automatically set up pinning and add relevant headers for any request to the API. @@ -13,7 +13,7 @@ Pod::Spec.new do |s| s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.dependency 'approov-ios-sdk', '~> 3.5.1' + s.dependency 'approov-ios-sdk', '~> 3.5.2' s.platform = :ios, '11.0' # Flutter.framework does not contain an i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } diff --git a/lib/approov_service_flutter_httpclient.dart b/lib/approov_service_flutter_httpclient.dart index 2051862..05ce0f2 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,15 @@ 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, + SignatureBaseBuilder, + SignatureDigest, + SignatureParameters, + SignatureParametersFactory; // Logger final Logger Log = Logger(); @@ -89,12 +99,15 @@ class _TokenFetchResult { // Loggable Approov token string. String loggableToken = ""; + // Trace identifier associated with the last Approov token fetch, if provided by the SDK. + String traceID = ""; + /// Convenience constructor to generate the results from a results map from the underlying platform call. /// /// @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"]; @@ -106,6 +119,8 @@ class _TokenFetchResult { Uint8List? newMeasurementConfig = tokenFetchResultMap["MeasurementConfig"]; if (newMeasurementConfig != null) measurementConfig = newMeasurementConfig; loggableToken = tokenFetchResultMap["LoggableToken"]; + String? newTraceID = tokenFetchResultMap["TraceID"]; + if (newTraceID != null) traceID = newTraceID; } } @@ -154,7 +169,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; } @@ -174,16 +190,21 @@ 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"; + // header that will carry the Approov TraceID associated with a token fetch + static const String APPROOV_TRACE_ID_HEADER = "Approov-TraceID"; + // any prefix to be added before the Approov token, such as "Bearer " static const String APPROOV_TOKEN_PREFIX = ""; @@ -196,6 +217,9 @@ class ApproovService { // header used when adding the Approov Token to network requests static String _approovTokenHeader = APPROOV_HEADER; + // header used when adding the Approov TraceID to network requests, null disables the header + static String? _approovTraceIDHeader = APPROOV_TRACE_ID_HEADER; + // prefix for the above header (like Bearer) static String _approovTokenPrefix = APPROOV_TOKEN_PREFIX; @@ -227,15 +251,21 @@ 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?>(); + 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 @@ -247,13 +277,22 @@ 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); } } + 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. @@ -290,12 +329,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 { @@ -386,6 +428,53 @@ class ApproovService { _approovTokenPrefix = prefix; } + /// Sets the header that receives any Approov TraceID value provided by the SDK. Passing null disables adding the header. + /// + /// @param header is the header to carry the Approov TraceID, or null to disable + static void setApproovTraceIDHeader(String? header) { + Log.d("$TAG: setApproovTraceIDHeader $header"); + _approovTraceIDHeader = header; + } + + /// Gets the header used to carry the Approov TraceID, or null if disabled. + static String? getApproovTraceIDHeader() { + return _approovTraceIDHeader; + } + + /// 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 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() + ..setDefaultFactory(effectiveDefaultFactory); + hostFactories?.forEach(messageSigning.putHostFactory); + _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; + } + + @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 @@ -458,7 +547,7 @@ class ApproovService { /// starting the operation earlier so the subsequent fetch should be able to use cached data. static void prefetch() async { try { - ApproovService._fetchApproovToken("approov.io"); + ApproovService._fetchApproovToken("https://approov.io/"); Log.d("$TAG: prefetch started"); } on ApproovException catch (e) { Log.e("$TAG: prefetch: exception ${e.cause}"); @@ -506,7 +595,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); } @@ -534,11 +624,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 @@ -589,14 +681,14 @@ class ApproovService { /// attestation is rejected by the Approov cloud service then a token is still returned, it just won't be signed /// with the correct signature so the failure is detected when any API, to which the token is presented, verifies it. /// - /// All calls must provide a URL which provides the high level domain of the API to which the Approov token is going + /// All calls must provide the full request URL (including path) of the API to which the Approov token is going /// to be sent. Different API domains will have different Approov tokens associated with them so it is important that - /// the Approov token is only sent to requests for that domain. If the domain has not been configured using the Approov + /// the Approov token is only sent to requests for that URL. If the domain has not been configured using the Approov /// CLI then an ApproovException is thrown. Note that there are various other reasons that an ApproovException might also /// be thrown. If the fetch fails due to a networking issue, and should be retried at some later point, then an /// ApproovNetworkException is thrown. /// - /// @param url provides the top level domain URL for which a token is being fetched + /// @param url provides the full request URL (including path) for which a token is being fetched /// @return results of fetching a token /// @throws ApproovException if there was a problem static Future fetchToken(String url) async { @@ -607,7 +699,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) || @@ -615,10 +708,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}"); } } @@ -639,13 +734,33 @@ 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'); } } + /// 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 @@ -696,7 +811,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); } @@ -707,7 +823,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'); } @@ -724,11 +841,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; } @@ -773,7 +892,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); } @@ -784,7 +904,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'); } @@ -801,10 +922,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; @@ -855,7 +978,7 @@ class ApproovService { /// Internal method for fetching an Approov token from the SDK. /// - /// @param url provides the top level domain URL for which a token is being fetched + /// @param url provides the full request URL (including path) for which a token is being fetched /// @return results of fetching a token /// @throws ApproovException if there was a problem static Future<_TokenFetchResult> _fetchApproovToken(String url) async { @@ -884,7 +1007,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); } @@ -892,7 +1016,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'); @@ -912,7 +1037,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) { @@ -951,7 +1077,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); } @@ -962,7 +1089,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'); } @@ -970,7 +1098,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) @@ -1004,8 +1133,10 @@ 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(); @@ -1020,17 +1151,20 @@ class ApproovService { if (headerValue != null) setDataHashInToken(headerValue); } - // request an Approov token for the host domain + // request an Approov token for the full request URL final stopWatch = Stopwatch(); stopWatch.start(); - String host = request.uri.host; - _TokenFetchResult fetchResult = await _fetchApproovToken(host); + final Uri requestUri = request.uri; + final String host = requestUri.host; + final String requestUrl = requestUri.toString(); + _TokenFetchResult fetchResult = await _fetchApproovToken(requestUrl); // provide information about the obtained token or error (note "approov token -check" can // 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 @@ -1044,25 +1178,34 @@ class ApproovService { 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); + final String? traceIDHeader = _approovTraceIDHeader; + final String traceID = fetchResult.traceID; + if ((traceIDHeader != null) && traceID.isNotEmpty) { + request.headers.set(traceIDHeader, traceID, 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 @@ -1070,7 +1213,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(); @@ -1099,7 +1244,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); } @@ -1108,37 +1254,285 @@ 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'); } // 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); + } 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) { + 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(); + 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}"); + return; + } + + final signatureLabel = _signatureLabelForAlg(alg); + 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, String algorithmIdentifier) async { + switch (algorithmIdentifier) { + case 'ecdsa-p256-sha256': + return await _getInstallMessageSignature(message); + case 'hmac-sha256': + return await getAccountMessageSignature(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'); + } + } + + 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.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"); + throw StateError('install message signing not supported'); + } catch (err) { + _installMessageSigningAvailable = false; + Log.w("$TAG: getInstallMessageSignature error: $err"); + throw StateError('install message signing not supported'); } } + static Uint8List _decodeDerEcdsaSignature(Uint8List der) { + int offset = 0; + 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); + 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); + 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); + 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); + final sFixed = _toFixedLength(sBytes); + return Uint8List.fromList([...rFixed, ...sFixed]); + } + + 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; + } + final numBytes = lengthByte & 0x7F; + 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]; + } + 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 @@ -1181,7 +1575,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); } @@ -1191,13 +1586,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; @@ -1210,7 +1607,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) { @@ -1256,7 +1654,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); @@ -1287,7 +1686,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); @@ -1304,7 +1704,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 @@ -1398,6 +1806,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 +1822,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,8 +1835,56 @@ 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; + set bufferOutput(bool _bufferOutput) => + _delegateRequest.bufferOutput = _bufferOutput; @override bool get bufferOutput => _delegateRequest.bufferOutput; @@ -1432,7 +1892,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; @@ -1448,7 +1909,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; @@ -1456,7 +1918,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; @@ -1464,7 +1927,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; @@ -1500,6 +1964,7 @@ class _ApproovHttpClientRequest implements HttpClientRequest { @override Future addStream(Stream> stream) async { + _hasStreamBody = true; await _updateRequestIfRequired(); return _delegateRequest.addStream(stream); } @@ -1587,20 +2052,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; @@ -1625,7 +2094,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); } @@ -1666,12 +2136,13 @@ class ApproovHttpClient implements HttpClient { allPins = await ApproovService._getPins("public-key-sha256"); } else { // start the process of fetching an Approov token to get the latest configuration - final futureApproovToken = ApproovService._fetchApproovToken(url.host); + final futureApproovToken = ApproovService._fetchApproovToken(urlString); tokenStartTime = stopWatch.elapsedMilliseconds - certStartTime; // 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"); @@ -1679,7 +2150,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 @@ -1691,7 +2163,7 @@ class ApproovHttpClient implements HttpClient { if ((fetchResult.tokenFetchStatus != _TokenFetchStatus.SUCCESS) && (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNKNOWN_URL)) { // perform another attempted token fetch - fetchResult = await ApproovService._fetchApproovToken(url.host); + fetchResult = await ApproovService._fetchApproovToken(urlString); 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 @@ -1700,7 +2172,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; } } @@ -1729,17 +2202,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 @@ -1761,7 +2241,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; @@ -1805,7 +2286,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 @@ -1834,7 +2316,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; @@ -1845,7 +2328,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); @@ -1868,37 +2352,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); @@ -1955,7 +2445,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(); } @@ -1967,7 +2459,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(); } @@ -1979,19 +2472,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; } @@ -2035,7 +2531,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 new file mode 100644 index 0000000..3d9cdf7 --- /dev/null +++ b/lib/src/message_signing.dart @@ -0,0 +1,502 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +import 'structured_fields.dart'; + +/// 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) { + 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 { + /// 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), + debugMode = other.debugMode; + + final List _componentIdentifiers; + final LinkedHashMap _parameters; + + bool debugMode = false; + + /// 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); + + /// 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); + // Skip adding duplicate component identifiers that only differ in letter case or parameter identity. + if (_componentIdentifiers.any( + (item) => + _componentIdentifierMatches(item, normalized, candidateParameters), + )) { + return; + } + _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(); + // 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]; + if (existingValue == null) return false; + if (existingValue.serialize() != entry.value.serialize()) return false; + } + return true; + } + + /// Sets the `alg` parameter that advertises the signing algorithm. hmac-sha256 / ecdsa-p256-sha256 + 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); + } + + /// 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; + String? _bodyDigestAlgorithm; + bool _bodyDigestRequired = false; + bool _useAccountMessageSigning = true; + bool _addCreated = false; + int _expiresLifetimeSeconds = 0; + bool _addApproovTokenHeader = false; + 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 && + algorithm != SignatureDigest.sha512.identifier) { + throw ArgumentError('Unsupported body digest algorithm: $algorithm'); + } + _bodyDigestAlgorithm = algorithm; + _bodyDigestRequired = required; + 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(); + if (!_optionalHeaders.contains(normalized)) { + _optionalHeaders.add(normalized); + } + } + 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; + 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)) continue; + 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) { + // 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) { + final digestHeader = context.ensureContentDigest( + SignatureDigest.fromIdentifier(_bodyDigestAlgorithm!), + required: _bodyDigestRequired); + if (digestHeader != null) { + params.addComponentIdentifier('content-digest'); + } + } + + return params; + } + + /// Generates the default Approov configuration, optionally layering on an override base. + 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); + } +} + +/// 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(); + for (final component in params.componentIdentifiers) { + final value = context.getComponentValue(component); + if (value == null) { + throw StateError( + 'Missing component value for ${_componentIdentifierValue(component)}'); + } + 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; + + /// Looks up a digest configuration by its HTTP identifier. + static SignatureDigest fromIdentifier(String id) { + return SignatureDigest.values.firstWhere( + (value) => value.identifier == id, + orElse: () => throw ArgumentError('Unsupported digest identifier: $id'), + ); + } +} + +/// 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, + 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; + + /// 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('@')) { + 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 paramValue = component.parameters.asMap()['name']; + if (paramValue == null) { + 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'); + } + return _queryParameterValue(paramValue.value as String); + default: + throw StateError('Unknown derived component: $identifier'); + } + } else { + final values = _headers[identifier.toLowerCase()]; + if (values == null || values.isEmpty) return null; + return _combineFieldValues(values); + } + } + + /// Ensures the `Content-Digest` header exists by hashing the request body. + String? ensureContentDigest(SignatureDigest digest, + {required bool required}) { + if (bodyBytes == null) { + if (required) { + throw StateError('Body digest required but body is not available'); + } + 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, + }; + final headerValue = '${digest.identifier}=:${base64Encode(bytes)}:'; + setHeader('Content-Digest', headerValue); + 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; + } + 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; + if (values.length > 1) return null; + 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(); + // 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(', '); + } + + /// 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; + return factory.build(context); + } +} diff --git a/lib/src/structured_fields.dart b/lib/src/structured_fields.dart new file mode 100644 index 0000000..296025b --- /dev/null +++ b/lib/src/structured_fields.dart @@ -0,0 +1,641 @@ +import 'dart:collection'; +import 'dart:convert'; +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 = { + 0x21, // ! + 0x23, // # + 0x24, // $ + 0x25, // % + 0x26, // & + 0x27, // ' + 0x2a, // * + 0x2b, // + + 0x2d, // - + 0x2e, // . + 0x5e, // ^ + 0x5f, // _ + 0x60, // ` + 0x7c, // | + 0x7e, // ~ + }; + 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'); + } + 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 + ? (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'); + } + } +} + +/// 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); + 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', + ); + } + } +} + +/// Validates the character set of an sf-token. +void _validateToken(String value) { + if (value.isEmpty) { + 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 + ? (_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', + ); + } + } +} + +/// 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) { + 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'); + } + } +} + +/// Represents an sf-token value. +class SfToken { + /// Validates and stores the Structured Field token value. + SfToken(String value) : value = value { + _validateToken(value); + } + + final String value; +} + +/// Represents a display string bare item. +class SfDisplayString { + /// Validates and stores the Structured Field display string. + SfDisplayString(String value) : value = value { + _validateDisplayString(value); + } + + final String value; +} + +/// 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) { + if (value.isInfinite || value.isNaN) { + throw SfFormatException('Decimals must be finite numbers: $value'); + } + final scaled = value * 1000; + final rounded = _roundTiesToEven(scaled); + 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'); + } + 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); + } + + /// Ensures the scaled value falls within the allowed magnitude. + static SfDecimal _checked(int scaled) { + const max = 999999999999999; + if (scaled.abs() > max) { + throw SfFormatException('Decimal magnitude exceeds allowed range'); + } + return SfDecimal._(scaled); + } + + /// Rounds the scaled value using ties-to-even (bankers rounding). + static int _roundTiesToEven(num value) { + if (value is int) return value; + final doubleValue = value.toDouble(); + final lower = doubleValue.floor(); + final fraction = doubleValue - lower; + const epsilon = 1e-9; + if ((fraction - 0.5).abs() <= epsilon) { + return lower.isEven ? lower : lower + 1; + } + return fraction < 0.5 ? lower : lower + 1; + } + + 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(); + 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 { + /// 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; + return SfDate.fromSeconds(seconds); + } + + 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 + 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 { + /// 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; + if (value < min || value > max) { + throw SfFormatException('Integer magnitude exceeds allowed range: $value'); + } + 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); + } 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}'); + } + + /// 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); + } 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}'); + } + + /// 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); + 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 { + 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) { + 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; + + /// 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: + // Integers serialize as plain decimal digits. + 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)); + } + } + + /// 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); + 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')); + } else { + buffer.write(String.fromCharCode(byte)); + } + } + buffer.write('"'); + return buffer.toString(); + } +} + +/// 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())); + } + final map = LinkedHashMap(); + entries.forEach((key, value) { + _validateKey(key); + map[key] = SfBareItem.fromDynamic(value); + }); + return SfParameters._(UnmodifiableMapView(map)); + } + + 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 + ..write(';') + ..write(key); + if (!value.isBooleanTrue) { + // Boolean true omits "=value"; all other entries include the serialized bare item. + buffer.write('='); + value.serializeTo(buffer); + } + }); + } +} + +/// 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); + return buffer.toString(); + } +} + +/// 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); + + 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++) { + if (index > 0) buffer.write(' '); + items[index].serializeTo(buffer); + } + buffer.write(')'); + parameters.serializeTo(buffer); + } + + /// Serializes the inner list into a string. + String serialize() { + final buffer = StringBuffer(); + serializeTo(buffer); + return buffer.toString(); + } +} + +/// 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; + + 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); + } else { + innerList!.serializeTo(buffer); + } + } +} + +/// 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++) { + 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 { + /// 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, + parameters = null; + + final SfItem? item; + final SfInnerList? innerList; + final SfParameters? parameters; + + /// Serializes the dictionary member according to its stored variant. + 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) { + // Bare dictionary boolean members serialize only their attached parameters. + parameters!.serializeTo(buffer); + } + } +} + +/// 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) { + _validateKey(entry.key); + return MapEntry(entry.key, entry.value); + }))); + + 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; + _entries.forEach((key, member) { + // Preserve insertion order so signature bases remain stable. + if (index > 0) buffer.write(', '); + buffer.write(key); + member.serializeTo(buffer); + index++; + }); + return buffer.toString(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index a6f2c8a..76d104c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: approov_service_flutter_httpclient description: Approov enabled HttpClient -version: 3.5.1 +version: 3.5.2 repository: https://github.com/approov/approov-service-flutter-httpclient homepage: https://pub.dev/publishers/approov.io/packages diff --git a/test/approov_http_client_test.dart b/test/approov_http_client_test.dart index 32c7b69..ac83833 100644 --- a/test/approov_http_client_test.dart +++ b/test/approov_http_client_test.dart @@ -1,20 +1,264 @@ +import 'dart:convert'; + +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'; 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(); + }); + + 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); }); - test('getPlatformVersion', () async {}); + 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.bareItem.value as String) + .toList(); + 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); + }); + + 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.algorithmIdentifier, 'hmac-sha256'); + + 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.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) { + 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_conformance_test.dart b/test/structured_fields_conformance_test.dart new file mode 100644 index 0000000..e5434da --- /dev/null +++ b/test/structured_fields_conformance_test.dart @@ -0,0 +1,278 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:approov_service_flutter_httpclient/src/structured_fields.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const fixturesRoot = 'test/third_party/structured_field_tests'; + final rootDirectory = Directory(fixturesRoot); + if (!rootDirectory.existsSync()) { + fail( + 'Expected Structured Field fixtures to be checked out at $fixturesRoot. ' + 'Run the test preparation script before executing this suite.', + ); + } + + final conformanceFiles = _collectFixtureFiles(rootDirectory, includeSerialisationTests: false); + group('Structured Field canonical serialization', () { + for (final file in conformanceFiles) { + final relativePath = file.path.substring(rootDirectory.path.length + 1); + final records = _loadRecords(file); + group(relativePath, () { + for (final record in records) { + final headerType = record.headerType; + if (record.mustFail || record.expected == null) { + continue; + } + final expectedValue = _expectedSerializedValue(record); + if (expectedValue == null) { + test( + record.name, + () {}, + skip: 'Canonical form spans multiple header lines; serializer emits single values.', + ); + continue; + } + test(record.name, () { + final structure = _buildStructure(headerType, record.expected!); + final serialized = _serializeStructure(headerType, structure); + expect(serialized, expectedValue); + }); + } + }); + } + }); + + final serializationFiles = _collectFixtureFiles(rootDirectory, includeSerialisationTests: true) + .where((file) => file.path.contains('${Platform.pathSeparator}serialisation-tests${Platform.pathSeparator}')) + .toList(); + + group('Structured Field serialization edge cases', () { + for (final file in serializationFiles) { + final relativePath = file.path.substring(rootDirectory.path.length + 1); + final records = _loadRecords(file); + group(relativePath, () { + for (final record in records) { + if (record.expected == null) continue; + final expectedValue = _expectedSerializedValue(record); + test(record.name, () { + final buildStructure = () => _buildStructure(record.headerType, record.expected!); + if (record.mustFail) { + expect(buildStructure, throwsA(isA())); + return; + } + final structure = buildStructure(); + final serialized = _serializeStructure(record.headerType, structure); + expect(serialized, expectedValue ?? '', reason: 'Missing canonical/raw fallback.'); + }); + } + }); + } + }); +} + +class _FixtureRecord { + _FixtureRecord(this.name, this.headerType, this.expected, this.mustFail, this.canFail, + this.rawValues, this.canonicalValues); + + final String name; + final String headerType; + final dynamic expected; + final bool mustFail; + final bool canFail; + final List? rawValues; + final List? canonicalValues; +} + +List<_FixtureRecord> _loadRecords(File file) { + final json = jsonDecode(file.readAsStringSync()) as List; + return json.map((dynamic entry) { + final map = entry as Map; + return _FixtureRecord( + map['name'] as String? ?? file.path, + map['header_type'] as String? ?? 'item', + map['expected'], + map['must_fail'] == true, + map['can_fail'] == true, + (map['raw'] as List?)?.cast(), + (map['canonical'] as List?)?.cast(), + ); + }).toList(); +} + +List _collectFixtureFiles(Directory root, {required bool includeSerialisationTests}) { + final files = []; + for (final entity in root.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.json')) continue; + final isSerialisationTest = entity.path.contains('${Platform.pathSeparator}serialisation-tests${Platform.pathSeparator}'); + final isSchemaFile = entity.path.contains('${Platform.pathSeparator}schema${Platform.pathSeparator}'); + if (isSchemaFile) continue; + if (!includeSerialisationTests && isSerialisationTest) continue; + files.add(entity); + } + files.sort((a, b) => a.path.compareTo(b.path)); + return files; +} + +String? _expectedSerializedValue(_FixtureRecord record) { + final values = record.canonicalValues ?? record.rawValues; + if (values == null) return null; + if (values.isEmpty) return ''; + if (values.length == 1) return values.first; + // Multiple header field lines collapse into a comma-separated representation for comparison. + return values.join(', '); +} + +Object _buildStructure(String headerType, dynamic expected) { + switch (headerType.toLowerCase()) { + case 'item': + return _itemFromJson(expected as List); + case 'list': + return _listFromJson(expected as List); + case 'dictionary': + return _dictionaryFromJson(expected as List); + default: + throw ArgumentError('Unsupported header type: $headerType'); + } +} + +String _serializeStructure(String headerType, Object structure) { + switch (headerType.toLowerCase()) { + case 'item': + return (structure as SfItem).serialize(); + case 'list': + return (structure as SfList).serialize(); + case 'dictionary': + return (structure as SfDictionary).serialize(); + default: + throw ArgumentError('Unsupported header type: $headerType'); + } +} + +SfItem _itemFromJson(List json) { + if (json.length != 2) { + throw ArgumentError('Invalid SfItem representation: $json'); + } + final bare = _bareItemFromJson(json[0]); + final params = _parametersFromJson(json[1] as List?); + return SfItem(bare, params.isEmpty ? null : params); +} + +SfList _listFromJson(List json) { + final members = []; + for (final entry in json) { + if (entry is List && entry.length == 2 && entry[0] is List) { + members.add(SfListMember.innerList(_innerListFromJson(entry))); + } else if (entry is List) { + members.add(SfListMember.item(_itemFromJson(entry))); + } else { + throw ArgumentError('Invalid list member: $entry'); + } + } + return SfList(members); +} + +SfInnerList _innerListFromJson(List json) { + if (json.length != 2) { + throw ArgumentError('Invalid inner list representation: $json'); + } + final itemsList = json[0] as List; + final items = itemsList.map((dynamic entry) => _itemFromJson(entry as List)).toList(); + final params = _parametersFromJson(json[1] as List?); + return SfInnerList(items, params.isEmpty ? null : params); +} + +SfDictionary _dictionaryFromJson(List json) { + final entries = LinkedHashMap(); + for (final entry in json) { + if (entry is! List || entry.length != 2) { + throw ArgumentError('Invalid dictionary entry: $entry'); + } + final key = entry[0] as String; + final value = entry[1] as List; + entries[key] = _dictionaryMemberFromJson(value); + } + return SfDictionary(entries); +} + +SfDictionaryMember _dictionaryMemberFromJson(List json) { + if (json.length != 2) { + throw ArgumentError('Invalid dictionary member: $json'); + } + final value = json[0]; + if (value is List) { + return SfDictionaryMember.innerList(_innerListFromJson(json)); + } + final params = _parametersFromJson(json[1] as List?); + if (value == true) { + return SfDictionaryMember.booleanTrue(params.isEmpty ? null : params); + } + final item = _itemFromJson(json); + return SfDictionaryMember.item(item); +} + +Map _parametersFromJson(List? json) { + if (json == null || json.isEmpty) { + return const {}; + } + final map = {}; + for (final entry in json) { + if (entry is! List || entry.length != 2) { + throw ArgumentError('Invalid parameter entry: $entry'); + } + final key = entry[0] as String; + final value = _bareItemFromJson(entry[1]); + map[key] = value; + } + return map; +} + +SfBareItem _bareItemFromJson(dynamic json) { + if (json is SfBareItem) return json; + if (json is int) return SfBareItem.integer(json); + if (json is double) return SfBareItem.decimal(json); + if (json is bool) return SfBareItem.boolean(json); + if (json is String) return SfBareItem.string(json); + if (json is Map) { + switch (json['__type']) { + case 'token': + return SfBareItem.token(SfToken(json['value'] as String)); + case 'binary': + return SfBareItem.byteSequence(_decodeBase32(json['value'] as String)); + case 'date': + return SfBareItem.date(json['value'] as int); + case 'displaystring': + return SfBareItem.displayString(SfDisplayString(json['value'] as String)); + } + } + throw ArgumentError('Unsupported bare item representation: $json'); +} + +const _base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +final Map _base32Lookup = { + for (var i = 0; i < _base32Alphabet.length; i++) + _base32Alphabet.codeUnitAt(i): i, +}; + +Uint8List _decodeBase32(String input) { + final sanitized = input.replaceAll('=', '').toUpperCase(); + var bits = 0; + var value = 0; + final output = []; + for (final unit in sanitized.codeUnits) { + final digit = _base32Lookup[unit]; + if (digit == null) { + throw SfFormatException('Invalid base32 character: ${String.fromCharCode(unit)}'); + } + value = (value << 5) | digit; + bits += 5; + if (bits >= 8) { + bits -= 8; + output.add((value >> bits) & 0xff); + } + } + return Uint8List.fromList(output); +} diff --git a/test/structured_fields_test.dart b/test/structured_fields_test.dart new file mode 100644 index 0000000..bd1605a --- /dev/null +++ b/test/structured_fields_test.dart @@ -0,0 +1,147 @@ +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('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'); + }); + + // Commented out as we do not want to throw an exception in this case currently. This renders the test invalid. + // 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"'); + }); + + 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"'); + }); + + 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', () { + 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")'); + }); + + test('empty collections serialize to empty string', () { + expect(SfInnerList([]).serialize(), '()'); + expect(SfList([]).serialize(), ''); + expect(SfDictionary({}).serialize(), ''); + }); + }); + + 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())); + }); + }); +}