diff --git a/.gitignore b/.gitignore index 2870ebc..8468b5f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ .gradle .dart_tool pubspec.lock +.idea +android/local.properties +android/build/ +AGENTS.md +build/ \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 4927bae..15b7744 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -4,18 +4,17 @@ version '1.0' buildscript { repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'com.android.tools.build:gradle:8.5.0' } } rootProject.allprojects { repositories { google() - jcenter() mavenCentral() } } @@ -25,18 +24,18 @@ apply plugin: 'com.android.library' android { namespace 'com.criticalblue.approov_service_flutter_httpclient' - compileSdkVersion 29 + compileSdk 34 defaultConfig { - minSdkVersion 19 + minSdk 21 } - lintOptions { + lint { disable 'InvalidPackage' } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 01a286e..b423410 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Thu Oct 16 13:35:42 BST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip 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..8acea6d 100644 --- a/ios/Classes/ApproovHttpClientPlugin.m +++ b/ios/Classes/ApproovHttpClientPlugin.m @@ -471,8 +471,37 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } else if ([@"setDevKey" isEqualToString:call.method]) { [Approov setDevKey:call.arguments[@"devKey"]]; result(nil); - } else if ([@"getMessageSignature" isEqualToString:call.method]) { + } 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); diff --git a/lib/approov_service_flutter_httpclient.dart b/lib/approov_service_flutter_httpclient.dart index 2051862..b9976e1 100644 --- a/lib/approov_service_flutter_httpclient.dart +++ b/lib/approov_service_flutter_httpclient.dart @@ -21,6 +21,7 @@ import 'dart:convert'; import 'dart:core'; import 'dart:io'; import 'dart:math'; +import 'dart:typed_data'; import 'package:asn1lib/asn1lib.dart'; import 'package:crypto/crypto.dart'; @@ -33,6 +34,16 @@ import 'package:http/io_client.dart' as httpio; import 'package:logger/logger.dart'; import 'package:pem/pem.dart'; import 'package:mutex/mutex.dart'; +import 'package:meta/meta.dart'; +import 'src/message_signing.dart'; +export 'src/message_signing.dart' + show + ApproovMessageSigning, + ApproovSigningContext, + SignatureBaseBuilder, + SignatureDigest, + SignatureParameters, + SignatureParametersFactory; // Logger final Logger Log = Logger(); @@ -93,8 +104,8 @@ class _TokenFetchResult { /// /// @param tokenFetchResultMap holds the results of the fetch _TokenFetchResult.fromTokenFetchResultMap(Map tokenFetchResultMap) { - _TokenFetchStatus? newTokenFetchStatus = - EnumToString.fromString(_TokenFetchStatus.values, tokenFetchResultMap["TokenFetchStatus"]); + _TokenFetchStatus? newTokenFetchStatus = EnumToString.fromString( + _TokenFetchStatus.values, tokenFetchResultMap["TokenFetchStatus"]); if (newTokenFetchStatus != null) tokenFetchStatus = newTokenFetchStatus; token = tokenFetchResultMap["Token"]; String? newSecureString = tokenFetchResultMap["SecureString"]; @@ -154,7 +165,8 @@ class ApproovRejectionException extends ApproovException { /// @param cause is a message giving the cause of the exception /// @param arc is the code that can be used for support purposes /// @param rejectionReasons may provide a comma separated list of rejection reasons - ApproovRejectionException(String cause, String arc, String rejectionReasons) : super(cause) { + ApproovRejectionException(String cause, String arc, String rejectionReasons) + : super(cause) { this.arc = arc; this.rejectionReasons = rejectionReasons; } @@ -174,12 +186,14 @@ class ApproovService { // foreground channel for communicating with the platform specific layers (used by the root isolate) - this is // used in all cases where the operation is not expected to block for an extended period and also from the root // isolate where a callback may be received - static const MethodChannel _fgChannel = const MethodChannel('approov_service_flutter_httpclient_fg'); + static const MethodChannel _fgChannel = + const MethodChannel('approov_service_flutter_httpclient_fg'); // background channel for communicating with the platform specific layers (used by background isolates) - this is // used in cases where the operation may block for an extended period and it is necessary to use this in that // case to avoid the main isolate thread being blocked by the operation - static const MethodChannel _bgChannel = const MethodChannel('approov_service_flutter_httpclient_bg'); + static const MethodChannel _bgChannel = + const MethodChannel('approov_service_flutter_httpclient_bg'); // header that will be added to Approov enabled requests static const String APPROOV_HEADER = "Approov-Token"; @@ -227,15 +241,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 +267,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 +319,15 @@ class ApproovService { await _initMutex.protect(() async { bool isRootIsolate = (RootIsolateToken.instance != null); String isolate = isRootIsolate ? "root" : "background"; - if (_isInitialized && ((comment == null) || !comment.startsWith("reinit"))) { + if (_isInitialized && + ((comment == null) || !comment.startsWith("reinit"))) { // this is a reinitialization attempt and we need to check if the config is the same if (_initialConfig != config) { - throw ApproovException("Attempt to reinitialize the Approov SDK with a different configuration $config"); + throw ApproovException( + "Attempt to reinitialize the Approov SDK with a different configuration $config"); } - Log.d("$TAG: $isolate initialization ignoring attempt with the same config"); + Log.d( + "$TAG: $isolate initialization ignoring attempt with the same config"); } else { // perform the actual initialization try { @@ -386,6 +418,40 @@ class ApproovService { _approovTokenPrefix = prefix; } + /// Enables automatic message signing for outgoing requests. The Approov SDK provides the signing key after a + /// successful attestation and the resulting signature is attached to each protected request via the standard + /// `Signature` and `Signature-Input` headers as defined by the HTTP Message Signatures specification. Provide + /// a [defaultFactory] to control which components are included in the canonical representation, or optionally + /// override the configuration for specific hosts via [hostFactories]. + static void enableMessageSigning({ + SignatureParametersFactory? defaultFactory, + Map? hostFactories, + }) { + final 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 @@ -506,7 +572,8 @@ class ApproovService { final Map waitArgs = { "transactionID": transactionID, }; - final results = await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); + final results = + await _bgChannel.invokeMethod('waitForFetchValue', waitArgs); completer.complete(results); _platformTransactions.remove(transactionID); } @@ -534,11 +601,13 @@ class ApproovService { (fetchResult.tokenFetchStatus == _TokenFetchStatus.MITM_DETECTED)) // we are unable to get the secure string due to network conditions so the request can // be retried by the user later - throw new ApproovNetworkException("precheck: ${fetchResult.tokenFetchStatus.name}"); + throw new ApproovNetworkException( + "precheck: ${fetchResult.tokenFetchStatus.name}"); else if ((fetchResult.tokenFetchStatus != _TokenFetchStatus.SUCCESS) && (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNKNOWN_KEY)) // we are unable to get the secure string due to a more permanent error - throw new ApproovException("precheck: ${fetchResult.tokenFetchStatus.name}"); + throw new ApproovException( + "precheck: ${fetchResult.tokenFetchStatus.name}"); } /// Gets the device ID used by Approov to identify the particular device that the SDK is running on. Note that @@ -607,7 +676,8 @@ class ApproovService { // check the status of Approov token fetch if ((fetchResult.tokenFetchStatus == _TokenFetchStatus.SUCCESS) || - (fetchResult.tokenFetchStatus == _TokenFetchStatus.NO_APPROOV_SERVICE)) { + (fetchResult.tokenFetchStatus == + _TokenFetchStatus.NO_APPROOV_SERVICE)) { // we successfully obtained a token so provide it, or provide an empty one on complete Approov service failure return fetchResult.token; } else if ((fetchResult.tokenFetchStatus == _TokenFetchStatus.NO_NETWORK) || @@ -615,10 +685,12 @@ class ApproovService { (fetchResult.tokenFetchStatus == _TokenFetchStatus.MITM_DETECTED)) { // we are unable to get an Approov token due to network conditions so the request can // be retried by the user later - throw new ApproovNetworkException("fetchToken for $url: ${fetchResult.tokenFetchStatus.name}"); + throw new ApproovNetworkException( + "fetchToken for $url: ${fetchResult.tokenFetchStatus.name}"); } else { // we have failed to get an Approov token with a more serious permanent error - throw ApproovException("fetchToken for $url: ${fetchResult.tokenFetchStatus.name}"); + throw ApproovException( + "fetchToken for $url: ${fetchResult.tokenFetchStatus.name}"); } } @@ -639,13 +711,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 +788,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 +800,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 +818,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 +869,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 +881,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 +899,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; @@ -884,7 +984,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 +993,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 +1014,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 +1054,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 +1066,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 +1075,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 +1110,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(); @@ -1030,7 +1138,8 @@ class ApproovService { // be used to check the validity of the token and if you use token annotations they // will appear here to determine why a request is being rejected) String isolate = _isRootIsolate ? "root" : "background"; - Log.d("$TAG: $isolate updateRequest for $host: ${fetchResult.loggableToken}, ${stopWatch.elapsedMilliseconds}ms"); + Log.d( + "$TAG: $isolate updateRequest for $host: ${fetchResult.loggableToken}, ${stopWatch.elapsedMilliseconds}ms"); // if there was a configuration change we fetch a new configuration, which will update // the configuration epoch across all isolates and cause all delegate HttpClient caches to be @@ -1043,26 +1152,32 @@ class ApproovService { // check the status of Approov token fetch if (fetchResult.tokenFetchStatus == _TokenFetchStatus.SUCCESS) { // we successfully obtained a token so add it to the header for the request - request.headers.set(_approovTokenHeader, _approovTokenPrefix + fetchResult.token, preserveHeaderCase: true); + request.headers.set( + _approovTokenHeader, _approovTokenPrefix + fetchResult.token, + preserveHeaderCase: true); } else if ((fetchResult.tokenFetchStatus == _TokenFetchStatus.NO_NETWORK) || (fetchResult.tokenFetchStatus == _TokenFetchStatus.POOR_NETWORK) || (fetchResult.tokenFetchStatus == _TokenFetchStatus.MITM_DETECTED)) { // we are unable to get an Approov token due to network conditions so the request can // be retried by the user later - unless overridden if (!_proceedOnNetworkFail) - throw new ApproovNetworkException("Approov token fetch for $host: ${fetchResult.tokenFetchStatus.name}"); - } else if ((fetchResult.tokenFetchStatus != _TokenFetchStatus.NO_APPROOV_SERVICE) && + throw new ApproovNetworkException( + "Approov token fetch for $host: ${fetchResult.tokenFetchStatus.name}"); + } else if ((fetchResult.tokenFetchStatus != + _TokenFetchStatus.NO_APPROOV_SERVICE) && (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNKNOWN_URL) && (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNPROTECTED_URL)) { // we have failed to get an Approov token with a more serious permanent error - throw ApproovException("Approov token fetch for $host: ${fetchResult.tokenFetchStatus.name}"); + throw ApproovException( + "Approov token fetch for $host: ${fetchResult.tokenFetchStatus.name}"); } // we only continue additional processing if we had a valid status from Approov, to prevent additional delays // by trying to fetch from Approov again and this also protects against header substiutions in domains not // protected by Approov and therefore potentially subject to a MitM if ((fetchResult.tokenFetchStatus != _TokenFetchStatus.SUCCESS) && - (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNPROTECTED_URL)) return; + (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNPROTECTED_URL)) + return; // we now deal with any header substitutions, which may require further fetches but these // should be using cached results @@ -1070,7 +1185,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 +1216,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,35 +1226,283 @@ 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 @@ -1181,7 +1547,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 +1558,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 +1579,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 +1626,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 +1658,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 +1676,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 +1778,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 +1794,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 +1807,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 +1864,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 +1881,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 +1890,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 +1899,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 +1936,7 @@ class _ApproovHttpClientRequest implements HttpClientRequest { @override Future addStream(Stream> stream) async { + _hasStreamBody = true; await _updateRequestIfRequired(); return _delegateRequest.addStream(stream); } @@ -1587,20 +2024,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 +2066,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); } @@ -1671,7 +2113,8 @@ class ApproovHttpClient implements HttpClient { // wait on the Approov token fetching to complete - but note we do not fail if a token fetch was not possible _TokenFetchResult fetchResult = await futureApproovToken; - tokenFinishTime = stopWatch.elapsedMilliseconds - tokenStartTime - certStartTime; + tokenFinishTime = + stopWatch.elapsedMilliseconds - tokenStartTime - certStartTime; Log.d( "$TAG: $isolate pinning setup fetch token for ${url.host}: ${fetchResult.tokenFetchStatus.name}, certStart ${certStartTime}ms, tokenStart ${tokenStartTime}ms, tokenFinish ${tokenFinishTime}ms"); @@ -1679,7 +2122,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 @@ -1692,7 +2136,8 @@ class ApproovHttpClient implements HttpClient { (fetchResult.tokenFetchStatus != _TokenFetchStatus.UNKNOWN_URL)) { // perform another attempted token fetch fetchResult = await ApproovService._fetchApproovToken(url.host); - Log.d("$TAG: $isolate pinning setup retry fetch token for ${url.host}: ${fetchResult.tokenFetchStatus.name}"); + Log.d( + "$TAG: $isolate pinning setup retry fetch token for ${url.host}: ${fetchResult.tokenFetchStatus.name}"); // if we are forced to update pins then this likely means that no pins were ever fetched and in this // case we must force a no connection so that another fetch can be tried again. This is because @@ -1700,7 +2145,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 +2175,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 +2214,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 +2259,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 +2289,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 +2301,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 +2325,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 +2418,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 +2432,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 +2445,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 +2504,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..130207b --- /dev/null +++ b/lib/src/structured_fields.dart @@ -0,0 +1,629 @@ +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) { + final scaled = value * 1000; + final rounded = scaled.round(); + if ((scaled - rounded).abs() > 1e-9) { + // Enforce the three-decimal fixed scale defined by Structured Fields decimals. + throw SfFormatException('Decimals must have at most three fractional digits: $value'); + } + return SfDecimal._checked(rounded); + } + + /// 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); + } + + 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/test/approov_http_client_test.dart b/test/approov_http_client_test.dart index 32c7b69..5984d05 100644 --- a/test/approov_http_client_test.dart +++ b/test/approov_http_client_test.dart @@ -1,20 +1,265 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:approov_service_flutter_httpclient/approov_service_flutter_httpclient.dart'; +import 'package:crypto/crypto.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; 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_test.dart b/test/structured_fields_test.dart new file mode 100644 index 0000000..4016a0d --- /dev/null +++ b/test/structured_fields_test.dart @@ -0,0 +1,146 @@ +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'); + }); + + 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())); + }); + }); +}