diff --git a/.gitignore b/.gitignore index e623fb8..b097783 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,12 @@ captures/ # OSX filesystem .DS_Store +# Generated documentation artifacts +approov-service/docs/*.jar + +# Local tool state +.wrangler/ + # Pods Podfile.lock Pods/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e41c382 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this package will be documented in this file. + +The format is based on Keep a Changelog and this project adheres to Semantic Versioning. + +## [Unreleased] + +### Added +- `ApproovServiceMutator` support to centralize decision points in the `HttpsURLConnection` service flow. +- `USAGE.md`, `REFERENCE.md`, and `CHANGELOG.md` at the repository root. +- `setUseApproovStatusIfNoToken` support for propagating fetch status in the Approov token header when no token is available. +- `Approov-TraceID` configuration helpers. +- Configurable query parameter substitution APIs. +- `addApproovToConnection(HttpsURLConnection)` for flows that need to continue with a wrapped connection. + +### Changed +- `ApproovService` now routes request-preparation decisions through the service mutator. +- `addApproov(HttpsURLConnection)` preserves the original in-place API, while `addApproovToConnection(HttpsURLConnection)` supports mutator-driven signing, optional URL substitution, and deferred body-aware processing. +- Message signing now supports the `HttpsURLConnection` request path, including optional body digest generation when request buffering is used. + +### Deprecated +- `ApproovInterceptorExtensions` in favor of `ApproovServiceMutator`. +- `setProceedOnNetworkFail()` and `getProceedOnNetworkFail()` in favor of `ApproovServiceMutator`. +- `getMessageSignature()` in favor of `getAccountMessageSignature()`. diff --git a/README.md b/README.md index 0f6fe8f..bf8ae51 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,17 @@ -# Approov Service for HttpsUrlConnection +# Approov Service for HttpsURLConnection -A wrapper for the [Approov SDK](https://github.com/approov/approov-android-sdk) to enable easy integration when using [`HttpsUrlConnection`](https://developer.android.com/reference/javax/net/ssl/HttpsURLConnection) for making the API calls that you wish to protect with Approov. In order to use this you will need a trial or paid [Approov](https://www.approov.io) account. +A wrapper for the [Approov SDK](https://github.com/approov/approov-android-sdk) to enable easy integration when using [`HttpsURLConnection`](https://developer.android.com/reference/javax/net/ssl/HttpsURLConnection) for making the API calls that you wish to protect with Approov. In order to use this you will need a trial or paid [Approov](https://www.approov.io) account. -Please follow the [Quickstart](https://github.com/approov/quickstart-android-java-httpsurlconn) for instructions on usage. +Please see the [Quickstart](https://github.com/approov/quickstart-android-java-httpsurlconn/blob/master/README.md) for example integration. + +# Changelog + +Please see the [CHANGELOG.md](CHANGELOG.md) for more information on the changes in each version. + +# Reference + +Please see the [REFERENCE.md](REFERENCE.md) for more information on the Approov Service for HttpsURLConnection. + +# Usage + +Please see the [USAGE.md](USAGE.md) for more information on how to use this wrapper. diff --git a/REFERENCE.md b/REFERENCE.md new file mode 100644 index 0000000..f927015 --- /dev/null +++ b/REFERENCE.md @@ -0,0 +1,360 @@ +# Reference + +This provides a reference for the main static methods defined on `ApproovService`. These are available if you import: + +**Java** +```java +import io.approov.service.httpsurlconn.ApproovService; +``` + +Various methods may throw an `ApproovException` if there is a problem. The method `getMessage()` provides a descriptive message. + +If a method throws an `ApproovNetworkException`, a user-initiated retry should be allowed. + +If a method throws an `ApproovRejectionException`, the app failed attestation. Additional methods `getARC()` and `getRejectionReasons()` provide more detail when available. + +## initialize + +Initializes the Approov SDK and enables the Approov features. + +```java +void initialize(Context context, String config) +``` + +The application context must be provided using the `context` parameter. It is possible to pass an empty `config` string to indicate that no initialization is required. Only do this if you are also using a different Approov service layer in your app and that layer initializes the shared SDK first. + +## setServiceMutator + +Sets the `ApproovServiceMutator` instance used to customize request preparation and attestation handling. + +```java +void setServiceMutator(ApproovServiceMutator mutator) +``` + +Passing `null` restores the default behavior. + +## getServiceMutator + +Gets the currently active mutator. + +```java +ApproovServiceMutator getServiceMutator() +``` + +## setApproovInterceptorExtensions + +Deprecated compatibility alias for `setServiceMutator`. + +```java +void setApproovInterceptorExtensions(ApproovServiceMutator mutator) +``` + +## getApproovInterceptorExtensions + +Deprecated compatibility alias for `getServiceMutator`. + +```java +ApproovServiceMutator getApproovInterceptorExtensions() +``` + +## setProceedOnNetworkFail + +If `proceed` is `true` then request preparation may continue when it is not possible to obtain an Approov token due to a networking failure. + +```java +void setProceedOnNetworkFail(boolean proceed) +``` + +Deprecated: use `setServiceMutator` instead to control this behavior. + +## getProceedOnNetworkFail + +Gets the legacy proceed-on-network-failure flag. + +```java +boolean getProceedOnNetworkFail() +``` + +Deprecated: use `setServiceMutator` instead to control this behavior. + +## setUseApproovStatusIfNoToken + +If `shouldUse` is `true` then the Approov fetch status, for example `NO_NETWORK`, is used as the token header value if the actual token fetch fails or returns an empty token. + +```java +void setUseApproovStatusIfNoToken(boolean shouldUse) +``` + +## getUseApproovStatusIfNoToken + +Gets the current status-as-token behavior flag. + +```java +boolean getUseApproovStatusIfNoToken() +``` + +## setDevKey + +Sets a development key in order to force the app to pass attestation in a test environment. + +```java +void setDevKey(String devKey) throws ApproovException +``` + +## setApproovHeader + +Sets the header that carries the Approov token and an optional prefix string such as `Bearer `. + +```java +void setApproovHeader(String header, String prefix) +``` + +## getApproovTokenHeader + +Gets the header currently used for the Approov token. + +```java +String getApproovTokenHeader() +``` + +## getApproovTokenPrefix + +Gets the prefix currently used before the Approov token value. + +```java +String getApproovTokenPrefix() +``` + +## setApproovTraceIDHeader + +Sets the header used to transmit any optional Approov TraceID debug value. + +```java +void setApproovTraceIDHeader(String header) +``` + +Passing `null` disables the TraceID header. + +## getApproovTraceIDHeader + +Gets the header currently used for the optional Approov TraceID. + +```java +String getApproovTraceIDHeader() +``` + +## setBindingHeader + +Sets a binding header used for [token binding](https://approov.io/docs/latest/approov-usage-documentation/#token-binding). + +```java +void setBindingHeader(String header) +``` + +## addSubstitutionHeader + +Adds a header that should be subject to secure string substitution. + +```java +void addSubstitutionHeader(String header, String requiredPrefix) +``` + +## removeSubstitutionHeader + +Removes a header previously added using `addSubstitutionHeader`. + +```java +void removeSubstitutionHeader(String header) +``` + +## getSubstitutionHeaders + +Gets the currently configured substitution headers. + +```java +Map getSubstitutionHeaders() +``` + +## addSubstitutionQueryParam + +Adds a query parameter key that should be subject to secure string substitution. + +```java +void addSubstitutionQueryParam(String key) +``` + +## removeSubstitutionQueryParam + +Removes a query parameter key previously added using `addSubstitutionQueryParam`. + +```java +void removeSubstitutionQueryParam(String key) +``` + +## getSubstitutionQueryParams + +Gets the currently configured substitution query parameters. + +```java +Map getSubstitutionQueryParams() +``` + +## addExclusionURLRegex + +Adds an exclusion URL regular expression. Matching URLs are not subject to Approov protection. + +```java +void addExclusionURLRegex(String urlRegex) +``` + +## removeExclusionURLRegex + +Removes an exclusion URL regular expression previously added using `addExclusionURLRegex`. + +```java +void removeExclusionURLRegex(String urlRegex) +``` + +## prefetch + +Starts a background Approov fetch operation early so a later token or secure string fetch may use cached data. + +```java +void prefetch() +``` + +## precheck + +Performs a precheck to determine if the app will pass attestation. + +```java +void precheck() throws ApproovException +``` + +This may require network access and should not be called from the UI thread. + +## getDeviceID + +Gets the device ID used by Approov to identify the current app installation. + +```java +String getDeviceID() throws ApproovException +``` + +## setDataHashInToken + +Directly sets the data hash to be included in subsequently fetched Approov tokens. + +```java +void setDataHashInToken(String data) throws ApproovException +``` + +This is an alternative to using `setBindingHeader`; you should not use both at the same time. + +## fetchToken + +Performs an Approov token fetch for the given URL. + +```java +String fetchToken(String url) throws ApproovException +``` + +Use this when it is not possible to use `addApproov(...)` or `addApproovToConnection(...)` to prepare the request automatically. + +## getMessageSignature + +Deprecated alias for `getAccountMessageSignature`. + +```java +String getMessageSignature(String message) throws ApproovException +``` + +## getAccountMessageSignature + +Gets the account message signature for the given message. + +```java +String getAccountMessageSignature(String message) throws ApproovException +``` + +## getInstallMessageSignature + +Gets the install message signature for the given message. + +```java +String getInstallMessageSignature(String message) throws ApproovException +``` + +## fetchSecureString + +Fetches a secure string with the given `key`. If `newDef` is not `null` then the string definition is updated for the current app installation. + +```java +String fetchSecureString(String key, String newDef) throws ApproovException +``` + +## fetchCustomJWT + +Fetches a custom JWT with the given marshaled JSON payload. + +```java +String fetchCustomJWT(String payload) throws ApproovException +``` + +## getLastARC + +Obtains the last Attestation Response Code, provided a network request to the Approov servers has succeeded. + +```java +String getLastARC() +``` + +This returns an empty string if no suitable ARC is available. + +## setInstallAttrsInToken + +Sets an install attributes token to be sent to the server and associated with this app installation for future token fetches. + +```java +void setInstallAttrsInToken(String attrs) throws ApproovException +``` + +## addApproov + +Prepares an `HttpsURLConnection` request in place by adding the Approov token header, applying header substitutions, applying pinning, and invoking the configured mutator when a wrapper is not required. + +```java +void addApproov(HttpsURLConnection request) throws ApproovException +``` + +This preserves the original binary-compatible API. Use `addApproovToConnection(...)` for query parameter substitution or deferred body-aware processing. + +## addApproovToConnection + +Prepares an `HttpsURLConnection` request and returns the connection reference that should be used for the network call. + +```java +HttpsURLConnection addApproovToConnection(HttpsURLConnection request) throws ApproovException +``` + +In the common case this is the same instance that was passed in. If configured query substitutions change the effective URL, or if deferred body-aware processing is required, then a wrapped connection is returned instead. + +## substituteQueryParams + +Applies all configured query parameter substitutions to the supplied URL. + +```java +URL substituteQueryParams(URL url) throws ApproovException +``` + +Since this modifies the URL itself, it must be done before opening the `HttpsURLConnection`. + +## substituteQueryParam + +Substitutes a single query parameter in the supplied URL. + +```java +URL substituteQueryParam(URL url, String queryParameter) throws ApproovException +``` + +Since this modifies the URL itself, it must be done before opening the `HttpsURLConnection`. diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..65eef45 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,260 @@ +# Usage + +This document describes the features and functionality of the Approov Service for `HttpsURLConnection`. It provides details on how to interact with the service layer and customize its behavior to suit your application's needs, specifically through the `ApproovServiceMutator`. For a basic integration example, please refer to the [Quickstart guide](https://github.com/approov/quickstart-android-java-httpsurlconn/blob/master/README.md). + +# Approov Service Mutator + +The `ApproovServiceMutator` allows you to customize the behavior of the Approov `HttpsURLConnection` layer at key points in request preparation. You can override specific methods to tailor the handling of attestations and requests while retaining the default behavior for other cases. + +## Why use a mutator + +- Centralize app-specific policy without forking the service layer. +- Add telemetry on rejections or network failures. +- Skip Approov processing for health checks or local endpoints. +- Customize pinning decisions per request. +- Adjust behavior when token or secure string fetches fail. + +## Default behavior + +By default, the `ApproovService` prepares requests based on the attestation status. It relies on the underlying SDK to provide a proof of attestation, which is a cryptographically signed JWT token. Requesting this attestation typically returns the token immediately; however, a network connection to the Approov cloud is required upon app launch or when the token is nearing expiration. Note that the SDK only knows if an attestation token has been obtained; it cannot determine if the token is valid, because validity is checked by your backend. The default behavior is described in more detail in the official documentation section [Approov Token Fetch Results](https://approov.io/docs/latest/approov-usage-documentation/#approov-token-fetch-results) and is summarized in the table below: + +| Approov Fetch Status | Action | Result | +| :--- | :--- | :--- | +| **Success** | Proceed | The request is sent with the `Approov-Token`. | +| **No Network / Poor Network** | Throw Exception | An `ApproovNetworkException` is thrown. The request should be retried. | +| **Rejection** | Throw Exception | An `ApproovRejectionException` is thrown. The request is marked as rejected. | +| **No Approov Service / Unknown URL / Unprotected URL** | Proceed | The request is sent without an `Approov-Token`. | + +## Customizing request handling with mutators + +You may want to modify this behavior to suit specific app requirements. A common use case is handling `NO_APPROOV_SERVICE` statuses differently. + +### Prevent access without a token + +The standard behavior for statuses like `NO_APPROOV_SERVICE` is to proceed with the request without adding an Approov token. This might occur, for example, if a device cannot connect to the Approov cloud due to a restricted network environment. You may wish to prevent this behavior to ensure that only requests with valid proof of attestation reach your backend API, allowing you to explicitly handle this case within your application. + +You can use a mutator to enforce this policy by throwing an error for such statuses. + +```java +import com.criticalblue.approovsdk.Approov; + +import io.approov.service.httpsurlconn.ApproovNetworkException; +import io.approov.service.httpsurlconn.ApproovServiceMutator; + +public class EnforceTokenMutator implements ApproovServiceMutator { + @Override + public boolean handleInterceptorFetchTokenResult(Approov.TokenFetchResult approovResults, String url) + throws io.approov.service.httpsurlconn.ApproovException { + if (approovResults.getStatus() == Approov.TokenFetchStatus.NO_APPROOV_SERVICE) { + throw new ApproovNetworkException( + approovResults.getStatus(), + "Network issue. Will attempt connection again." + ); + } + return ApproovServiceMutator.DEFAULT.handleInterceptorFetchTokenResult(approovResults, url); + } +} +``` + +### Allow access without a token + +Conversely, if the device could not obtain proof of attestation, for example because of a `POOR_NETWORK` or `NO_NETWORK` response from the SDK, the default behavior is to cancel the request to your API. However, you might prefer to let the request attempt the connection to your backend without the Approov token to allow for server-side handling. + +To implement this, check for `POOR_NETWORK` and return `false`, which proceeds without adding the token. + +```java +if (approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) { + return false; +} +``` + +### Add custom headers using a mutator + +You can override `handleInterceptorProcessedRequest` to add additional headers or modify the request after Approov has processed it. This is useful for adding app metadata or other diagnostics. + +```java +import javax.net.ssl.HttpsURLConnection; + +import io.approov.service.httpsurlconn.ApproovRequestMutations; +import io.approov.service.httpsurlconn.ApproovServiceMutator; + +public class MyMutator implements ApproovServiceMutator { + private final ApproovServiceMutator signer = ApproovServiceMutator.DEFAULT; + + @Override + public HttpsURLConnection handleInterceptorProcessedRequest( + HttpsURLConnection request, + ApproovRequestMutations changes + ) throws io.approov.service.httpsurlconn.ApproovException { + HttpsURLConnection processed = signer.handleInterceptorProcessedRequest(request, changes); + processed.setRequestProperty("Client-Platform", "android"); + return processed; + } +} +``` + +## How to use a custom mutator in your application + +Create a mutator, then install it once during app startup, for example in your `Application` class or initialization path. + +```java +import io.approov.service.httpsurlconn.ApproovService; +import io.approov.service.httpsurlconn.ApproovServiceMutator; + +public final class Example { + public static void install() { + ApproovService.setServiceMutator(new MyMutator()); + } +} +``` + +## Preparing `HttpsURLConnection` requests + +Requests are prepared by passing a connection through `ApproovService.addApproovToConnection(...)` before the request is sent: + +```java +URL url = new URL("https://api.example.com/shapes"); +HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); +connection.setRequestMethod("GET"); +connection = ApproovService.addApproovToConnection(connection); +``` + +You should always continue using the returned connection reference. In the common case this is the same instance that you passed in. If configured query substitutions change the effective URL then a wrapped connection is returned. + +If you need to substitute configured query parameters before opening the connection, you can do so explicitly: + +```java +URL url = new URL("https://api.example.com/shapes?api_key=shapes-key"); +URL substitutedUrl = ApproovService.substituteQueryParams(url); +HttpsURLConnection connection = (HttpsURLConnection) substitutedUrl.openConnection(); +connection = ApproovService.addApproovToConnection(connection); +``` + +## Message signing + +It is possible to sign HTTP requests using Approov to ensure message integrity and authenticity. There are two types of message signing available: + +1. [Installation Message Signing](https://ext.approov.io/docs/latest/approov-usage-documentation/#installation-message-signing): Uses an installation-specific key held by the device to sign requests. +2. [Account Message Signing](https://ext.approov.io/docs/latest/approov-usage-documentation/#account-message-signing): Uses a shared account-specific secret key delivered to the SDK only upon successful attestation. + +Message signing is not enabled unless you opt in. Even if you install `ApproovDefaultMessageSigning`, a signature is only added when: + +- The request already has an `Approov-Token` header, meaning Approov processing ran. +- A `SignatureParametersFactory` is configured for the request host. + +### Enable with default settings + +```java +import io.approov.service.httpsurlconn.ApproovDefaultMessageSigning; +import io.approov.service.httpsurlconn.ApproovService; + +ApproovDefaultMessageSigning.SignatureParametersFactory factory = + ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory(); +ApproovDefaultMessageSigning signer = + new ApproovDefaultMessageSigning().setDefaultFactory(factory); +ApproovService.setServiceMutator(signer); +``` + +### Customize behavior + +```java +import io.approov.service.httpsurlconn.ApproovDefaultMessageSigning; + +ApproovDefaultMessageSigning.SignatureParametersFactory factory = + ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory() + .setUseAccountMessageSigning() + .setAddCreated(true) + .setExpiresLifetime(60); + +ApproovDefaultMessageSigning signer = new ApproovDefaultMessageSigning() + .setDefaultFactory(factory) + .putHostFactory("api.example.com", factory); + +ApproovService.setServiceMutator(signer); +``` + +Account message signing must also be enabled on the Approov account before the +SDK can generate account signatures. See the Approov CLI documentation for the +`approov secret -messageSigningKey change` command. + +To disable signing, remove the signer using `setServiceMutator(null)`, or return `null` from your factory for hosts you want to skip. + +## Token binding + +[Token Binding](https://ext.approov.io/docs/latest/approov-usage-documentation/#token-binding) allows you to bind the Approov token to a specific piece of data, such as an OAuth token or user session identifier. The `ApproovService` calculates a hash of the binding data locally and includes this hash in the Approov token claims. The actual binding data is never sent to the Approov cloud service; only the hash is transmitted. + +To set up token binding, specify a header name. The value of this header in your requests will be used for the binding. + +```java +ApproovService.setBindingHeader("Authorization"); +``` + +If the value of the binding header changes, the SDK automatically invalidates the current Approov token and fetches a new one with the updated binding on the next request. + +## Real-world example + +This example demonstrates how to customize `ApproovServiceMutator` to apply different options to requests based on hostname. + +```java +import java.net.URI; +import java.util.Set; + +import javax.net.ssl.HttpsURLConnection; + +import com.criticalblue.approovsdk.Approov; + +import io.approov.service.httpsurlconn.ApproovDefaultMessageSigning; +import io.approov.service.httpsurlconn.ApproovRequestMutations; +import io.approov.service.httpsurlconn.ApproovServiceMutator; + +public class CustomLogic implements ApproovServiceMutator { + private final ApproovServiceMutator signer = new ApproovDefaultMessageSigning(); + private final Set protectedHosts = Set.of("api.example.com"); + private final Set allowOfflineForHosts = Set.of("status.example.com"); + private final Set skipPinningHosts = Set.of("metrics.example.com"); + + @Override + public boolean handleInterceptorShouldProcessConnection(HttpsURLConnection request) + throws io.approov.service.httpsurlconn.ApproovException { + String host = request.getURL().getHost(); + if (!protectedHosts.contains(host)) { + return false; + } + return ApproovServiceMutator.DEFAULT.handleInterceptorShouldProcessConnection(request); + } + + @Override + public boolean handleInterceptorFetchTokenResult(Approov.TokenFetchResult approovResults, String url) + throws io.approov.service.httpsurlconn.ApproovException { + String host = URI.create(url).getHost(); + if ((approovResults.getStatus() == Approov.TokenFetchStatus.NO_NETWORK + || approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) + && allowOfflineForHosts.contains(host)) { + return false; + } + return ApproovServiceMutator.DEFAULT.handleInterceptorFetchTokenResult(approovResults, url); + } + + @Override + public HttpsURLConnection handleInterceptorProcessedRequest( + HttpsURLConnection request, + ApproovRequestMutations changes + ) throws io.approov.service.httpsurlconn.ApproovException { + HttpsURLConnection processed = signer.handleInterceptorProcessedRequest(request, changes); + processed.setRequestProperty("X-Client-Platform", "android"); + return processed; + } + + @Override + public boolean handlePinningShouldProcessRequest(java.net.HttpURLConnection request) { + String host = request.getURL().getHost(); + return !skipPinningHosts.contains(host); + } +} +``` + +## Tips + +- Keep mutator logic fast and side-effect safe. These hooks run on the request path. +- Use `ApproovServiceMutator.DEFAULT` to preserve the existing behavior and layer your changes on top. +- If you override multiple hooks, keep them focused so they remain easy to test and maintain. diff --git a/approov-service/build.gradle b/approov-service/build.gradle index 7d9eec2..a8364be 100644 --- a/approov-service/build.gradle +++ b/approov-service/build.gradle @@ -15,8 +15,8 @@ android { compileSdkVersion 30 defaultConfig { - minSdkVersion 21 - targetSdkVersion 28 + minSdkVersion 23 + targetSdkVersion 34 } buildTypes { @@ -26,13 +26,24 @@ android { } } compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + testOptions { + unitTests.returnDefaultValues = true + } } dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'io.approov:approov-android-sdk:3.5.1' -} + // Bouncycastle for ASN.1 parsing + implementation 'org.bouncycastle:bcprov-jdk15on:1.70' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.14.2' +} diff --git a/approov-service/docs/javadoc.jar b/approov-service/docs/javadoc.jar deleted file mode 100644 index f6317db..0000000 Binary files a/approov-service/docs/javadoc.jar and /dev/null differ diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovBufferedHttpsURLConnection.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovBufferedHttpsURLConnection.java new file mode 100644 index 0000000..501fa83 --- /dev/null +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovBufferedHttpsURLConnection.java @@ -0,0 +1,678 @@ +// +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package io.approov.service.httpsurlconn; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ProtocolException; +import java.net.URL; +import java.security.Permission; +import java.security.Principal; +import java.security.cert.Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSocketFactory; + +/** + * A delegating {@link HttpsURLConnection} that buffers request body bytes so the + * final processed-request callback can run immediately before the real network + * request is sent. This allows the httpsurlconn service layer to finish any + * URL substitutions on the effective connection and include the actual request + * body bytes in message signing. + */ +final class ApproovBufferedHttpsURLConnection extends HttpsURLConnection { + private final HttpsURLConnection originalRequest; + private final ApproovService.PreparedRequestData preparedRequestData; + + private URL configuredUrl; + private String requestMethod; + private int connectTimeout; + private int readTimeout; + private boolean doInput; + private boolean doOutput; + private boolean allowUserInteraction; + private boolean useCaches; + private boolean defaultUseCaches; + private long ifModifiedSince; + private boolean instanceFollowRedirects; + private int fixedLengthStreamingMode = -1; + private long fixedLengthStreamingModeLong = -1L; + private int chunkLength = -1; + private SSLSocketFactory sslSocketFactory; + private HostnameVerifier hostnameVerifier; + private Map> requestProperties; + + private ByteArrayOutputStream requestBodyBuffer; + private OutputStream bufferedOutputStream; + + private HttpsURLConnection networkRequest; + private boolean processedRequestApplied; + private boolean requestBodyTransmitted; + + /** + * Constructs a new buffered connection wrapper from a request that has + * already had all non-signing Approov processing applied. + * + * @param request the request to wrap + * @param preparedRequestData the precomputed token/header substitution result + * @param queryResult the precomputed query substitution result + */ + ApproovBufferedHttpsURLConnection( + HttpsURLConnection request, + ApproovService.PreparedRequestData preparedRequestData, + ApproovService.QuerySubstitutionResult queryResult + ) { + super(queryResult.url); + this.originalRequest = request; + this.preparedRequestData = preparedRequestData; + this.configuredUrl = queryResult.url; + this.requestMethod = request.getRequestMethod(); + this.connectTimeout = request.getConnectTimeout(); + this.readTimeout = request.getReadTimeout(); + this.doInput = request.getDoInput(); + this.doOutput = request.getDoOutput(); + this.allowUserInteraction = request.getAllowUserInteraction(); + this.useCaches = request.getUseCaches(); + this.defaultUseCaches = request.getDefaultUseCaches(); + this.ifModifiedSince = request.getIfModifiedSince(); + this.instanceFollowRedirects = request.getInstanceFollowRedirects(); + this.sslSocketFactory = request.getSSLSocketFactory(); + this.hostnameVerifier = request.getHostnameVerifier(); + this.requestProperties = copyRequestProperties(request.getRequestProperties()); + + if (!queryResult.substitutedQueryKeys.isEmpty()) { + preparedRequestData.changes.setSubstitutionQueryParamResults( + queryResult.originalURL, + queryResult.substitutedQueryKeys + ); + } + } + + /** + * Gets the buffered request body bytes, or null if no body has been written. + * + * @return a copy of the buffered request body + */ + byte[] getBufferedRequestBody() { + if (requestBodyBuffer == null) { + return null; + } + return requestBodyBuffer.toByteArray(); + } + + private static Map> copyRequestProperties(Map> headers) { + Map> copy = new LinkedHashMap<>(); + if (headers == null) { + return copy; + } + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() == null) { + continue; + } + List values = entry.getValue(); + if (values == null) { + copy.put(entry.getKey(), new ArrayList<>()); + } else { + copy.put(entry.getKey(), new ArrayList<>(values)); + } + } + return copy; + } + + private void ensureMutable() { + if (processedRequestApplied || networkRequest != null) { + throw new IllegalStateException("Cannot modify request after network processing has started"); + } + } + + private void setHeaderValue(String key, String value) { + List values = new ArrayList<>(1); + values.add(value); + requestProperties.put(key, values); + } + + private void addHeaderValue(String key, String value) { + List values = requestProperties.get(key); + if (values == null) { + values = new ArrayList<>(); + requestProperties.put(key, values); + } + values.add(value); + } + + private void applyProcessedRequestIfNeeded() throws IOException { + if (processedRequestApplied) { + return; + } + + if (!preparedRequestData.invokeProcessedCallback) { + processedRequestApplied = true; + return; + } + + try { + HttpsURLConnection processedRequest = preparedRequestData.mutator.handleInterceptorProcessedRequest( + this, + preparedRequestData.changes + ); + if ((processedRequest != null) && (processedRequest != this)) { + synchronizeFromReturnedRequest(processedRequest); + } + processedRequestApplied = true; + } catch (ApproovException e) { + throw new IOException("Approov processed request callback failed", e); + } + } + + private void synchronizeFromReturnedRequest(HttpsURLConnection processedRequest) { + this.configuredUrl = processedRequest.getURL(); + this.requestMethod = processedRequest.getRequestMethod(); + this.connectTimeout = processedRequest.getConnectTimeout(); + this.readTimeout = processedRequest.getReadTimeout(); + this.doInput = processedRequest.getDoInput(); + this.doOutput = processedRequest.getDoOutput(); + this.allowUserInteraction = processedRequest.getAllowUserInteraction(); + this.useCaches = processedRequest.getUseCaches(); + this.defaultUseCaches = processedRequest.getDefaultUseCaches(); + this.ifModifiedSince = processedRequest.getIfModifiedSince(); + this.instanceFollowRedirects = processedRequest.getInstanceFollowRedirects(); + this.sslSocketFactory = processedRequest.getSSLSocketFactory(); + this.hostnameVerifier = processedRequest.getHostnameVerifier(); + this.requestProperties = copyRequestProperties(processedRequest.getRequestProperties()); + } + + private HttpsURLConnection createNetworkRequestIfNeeded() throws IOException { + if (networkRequest != null) { + return networkRequest; + } + + HttpsURLConnection targetRequest; + if (configuredUrl.toString().equals(originalRequest.getURL().toString())) { + targetRequest = originalRequest; + } else { + targetRequest = (HttpsURLConnection) configuredUrl.openConnection(); + } + + targetRequest.setConnectTimeout(connectTimeout); + targetRequest.setReadTimeout(readTimeout); + targetRequest.setDoInput(doInput); + targetRequest.setDoOutput(doOutput); + targetRequest.setAllowUserInteraction(allowUserInteraction); + targetRequest.setUseCaches(useCaches); + targetRequest.setDefaultUseCaches(defaultUseCaches); + targetRequest.setIfModifiedSince(ifModifiedSince); + targetRequest.setInstanceFollowRedirects(instanceFollowRedirects); + if (sslSocketFactory != null) { + targetRequest.setSSLSocketFactory(sslSocketFactory); + } + if (hostnameVerifier != null) { + targetRequest.setHostnameVerifier(hostnameVerifier); + } + try { + targetRequest.setRequestMethod(requestMethod); + } catch (ProtocolException e) { + throw new IOException("Failed to set request method", e); + } + if (fixedLengthStreamingModeLong >= 0) { + targetRequest.setFixedLengthStreamingMode(fixedLengthStreamingModeLong); + } else if (fixedLengthStreamingMode >= 0) { + targetRequest.setFixedLengthStreamingMode(fixedLengthStreamingMode); + } else if (chunkLength >= 0) { + targetRequest.setChunkedStreamingMode(chunkLength); + } + for (Map.Entry> entry : requestProperties.entrySet()) { + List values = entry.getValue(); + if (values == null || values.isEmpty()) { + continue; + } + targetRequest.setRequestProperty(entry.getKey(), values.get(0)); + for (int i = 1; i < values.size(); i++) { + targetRequest.addRequestProperty(entry.getKey(), values.get(i)); + } + } + + networkRequest = targetRequest; + return networkRequest; + } + + private void ensureRequestReadyForNetwork() throws IOException { + applyProcessedRequestIfNeeded(); + createNetworkRequestIfNeeded(); + } + + private void ensureRequestBodyTransmitted() throws IOException { + ensureRequestReadyForNetwork(); + if (requestBodyTransmitted) { + return; + } + + if (doOutput || requestBodyBuffer != null) { + OutputStream outputStream = networkRequest.getOutputStream(); + if (requestBodyBuffer != null) { + requestBodyBuffer.writeTo(outputStream); + } + outputStream.close(); + } + + requestBodyTransmitted = true; + connected = true; + } + + private void ensureResponseReady() throws IOException { + ensureRequestBodyTransmitted(); + } + + @Override + public String getCipherSuite() { + try { + ensureResponseReady(); + } catch (IOException e) { + throw new IllegalStateException("Failed to prepare network request", e); + } + return networkRequest.getCipherSuite(); + } + + @Override + public Certificate[] getLocalCertificates() { + try { + ensureResponseReady(); + } catch (IOException e) { + throw new IllegalStateException("Failed to prepare network request", e); + } + return networkRequest.getLocalCertificates(); + } + + @Override + public Certificate[] getServerCertificates() throws SSLPeerUnverifiedException { + try { + ensureResponseReady(); + } catch (IOException e) { + SSLPeerUnverifiedException exception = + new SSLPeerUnverifiedException("Failed to prepare network request"); + exception.initCause(e); + throw exception; + } + return networkRequest.getServerCertificates(); + } + + @Override + public Principal getPeerPrincipal() throws SSLPeerUnverifiedException { + try { + ensureResponseReady(); + } catch (IOException e) { + SSLPeerUnverifiedException exception = + new SSLPeerUnverifiedException("Failed to prepare network request"); + exception.initCause(e); + throw exception; + } + return networkRequest.getPeerPrincipal(); + } + + @Override + public Principal getLocalPrincipal() { + try { + ensureResponseReady(); + } catch (IOException e) { + throw new IllegalStateException("Failed to prepare network request", e); + } + return networkRequest.getLocalPrincipal(); + } + + @Override + public void disconnect() { + if (networkRequest != null) { + networkRequest.disconnect(); + } else { + originalRequest.disconnect(); + } + } + + @Override + public boolean usingProxy() { + if (networkRequest != null) { + return networkRequest.usingProxy(); + } + return originalRequest.usingProxy(); + } + + @Override + public void connect() throws IOException { + ensureResponseReady(); + networkRequest.connect(); + connected = true; + } + + @Override + public URL getURL() { + return configuredUrl; + } + + @Override + public void setConnectTimeout(int timeout) { + ensureMutable(); + this.connectTimeout = timeout; + } + + @Override + public int getConnectTimeout() { + return connectTimeout; + } + + @Override + public void setReadTimeout(int timeout) { + ensureMutable(); + this.readTimeout = timeout; + } + + @Override + public int getReadTimeout() { + return readTimeout; + } + + @Override + public void setDoInput(boolean doinput) { + ensureMutable(); + this.doInput = doinput; + } + + @Override + public boolean getDoInput() { + return doInput; + } + + @Override + public void setDoOutput(boolean dooutput) { + ensureMutable(); + this.doOutput = dooutput; + } + + @Override + public boolean getDoOutput() { + return doOutput; + } + + @Override + public void setAllowUserInteraction(boolean allowuserinteraction) { + ensureMutable(); + this.allowUserInteraction = allowuserinteraction; + } + + @Override + public boolean getAllowUserInteraction() { + return allowUserInteraction; + } + + @Override + public void setUseCaches(boolean usecaches) { + ensureMutable(); + this.useCaches = usecaches; + } + + @Override + public boolean getUseCaches() { + return useCaches; + } + + @Override + public void setDefaultUseCaches(boolean defaultusecaches) { + ensureMutable(); + this.defaultUseCaches = defaultusecaches; + } + + @Override + public boolean getDefaultUseCaches() { + return defaultUseCaches; + } + + @Override + public void setIfModifiedSince(long ifmodifiedsince) { + ensureMutable(); + this.ifModifiedSince = ifmodifiedsince; + } + + @Override + public long getIfModifiedSince() { + return ifModifiedSince; + } + + @Override + public void setInstanceFollowRedirects(boolean followRedirects) { + ensureMutable(); + this.instanceFollowRedirects = followRedirects; + } + + @Override + public boolean getInstanceFollowRedirects() { + return instanceFollowRedirects; + } + + @Override + public void setRequestMethod(String method) throws ProtocolException { + ensureMutable(); + this.requestMethod = method; + } + + @Override + public String getRequestMethod() { + return requestMethod; + } + + @Override + public void setFixedLengthStreamingMode(int contentLength) { + ensureMutable(); + this.fixedLengthStreamingMode = contentLength; + this.fixedLengthStreamingModeLong = -1L; + this.chunkLength = -1; + } + + @Override + public void setFixedLengthStreamingMode(long contentLength) { + ensureMutable(); + this.fixedLengthStreamingModeLong = contentLength; + this.fixedLengthStreamingMode = -1; + this.chunkLength = -1; + } + + @Override + public void setChunkedStreamingMode(int chunklen) { + ensureMutable(); + this.chunkLength = chunklen; + this.fixedLengthStreamingMode = -1; + this.fixedLengthStreamingModeLong = -1L; + } + + @Override + public void setRequestProperty(String key, String value) { + ensureMutable(); + setHeaderValue(key, value); + } + + @Override + public void addRequestProperty(String key, String value) { + ensureMutable(); + addHeaderValue(key, value); + } + + @Override + public String getRequestProperty(String key) { + List values = requestProperties.get(key); + if (values == null || values.isEmpty()) { + return null; + } + return values.get(values.size() - 1); + } + + @Override + public Map> getRequestProperties() { + Map> copy = new LinkedHashMap<>(); + for (Map.Entry> entry : requestProperties.entrySet()) { + copy.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + return Collections.unmodifiableMap(copy); + } + + @Override + public void setSSLSocketFactory(SSLSocketFactory sf) { + ensureMutable(); + this.sslSocketFactory = sf; + } + + @Override + public SSLSocketFactory getSSLSocketFactory() { + return sslSocketFactory; + } + + @Override + public void setHostnameVerifier(HostnameVerifier v) { + ensureMutable(); + this.hostnameVerifier = v; + } + + @Override + public HostnameVerifier getHostnameVerifier() { + return hostnameVerifier; + } + + @Override + public OutputStream getOutputStream() throws IOException { + if (processedRequestApplied) { + throw new IllegalStateException("Cannot obtain the request body stream after signing has been applied"); + } + doOutput = true; + if (requestBodyBuffer == null) { + requestBodyBuffer = new ByteArrayOutputStream(); + bufferedOutputStream = new OutputStream() { + @Override + public void write(int b) { + requestBodyBuffer.write(b); + } + + @Override + public void write(byte[] b, int off, int len) { + requestBodyBuffer.write(b, off, len); + } + + @Override + public void flush() { + // nothing to flush until the real request is sent + } + + @Override + public void close() { + // keep the buffer available until the wrapped request is finalized + } + }; + } + return bufferedOutputStream; + } + + @Override + public InputStream getInputStream() throws IOException { + ensureResponseReady(); + return networkRequest.getInputStream(); + } + + @Override + public InputStream getErrorStream() { + try { + ensureResponseReady(); + return networkRequest.getErrorStream(); + } catch (IOException e) { + return null; + } + } + + @Override + public int getResponseCode() throws IOException { + ensureResponseReady(); + return networkRequest.getResponseCode(); + } + + @Override + public String getResponseMessage() throws IOException { + ensureResponseReady(); + return networkRequest.getResponseMessage(); + } + + @Override + public Permission getPermission() throws IOException { + ensureRequestReadyForNetwork(); + return networkRequest.getPermission(); + } + + @Override + public Map> getHeaderFields() { + try { + ensureResponseReady(); + } catch (IOException e) { + throw new IllegalStateException("Failed to prepare network request", e); + } + return networkRequest.getHeaderFields(); + } + + @Override + public String getHeaderField(String name) { + try { + ensureResponseReady(); + } catch (IOException e) { + throw new IllegalStateException("Failed to prepare network request", e); + } + return networkRequest.getHeaderField(name); + } + + @Override + public String getHeaderField(int n) { + try { + ensureResponseReady(); + } catch (IOException e) { + throw new IllegalStateException("Failed to prepare network request", e); + } + return networkRequest.getHeaderField(n); + } + + @Override + public String getHeaderFieldKey(int n) { + try { + ensureResponseReady(); + } catch (IOException e) { + throw new IllegalStateException("Failed to prepare network request", e); + } + return networkRequest.getHeaderFieldKey(n); + } + + @Override + public Object getContent() throws IOException { + ensureResponseReady(); + return networkRequest.getContent(); + } + + @Override + public Object getContent(Class[] classes) throws IOException { + ensureResponseReady(); + return networkRequest.getContent(classes); + } +} diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java new file mode 100644 index 0000000..048095a --- /dev/null +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java @@ -0,0 +1,707 @@ +// +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package io.approov.service.httpsurlconn; + +import android.util.Log; +import android.util.Base64; + +import org.bouncycastle.asn1.ASN1InputStream; +import org.bouncycastle.asn1.ASN1Integer; +import org.bouncycastle.asn1.ASN1Sequence; + +import java.io.IOException; +import java.math.BigInteger; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.net.ssl.HttpsURLConnection; + +import io.approov.util.http.sfv.ByteSequenceItem; +import io.approov.util.http.sfv.Dictionary; +import io.approov.util.http.sfv.ListElement; +import io.approov.util.sig.ComponentProvider; +import io.approov.util.sig.SignatureBaseBuilder; +import io.approov.util.sig.SignatureParameters; +import okio.ByteString; + + +/** + * Provides a base implementation of message signing for Approov when using + * httpsurlconn requests. This class provides mechanisms to configure and apply + * message signatures to HTTP requests based on specified parameters and + * algorithms. + */ +public class ApproovDefaultMessageSigning implements ApproovServiceMutator { + // logging tag + private static final String TAG = "ApproovMsgSign"; + + /** + * Constant for the SHA-256 digest algorithm (used for body digests). + */ + public static final String DIGEST_SHA256 = "sha-256"; + + /** + * Constant for the SHA-512 digest algorithm (used for body digests). + */ + public static final String DIGEST_SHA512 = "sha-512"; + + /** + * Constant for the ECDSA P-256 with SHA-256 algorithm (used when signing with install private key). + */ + public final static String ALG_ES256 = "ecdsa-p256-sha256"; + + /** + * Constant for the HMAC with SHA-256 algorithm (used when signing with the account signing key). + */ + public final static String ALG_HS256 = "hmac-sha256"; + + /** + * The default factory for generating signature parameters. + */ + protected SignatureParametersFactory defaultFactory; + + /** + * A map of host-specific factories for generating signature parameters. + */ + protected final Map hostFactories; + + /** + * Constructs an instance of {@code ApproovDefaultMessageSigning}. + */ + public ApproovDefaultMessageSigning() { + hostFactories = new HashMap<>(); + } + + @Override + public String toString() { + return "ApproovDefaultMessageSigning"; + } + + /** + * Sets the default factory for generating signature parameters. + * + * @param factory The factory to set as the default. + * @return The current instance for method chaining. + */ + public ApproovDefaultMessageSigning setDefaultFactory(SignatureParametersFactory factory) { + this.defaultFactory = factory; + return this; + } + + /** + * Associates a specific host with a factory for generating signature parameters. + * + * @param hostName The host name. + * @param factory The factory to associate with the host. + * @return The current instance for method chaining. + */ + public ApproovDefaultMessageSigning putHostFactory(String hostName, SignatureParametersFactory factory) { + this.hostFactories.put(hostName, factory); + return this; + } + + /** + * Builds the signature parameters for a given request. + * + * @param provider The component provider for the request. + * @param changes The request mutations to apply. + * @return The generated {@link SignatureParameters}, or {@code null} if no factory is available. + */ + protected SignatureParameters buildSignatureParameters( + HttpsURLConnectionComponentProvider provider, + ApproovRequestMutations changes + ) { + SignatureParametersFactory factory = hostFactories.get(provider.getAuthority()); + if (factory == null) { + factory = defaultFactory; + if (factory == null) { + return null; + } + } + return factory.buildSignatureParameters(provider, changes); + } + + /** + * Retrieves an install message signature for the supplied message. + * + * @param message The message to be signed. + * @return The base64-encoded ASN.1 DER signature. + * @throws ApproovException If signing is unavailable. + */ + protected String getInstallMessageSignature(String message) throws ApproovException { + return ApproovService.getInstallMessageSignature(message); + } + + /** + * Retrieves an account message signature for the supplied message. + * + * @param message The message to be signed. + * @return The base64-encoded signature. + * @throws ApproovException If signing is unavailable. + */ + protected String getAccountMessageSignature(String message) throws ApproovException { + return ApproovService.getAccountMessageSignature(message); + } + + /** + * Decodes a base64-encoded signature value. + * + * @param base64 The signature bytes encoded as base64. + * @return The decoded bytes. + */ + protected byte[] decodeBase64(String base64) { + return Base64.decode(base64, Base64.NO_WRAP); + } + + /** + * Converts one part, encoded as an ASN1Integer, of an ASN.1 DER encoded ES256 signature to a byte array of + * exactly 32 bytes. Throws IllegalArgumentException if this is not possible. + * + * @param bytesAsASN1Integer The ASN1Integer to convert. + * @return A byte array of length 32, containing the raw bytes of the signature part. + * @throws IllegalArgumentException if the ASN1Integer is not representing a 32 byte array. + */ + private static byte[] to32ByteArray(ASN1Integer bytesAsASN1Integer) { + BigInteger bytesAsBigInteger = bytesAsASN1Integer.getValue(); + byte[] bytes = bytesAsBigInteger.toByteArray(); + byte[] bytes32; + if (bytes.length < 32) { + bytes32 = new byte[32]; + System.arraycopy(bytes, 0, bytes32, 32 - bytes.length, bytes.length); + } else if (bytes.length == 32) { + bytes32 = bytes; + } else if (bytes.length == 33 && bytes[0] == 0) { + bytes32 = new byte[32]; + System.arraycopy(bytes, 1, bytes32, 0, 32); + } else { + throw new IllegalArgumentException("Not an ASN.1 DER ES256 signature part"); + } + return bytes32; + } + + /** + * Adds message signature headers to a request after the httpsurlconn + * service layer has applied its token and substitution changes. The request + * is only modified if an Approov token header was added and if there is a + * defined SignatureParametersFactory for the request host. + * + * @param request The prepared HTTP request. + * @param changes The request mutations that were applied during Approov + * processing. + * @return The processed HTTP request with the signature headers added. + * @throws ApproovException If an error occurs during processing. + */ + @Override + public HttpsURLConnection handleInterceptorProcessedRequest(HttpsURLConnection request, ApproovRequestMutations changes) throws ApproovException { + if (changes == null || changes.getTokenHeaderKey() == null) { + // the request doesn't have an Approov token, so we don't need to sign it + return request; + } + // generate and add a message signature + HttpsURLConnectionComponentProvider provider = new HttpsURLConnectionComponentProvider(request); + SignatureParameters params = buildSignatureParameters(provider, changes); + if (params == null) { + // No sig to be added to the request; return the original request. + return request; + } + + // Apply the params to get the message + SignatureBaseBuilder baseBuilder = new SignatureBaseBuilder(params, provider); + String message = baseBuilder.createSignatureBase(); + // WARNING never log the message as it contains an Approov token which provides access to your API. + + // Generate the signature + String sigId; + byte[] signature; + switch (params.getAlg()) { + case ALG_ES256: { + sigId = "install"; + String base64; + try { + base64 = getInstallMessageSignature(message); + } catch (ApproovException e) { + Log.d(TAG, "Failed to get InstallMessageSignature - skipping message signing " + e); + return request; + } + if (base64.isEmpty()) { + Log.d(TAG, "InstallMessageSignature is empty - skipping message signing"); + return request; + } + signature = decodeBase64(base64); + // decode the signature from ASN.1 DER format + try (ASN1InputStream asn1InputStream = new ASN1InputStream(signature)) { + ASN1Sequence sequence = (ASN1Sequence) asn1InputStream.readObject(); + if (sequence instanceof ASN1Sequence) { + // Combine r and s into a single byte array + byte[] rBytes = to32ByteArray((ASN1Integer) sequence.getObjectAt(0)); + byte[] sBytes = to32ByteArray((ASN1Integer) sequence.getObjectAt(1)); + signature = new byte[rBytes.length + sBytes.length]; + System.arraycopy(rBytes, 0, signature, 0, rBytes.length); + System.arraycopy(sBytes, 0, signature, rBytes.length, sBytes.length); + } else { + throw new IllegalStateException("Not an ASN1Sequence"); + } + } catch (Exception e) { + throw new IllegalStateException("Failed to decode ASN.1 DER ES256 signature", e); + } + break; + } + case ALG_HS256: { + sigId = "account"; + String base64 = getAccountMessageSignature(message); + signature = decodeBase64(base64); + break; + } + default: + throw new IllegalStateException("Unsupported algorithm identifier: " + params.getAlg()); + } + + // Calculate the signature and message descriptor headers. + Map> sigMap = new LinkedHashMap<>(); + sigMap.put(sigId, ByteSequenceItem.valueOf(signature)); + String sigHeader = Dictionary.valueOf(sigMap).serialize(); + Map> sigInputMap = new LinkedHashMap<>(); + sigInputMap.put(sigId, params.toComponentValue()); + String sigInputHeader = Dictionary.valueOf(sigInputMap).serialize(); + + // HttpURLConnection doesn't have a removeHeader function, so we use + // setRequestProperty to replace any previous values and avoid accumulating + // duplicate signature headers across retries or repeated processing. + request.setRequestProperty("Signature", sigHeader); + request.setRequestProperty("Signature-Input", sigInputHeader); + + Log.d(TAG, "Constructed Signature header: " + sigHeader); + Log.d(TAG, "Request Signature header after set: " + request.getRequestProperty("Signature")); + Log.d(TAG, "Constructed Signature-Input header: " + sigInputHeader); + + // Debugging - log the message and signature-related headers + // WARNING never log the message in production code as it contains the Approov token which allows API access + // Log.d(TAG, "Message Value - Signature Message: " + message); + // Log.d(TAG, "Message Header - Signature: " + sigHeader); + // Log.d(TAG, "Message Header Signature-Input: " + sigInputHeader); + + if (params.isDebugMode()) { + try { + MessageDigest digestBuilder = MessageDigest.getInstance("SHA-256"); + byte[] digest = digestBuilder.digest(message.getBytes(StandardCharsets.UTF_8)); + Map> digestMap = new LinkedHashMap<>(); + digestMap.put(DIGEST_SHA256, ByteSequenceItem.valueOf(digest)); + String digestHeader = Dictionary.valueOf(digestMap).serialize(); + request.setRequestProperty("Signature-Base-Digest", digestHeader); + } catch (NoSuchAlgorithmException e) { + Log.d(TAG, "Failed to get digest algorithm - no debug entry " + e); + } + } + return request; + } + + /** + * Generates a default {@link SignatureParametersFactory} with predefined settings. + * + * @return A new instance of {@link SignatureParametersFactory}. + */ + public static SignatureParametersFactory generateDefaultSignatureParametersFactory() { + return generateDefaultSignatureParametersFactory(null); + } + + /** + * Generates a default {@link SignatureParametersFactory} with optional base parameters. + * + * @param baseParametersOverride The base parameters to override, or {@code null} to use defaults. + * @return A new instance of {@link SignatureParametersFactory}. + */ + public static SignatureParametersFactory generateDefaultSignatureParametersFactory( + SignatureParameters baseParametersOverride + ) { + // default expiry seconds - must encompass worst case request retry + // time and clock skew + long defaultExpiresLifetime = 15; + SignatureParameters baseParameters; + if (baseParametersOverride != null) { + baseParameters = baseParametersOverride; + } else { + baseParameters = new SignatureParameters() + .addComponentIdentifier(ComponentProvider.DC_METHOD) + .addComponentIdentifier(ComponentProvider.DC_TARGET_URI) + ; + } + return new SignatureParametersFactory() + .setBaseParameters(baseParameters) + .setUseInstallMessageSigning() + .setAddCreated(true) + .setExpiresLifetime(defaultExpiresLifetime) + .setAddApproovTokenHeader(true) + .setAddApproovTraceIDHeader(true) + .addOptionalHeaders("Authorization", "Content-Length", "Content-Type") + .setBodyDigestConfig(DIGEST_SHA256, false) + ; + } + + /** + * Factory class for creating pre-request {@link SignatureParameters} with + * configurable settings. Each request passed to the factory builds a new + * SignatureParameters instance based on the configured settings and + * specific for the request. + */ + public static class SignatureParametersFactory { + protected SignatureParameters baseParameters; + protected String bodyDigestAlgorithm; + protected boolean bodyDigestRequired; + protected boolean useAccountMessageSigning; + protected boolean addCreated; + protected long expiresLifetime; + protected boolean addApproovTokenHeader; + protected boolean addApproovTraceIDHeader; + protected List optionalHeaders; + + /** + * Sets the base parameters for the factory. + * + * @param baseParameters The base parameters to set. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setBaseParameters(SignatureParameters baseParameters) { + this.baseParameters = baseParameters; + return this; + } + + /** + * Configures the body digest settings for the factory. + * + * @param bodyDigestAlgorithm The digest algorithm to use, or {@code null} to disable. + * @param required Whether the body digest is required. + * @return The current instance for method chaining. + * @throws IllegalArgumentException If an unsupported algorithm is specified. + */ + public SignatureParametersFactory setBodyDigestConfig(String bodyDigestAlgorithm, boolean required) { + if (bodyDigestAlgorithm == null) { + required = false; + } else if (!bodyDigestAlgorithm.equals(DIGEST_SHA256) + && !bodyDigestAlgorithm.equals(DIGEST_SHA512)) { + throw new IllegalArgumentException("Unsupported body digest algorithm: " + bodyDigestAlgorithm); + } + this.bodyDigestAlgorithm = bodyDigestAlgorithm; + this.bodyDigestRequired = required; + return this; + } + + /** + * Configures the factory to use device message signing. + * + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setUseInstallMessageSigning() { + this.useAccountMessageSigning = false; + return this; + } + + /** + * Configures the factory to use account message signing. + * + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setUseAccountMessageSigning() { + this.useAccountMessageSigning = true; + return this; + } + + /** + * Sets whether the "created" field should be added to the signature parameters. + * + * @param addCreated Whether to add the "created" field. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setAddCreated(boolean addCreated) { + this.addCreated = addCreated; + return this; + } + + /** + * Sets the expiration lifetime for the signature parameters. Only a + * value >0 will cause the expires attribute to be added to the + * SignatureParameters for a request. + * + * @param expiresLifetime The expiration lifetime in seconds, if <=0 + * no expiration is added. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setExpiresLifetime(long expiresLifetime) { + this.expiresLifetime = expiresLifetime; + return this; + } + + /** + * Sets whether the Approov token header should be added to the signature parameters. + * + * @param addApproovTokenHeader Whether to add the Approov token header. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setAddApproovTokenHeader(boolean addApproovTokenHeader) { + this.addApproovTokenHeader = addApproovTokenHeader; + return this; + } + + /** + * Sets whether the optional Approov traceID header should be added to the signature + * parameters. + * + * @param addApproovTraceIDHeader Whether to add the Approov traceID header. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setAddApproovTraceIDHeader(boolean addApproovTraceIDHeader) { + this.addApproovTraceIDHeader = addApproovTraceIDHeader; + return this; + } + + /** + * Adds optional headers to the signature parameters. Headers + * configured as optional are added to the generated + * SignatureParameters if the target request includes the header + * otherwise they are ignored. + * + * @param headers The headers to add. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory addOptionalHeaders(String ... headers) { + if (this.optionalHeaders == null) { + this.optionalHeaders = new ArrayList<>(Arrays.asList(headers)); + } else { + this.optionalHeaders.addAll(Arrays.asList(headers)); + } + return this; + } + + /** + * Generates a body digest for the request if possible. + * + * @param provider The component provider for the request. + * @param requestParameters The signature parameters to update. + * @return {@code true} if the body digest was successfully generated, {@code false} otherwise. + */ + protected boolean generateBodyDigest( + HttpsURLConnectionComponentProvider provider, + SignatureParameters requestParameters + ) { + HttpsURLConnection request = provider.request; + if (!(request instanceof ApproovBufferedHttpsURLConnection)) { + return false; + } + + byte[] body = ((ApproovBufferedHttpsURLConnection) request).getBufferedRequestBody(); + if (body == null || body.length == 0) { + return false; + } + + ByteString digest; + switch (bodyDigestAlgorithm) { + case DIGEST_SHA256: + digest = ByteString.of(body).sha256(); + break; + case DIGEST_SHA512: + digest = ByteString.of(body).sha512(); + break; + default: + return false; + } + + Map> digestMap = new LinkedHashMap<>(); + digestMap.put(bodyDigestAlgorithm, ByteSequenceItem.valueOf(digest.toByteArray())); + Dictionary digestHeader = Dictionary.valueOf(digestMap); + + request.setRequestProperty("Content-Digest", digestHeader.serialize()); + requestParameters.addComponentIdentifier("Content-Digest"); + return true; + } + + + /** + * Builds the signature parameters for a given request. + * + * @param provider The component provider for the request. + * @param changes The request mutations to apply. + * @return The generated {@link SignatureParameters}. + * @throws IllegalStateException If required parameters cannot be generated. + */ + protected SignatureParameters buildSignatureParameters(HttpsURLConnectionComponentProvider provider, ApproovRequestMutations changes) { + if (baseParameters == null) { + throw new IllegalStateException("Message signing base parameters have not been configured"); + } + SignatureParameters requestParameters = new SignatureParameters(baseParameters); + if (useAccountMessageSigning) { + requestParameters.setAlg(ALG_HS256); + } else { + requestParameters.setAlg(ALG_ES256); + } + if (addCreated || expiresLifetime > 0) { + long currentTime = System.currentTimeMillis() / 1000; + if (addCreated) { + requestParameters.setCreated(currentTime); + } + if (expiresLifetime > 0) { + requestParameters.setExpires(currentTime + expiresLifetime); + } + } + if (addApproovTokenHeader) { + requestParameters.addComponentIdentifier(changes.getTokenHeaderKey()); + } + if (addApproovTraceIDHeader && changes.getTraceIDHeaderKey() != null) { + requestParameters.addComponentIdentifier(changes.getTraceIDHeaderKey()); + } + if (optionalHeaders != null && !optionalHeaders.isEmpty()) { + for (String headerName: optionalHeaders) { + if (provider.hasField(headerName)) { + requestParameters.addComponentIdentifier(headerName); + } + } + } + if (bodyDigestAlgorithm != null) { + if (!generateBodyDigest(provider, requestParameters) && bodyDigestRequired) { + throw new IllegalStateException("Failed to create required body digest"); + } + } + return requestParameters; + } + } + + /** + * HttpsURLConnectionComponentProvider adapts a {@link HttpsURLConnection} + * request to the generic signature ComponentProvider interface. + */ + protected static final class HttpsURLConnectionComponentProvider implements ComponentProvider { + private HttpsURLConnection request; + + private java.net.URL url; + + /** + * Constructs an instance of {@code HttpsURLConnectionComponentProvider}. + * + * @param request The HttpsURLConnection request to wrap. + */ + HttpsURLConnectionComponentProvider(HttpsURLConnection request) { + this.request = request; + this.url = request.getURL(); + } + + @Override + public String getMethod() { + return request.getRequestMethod(); + } + + @Override + public String getAuthority() { + return url.getHost(); + } + + @Override + public String getScheme() { return url.getProtocol(); } + + @Override + public String getTargetUri() { + // Use URI canonical form to avoid subtle encoding differences in signed target-uri. + try { + return url.toURI().toString(); + } catch (Exception e) { + return url.toString(); + } + } + + @Override + public String getRequestTarget() { + try { + URI uri = url.toURI(); + String path = uri.getRawPath() == null ? "" : uri.getRawPath(); + String query = uri.getRawQuery(); + return (query == null || query.isEmpty()) ? path : path + "?" + query; + } catch (Exception e){ + String path = (url.getPath() == null) ? "" : url.getPath(); + String query = url.getQuery(); + return (query == null || query.isEmpty()) ? path : path + "?" + query; + } + } + + @Override + public String getPath() { + try { + URI uri = url.toURI(); + return uri.getRawPath(); + } catch (Exception e) { + return url.getPath(); + } + } + + @Override + public String getQuery() { + try { + URI uri = url.toURI(); + return uri.getRawQuery(); + } catch (Exception e) { + return url.getQuery(); + } + } + + @Override + public String getQueryParam(String name) { + // Parse from raw query to preserve encoded bytes exactly as used by signature construction. + String query; + try { + query = url.toURI().getRawQuery(); + } catch (Exception e) { + query = url.getQuery(); + } + if (query == null || query.isEmpty()) throw new IllegalArgumentException("Could not find query parameter named " + name); + String[] parts = query.split("&"); + String found = null; + for (String part : parts) { + int idx = part.indexOf('='); + String k = idx >= 0 ? part.substring(0, idx) : part; + String v = idx >= 0 ? part.substring(idx + 1) : ""; + if (k.equals(name)) { + if (found != null) return null; + found = v; + } + } + if (found == null) throw new IllegalArgumentException("Could not find query parameter named " + name); + return found; + } + @Override + public String getStatus() { throw new IllegalStateException("Only requests are supported"); } + + @Override + public boolean hasField(String name) { return request.getRequestProperty(name) != null; } + + @Override + public String getField(String name) { + String value = request.getRequestProperty(name); + return value == null ? "" : value; + } + + @Override + public boolean hasBody() { + String method = request.getRequestMethod(); + return "POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method) || "PATCH".equalsIgnoreCase(method); + } + } +} diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovFetchStatusException.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovFetchStatusException.java new file mode 100644 index 0000000..65b8690 --- /dev/null +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovFetchStatusException.java @@ -0,0 +1,48 @@ +// +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package io.approov.service.httpsurlconn; + +import com.criticalblue.approovsdk.Approov; + +/** + * Exception raised when an Approov token fetch returns a status other than success. + */ +public class ApproovFetchStatusException extends ApproovException { + + private final Approov.TokenFetchStatus tokenFetchStatus; + + /** + * Constructs a token fetch status exception with the provided status. + * + * @param status status returned by the Approov SDK, may be {@code null} if unavailable + * @param message information describing the exception cause + */ + public ApproovFetchStatusException(Approov.TokenFetchStatus status, String message) { + super(message); + this.tokenFetchStatus = status; + } + + /** + * Retrieves the token fetch status associated with this exception. + * + * @return the status returned by the Approov SDK, or {@code null} if not provided + */ + public Approov.TokenFetchStatus getTokenFetchStatus() { + return tokenFetchStatus; + } +} \ No newline at end of file diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovInterceptorExtensions.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovInterceptorExtensions.java new file mode 100644 index 0000000..6d91933 --- /dev/null +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovInterceptorExtensions.java @@ -0,0 +1,60 @@ +// +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package io.approov.service.httpsurlconn; + +import java.net.URL; +import java.net.HttpURLConnection; + +import javax.net.ssl.HttpsURLConnection; + +/** + * Legacy callback interface for customizing the httpsurlconn service layer after + * Approov has applied its request changes. + * + * @deprecated Replace implementations of this interface with ApproovServiceMutator + * while changing the name of the ApproovInterceptorExtensions.processedRequest + * method to ApproovServiceMutator.handleInterceptorProcessedRequest. + */ +@Deprecated +public interface ApproovInterceptorExtensions extends ApproovServiceMutator{ + + /** + * Replaces the default implementation of + * ApproovServiceMutator.handleInterceptorProcessedRequest so existing + * ApproovInterceptorExtensions implementations continue to receive the final + * prepared HttpsURLConnection request. + * + * @param request the processed request + * @param changes the mutations applied to the request by Approov + * @return the final request to use to complete Approov request preparation + * @throws ApproovException if there is an error during processing + */ + default HttpsURLConnection handleInterceptorProcessedRequest(HttpsURLConnection request, ApproovRequestMutations changes) throws ApproovException { + // call the deprecated method to maintain backwards compatibility + return processedRequest(request, changes); + } + + /** + * @deprecated Use ApproovServiceMutator.handleInterceptorProcessedRequest instead. + */ + @Deprecated + default HttpsURLConnection processedRequest(HttpsURLConnection request, ApproovRequestMutations changes) throws ApproovException { + // No further changes to the request are required + return request; + } +} diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovNetworkException.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovNetworkException.java index 8a5f427..228374c 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovNetworkException.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovNetworkException.java @@ -17,9 +17,13 @@ package io.approov.service.httpsurlconn; +import com.criticalblue.approovsdk.Approov; + // ApproovNetworkException indicates an exception caused by networking conditions which is likely to be // temporary so a user initiated retry should be performed + public class ApproovNetworkException extends ApproovException { + private final Approov.TokenFetchStatus tokenFetchStatus; /** * Constructs an Approov networking exception. @@ -28,5 +32,15 @@ public class ApproovNetworkException extends ApproovException { */ public ApproovNetworkException(String message) { super(message); + this.tokenFetchStatus = null; + } + + public ApproovNetworkException(Approov.TokenFetchStatus status, String message){ + super(message); + this.tokenFetchStatus = status; + } + + public Approov.TokenFetchStatus getTokenFetchStatus() { + return tokenFetchStatus; } } diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java new file mode 100644 index 0000000..8a35de7 --- /dev/null +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java @@ -0,0 +1,116 @@ +// +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package io.approov.service.httpsurlconn; + +import java.util.List; + +/** + * ApproovRequestMutations stores information about changes made to a network request + * during Approov processing, such as token headers, substituted headers, and query parameters. + */ +public class ApproovRequestMutations { + private String tokenHeaderKey; + private List substitutionHeaderKeys; + private String originalURL; + private List substitutionQueryParamKeys; + private String traceIDHeaderKey; + + + /** + * Gets the header key used for the Approov token. + * + * @return the Approov token header key + */ + public String getTokenHeaderKey() { + return tokenHeaderKey; + } + + /** + * Sets the header key used for the Approov token. + * + * @param tokenHeaderKey the Approov token header key + */ + public void setTokenHeaderKey(String tokenHeaderKey) { + this.tokenHeaderKey = tokenHeaderKey; + } + + /** + * Gets the list of headers that were substituted with secure strings. + * + * @return the list of substituted header keys + */ + public List getSubstitutionHeaderKeys() { + return substitutionHeaderKeys; + } + + /** + * Sets the list of headers that were substituted with secure strings. + * + * @param substitutionHeaderKeys the list of substituted header keys + */ + public void setSubstitutionHeaderKeys(List substitutionHeaderKeys) { + this.substitutionHeaderKeys = substitutionHeaderKeys; + } + + /** + * Gets the original URL before any query parameter substitutions. + * + * @return the original URL + */ + public String getOriginalURL() { + return originalURL; + } + + /** + * Gets the list of query parameter keys that were substituted with secure strings. + * + * @return the list of substituted query parameter keys + */ + public List getSubstitutionQueryParamKeys() { + return substitutionQueryParamKeys; + } + + /** + * Sets the results of query parameter substitutions, including the original URL and the keys of substituted parameters. + * + * @param originalURL the original URL before substitutions + * @param substitutionQueryParamKeys the list of substituted query parameter keys + */ + public void setSubstitutionQueryParamResults(String originalURL, List substitutionQueryParamKeys) { + this.originalURL = originalURL; + this.substitutionQueryParamKeys = substitutionQueryParamKeys; + } + + /** + * Gets the header key used for the optional Approov TraceID debug header. + * + * @return the Approov TraceID header key. Null if the TraceID header was not used. + */ + public String getTraceIDHeaderKey() { + return traceIDHeaderKey; + } + + /** + * Sets the header key used for the optional Approov TraceID debug header. + * + * @param traceIDHeaderKey the Approov TraceID header key + */ + public void setTraceIDHeaderKey(String traceIDHeaderKey) { + this.traceIDHeaderKey = traceIDHeaderKey; + } +} diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovService.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovService.java index 2983129..de9127a 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovService.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovService.java @@ -24,10 +24,23 @@ import java.net.MalformedURLException; import java.net.URL; +import java.lang.reflect.Method; +import java.security.PublicKey; +import java.security.KeyStore; import java.security.cert.Certificate; +import java.security.cert.CertPath; +import java.security.cert.CertPathValidator; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.PKIXCertPathValidatorResult; +import java.security.cert.PKIXParameters; +import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -39,6 +52,9 @@ import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLException; import javax.net.ssl.SSLSession; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; import okio.ByteString; @@ -47,6 +63,10 @@ public class ApproovService { // logging tag private static final String TAG = "ApproovService"; + // default header that will carry any optional Approov TraceID debug value from + // the SDK + private static final String APPROOV_TRACE_ID_HEADER = "Approov-TraceID"; + // header that will be added to Approov enabled requests private static final String APPROOV_TOKEN_HEADER = "Approov-Token"; @@ -56,23 +76,41 @@ public class ApproovService { // hostname verifier that checks against the current Approov pins or null if SDK not initialized private static PinningHostnameVerifier pinningHostnameVerifier = null; - // true if the interceptor should proceed on network failures and not add an - // Approov token + // true if request preparation should proceed on network failures and not add + // an Approov token private static boolean proceedOnNetworkFail = false; + // true if the Approov fetch status should be used as the token header value if the + // actual token fetch fails or returns an empty token + private static boolean useApproovStatusIfNoToken = false; + // header to be used to send Approov tokens private static String approovTokenHeader = null; + // header used to send any optional Approov TraceID debug value provided by the + // SDK + private static String approovTraceIDHeader = null; + + // any prefix String to be added before the transmitted Approov token private static String approovTokenPrefix = null; // any header to be used for binding in Approov tokens or null if not set private static String bindingHeader = null; + // The mutator instance used to control ApproovService behaviour at key points in the flow. + // Unless set using the ApproovService.setServiceMutator() method, the default behaviour + // defined in the default implementation of ApproovServiceMutator will be used. + private static ApproovServiceMutator serviceMutator = ApproovServiceMutator.DEFAULT; + // map of headers that should have their values substituted for secure strings, mapped to their // required prefixes private static Map substitutionHeaders = null; + // set of query parameters that may be substituted, specified by the key name + // and mapped to the compiled Pattern + private static Map substitutionQueryParams = null; + // set of URL regexs that should be excluded from any Approov protection, mapped to the compiled Pattern private static Map exclusionURLRegexs = null; @@ -92,12 +130,15 @@ public static void initialize(Context context, String config) { // setup for using Appproov pinningHostnameVerifier = null; proceedOnNetworkFail = false; + useApproovStatusIfNoToken = false; approovTokenHeader = APPROOV_TOKEN_HEADER; + approovTraceIDHeader = APPROOV_TRACE_ID_HEADER; approovTokenPrefix = APPROOV_TOKEN_PREFIX; bindingHeader = null; substitutionHeaders = new HashMap<>(); + substitutionQueryParams = new HashMap<>(); exclusionURLRegexs = new HashMap<>(); - + serviceMutator = ApproovServiceMutator.DEFAULT; // initialize the Approov SDK try { if (config.length() != 0) @@ -113,11 +154,11 @@ public static void initialize(Context context, String config) { } /** - * Sets a flag indicating if the network interceptor should proceed anyway if it is + * Sets a flag indicating if request preparation should proceed anyway if it is * not possible to obtain an Approov token due to a networking failure. If this is set * then your backend API can receive calls without the expected Approov token header * being added, or without header/query parameter substitutions being made. Note that - * this should be used with caution because it may allow a connection to be established + * this should be used with caution because it may allow a request to be established * before any dynamic pins have been received via Approov, thus potentially opening * the channel to a MitM. * @@ -166,6 +207,48 @@ public static synchronized void setApproovHeader(String header, String prefix) { approovTokenPrefix = prefix; } + /** + * Sets the header name that is used to pass any optional Approov TraceID debug + * value. By default the TraceID is provided on "Approov-TraceID" if one is + * available. Passing null disables adding the TraceID header. + * + * @param header is the name of the header on which to place the Approov + * TraceID, or null to disable the header + */ + public static synchronized void setApproovTraceIDHeader(String header) { + Log.d(TAG, "setApproovTraceIDHeader " + header); + approovTraceIDHeader = header; + } + + /** + * Gets the header that is used to add the Approov token. + * + * @return String of the header used for the Approov token + */ + public static synchronized String getApproovTokenHeader() { + return approovTokenHeader; + } + + /** + * Gets the name of the header that is used to hold the optional Approov + * TraceID. + * + * @return String the name of the header used for the Approov TraceID, or + * null if disabled + */ + public static synchronized String getApproovTraceIDHeader() { + return approovTraceIDHeader; + } + + /** + * Gets the prefix that is added before the Approov token in the header. + * + * @return String of the prefix added before the Approov token + */ + public static synchronized String getApproovTokenPrefix() { + return approovTokenPrefix; + } + /** * 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 @@ -180,12 +263,22 @@ public static synchronized void setBindingHeader(String header) { bindingHeader = header; } + /** + * Gets any current binding header. + * + * @return binding header or null if not set + */ + static synchronized String getBindingHeader() { + return bindingHeader; + } + /** * Adds the name of a header which should be subject to secure strings substitution. This * means that if the header is present then the value will be used as a key to look up a * secure string value which will be substituted into the header value instead. This allows * easy migration to the use of secure strings. Note that this should be done on initialization - * rather than for every request as it will require a new OkHttpClient to be built. A required + * rather than for every request so the same configuration is applied consistently to each + * HttpsURLConnection. A required * prefix may be specified to deal with cases such as the use of "Bearer " prefixed before values * in an authorization header. * @@ -214,6 +307,60 @@ public static synchronized void removeSubstitutionHeader(String header) { } } + /** + * Gets the map of headers that are subject to substitution. + * + * @return a map of headers that are subject to substitution, mapped to the + * required prefix + */ + public static synchronized Map getSubstitutionHeaders() { + return new HashMap<>(substitutionHeaders); + } + + /** + * Adds a key name for a query parameter that should be subject to secure + * strings substitution. This means that if the query parameter is present in a + * URL then the value will be used as a key to look up a secure string value + * which will be substituted as the query parameter value instead. This allows + * easy migration to the use of secure strings. + * + * @param key is the query parameter key name to be added for substitution + */ + public static synchronized void addSubstitutionQueryParam(String key) { + if (pinningHostnameVerifier != null) { + Log.d(TAG, "addSubstitutionQueryParam " + key); + try { + Pattern pattern = Pattern.compile("[\\?&]" + Pattern.quote(key) + "=([^&;]+)"); + substitutionQueryParams.put(key, pattern); + } catch (PatternSyntaxException e) { + Log.e(TAG, "addSubstitutionQueryParam " + key + " error: " + e.getMessage()); + } + } + } + + /** + * Removes a query parameter key name previously added using + * addSubstitutionQueryParam. + * + * @param key is the query parameter key name to be removed for substitution + */ + public static synchronized void removeSubstitutionQueryParam(String key) { + if (pinningHostnameVerifier != null) { + Log.d(TAG, "removeSubstitutionQueryParam " + key); + substitutionQueryParams.remove(key); + } + } + + /** + * Gets the map of substitution query parameters. + * + * @return a map of query parameters to be substituted, mapped to the compiled + * Pattern + */ + public static synchronized Map getSubstitutionQueryParams() { + return new HashMap<>(substitutionQueryParams); + } + /** * Adds an exclusion URL regular expression. If a URL for a request matches this regular expression * then it will not be subject to any Approov protection. Note that this facility must be used with @@ -223,7 +370,7 @@ public static synchronized void removeSubstitutionHeader(String header) { * Approov pins but without a path to update the pins until a URL is used that is not excluded. Thus * you are responsible for ensuring that there is always a possibility of calling a non-excluded * URL, or you should make an explicit call to fetchToken if there are persistent pinning failures. - * Conversely, use of those option may allow a connection to be established before any dynamic pins + * Conversely, use of those option may allow a request to be established before any dynamic pins * have been received via Approov, thus potentially opening the channel to a MitM. * * @param urlRegex is the regular expression that will be compared against URLs to exclude them @@ -252,6 +399,58 @@ public static synchronized void removeExclusionURLRegex(String urlRegex) { } } + /** + * Sets the ApproovServiceMutator instance to handle callbacks from the + * ApproovService implementation. This facility enables customization of + * ApproovService operations at key points in the configuration and + * attestation flows. It should reduce the number of times this service + * layer implementation needs to be forked in order to introduce custom + * behavior. + * + * @param mutator is the ApproovServiceMutator with callback handlers that may + * override the default behavior of the ApproovService singleton. + * Passing null to this method will reinstate the default + * behavior. + */ + public static synchronized void setServiceMutator(ApproovServiceMutator mutator) { + if (mutator == null) { + mutator = ApproovServiceMutator.DEFAULT; + } + Log.d(TAG, "Applied ApproovServiceMutator:" + mutator.toString()); + serviceMutator = mutator; + } + + + /** + * Gets the active service mutator instance that is handling callbacks from + * ApproovService. + * + * @return the service mutator instance (never null) + */ + public static synchronized ApproovServiceMutator getServiceMutator() { + return serviceMutator; + } + + /** + * @deprecated Use setServiceMutator instead + */ + @Deprecated + public static void setApproovInterceptorExtensions(ApproovServiceMutator mutator) { + setServiceMutator(mutator); + } + + /** + * Gets the legacy interceptor extensions callback handlers. + * + * @return the callback handlers currently used by the service mutator API + * @deprecated Use getServiceMutator instead + */ + @Deprecated + public static ApproovServiceMutator getApproovInterceptorExtensions() { + return getServiceMutator(); + } + + /** * Prefetches in the background to lower the effective latency of a subsequent token fetch or * secure string fetch by starting the operation earlier so the subsequent fetch may be able to @@ -286,22 +485,8 @@ public static void precheck() throws ApproovException { throw new ApproovException("IllegalArgument: " + e.getMessage()); } - // process the returned Approov status - if (approovResults.getStatus() == Approov.TokenFetchStatus.REJECTED) - // if the request is rejected then we provide a special exception with additional information - throw new ApproovRejectionException("precheck: " + approovResults.getStatus().toString() + ": " + - approovResults.getARC() + " " + approovResults.getRejectionReasons(), - approovResults.getARC(), approovResults.getRejectionReasons()); - else if ((approovResults.getStatus() == Approov.TokenFetchStatus.NO_NETWORK) || - (approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) || - (approovResults.getStatus() == Approov.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: " + approovResults.getStatus().toString()); - else if ((approovResults.getStatus() != Approov.TokenFetchStatus.SUCCESS) && - (approovResults.getStatus() != Approov.TokenFetchStatus.UNKNOWN_KEY)) - // we are unable to get the secure string due to a more permanent error - throw new ApproovException("precheck:" + approovResults.getStatus().toString()); + // process the returned Approov status using decision maker + getServiceMutator().handlePrecheckResult(approovResults); } /** @@ -373,18 +558,9 @@ public static String fetchToken(String url) throws ApproovException { throw new ApproovException("IllegalArgument: " + e.getMessage()); } - // process the status - if ((approovResults.getStatus() == Approov.TokenFetchStatus.NO_NETWORK) || - (approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) || - (approovResults.getStatus() == Approov.TokenFetchStatus.MITM_DETECTED)) - // we are unable to get the token due to network conditions - throw new ApproovNetworkException("fetchToken: " + approovResults.getStatus().toString()); - else if (approovResults.getStatus() != Approov.TokenFetchStatus.SUCCESS) - // we are unable to get the token due to a more permanent error - throw new ApproovException("fetchToken: " + approovResults.getStatus().toString()); - else - // provide the Approov token result - return approovResults.getToken(); + // process the status using decision maker + getServiceMutator().handleFetchTokenResult(approovResults); + return approovResults.getToken(); } /** @@ -400,20 +576,9 @@ else if (approovResults.getStatus() != Approov.TokenFetchStatus.SUCCESS) * @return String of the base64 encoded message signature * @throws ApproovException if there was a problem */ + @Deprecated public static String getMessageSignature(String message) throws ApproovException { - try { - String signature = Approov.getMessageSignature(message); - Log.d(TAG, "getMessageSignature"); - if (signature == null) - throw new ApproovException("no signature available"); - return signature; - } - catch (IllegalStateException e) { - throw new ApproovException("IllegalState: " + e.getMessage()); - } - catch (IllegalArgumentException e) { - throw new ApproovException("IllegalArgument: " + e.getMessage()); - } + return getAccountMessageSignature(message); } /** @@ -451,25 +616,8 @@ public static String fetchSecureString(String key, String newDef) throws Approov throw new ApproovException("IllegalArgument: " + e.getMessage()); } - // process the returned Approov status - if (approovResults.getStatus() == Approov.TokenFetchStatus.REJECTED) - // if the request is rejected then we provide a special exception with additional information - throw new ApproovRejectionException("fetchSecureString " + type + " for " + key + ": " + - approovResults.getStatus().toString() + ": " + approovResults.getARC() + - " " + approovResults.getRejectionReasons(), - approovResults.getARC(), approovResults.getRejectionReasons()); - else if ((approovResults.getStatus() == Approov.TokenFetchStatus.NO_NETWORK) || - (approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) || - (approovResults.getStatus() == Approov.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 + ":" + - approovResults.getStatus().toString()); - else if ((approovResults.getStatus() != Approov.TokenFetchStatus.SUCCESS) && - (approovResults.getStatus() != Approov.TokenFetchStatus.UNKNOWN_KEY)) - // we are unable to get the secure string due to a more permanent error - throw new ApproovException("fetchSecureString " + type + " for " + key + ":" + - approovResults.getStatus().toString()); + // process the returned Approov status using decision maker + getServiceMutator().handleFetchSecureStringResult(approovResults, type, key); return approovResults.getSecureString(); } @@ -498,21 +646,8 @@ public static String fetchCustomJWT(String payload) throws ApproovException { throw new ApproovException("IllegalArgument: " + e.getMessage()); } - // process the returned Approov status - if (approovResults.getStatus() == Approov.TokenFetchStatus.REJECTED) - // if the request is rejected then we provide a special exception with additional information - throw new ApproovRejectionException("fetchCustomJWT: "+ approovResults.getStatus().toString() + ": " + - approovResults.getARC() + " " + approovResults.getRejectionReasons(), - approovResults.getARC(), approovResults.getRejectionReasons()); - else if ((approovResults.getStatus() == Approov.TokenFetchStatus.NO_NETWORK) || - (approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) || - (approovResults.getStatus() == Approov.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: " + approovResults.getStatus().toString()); - else if (approovResults.getStatus() != Approov.TokenFetchStatus.SUCCESS) - // we are unable to get the custom JWT due to a more permanent error - throw new ApproovException("fetchCustomJWT: " + approovResults.getStatus().toString()); + // process the returned Approov status using decision maker + getServiceMutator().handleFetchCustomJWTResult(approovResults); return approovResults.getToken(); } @@ -585,110 +720,476 @@ public static void setInstallAttrsInToken(String attrs) throws ApproovException } /** - * Adds Approov to the given connection. The Approov token is added in a header and this - * also overrides the HostnameVerifier with something that pins the connections. If a - * binding header has been specified then its hash will be set if it is present. This function - * may also substitute header values to hold secure string secrets. If it is not - * currently possible to fetch an Approov token due to networking issues then - * ApproovNetworkException is thrown and a user initiated retry of the operation should - * be allowed. ApproovRejectionException is thrown if header substitution is being attempted and - * the app fails attestation. Other ApproovExecptions represent a more permanent error condition. + * Gets a copy of the current exclusion URL regexs. + * + * @return Map of the exclusion regexs to their respective Patterns + */ + static synchronized Map getExclusionURLRegexs() { + return new HashMap<>(exclusionURLRegexs); + } + + /** + * Sets a flag indicating if the Approov fetch status (e.g. "NO_NETWORK", + * "MITM_DETECTED") + * should be used as the token header value if the actual token fetch fails or + * returns an empty token. + * This allows passing error condition information to the backend via the + * Approov-Token header, + * which might otherwise be empty or missing. + * + * @param shouldUse is true if the status should be used as the token value + */ + public static synchronized void setUseApproovStatusIfNoToken(boolean shouldUse) { + Log.d(TAG, "setUseApproovStatusIfNoToken " + shouldUse); + useApproovStatusIfNoToken = shouldUse; + } + /** + * Gets a flag indicating if the Approov fetch status should be used as the token header value + * if the actual token fetch fails or returns an empty token. + * + * @return true if the status should be used as the token value, false otherwise + */ + public static synchronized boolean getUseApproovStatusIfNoToken() { + return useApproovStatusIfNoToken; + } + + /** + * Gets a flag indicating if request preparation should proceed anyway if it is + * not possible to obtain an Approov token due to a networking failure. + * + * @return true if Approov networking fails should allow continuation, false otherwise + * @deprecated Use setServiceMutator to control this behavior + */ + @Deprecated + public static synchronized boolean getProceedOnNetworkFail() { + return proceedOnNetworkFail; + } + + /** + * Gets the signature for the given message. This uses an account specific message signing key that is + * transmitted to the SDK after a successful fetch if the facility is enabled for the account. Note + * that if the attestation failed then the signing key provided is actually random so that the + * signature will be incorrect. An Approov token should always be included in the message + * being signed and sent alongside this signature to prevent replay attacks. If no signature is + * available, because there has been no prior fetch or the feature is not enabled, then an + * ApproovException is thrown. + * + * @param message is the message whose content is to be signed + * @return String of the base64 encoded message signature + * @throws ApproovException if there was a problem + */ + public static String getAccountMessageSignature(String message) throws ApproovException { + try { + String signature = Approov.getAccountMessageSignature(message); + Log.d(TAG, "getAccountMessageSignature"); + if (signature == null) + throw new ApproovException("no account signature available"); + return signature; + } + catch (IllegalStateException e) { + throw new ApproovException("IllegalState: " + e.getMessage()); + } + catch (IllegalArgumentException e) { + throw new ApproovException("IllegalArgument: " + e.getMessage()); + } + } + + /** + * Gets the install signature for the given message. This uses an app install specific message + * signing key that is generated the first time an app launches. This signing mechanism uses an + * ECC key pair where the private key is managed by the secure element or trusted execution + * environment of the device. Where it can, Approov uses attested key pairs to perform the + * message signing. + *

+ * An Approov token should always be included in the message being signed and sent alongside + * this signature to prevent replay attacks. + *

+ * If no signature is available, because there has been no prior fetch or the feature is not + * enabled, then an ApproovException is thrown. + * + * @param message is the message whose content is to be signed + * @return String of the base64 encoded message signature in ASN.1 DER format + * @throws ApproovException if there was a problem + */ + public static String getInstallMessageSignature(String message) throws ApproovException { + try { + String signature = Approov.getInstallMessageSignature(message); + Log.d(TAG, "getInstallMessageSignature"); + if (signature == null) + throw new ApproovException("no device signature available"); + return signature; + } + catch (IllegalStateException e) { + throw new ApproovException("IllegalState: " + e.getMessage()); + } + catch (IllegalArgumentException e) { + throw new ApproovException("IllegalArgument: " + e.getMessage()); + } + } + + /** + * Gets any optional TraceID debug value carried on a token fetch result. Older + * SDK versions do not expose this method, so we resolve it reflectively and + * silently treat it as unavailable when the runtime does not support it. + * + * @param approovResults the token fetch result returned by the SDK + * @return the TraceID value, or null if unavailable + */ + private static String getTokenFetchTraceID(Approov.TokenFetchResult approovResults) { + try { + Method method = approovResults.getClass().getMethod("getTraceID"); + Object value = method.invoke(approovResults); + return (value instanceof String) ? (String) value : null; + } catch (Exception e) { + return null; + } + } + + /** + * Holds the outcome of the non-signing part of request processing. In the + * httpsurlconn service layer, token fetch, header substitution, and query + * substitution are resolved before the final processed-request callback is + * invoked. + */ + static final class PreparedRequestData { + final ApproovServiceMutator mutator; + final ApproovRequestMutations changes; + final boolean invokeProcessedCallback; + + PreparedRequestData( + ApproovServiceMutator mutator, + ApproovRequestMutations changes, + boolean invokeProcessedCallback + ) { + this.mutator = mutator; + this.changes = changes; + this.invokeProcessedCallback = invokeProcessedCallback; + } + } + + /** + * Holds the outcome of configured query parameter substitutions so callers can + * update both the effective URL and the mutation metadata in a single step. + */ + static final class QuerySubstitutionResult { + final URL url; + final String originalURL; + final List substitutedQueryKeys; + + QuerySubstitutionResult(URL url, String originalURL, List substitutedQueryKeys) { + this.url = url; + this.originalURL = originalURL; + this.substitutedQueryKeys = substitutedQueryKeys; + } + + boolean hasEffectiveUrlChange() { + return !originalURL.equals(url.toString()); + } + } + + /** + * Determines if request processing should be deferred through a buffered + * connection so the final processed-request callback can see any body bytes + * written after addApproov returns. + * + * @param request is the request being prepared + * @param querySubstitutionResult is the configured URL substitution result + * @return true if the caller must continue using a buffered connection wrapper + */ + private static boolean shouldUseBufferedConnection( + HttpsURLConnection request, + QuerySubstitutionResult querySubstitutionResult + ) { + if (querySubstitutionResult.hasEffectiveUrlChange()) { + return true; + } + + if (request.getDoOutput()) { + return true; + } + + String method = request.getRequestMethod(); + return "POST".equalsIgnoreCase(method) + || "PUT".equalsIgnoreCase(method) + || "PATCH".equalsIgnoreCase(method); + } + + /** + * Performs the token fetch, optional TraceID propagation, and secure string + * header substitutions for a request. This method intentionally stops short of + * invoking the processed request callback so wrappers can finish any remaining + * transport-specific work, such as URL substitution or body buffering, before + * message signing occurs. * - * @param connection is the HttpsUrlConnection to which Approov is being added - * @throws ApproovException if it is not possible to obtain an Approov token or secure strings + * @param request is the request being prepared + * @return the request preparation result + * @throws ApproovException if a token or secure string could not be obtained */ - public static synchronized void addApproov(HttpsURLConnection connection) throws ApproovException { + static synchronized PreparedRequestData prepareApproovRequest(HttpsURLConnection request) throws ApproovException { // throw if we couldn't initialize the SDK if (pinningHostnameVerifier == null) throw new ApproovException("Approov not initialized"); - // ensure the connection is pinned - this is done even if the URL is excluded in case - // the same domain is used for an Approov protected request and the same connection is live - connection.setHostnameVerifier(pinningHostnameVerifier); + // cache the mutator for the duration of the request processing to make + // sure it is not changed mid-flight + ApproovServiceMutator mutator = getServiceMutator(); + ApproovRequestMutations changes = new ApproovRequestMutations(); - // check if the URL matches one of the exclusion regexs and just return if so - String url = connection.getURL().toString(); - for (Pattern pattern: exclusionURLRegexs.values()) { - Matcher matcher = pattern.matcher(url); - if (matcher.find()) - return; + // first check if we are to proceed with pinning processing + if (mutator.handlePinningShouldProcessRequest(request)) { + // ensure the request is pinned even if the request later turns out to be + // excluded from Approov token processing + request.setHostnameVerifier(pinningHostnameVerifier); + } + + // first check if we are to proceed with any Approov processing + if (!mutator.handleInterceptorShouldProcessConnection(request)) { + // we are not to proceed with any Approov processing so just continue + return new PreparedRequestData(mutator, changes, false); } - // update the data hash based on any token binding header if it is available - if (bindingHeader != null) { - String headerValue = connection.getRequestProperty(bindingHeader); - if (headerValue != null) - Approov.setDataHashInToken(headerValue); + // update the data hash based on any token binding header (presence is optional) + String currentBindingHeader = getBindingHeader(); + if (currentBindingHeader != null) { + String bindingValue = request.getRequestProperty(currentBindingHeader); + if (bindingValue != null) + Approov.setDataHashInToken(bindingValue); } - // request an Approov token for the domain - String host = connection.getURL().getHost(); - Approov.TokenFetchResult approovResults = Approov.fetchApproovTokenAndWait(host); + String urlString = request.getURL().toString(); - // provide information about the obtained token or error (note "approov token -check" can - // be used to check the validity of the token and if you use token annotations they - // will appear here to determine why a request is being rejected) - Log.d(TAG, "Token for " + host + ": " + approovResults.getLoggableToken()); + // request an Approov token for the request URL + Approov.TokenFetchResult approovResults = Approov.fetchApproovTokenAndWait(urlString); - // check the status of Approov token fetch - if (approovResults.getStatus() == Approov.TokenFetchStatus.SUCCESS) + // provide information about the obtained token or error (note "approov token + // -check" can be used to check the validity of the token and if you use + // token annotations they will appear here to determine why a request is + // being rejected) + Log.d(TAG, "Token for " + urlString + ": " + approovResults.getLoggableToken()); + + // check the status of Approov token fetch using decision maker + String setTokenHeaderKey = null; + String setTokenHeaderValue = null; + String setTraceIDHeaderKey = null; + String setTraceIDHeaderValue = null; + if (mutator.handleInterceptorFetchTokenResult(approovResults, urlString)) { // we successfully obtained a token so add it to the header for the request - connection.addRequestProperty(approovTokenHeader, approovTokenPrefix + approovResults.getToken()); - else if ((approovResults.getStatus() == Approov.TokenFetchStatus.NO_NETWORK) || - (approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) || - (approovResults.getStatus() == Approov.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 + ": " + approovResults.getStatus().toString()); - } - else if ((approovResults.getStatus() != Approov.TokenFetchStatus.NO_APPROOV_SERVICE) && - (approovResults.getStatus() != Approov.TokenFetchStatus.UNKNOWN_URL) && - (approovResults.getStatus() != Approov.TokenFetchStatus.UNPROTECTED_URL)) - // we have failed to get an Approov token with a more serious permanent error - throw new ApproovException("Approov token fetch for " + host + ": " + approovResults.getStatus().toString()); - - // 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 substitutions in domains not - // protected by Approov and therefore potential subject to a MitM - if ((approovResults.getStatus() != Approov.TokenFetchStatus.SUCCESS) && - (approovResults.getStatus() != Approov.TokenFetchStatus.UNPROTECTED_URL)) - return; + setTokenHeaderKey = getApproovTokenHeader(); - // we now deal with any header substitutions, which may require further fetches but these - // should be using cached results - for (Map.Entry entry: substitutionHeaders.entrySet()) { + String fetchedToken = approovResults.getToken(); + if (((fetchedToken == null) || fetchedToken.isEmpty()) && getUseApproovStatusIfNoToken()) + setTokenHeaderValue = getApproovTokenPrefix() + approovResults.getStatus().toString(); + else + setTokenHeaderValue = getApproovTokenPrefix() + (fetchedToken == null ? "" : fetchedToken); + + String traceIDHeader = getApproovTraceIDHeader(); + String traceID = getTokenFetchTraceID(approovResults); + if ((traceIDHeader != null) && (traceID != null) && !traceID.isEmpty()) { + setTraceIDHeaderKey = traceIDHeader; + setTraceIDHeaderValue = traceID; + } + } else { + // 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 substitutions in domains not + // protected by Approov and therefore potentially subject to a MitM + return new PreparedRequestData(mutator, changes, false); + } + + if (setTokenHeaderKey != null) { + request.setRequestProperty(setTokenHeaderKey, setTokenHeaderValue); + changes.setTokenHeaderKey(setTokenHeaderKey); + } + if (setTraceIDHeaderKey != null) { + request.setRequestProperty(setTraceIDHeaderKey, setTraceIDHeaderValue); + changes.setTraceIDHeaderKey(setTraceIDHeaderKey); + } + + // we now deal with any header substitutions, which may require further fetches + // but these should be using cached results + Map substitutionHeaders = getSubstitutionHeaders(); + Map setSubstitutionHeaders = new LinkedHashMap<>(substitutionHeaders.size()); + for (Map.Entry entry : substitutionHeaders.entrySet()) { String header = entry.getKey(); String prefix = entry.getValue(); - String value = connection.getRequestProperty(header); + String value = request.getRequestProperty(header); if ((value != null) && value.startsWith(prefix) && (value.length() > prefix.length())) { approovResults = Approov.fetchSecureStringAndWait(value.substring(prefix.length()), null); Log.d(TAG, "Substituting header: " + header + ", " + approovResults.getStatus().toString()); - if (approovResults.getStatus() == Approov.TokenFetchStatus.SUCCESS) { - // perform the header substitution - connection.setRequestProperty(header, prefix + approovResults.getSecureString()); + if (mutator.handleInterceptorHeaderSubstitutionResult(approovResults, header)) { + setSubstitutionHeaders.put(header, prefix + approovResults.getSecureString()); } - else if (approovResults.getStatus() == Approov.TokenFetchStatus.REJECTED) - // if the request is rejected then we provide a special exception with additional information - throw new ApproovRejectionException("Header substitution for " + header + ": " + - approovResults.getStatus().toString() + ": " + approovResults.getARC() + - " " + approovResults.getRejectionReasons(), - approovResults.getARC(), approovResults.getRejectionReasons()); - else if ((approovResults.getStatus() == Approov.TokenFetchStatus.NO_NETWORK) || - (approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) || - (approovResults.getStatus() == Approov.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 + ": " + - approovResults.getStatus().toString()); + } + } + + if (!setSubstitutionHeaders.isEmpty()) { + for (Map.Entry entry : setSubstitutionHeaders.entrySet()) { + // substitute the header + request.setRequestProperty(entry.getKey(), entry.getValue()); + } + changes.setSubstitutionHeaderKeys(new ArrayList<>(setSubstitutionHeaders.keySet())); + } + + return new PreparedRequestData(mutator, changes, true); + } + + /** + * Performs the configured query parameter substitutions for a URL and captures + * the mutation metadata needed by the httpsurlconn service layer to keep + * request-header and URL substitutions in sync. + * + * @param url is the URL being analyzed for substitution + * @param mutator is the mutator that decides how substitution results are handled + * @return the query substitution result + * @throws ApproovException if it is not possible to obtain secure strings for + * substitution + */ + static synchronized QuerySubstitutionResult substituteQueryParamsDetailed(URL url, ApproovServiceMutator mutator) + throws ApproovException { + // throw if we couldn't initialize the SDK + if (pinningHostnameVerifier == null) + throw new ApproovException("Approov not initialized"); + + // check if the URL matches one of the exclusion regexs and just return if so + String originalURL = url.toString(); + for (Pattern pattern : exclusionURLRegexs.values()) { + Matcher matcher = pattern.matcher(originalURL); + if (matcher.find()) + return new QuerySubstitutionResult(url, originalURL, Collections.emptyList()); + } + + String replacementURL = originalURL; + Map queryParams = getSubstitutionQueryParams(); + List queryKeys = new ArrayList<>(queryParams.size()); + for (Map.Entry entry : queryParams.entrySet()) { + String queryKey = entry.getKey(); + Matcher matcher = entry.getValue().matcher(replacementURL); + if (matcher.find()) { + // we have found an occurrence of the query parameter to be replaced so + // we look up the existing value as a key for a secure string + String queryValue = matcher.group(1); + Approov.TokenFetchResult approovResults = Approov.fetchSecureStringAndWait(queryValue, null); + Log.d(TAG, "Substituting query parameter: " + queryKey + ", " + approovResults.getStatus().toString()); + if (mutator.handleInterceptorQueryParamSubstitutionResult(approovResults, queryKey)) { + queryKeys.add(queryKey); + replacementURL = new StringBuilder(replacementURL) + .replace(matcher.start(1), matcher.end(1), approovResults.getSecureString()) + .toString(); } - else if (approovResults.getStatus() != Approov.TokenFetchStatus.UNKNOWN_KEY) - // we have failed to get a secure string with a more serious permanent error - throw new ApproovException("Header substitution for " + header + ": " + - approovResults.getStatus().toString()); } } + + if (originalURL.equals(replacementURL)) + return new QuerySubstitutionResult(url, originalURL, Collections.emptyList()); + + try { + return new QuerySubstitutionResult(new URL(replacementURL), originalURL, queryKeys); + } catch (MalformedURLException e) { + throw new ApproovException("Malformed substituted URL: " + e.getMessage()); + } + } + + /** + * Adds Approov to the given request using the legacy in-place API. The + * Approov token is added in a header, any optional TraceID debug value is + * added in a separate header, the HostnameVerifier may be overridden to pin + * the request, and configured secure string header substitutions are applied. + * + * This method preserves the binary-compatible API from earlier releases. Use + * addApproovToConnection(HttpsURLConnection) when configured query + * substitutions may change the effective URL or when the caller needs + * deferred body-aware processing such as message-signing body digests. + * + * @param request is the HttpsUrlConnection to which Approov is being added + * @throws ApproovException if it is not possible to obtain an Approov token or + * secure strings + */ + public static synchronized void addApproov(HttpsURLConnection request) throws ApproovException { + addApproovInternal(request, false); + } + + /** + * Adds Approov to the given request. The Approov token is added in a header, + * any optional TraceID debug value is added in a separate header, the + * HostnameVerifier may be overridden to pin the request, and configured secure + * string substitutions are applied. The mutator acts as the single place + * where token fetch, substitution, pinning, and final processed-request + * behavior can be customized for HttpsURLConnection requests. + * + * @param request is the HttpsUrlConnection to which Approov is being added + * @return the processed request, ready to be used by the caller. In the + * common case this is the same connection instance that was passed in. + * If configured query substitutions change the target URL, or if + * deferred body-aware processing is required, then a wrapped + * connection is returned and the caller must continue to use that + * returned instance. + * @throws ApproovException if it is not possible to obtain an Approov token or + * secure strings + */ + public static synchronized HttpsURLConnection addApproovToConnection(HttpsURLConnection request) throws ApproovException { + return addApproovInternal(request, true); + } + + private static HttpsURLConnection addApproovInternal( + HttpsURLConnection request, + boolean allowBufferedConnection + ) throws ApproovException { + // throw if we couldn't initialize the SDK + if (pinningHostnameVerifier == null) + throw new ApproovException("Approov not initialized"); + + // Apply the non-signing parts of the HttpsURLConnection preparation flow immediately so + // callers continue to see any ApproovException at addApproov() time. + PreparedRequestData preparedRequestData = prepareApproovRequest(request); + + // Apply any configured query parameter substitutions before deciding if we + // can finish processing on the original connection or if we need a wrapper + // because the effective URL changed. + QuerySubstitutionResult querySubstitutionResult; + if (preparedRequestData.invokeProcessedCallback) { + querySubstitutionResult = substituteQueryParamsDetailed(request.getURL(), preparedRequestData.mutator); + } else { + querySubstitutionResult = new QuerySubstitutionResult( + request.getURL(), + request.getURL().toString(), + Collections.emptyList() + ); + } + + if (!preparedRequestData.invokeProcessedCallback) { + return request; + } + + if (shouldUseBufferedConnection(request, querySubstitutionResult) && allowBufferedConnection) { + return new ApproovBufferedHttpsURLConnection(request, preparedRequestData, querySubstitutionResult); + } + + if (querySubstitutionResult.hasEffectiveUrlChange()) { + throw new ApproovException( + "Configured query parameter substitution changed the request URL; " + + "use addApproovToConnection(HttpsURLConnection) and continue with the returned connection" + ); + } + + return preparedRequestData.mutator.handleInterceptorProcessedRequest( + request, + preparedRequestData.changes + ); + } + + /** + * Applies all configured query parameter substitutions to the supplied URL. + * Since this modifies the URL itself it must be done before opening the + * HttpsURLConnection. The mutator is consulted for each substitution result so + * callers can customize how secure string fetch outcomes are handled. + * + * @param url is the URL being analyzed for substitution + * @return URL passed in, or modified with a new URL if substitutions were made + * @throws ApproovException if it is not possible to obtain secure strings for + * substitution + */ + public static synchronized URL substituteQueryParams(URL url) throws ApproovException { + return substituteQueryParamsDetailed(url, getServiceMutator()).url; } /** @@ -719,8 +1220,10 @@ public static synchronized URL substituteQueryParam(URL url, String queryParamet return url; } - // perform the header substitution if it is present - Pattern pattern = Pattern.compile("[\\?&]"+queryParameter+"=([^&;]+)"); + ApproovServiceMutator mutator = getServiceMutator(); + + // perform the query substitution if it is present + Pattern pattern = Pattern.compile("[\\?&]" + Pattern.quote(queryParameter) + "=([^&;]+)"); Matcher matcher = pattern.matcher(urlString); if (matcher.find()) { // we have found an occurrence of the query parameter to be replaced so we look up the existing @@ -728,7 +1231,7 @@ public static synchronized URL substituteQueryParam(URL url, String queryParamet String queryValue = matcher.group(1); Approov.TokenFetchResult approovResults = Approov.fetchSecureStringAndWait(queryValue, null); Log.d(TAG, "Substituting query parameter: " + queryParameter + ", " + approovResults.getStatus().toString()); - if (approovResults.getStatus() == Approov.TokenFetchStatus.SUCCESS) { + if (mutator.handleInterceptorQueryParamSubstitutionResult(approovResults, queryParameter)) { // perform a query substitution try { return new URL(new StringBuilder(urlString).replace(matcher.start(1), @@ -739,25 +1242,6 @@ public static synchronized URL substituteQueryParam(URL url, String queryParamet return url; } } - else if (approovResults.getStatus() == Approov.TokenFetchStatus.REJECTED) - // if the request is rejected then we provide a special exception with additional information - throw new ApproovRejectionException("Query parameter substitution for " + queryParameter + ": " + - approovResults.getStatus().toString() + ": " + approovResults.getARC() + - " " + approovResults.getRejectionReasons(), - approovResults.getARC(), approovResults.getRejectionReasons()); - else if ((approovResults.getStatus() == Approov.TokenFetchStatus.NO_NETWORK) || - (approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) || - (approovResults.getStatus() == Approov.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 this is overridden - if (!proceedOnNetworkFail) - throw new ApproovNetworkException("Query parameter substitution for " + queryParameter + ": " + - approovResults.getStatus().toString()); - } - else if (approovResults.getStatus() != Approov.TokenFetchStatus.UNKNOWN_KEY) - // we have failed to get a secure string with a more serious permanent error - throw new ApproovException("Query parameter substitution for " + queryParameter + ": " + - approovResults.getStatus().toString()); } return url; } @@ -796,6 +1280,10 @@ final class PinningHostnameVerifier implements HostnameVerifier { // HostnameVerifier you would normally be using private final HostnameVerifier delegate; + // trust anchors used to resolve the validated root certificate when it is not + // present in the peer chain + private final Set trustAnchors; + // Tag for log messages private static final String TAG = "ApproovPinVerifier"; @@ -808,6 +1296,72 @@ final class PinningHostnameVerifier implements HostnameVerifier { */ public PinningHostnameVerifier(HostnameVerifier delegate) { this.delegate = delegate; + this.trustAnchors = getDefaultTrustAnchors(); + } + + /** + * Gets the platform default trust anchors so we can validate the peer chain and + * identify the resolved trust root when it is not included in the TLS peer + * certificates. + */ + private Set getDefaultTrustAnchors() { + try { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + Set anchors = new HashSet<>(); + for (TrustManager trustManager : trustManagerFactory.getTrustManagers()) { + if (trustManager instanceof X509TrustManager) { + for (X509Certificate cert : ((X509TrustManager) trustManager).getAcceptedIssuers()) + anchors.add(new TrustAnchor(cert, null)); + } + } + return anchors; + } catch (Exception e) { + Log.e(TAG, "Unable to initialize default trust anchors", e); + return Collections.emptySet(); + } + } + + /** + * Hashes a public key using the Approov pinning format. + */ + private String hashPublicKey(PublicKey publicKey) { + ByteString digest = ByteString.of(publicKey.getEncoded()).sha256(); + return digest.base64(); + } + + /** + * Validates the peer chain with PKIX and returns the resolved trust anchor + * certificate if one is available as a certificate object. + */ + private X509Certificate getTrustAnchorCertificate(SSLSession session) { + if (trustAnchors.isEmpty()) + return null; + + try { + List peerCertificates = new ArrayList<>(); + for (Certificate cert : session.getPeerCertificates()) { + if (cert instanceof X509Certificate) + peerCertificates.add((X509Certificate) cert); + } + if (peerCertificates.isEmpty()) + return null; + + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + CertPath certPath = certificateFactory.generateCertPath(peerCertificates); + CertPathValidator validator = CertPathValidator.getInstance("PKIX"); + PKIXParameters pkixParameters = new PKIXParameters(trustAnchors); + pkixParameters.setRevocationEnabled(false); + PKIXCertPathValidatorResult result = (PKIXCertPathValidatorResult) validator.validate(certPath, pkixParameters); + return result.getTrustAnchor().getTrustedCert(); + } catch (CertificateException e) { + Log.e(TAG, "Unable to validate certificate chain", e); + } catch (SSLException e) { + Log.e(TAG, "Unable to read peer certificate chain", e); + } catch (Exception e) { + Log.e(TAG, "Unable to resolve trust anchor", e); + } + return null; } @Override @@ -837,8 +1391,7 @@ public boolean verify(String hostname, SSLSession session) { for (Certificate cert: session.getPeerCertificates()) { if (cert instanceof X509Certificate) { X509Certificate x509Cert = (X509Certificate) cert; - ByteString digest = ByteString.of(x509Cert.getPublicKey().getEncoded()).sha256(); - String hash = digest.base64(); + String hash = hashPublicKey(x509Cert.getPublicKey()); if (hostPins.contains(hash)) return true; } @@ -846,6 +1399,12 @@ public boolean verify(String hostname, SSLSession session) { Log.e(TAG, "Certificate not X.509"); } + // If the validated trust anchor/root was not presented by the peer, resolve it + // from the platform trust store and check its public key hash too. + X509Certificate trustAnchorCert = getTrustAnchorCertificate(session); + if ((trustAnchorCert != null) && hostPins.contains(hashPublicKey(trustAnchorCert.getPublicKey()))) + return true; + // the connection is rejected Log.w(TAG, "Pinning rejection for " + hostname); return false; diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovServiceMutator.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovServiceMutator.java new file mode 100644 index 0000000..4dd659a --- /dev/null +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovServiceMutator.java @@ -0,0 +1,359 @@ +// +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package io.approov.service.httpsurlconn; + +// HttpsURLConnection request equivalent imports +import java.net.URL; +import java.net.HttpURLConnection; + +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +import com.criticalblue.approovsdk.Approov; + +import javax.net.ssl.HttpsURLConnection; + +/** + * ApproovServiceMutator provides an interface for modifying the behavior of + * the ApproovService class by overriding the default implementations of the + * defined callbacks. Opportunities to modify behavior are offered at key + * points in the service and attestation flows. + * + * The interface provides default implementations for all methods, so + * implementing classes can choose to override only the methods they are + * interested in. The default implementations provide standard behavior + * that is suitable for most use cases and provides backwards compatibility + * with previous versions of this Approov service layer. + */ +public interface ApproovServiceMutator { + /** + * Default mutator that provides standard behavior with no changes. + */ + public static final ApproovServiceMutator DEFAULT = new ApproovServiceMutator() { + @Override + public String toString() { + return "ApproovServiceMutator.DEFAULT"; + } + }; + + /** + * Decides how to handle the token fetch result from an + * ApproovService.precheck() operation. + * + * @param approovResults the TokenFetchResult obtained by + * ApproovService.precheck() + * @throws ApproovException The implementation can either return, taking no + * action or throw an ApproovException encoding + * the cause of the failure. + */ + @SuppressWarnings("deprecation") + default void handlePrecheckResult(Approov.TokenFetchResult approovResults) throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + String arc = approovResults.getARC(); + String rejectionReasons = approovResults.getRejectionReasons(); + switch (status) { + case REJECTED: + throw new ApproovRejectionException( + "precheck: " + status.toString() + ": " + arc + " " + rejectionReasons, arc, rejectionReasons); + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + throw new ApproovNetworkException(status, "precheck: " + status.toString()); + case SUCCESS: + case UNKNOWN_KEY: + break; + default: + throw new ApproovFetchStatusException(status, "precheck: " + status.toString()); + } + } + + /** + * Decides how to handle the token fetch result from an + * ApproovService.fetchToken() operation. + * + * @param approovResults the TokenFetchResult obtained by + * ApproovService.fetchToken() + * @throws ApproovException The implementation can either return, taking no + * action or throw an ApproovException encoding + * the cause of the failure. + */ + @SuppressWarnings("deprecation") + default void handleFetchTokenResult(Approov.TokenFetchResult approovResults) throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + switch (status) { + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + throw new ApproovNetworkException(status, "fetchToken: " + status.toString()); + case SUCCESS: + break; + default: + throw new ApproovFetchStatusException(status, "fetchToken: " + status.toString()); + } + } + + /** + * Decides how to handle the token fetch result from an + * ApproovService.fetchSecureString() operation. + * + * @param approovResults the TokenFetchResult obtained by + * ApproovService.fetchSecureString() + * @param operation the operation type ("lookup" or "definition"); "lookup" + * indicates that an existing value was requested, while + * "definition" indicates that a new value was being added + * or set + * @param key the secure string key + * @throws ApproovException The implementation can either return, taking no + * action or throw an ApproovException encoding + * the cause of the failure + */ + @SuppressWarnings("deprecation") + default void handleFetchSecureStringResult(Approov.TokenFetchResult approovResults, String operation, String key) + throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + String arc = approovResults.getARC(); + String rejectionReasons = approovResults.getRejectionReasons(); + switch (status) { + case REJECTED: + throw new ApproovRejectionException("fetchSecureString " + operation + " for " + key + ": " + + status.toString() + ": " + arc + " " + rejectionReasons, arc, rejectionReasons); + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + throw new ApproovNetworkException(status, + "fetchSecureString " + operation + " for " + key + ": " + status.toString()); + case SUCCESS: + case UNKNOWN_KEY: + break; + default: + throw new ApproovFetchStatusException(status, + "fetchSecureString " + operation + " for " + key + ": " + status.toString()); + } + } + + /** + * Decides how to handle the token fetch result from an + * ApproovService.fetchCustomJWT() operation. + * + * @param approovResults the TokenFetchResult obtained by + * ApproovService.fetchCustomJWT() + * @throws ApproovException The implementation can either return, taking no + * action or throw an ApproovException encoding + * the cause of the failure + */ + @SuppressWarnings("deprecation") + default void handleFetchCustomJWTResult(Approov.TokenFetchResult approovResults) throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + String arc = approovResults.getARC(); + String rejectionReasons = approovResults.getRejectionReasons(); + switch (status) { + case REJECTED: + throw new ApproovRejectionException( + "fetchCustomJWT: " + status.toString() + ": " + arc + " " + rejectionReasons, arc, + rejectionReasons); + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + throw new ApproovNetworkException(status, "fetchCustomJWT: " + status.toString()); + case SUCCESS: + break; + default: + throw new ApproovFetchStatusException(status, "fetchCustomJWT: " + status.toString()); + } + } + + /** + * Decides whether a request should be processed by the httpsurlconn service + * layer or not. Called at the start of Approov request preparation. + * + * @param request the HttpsURLConnection being prepared + * @return true if the request should be processed by Approov, false if it + * should be issued unchanged + * @throws ApproovException The implementation can either return to indicate the + * action described above or throw an ApproovException + * encoding the cause of the failure + */ + default boolean handleInterceptorShouldProcessConnection(HttpsURLConnection request) throws ApproovException { + if (request == null) + throw new ApproovException( + "handleInterceptorShouldProcessConnection method was passed a request that is null!"); + + // check if the URL matches one of the exclusion regexs and skip + // Approov request preparation in these cases + String url = request.getURL().toString(); + for (Pattern pattern : ApproovService.getExclusionURLRegexs().values()) { + Matcher matcher = pattern.matcher(url); + if (matcher.find()) { + return false; + } + } + return true; + } + + /** + * Decides how to handle the token fetch result from a call to + * Approov.fetchApproovTokenAndWait() during request preparation. + * + * @param approovResults the TokenFetchResult from Approov + * @param url the URL string for which the token was requested + * @return true if processing should continue, false if request should proceed + * even though no token was obtained from the fetch + * @throws ApproovException The implementation can either return to indicate the + * action described above or throw an ApproovException + * encoding the cause of the failure + */ + @SuppressWarnings("deprecation") + default boolean handleInterceptorFetchTokenResult(Approov.TokenFetchResult approovResults, String url) + throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + switch (status) { + case SUCCESS: + return true; + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + if (ApproovService.getUseApproovStatusIfNoToken()) + return true; + if (!ApproovService.getProceedOnNetworkFail()) + throw new ApproovNetworkException(status, + "Approov token fetch for " + url + ": " + status.toString()); + return false; + case NO_APPROOV_SERVICE: + case UNKNOWN_URL: + case UNPROTECTED_URL: // Continue without token for unprotected URLs + return false; + default: + throw new ApproovFetchStatusException(status, + "Approov token fetch for " + url + ": " + status.toString()); + } + } + + /** + * Decides how to handle the token fetch result while substituting headers + * during request preparation. The passed fetch result to process is associated with + * a preceding call to Approov.fetchSecureStringAndWait which passed in the + * current header value (minus a prefix) as the key. This method is called once + * per header being processed for substitution. + * + * @param approovResults the TokenFetchResult from Approov + * @param header the header being substituted + * @return true if substitution should proceed, false if it should be skipped + * @throws ApproovException The implementation can either return to indicate the + * action described above or throw an ApproovException + * encoding the cause of the failure + */ + @SuppressWarnings("deprecation") + default boolean handleInterceptorHeaderSubstitutionResult(Approov.TokenFetchResult approovResults, String header) + throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + String arc = approovResults.getARC(); + String rejectionReasons = approovResults.getRejectionReasons(); + switch (status) { + case SUCCESS: + return true; + case REJECTED: + throw new ApproovRejectionException("Header substitution for " + header + ": " + status.toString() + + ": " + arc + " " + rejectionReasons, arc, rejectionReasons); + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + if (!ApproovService.getProceedOnNetworkFail()) + throw new ApproovNetworkException(status, + "Header substitution for " + header + ": " + status.toString()); + return false; + case UNKNOWN_KEY: + return false; + default: + throw new ApproovFetchStatusException(status, + "Header substitution for " + header + ": " + status.toString()); + } + } + + /** + * Decides how to handle the token fetch result while substituting query params + * during request preparation. The passed fetch result to process is associated + * with a preceding call to Approov.fetchSecureStringAndWait which passed in the + * query value of a matching query key. This method is called once for each + * matched + * query parameter being processed for substitution. + * + * @param approovResults the TokenFetchResult from Approov + * @param queryKey the query parameter key being substituted + * @return true if substitution should proceed, false if it should be skipped + * @throws ApproovException The implementation can either return to indicate the + * action described above or throw an ApproovException + * encoding the cause of the failure + */ + @SuppressWarnings("deprecation") + default boolean handleInterceptorQueryParamSubstitutionResult(Approov.TokenFetchResult approovResults, + String queryKey) throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + String arc = approovResults.getARC(); + String rejectionReasons = approovResults.getRejectionReasons(); + switch (status) { + case SUCCESS: + return true; + case REJECTED: + throw new ApproovRejectionException("Query parameter substitution for " + queryKey + ": " + + status.toString() + ": " + arc + " " + rejectionReasons, arc, rejectionReasons); + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + if (!ApproovService.getProceedOnNetworkFail()) + throw new ApproovNetworkException(status, + "Query parameter substitution for " + queryKey + ": " + status.toString()); + return false; + case UNKNOWN_KEY: + return false; + default: + throw new ApproovFetchStatusException(status, + "Query parameter substitution for " + queryKey + ": " + status.toString()); + } + } + + /** + * Called after the httpsurlconn service layer has applied its token and + * substitution changes, allowing further request modifications such as + * message signing. + * + * @param request the processed request + * @param changes the mutations applied to the request by Approov + * @return the final request to use to complete Approov request preparation + * @throws ApproovException The implementation can either return as described + * above or throw an ApproovException encoding the + * cause of the failure + */ + default HttpsURLConnection handleInterceptorProcessedRequest(HttpsURLConnection request, ApproovRequestMutations changes) + throws ApproovException { + // Modifies request in place and returns it by default, as no further + // changes to the request are required. + return request; + } + + /** + * Decides whether certificate pinning should be applied to a request or not. + * Called at the start of the ApproovService pinning processing. + * + * @param request the request being processed + * @return true if pinning should be applied, false to skip it + */ + default boolean handlePinningShouldProcessRequest(HttpURLConnection request) { + // By default do not skip pinning for any requests + return true; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/BooleanItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/BooleanItem.java new file mode 100644 index 0000000..0bcb867 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/BooleanItem.java @@ -0,0 +1,66 @@ +package io.approov.util.http.sfv; + +import java.util.Objects; + +/** + * Represents a Boolean. + * + * @see Section + * 3.3.6 of RFC 8941 + */ +public class BooleanItem implements Item { + + private final boolean value; + private final Parameters params; + + private static final BooleanItem TRUE = new BooleanItem(true, Parameters.EMPTY); + private static final BooleanItem FALSE = new BooleanItem(false, Parameters.EMPTY); + + private BooleanItem(boolean value, Parameters params) { + this.value = value; + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates a {@link BooleanItem} instance representing the specified + * {@code boolean} value. + * + * @param value + * a {@code boolean} value. + * @return a {@link BooleanItem} representing {@code value}. + */ + public static BooleanItem valueOf(boolean value) { + return value ? TRUE : FALSE; + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public BooleanItem withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new BooleanItem(this.value, params); + } + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + sb.append(value ? "?1" : "?0"); + params.serializeTo(sb); + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } + + @Override + public Boolean get() { + return value; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/ByteSequenceItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/ByteSequenceItem.java new file mode 100644 index 0000000..308b440 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/ByteSequenceItem.java @@ -0,0 +1,70 @@ +package io.approov.util.http.sfv; + +import android.util.Base64; + +import java.nio.ByteBuffer; +import java.util.Objects; + +/** + * Represents a Byte Sequence. + * + * @see Section + * 3.3.5 of RFC 8941 + */ +public class ByteSequenceItem implements Item { + + private final byte[] value; + private final Parameters params; + + private ByteSequenceItem(byte[] value, Parameters params) { + this.value = Objects.requireNonNull(value, "value must not be null"); + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates a {@link ByteSequenceItem} instance representing the specified + * {@code byte[]} value. + * + * @param value + * a {@code byte[]} value. + * @return a {@link ByteSequenceItem} representing {@code value}. + */ + public static ByteSequenceItem valueOf(byte[] value) { + return new ByteSequenceItem(value, Parameters.EMPTY); + } + + @Override + public ByteSequenceItem withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new ByteSequenceItem(this.value, params); + } + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + sb.append(':'); + sb.append(Base64.encodeToString(this.value, Base64.NO_WRAP)); + sb.append(':'); + params.serializeTo(sb); + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } + + @Override + public ByteBuffer get() { + // this returns a wrapper around a copy so that the object itself + // stays immutable + return ByteBuffer.wrap(this.value.clone()); + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/DateItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/DateItem.java new file mode 100644 index 0000000..1625029 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/DateItem.java @@ -0,0 +1,77 @@ +package io.approov.util.http.sfv; + +import java.util.Objects; + +/** + * Represents a Date. + */ +public class DateItem implements NumberItem { + + private final long value; + private final Parameters params; + + private static final long MIN = -999999999999999L; + private static final long MAX = 999999999999999L; + + private DateItem(long value, Parameters params) { + if (value < MIN || value > MAX) { + throw new IllegalArgumentException("value must be in the range from " + MIN + " to " + MAX); + } + this.value = value; + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates an {@link DateItem} instance representing the specified + * {@code long} value. + * + * @param value + * a {@code long} value. + * @return a {@link DateItem} representing {@code value}. + */ + public static DateItem valueOf(long value) { + return new DateItem(value, Parameters.EMPTY); + } + + @Override + public DateItem withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new DateItem(this.value, params); + } + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + sb.append('@'); + sb.append(value); + params.serializeTo(sb); + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } + + @Override + public Long get() { + return value; + } + + @Override + public long getAsLong() { + return value; + } + + @Override + public int getDivisor() { + return 1; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/DecimalItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/DecimalItem.java new file mode 100644 index 0000000..9e477fa --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/DecimalItem.java @@ -0,0 +1,118 @@ +package io.approov.util.http.sfv; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Represents a Decimal. + *

+ * A Decimal - despite it's name - is essentially the same thing as an Integer, + * but has an implied divisor of 1000 (in other words, a scale of 3). Thus, a + * value represented as {@code 0.5} in a field value will be internally stored + * as {@code long} with value {@code 500}. The only difference to + * {@link IntegerItem} is that {@link #get()} will return a {@link BigDecimal}, + * and that the implied divisor is taken into account when serializing the + * value. {@link #getAsLong()} provides access to the raw value when the + * overhead of {@link BigDecimal} is not needed. + * + * @see Section + * 3.3.2 of RFC 8941 + */ +public class DecimalItem implements NumberItem { + + private final long value; + private final Parameters params; + + private static final long MIN = -999999999999999L; + private static final long MAX = 999999999999999L; + private static final BigDecimal THOUSAND = new BigDecimal(1000); + + private DecimalItem(long value, Parameters params) { + if (value < MIN || value > MAX) { + throw new IllegalArgumentException("value must be in the range from " + MIN + " to " + MAX); + } + this.value = value; + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates a {@link DecimalItem} instance representing the specified + * {@code long} value, where the implied divisor is {@code 1000}. + * + * @param value + * a {@code long} value. + * @return a {@link DecimalItem} representing {@code value}. + */ + public static DecimalItem valueOf(long value) { + return new DecimalItem(value, Parameters.EMPTY); + } + + /** + * Creates a {@link DecimalItem} instance representing the specified + * {@code BigDecimal} value, with potential rounding. + * + * @param value + * a {@code BigDecimal} value. + * @return a {@link DecimalItem} representing {@code value}. + */ + public static DecimalItem valueOf(BigDecimal value) { + BigDecimal permille = (Objects.requireNonNull(value, "value must not be null")).multiply(THOUSAND); + return valueOf(permille.longValue()); + } + + @Override + public DecimalItem withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new DecimalItem(this.value, params); + } + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + + String sign = value < 0 ? "-" : ""; + + long abs = Math.abs(value); + long left = abs / 1000; + long right = abs % 1000; + + if (right % 10 == 0) { + right /= 10; + } + if (right % 10 == 0) { + right /= 10; + } + sb.append(sign).append(left).append('.').append(right); + + params.serializeTo(sb); + + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder(20)).toString(); + } + + @Override + public BigDecimal get() { + return BigDecimal.valueOf(value, 3); + } + + @Override + public long getAsLong() { + return value; + } + + @Override + public int getDivisor() { + return 1000; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/Dictionary.java b/approov-service/src/main/java/io/approov/util/http/sfv/Dictionary.java new file mode 100644 index 0000000..3c1d1c0 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/Dictionary.java @@ -0,0 +1,69 @@ +package io.approov.util.http.sfv; + +import java.util.Collections; +import java.util.Map; + +/** + * Represents a Dictionary. + * + * @see Section 3.2 of + * RFC 8941 + */ +public class Dictionary implements Type>> { + + private final Map> value; + + private Dictionary(Map> value) { + this.value = Collections.unmodifiableMap(Utils.checkKeys(value)); + } + + /** + * Creates a {@link Dictionary} instance representing the specified + * {@code Map} value. + *

+ * Note that the {@link Map} implementation that is used here needs to + * iterate predictably based on insertion order, such as + * {@link java.util.LinkedHashMap}. + * + * @param value + * a {@code Map} value + * @return a {@link Dictionary} representing {@code value}. + */ + public static Dictionary valueOf(Map> value) { + return new Dictionary(value); + } + + @Override + public Map> get() { + return value; + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + String separator = ""; + + for (Map.Entry> e : value.entrySet()) { + sb.append(separator); + separator = ", "; + + String name = e.getKey(); + ListElement value = e.getValue(); + + sb.append(name); + if (Boolean.TRUE.equals(value.get())) { + value.getParams().serializeTo(sb); + } else { + sb.append("="); + value.serializeTo(sb); + } + } + + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/DisplayStringItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/DisplayStringItem.java new file mode 100755 index 0000000..7e1e745 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/DisplayStringItem.java @@ -0,0 +1,72 @@ +package io.approov.util.http.sfv; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + */ +public class DisplayStringItem implements Item { + + private final String value; + private final Parameters params; + + private DisplayStringItem(String value, Parameters params) { + this.value = Objects.requireNonNull(value, "value must not be null"); + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates a {@link StringItem} instance representing the specified + * {@code String} value. + * + * @param value + * a {@code String} value. + * @return a {@link StringItem} representing {@code value}. + */ + public static DisplayStringItem valueOf(String value) { + return new DisplayStringItem(value, Parameters.EMPTY); + } + + @Override + public DisplayStringItem withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new DisplayStringItem(this.value, params); + } + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + sb.append("%\""); + byte[] octets = value.getBytes(StandardCharsets.UTF_8); + for (byte b : octets) { + int unsigned = b & 0xff; + if (unsigned == 0x25 || unsigned == 0x22 || unsigned <= 0x1f || unsigned >= 0x7f) { + sb.append('%'); + sb.append(Character.forDigit((unsigned >> 4) & 0xf, 16)); + sb.append(Character.forDigit(unsigned & 0xf, 16)); + } else { + sb.append((char) unsigned); + } + } + sb.append('"'); + params.serializeTo(sb); + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder(2 + value.length())).toString(); + } + + @Override + public String get() { + return this.value; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/InnerList.java b/approov-service/src/main/java/io/approov/util/http/sfv/InnerList.java new file mode 100644 index 0000000..4e1508f --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/InnerList.java @@ -0,0 +1,77 @@ +package io.approov.util.http.sfv; + +import java.util.List; +import java.util.Objects; + +/** + * Represents an Inner List. + * + * @see Section 3.1.1 + * of RFC 8941 + */ +public class InnerList implements ListElement>>, Parameterizable>> { + + private final List> value; + private final Parameters params; + + private InnerList(List> value, Parameters params) { + this.value = Objects.requireNonNull(value, "value must not be null"); + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates an {@link InnerList} instance representing the specified + * {@code List} value. + * + * @param value + * a {@code List} value. + * @return a {@link InnerList} representing {@code value}. + */ + public static InnerList valueOf(List> value) { + return new InnerList(value, Parameters.EMPTY); + } + + @Override + public InnerList withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new InnerList(this.value, params); + } + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + String separator = ""; + + sb.append('('); + + for (Item i : value) { + sb.append(separator); + separator = " "; + i.serializeTo(sb); + } + + sb.append(')'); + + params.serializeTo(sb); + + return sb; + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } + + @Override + public List> get() { + return value; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/IntegerItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/IntegerItem.java new file mode 100644 index 0000000..702fcc1 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/IntegerItem.java @@ -0,0 +1,79 @@ +package io.approov.util.http.sfv; + +import java.util.Objects; + +/** + * Represents an Integer. + * + * @see Section + * 3.3.1 of RFC 8941 + */ +public class IntegerItem implements NumberItem { + + private final long value; + private final Parameters params; + + private static final long MIN = -999999999999999L; + private static final long MAX = 999999999999999L; + + private IntegerItem(long value, Parameters params) { + if (value < MIN || value > MAX) { + throw new IllegalArgumentException("value must be in the range from " + MIN + " to " + MAX); + } + this.value = value; + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates an {@link IntegerItem} instance representing the specified + * {@code long} value. + * + * @param value + * a {@code long} value. + * @return a {@link IntegerItem} representing {@code value}. + */ + public static IntegerItem valueOf(long value) { + return new IntegerItem(value, Parameters.EMPTY); + } + + @Override + public IntegerItem withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new IntegerItem(this.value, params); + } + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + sb.append(value); + params.serializeTo(sb); + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } + + @Override + public Long get() { + return value; + } + + @Override + public long getAsLong() { + return value; + } + + @Override + public int getDivisor() { + return 1; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/Item.java b/approov-service/src/main/java/io/approov/util/http/sfv/Item.java new file mode 100644 index 0000000..bddcb3d --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/Item.java @@ -0,0 +1,95 @@ +package io.approov.util.http.sfv; + +import java.math.BigDecimal; + +/** + * Marker interface for Items. + * + * @param + * represented Java type + * @see Section 3.3 + * of RFC 8941 + */ +public interface Item extends ListElement, Parameterizable { + Item withParams(Parameters params); + + /** + * Convert an object of unknown type into the appropriate Item type. + *

+ * Supported types are Integer, Long, String, Boolean, byte[], BigDecimal + * + * @param o the object to wrap in an item + * @return an Item of the appropriate type or null if the provided value is null or not of a + * supported type. + */ + static Item asItem(Object o) { + if (o == null) { + return null; + } + if (o instanceof Item) { + return (Item) o; + } else if (o instanceof Integer) { + return IntegerItem.valueOf(((Integer) o).longValue()); + } else if (o instanceof Long) { + return IntegerItem.valueOf((Long) o); + } else if (o instanceof String) { + return StringItem.valueOf((String) o); + } else if (o instanceof Boolean) { + return BooleanItem.valueOf((Boolean) o); + } else if (o instanceof byte[]) { + return ByteSequenceItem.valueOf((byte[]) o); + } else if (o instanceof BigDecimal) { + return DecimalItem.valueOf((BigDecimal)o); + } + return null; + } + + /** + * Convert an object of unknown type into the appropriate Item type. + *

+ * Supported types are Integer, Long, String, Boolean, byte[], BigDecimal + * + * @param o the object to wrap in an item + * @param params the parameters to attach to the provided object. + * @return an Item of the appropriate type with the attached params or null if the provided + * value is null or not of a supported type. + */ + static Item asItem(Object o, Parameters params) { + Item item = asItem(o); + if (item == null) { + return null; + } + return item.withParams(params); + } + + /** + * Determine if an object of unknown type is of a type supported by Item.asItem(o). + *

+ * Supported types are Integer, Long, String, Boolean, byte[], BigDecimal + * + * @param o the object to test + * @return true if the object is one of the supported types; false otherwise + */ + static boolean isItemType(Object o) { + if (o == null) { + return false; + } + if (o instanceof Item) { + return true; + } else if (o instanceof Integer) { + return true; + } else if (o instanceof Long) { + return true; + } else if (o instanceof String) { + return true; + } else if (o instanceof Boolean) { + return true; + } else if (o instanceof byte[]) { + return true; + } else if (o instanceof BigDecimal) { + return true; + } + return false; + + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/LICENSE b/approov-service/src/main/java/io/approov/util/http/sfv/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/ListElement.java b/approov-service/src/main/java/io/approov/util/http/sfv/ListElement.java new file mode 100644 index 0000000..3528953 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/ListElement.java @@ -0,0 +1,12 @@ +package io.approov.util.http.sfv; + +/** + * Marker interface for things that can be elements of Outer Lists. + * + * @param + * represented Java type + * @see Section 3.3 + * of RFC 8941 + */ +public interface ListElement extends Parameterizable { +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/NumberItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/NumberItem.java new file mode 100644 index 0000000..207f5f7 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/NumberItem.java @@ -0,0 +1,31 @@ +package io.approov.util.http.sfv; + +import java.util.function.LongSupplier; + +/** + * Common interface for all {@link Type}s that can carry numbers. + * + * @param + * represented Java type + * @see Section + * 3.3.1 of RFC 8941 + * @see Section + * 3.3.2 of RFC 8941 + */ +public interface NumberItem extends Item { + /** Backport of java.util.function.LongSupplier which is only available at API 24 */ + long getAsLong(); + + /** + * Returns the divisor to be used to obtain the actual numerical value (as + * opposed to the underlying long value returned by + * {@link LongSupplier#getAsLong()}). + * + * @return the divisor ({@code 1} for Integers, {@code 1000} for Decimals) + */ + int getDivisor(); + + NumberItem withParams(Parameters params); +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/OuterList.java b/approov-service/src/main/java/io/approov/util/http/sfv/OuterList.java new file mode 100644 index 0000000..e3e385d --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/OuterList.java @@ -0,0 +1,54 @@ +package io.approov.util.http.sfv; + +import java.util.List; +import java.util.Objects; + +/** + * Represents a List. + * + * @see Section 3.1 + * of RFC 8941 + */ +public class OuterList implements Type>> { + + private final List> value; + + private OuterList(List> value) { + this.value = Objects.requireNonNull(value, "value must not be null"); + } + + /** + * Creates an {@link OuterList} instance representing the specified + * {@code List} value. + * + * @param value + * a {@code List} value. + * @return a {@link OuterList} representing {@code value}. + */ + public static OuterList valueOf(List> value) { + return new OuterList(value); + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + String separator = ""; + + for (ListElement i : value) { + sb.append(separator); + separator = ", "; + i.serializeTo(sb); + } + + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } + + @Override + public List> get() { + return value; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/Parameterizable.java b/approov-service/src/main/java/io/approov/util/http/sfv/Parameterizable.java new file mode 100644 index 0000000..496c920 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/Parameterizable.java @@ -0,0 +1,29 @@ +package io.approov.util.http.sfv; + +/** + * Common interface for all {@link Type}s that can carry {@link Parameters}. + * + * @param + * represented Java type + * @see Section + * 3.1.2 of RFC 8941 + */ +public interface Parameterizable extends Type { + + /** + * Given an existing {@link Item}, return a new instance with the specified + * {@link Parameters}. + * + * @param params + * {@link Parameters} to set (must be non-null) + * @return new instance with specified {@link Parameters}. + */ + Parameterizable withParams(Parameters params); + + /** + * Get the {@link Parameters} of this {@link Item}. + * + * @return the parameters. + */ + Parameters getParams(); +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/Parameters.java b/approov-service/src/main/java/io/approov/util/http/sfv/Parameters.java new file mode 100644 index 0000000..3328723 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/Parameters.java @@ -0,0 +1,195 @@ +package io.approov.util.http.sfv; + +import java.math.BigDecimal; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * Represents the Parameters of an Item or an Inner List. + * + * @see Section + * 3.1.2 of RFC 8941 + */ +@android.annotation.SuppressLint("NewApi") +public class Parameters implements Map> { + + private final Map> delegate; + + public static final Parameters EMPTY = new Parameters(Collections.emptyMap()); + + private Parameters(Map value) { + this.delegate = Collections.unmodifiableMap(checkAndTransformMap(value)); + } + + /** + * Creates an unmodifiable {@link Parameters} instance representing the + * specified {@code Map} value. + *

+ * Note that the {@link Map} implementation that is used here needs to + * iterate predictably based on insertion order, such as + * {@link java.util.LinkedHashMap}. + * + * @param value + * a {@code Map} value + * @return a {@link Parameters} representing {@code value}. + */ + public static Parameters valueOf(Map value) { + return new Parameters(value); + } + + public StringBuilder serializeTo(StringBuilder sb) { + for (Map.Entry> e : delegate.entrySet()) { + sb.append(';').append(e.getKey()); + if (!(e.getValue().get().equals(Boolean.TRUE))) { + sb.append('='); + e.getValue().serializeTo(sb); + } + } + return sb; + } + + private static Map> checkAndTransformMap(Map map) { + Map> result = new LinkedHashMap<>( + Objects.requireNonNull(map, "Map must not be null").size()); + for (Map.Entry entry : map.entrySet()) { + String key = Utils.checkKey(entry.getKey()); + Item value = asItem(key, entry.getValue()); + if (!value.getParams().isEmpty()) { + throw new IllegalArgumentException("Parameter value for '" + key + "' must be bare item (no parameters)"); + } + result.put(entry.getKey(), value); + } + return result; + } + + private static Item asItem(String key, Object o) { + Item item = Item.asItem(o); + if (item == null) { + throw new IllegalArgumentException("Can't map value for parameter '" + key + "': " + o.getClass()); + } + return item; + } + + // delegate methods, autogenerated + + public void clear() { + throw new UnsupportedOperationException(); + } + + public Item compute(String key, + BiFunction, ? extends Item> remappingFunction) { + throw new UnsupportedOperationException(); + } + + public Item computeIfAbsent(String key, + Function> mappingFunction) { + throw new UnsupportedOperationException(); + } + + public Item computeIfPresent(String key, + BiFunction, ? extends Item> remappingFunction) { + throw new UnsupportedOperationException(); + } + + public boolean containsKey(Object key) { + return delegate.containsKey(key); + } + + public boolean containsValue(Object value) { + return delegate.containsValue(value); + } + + public Set>> entrySet() { + return delegate.entrySet(); + } + + public boolean equals(Object o) { + return Objects.equals(delegate, o); + } + + public void forEach(BiConsumer> action) { + for (Map.Entry> entry : delegate.entrySet()) { + action.accept(entry.getKey(), entry.getValue()); + } + } + + public Item get(Object key) { + return delegate.get(key); + } + + public Item getOrDefault(Object key, Item defaultValue) { + if (delegate.containsKey(key)) { + return delegate.get(key); + } + return defaultValue; + } + + public int hashCode() { + return delegate.hashCode(); + } + + public boolean isEmpty() { + return delegate.isEmpty(); + } + + public Set keySet() { + return delegate.keySet(); + } + + public Item merge(String key, Item value, + BiFunction, ? super Item, ? extends Item> remappingFunction) { + throw new UnsupportedOperationException(); + } + + public Item put(String key, Item value) { + throw new UnsupportedOperationException(); + } + + public void putAll(Map> m) { + throw new UnsupportedOperationException(); + } + + public Item putIfAbsent(String key, Item value) { + throw new UnsupportedOperationException(); + } + + public boolean remove(Object key, Object value) { + throw new UnsupportedOperationException(); + } + + public Item remove(Object key) { + throw new UnsupportedOperationException(); + } + + public boolean replace(String key, Item oldValue, Item newValue) { + throw new UnsupportedOperationException(); + } + + public Item replace(String key, Item value) { + throw new UnsupportedOperationException(); + } + + public void replaceAll(BiFunction, ? extends Item> function) { + throw new UnsupportedOperationException(); + } + + public int size() { + return delegate.size(); + } + + public Collection> values() { + return delegate.values(); + } + + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/ParseException.java b/approov-service/src/main/java/io/approov/util/http/sfv/ParseException.java new file mode 100644 index 0000000..ad20e49 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/ParseException.java @@ -0,0 +1,140 @@ +package io.approov.util.http.sfv; + +import java.nio.CharBuffer; + +/** + * {@link IllegalArgumentException}, augmented with details. + */ +public class ParseException extends IllegalArgumentException { + + private final int position; + private final String data; + + /** + * Create instance of {@link ParseException}. + * + * @param message + * exception message. + * @param input + * parser input. + * @param position + * position where parse exception occurred. + * @param cause + * underlying exception, if any. + */ + public ParseException(String message, String input, int position, Throwable cause) { + super(message, cause); + this.position = position; + this.data = input; + } + + /** + * Create instance of {@link ParseException}. + * + * @param message + * exception message. + * @param input + * parser input. + * @param position + * position where parse exception occurred. + * @param cause + * underlying exception, if any. + */ + public ParseException(String message, CharBuffer input, int position, Throwable cause) { + super(message, cause); + this.position = position; + this.data = asString(input); + } + + /** + * Create instance of {@link ParseException}. + * + * @param message + * exception message. + * @param input + * parser input. + * @param position + * position where parse exception occurred. + */ + public ParseException(String message, String input, int position) { + this(message, input, position, null); + } + + /** + * Create instance of {@link ParseException}. + * + * @param message + * exception message. + * @param input + * current state of input buffer. + * @param cause + * underlying exception, if any. + */ + public ParseException(String message, CharBuffer input, Throwable cause) { + this(message, asString(input), input.position(), cause); + } + + /** + * Create instance of {@link ParseException}. + * + * @param message + * exception message. + * @param input + * current state of input buffer. + */ + public ParseException(String message, CharBuffer input) { + this(message, asString(input), input.position(), null); + } + + /** + * Return the raw data on which the parser operated.. + * + * @return the raw data. + */ + public String getData() { + return data; + } + + /** + * Return the approximate position where the parse error occurred. + * + * @return the position. + */ + public int getPosition() { + return position; + } + + /** + * Gets additional diagnostics. + * + * @return two lines of data; first contains the raw parse data enclosed in + * ">>" and "<<", the second ASCII artwork with "^" + * pointing to the parse position, followed by the actual exception + * message. + */ + public String getDiagnostics() { + StringBuilder sb = new StringBuilder(); + sb.append(">>").append(data).append("<<").append('\n'); + sb.append(" "); + for (int i = 0; i < position; i++) { + sb.append('-'); + } + sb.append("^ "); + if (position < data.length()) { + char c = data.charAt(position); + sb.append(String.format("(0x%02x) ", (int) c)); + } + sb.append(super.getMessage()).append('\n'); + return sb.toString(); + } + + private static String asString(CharBuffer input) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < input.position() + input.remaining(); i++) { + sb.append(input.get(i)); + } + return sb.toString(); + } + + private static final long serialVersionUID = -5222947525946866985L; +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/Parser.java b/approov-service/src/main/java/io/approov/util/http/sfv/Parser.java new file mode 100644 index 0000000..96d91f2 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/Parser.java @@ -0,0 +1,1068 @@ +package io.approov.util.http.sfv; + +import android.util.Base64; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; + +/** + * Implementation of the "Structured Field Values" Parser. + * + * @see Section 4.2 of + * RFC 8941 + */ +public class Parser { + + private final CharBuffer input; + private final List startPositions; + + /** + * Creates {@link Parser} for the given input. + * + * @param input + * single field line + * @throws ParseException + * for non-ASCII characters + */ + public Parser(String input) { + this(Collections.singletonList(Objects.requireNonNull(input, "input must not be null"))); + } + + /** + * Creates {@link Parser} for the given input. + * + * @param input + * field lines + * @throws ParseException + * for non-ASCII characters + */ + public Parser(String... input) { + this(Arrays.asList(input)); + } + + /** + * Creates {@link Parser} for the given input. + * + * @param fieldLines + * field lines + * @throws ParseException + * for non-ASCII characters or empty input + */ + public Parser(Iterable fieldLines) { + + StringBuilder sb = null; + String str = null; + List startPositions = Collections.emptyList(); + + for (String s : Objects.requireNonNull(fieldLines, "fieldLines must not be null")) { + Objects.requireNonNull(s, "field line must not be null"); + if (str == null) { + str = checkASCII(s); + } else { + if (sb == null) { + sb = new StringBuilder(); + sb.append(str); + } + if (startPositions.isEmpty()) { + startPositions = new ArrayList<>(); + } + startPositions.add(sb.length()); + sb.append(",").append(checkASCII(s)); + } + } + if (str == null) { + throw new ParseException("Empty input", "", 0); + } + this.input = CharBuffer.wrap(sb != null ? sb : str); + this.startPositions = startPositions; + } + + private static String checkASCII(String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c > 0x7f) { + throw new ParseException(String.format("Invalid character in field line at position %d: '%c' (0x%04x) (input: %s)", + i, c, (int) c, value), value, i); + } + } + return value; + } + + private DateItem internalParseBareDate() { + int sign = 1; + StringBuilder inputNumber = new StringBuilder(21); + + if (!checkNextChar("@")) { + throw complaint("Illegal start for Date: '" + input + "'"); + } + advance(); + + if (checkNextChar('-')) { + sign = -1; + advance(); + } + + if (!checkNextChar("0123456789")) { + throw complaint("Illegal start inside a Date: '" + input + "'"); + } + + boolean done = false; + while (hasRemaining() && !done) { + char c = peek(); + if (Utils.isDigit(c)) { + inputNumber.append(c); + advance(); + } else { + done = true; + } + if (inputNumber.length() > 15) { + backout(); + throw complaint("Date too long: " + inputNumber.length() + " characters"); + } + } + + long l = Long.parseLong(inputNumber.toString()); + return DateItem.valueOf(sign * l); + } + + private NumberItem internalParseBareIntegerOrDecimal() { + boolean isDecimal = false; + int sign = 1; + StringBuilder inputNumber = new StringBuilder(20); + + if (checkNextChar('-')) { + sign = -1; + advance(); + } + + if (!checkNextChar("0123456789")) { + throw complaint("Illegal start for Integer or Decimal: '" + input + "'"); + } + + boolean done = false; + while (hasRemaining() && !done) { + char c = peek(); + if (Utils.isDigit(c)) { + inputNumber.append(c); + advance(); + } else if (!isDecimal && c == '.') { + if (inputNumber.length() > 12) { + throw complaint("Illegal position for decimal point in Decimal after '" + inputNumber + "'"); + } + inputNumber.append(c); + isDecimal = true; + advance(); + } else { + done = true; + } + if (inputNumber.length() > (isDecimal ? 16 : 15)) { + backout(); + throw complaint((isDecimal ? "Decimal" : "Integer") + " too long: " + inputNumber.length() + " characters"); + } + } + + if (!isDecimal) { + long l = Long.parseLong(inputNumber.toString()); + return IntegerItem.valueOf(sign * l); + } else { + int dotPos = inputNumber.indexOf("."); + int fracLen = inputNumber.length() - dotPos - 1; + + if (fracLen < 1) { + backout(); + throw complaint("Decimal must not end in '.'"); + } else if (fracLen == 1) { + inputNumber.append("00"); + } else if (fracLen == 2) { + inputNumber.append("0"); + } else if (fracLen > 3) { + backout(); + throw complaint("Maximum number of fractional digits is 3, found: " + fracLen + ", in: " + inputNumber); + } + + inputNumber.deleteCharAt(dotPos); + long l = Long.parseLong(inputNumber.toString()); + return DecimalItem.valueOf(sign * l); + } + } + + private DateItem internalParseDate() { + DateItem result = internalParseBareDate(); + Parameters params = internalParseParameters(); + return result.withParams(params); + } + + private NumberItem internalParseIntegerOrDecimal() { + NumberItem result = internalParseBareIntegerOrDecimal(); + Parameters params = internalParseParameters(); + return result.withParams(params); + } + + private StringItem internalParseBareString() { + + if (getOrEOD() != '"') { + throw complaint("String must start with double quote: '" + input + "'"); + } + + StringBuilder outputString = new StringBuilder(length()); + + while (hasRemaining()) { + if (startPositions.contains(position())) { + throw complaint("String crosses field line boundary at position " + position()); + } + + char c = get(); + if (c == '\\') { + c = getOrEOD(); + if (c == EOD) { + throw complaint("Incomplete escape sequence at position " + position()); + } else if (c != '"' && c != '\\') { + backout(); + throw complaint("Invalid escape sequence character '" + c + "' at position " + position()); + } + outputString.append(c); + } else { + if (c == '"') { + return StringItem.valueOf(outputString.toString()); + } else if (c < 0x20 || c >= 0x7f) { + throw complaint("Invalid character in String at position " + position()); + } else { + outputString.append(c); + } + } + } + + throw complaint("Closing DQUOTE missing"); + } + + private DisplayStringItem internalParseBareDisplayString() { + + CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT); + + if (getOrEOD() != '%') { + throw complaint("DisplayString must start with a percent sign: '" + input + "'"); + } + + if (getOrEOD() != '"') { + throw complaint("DisplayString must continue with a double quote: '" + input + "'"); + } + + ByteArrayOutputStream output = new ByteArrayOutputStream(length() * 2); + int startpos = position(); + + while (hasRemaining()) { + if (startPositions.contains(position())) { + throw complaint("Display String crosses field line boundary at position " + position()); + } + + char c = get(); + if (c == '%') { + char c1 = getOrEOD(); + if (c1 == EOD) { + throw complaint("Incomplete percent escape sequence at position " + position()); + } else if (!isHex(c1)) { + backout(); + throw complaint("Invalid percent escape sequence character '" + c + "' at position " + position()); + } + char c2 = getOrEOD(); + if (c2 == EOD) { + throw complaint("Incomplete percent escape sequence at position " + position()); + } else if (!isHex(c2)) { + backout(); + throw complaint("Invalid percent escape sequence character '" + c + "' at position " + position()); + } + output.write(decodeHex(c1, c2)); + } else { + if (c == '"') { + ByteBuffer bytes = ByteBuffer.wrap(output.toByteArray()); + int blen = bytes.remaining(); + try { + return DisplayStringItem.valueOf(decoder.decode(bytes).toString()); + } catch (CharacterCodingException ex) { + int length = position() - startpos - 1; + char[] chars = new char[length]; + input.position(startpos); + input.get(chars, 0, length); +// System.err.println("s: " + new String(chars)); + + // map byte positions to input positions + int[] offsets = new int[blen]; + for (int i = 0, j = 0; i < blen; i++) { + offsets[i] = j; +// System.err.println(chars[j] + " " + i + " " + j); + if (chars[j] == '%') { + j+=3; + }else { + j += 1; + } + } + int failpos = startpos + offsets[blen - bytes.remaining()]; + throw complaint("Invalid UTF-8 sequence (" + ex.getMessage() + ") before position " + failpos, failpos, ex); + } + } else if (c < 0x20 || c >= 0x7f) { + throw complaint("Invalid character in Display String at position " + position()); + } else { + output.write(c); + } + } + } + + throw complaint("Closing DQUOTE missing"); + } + + private StringItem internalParseString() { + StringItem result = internalParseBareString(); + Parameters params = internalParseParameters(); + return result.withParams(params); + } + + private DisplayStringItem internalParseDisplayString() { + DisplayStringItem result = internalParseBareDisplayString(); + Parameters params = internalParseParameters(); + return result.withParams(params); + } + + private TokenItem internalParseBareToken() { + + char c = getOrEOD(); + if (c != '*' && !Utils.isAlpha(c)) { + throw complaint("Token must start with ALPHA or *: '" + input + "'"); + } + + StringBuilder outputString = new StringBuilder(length()); + outputString.append(c); + + boolean done = false; + while (hasRemaining() && !done) { + c = peek(); + if (c <= ' ' || c >= 0x7f || "\"(),;<=>?@[\\]{}".indexOf(c) >= 0) { + done = true; + } else { + advance(); + outputString.append(c); + } + } + + return TokenItem.valueOf(outputString.toString()); + } + + private TokenItem internalParseToken() { + TokenItem result = internalParseBareToken(); + Parameters params = internalParseParameters(); + return result.withParams(params); + } + + private static boolean isBase64Char(char c) { + return Utils.isAlpha(c) || Utils.isDigit(c) || c == '+' || c == '/' || c == '='; + } + + private ByteSequenceItem internalParseBareByteSequence() { + if (getOrEOD() != ':') { + throw complaint("Byte Sequence must start with colon: " + input); + } + + StringBuilder outputString = new StringBuilder(length()); + + boolean done = false; + while (hasRemaining() && !done) { + char c = get(); + if (c == ':') { + done = true; + } else { + if (!isBase64Char(c)) { + throw complaint("Invalid Byte Sequence Character '" + c + "' at position " + position()); + } + outputString.append(c); + } + } + + if (!done) { + throw complaint("Byte Sequence must end with COLON: '" + outputString + "'"); + } + + try { + return ByteSequenceItem.valueOf(Base64.decode(outputString.toString(), Base64.NO_WRAP)); + } catch (IllegalArgumentException ex) { + throw complaint(ex.getMessage(), ex); + } + } + + private ByteSequenceItem internalParseByteSequence() { + ByteSequenceItem result = internalParseBareByteSequence(); + Parameters params = internalParseParameters(); + return result.withParams(params); + } + + private BooleanItem internalParseBareBoolean() { + + char c = getOrEOD(); + + if (c == EOD) { + throw complaint("Missing data in Boolean"); + } else if (c != '?') { + backout(); + throw complaint(String.format("Boolean must start with question mark, got '%c'", c)); + } + + c = getOrEOD(); + + if (c == EOD) { + throw complaint("Missing data in Boolean"); + } else if (c != '0' && c != '1') { + backout(); + throw complaint(String.format("Expected '0' or '1' in Boolean, found '%c'", c)); + } + + return BooleanItem.valueOf(c == '1'); + } + + private BooleanItem internalParseBoolean() { + BooleanItem result = internalParseBareBoolean(); + Parameters params = internalParseParameters(); + return result.withParams(params); + } + + private String internalParseKey() { + + char c = getOrEOD(); + if (c == EOD) { + throw complaint("Missing data in Key"); + } else if (c != '*' && !Utils.isLcAlpha(c)) { + backout(); + throw complaint("Key must start with LCALPHA or '*': " + format(c)); + } + + StringBuilder result = new StringBuilder(); + result.append(c); + + boolean done = false; + while (hasRemaining() && !done) { + c = peek(); + if (Utils.isLcAlpha(c) || Utils.isDigit(c) || c == '_' || c == '-' || c == '.' || c == '*') { + result.append(c); + advance(); + } else { + done = true; + } + } + + return result.toString(); + } + + private Parameters internalParseParameters() { + + LinkedHashMap result = new LinkedHashMap<>(); + + boolean done = false; + while (hasRemaining() && !done) { + char c = peek(); + if (c != ';') { + done = true; + } else { + advance(); + removeLeadingSP(); + String name = internalParseKey(); + Item value = BooleanItem.valueOf(true); + if (peek() == '=') { + advance(); + value = internalParseBareItem(); + } + result.put(name, value); + } + } + + return Parameters.valueOf(result); + } + + private Item internalParseBareItem() { + if (!hasRemaining()) { + throw complaint("Empty string found when parsing Bare Item"); + } + + char c = peek(); + if (Utils.isDigit(c) || c == '-') { + return internalParseBareIntegerOrDecimal(); + } else if (c == '"') { + return internalParseBareString(); + } else if (c == '?') { + return internalParseBareBoolean(); + } else if (c == '*' || Utils.isAlpha(c)) { + return internalParseBareToken(); + } else if (c == ':') { + return internalParseBareByteSequence(); + } else if (c == '@') { + return internalParseBareDate(); + } else if (c == '%') { + return internalParseBareDisplayString(); + } else { + throw complaint("Unexpected start character in Bare Item: " + format(c)); + } + } + + private Item internalParseItem() { + Item result = internalParseBareItem(); + Parameters params = internalParseParameters(); + return result.withParams(params); + } + + private ListElement internalParseItemOrInnerList() { + return peek() == '(' ? internalParseInnerList() : internalParseItem(); + } + + private List> internalParseOuterList() { + List> result = new ArrayList<>(); + + while (hasRemaining()) { + result.add(internalParseItemOrInnerList()); + removeLeadingOWS(); + if (!hasRemaining()) { + return result; + } + char c = get(); + if (c != ',') { + backout(); + throw complaint("Expected COMMA in List, got: " + format(c)); + } + removeLeadingOWS(); + if (!hasRemaining()) { + throw complaint("Found trailing COMMA in List"); + } + } + + // Won't get here + return result; + } + + private List> internalParseBareInnerList() { + + char c = getOrEOD(); + if (c != '(') { + throw complaint("Inner List must start with '(': " + input); + } + + List> result = new ArrayList<>(); + + boolean done = false; + while (hasRemaining() && !done) { + removeLeadingSP(); + + c = peek(); + if (c == ')') { + advance(); + done = true; + } else { + Item item = internalParseItem(); + result.add(item); + + c = peek(); + if (c == EOD) { + throw complaint("Missing data in Inner List"); + } else if (c != ' ' && c != ')') { + throw complaint("Expected SP or ')' in Inner List, got: " + format(c)); + } + } + + } + + if (!done) { + throw complaint("Inner List must end with ')': " + input); + } + + return result; + } + + private InnerList internalParseInnerList() { + List> result = internalParseBareInnerList(); + Parameters params = internalParseParameters(); + return InnerList.valueOf(result).withParams(params); + } + + private Dictionary internalParseDictionary() { + + LinkedHashMap> result = new LinkedHashMap<>(); + + while (hasRemaining()) { + + ListElement member; + + String name = internalParseKey(); + + if (peek() == '=') { + advance(); + member = internalParseItemOrInnerList(); + } else { + member = BooleanItem.valueOf(true).withParams(internalParseParameters()); + } + + result.put(name, member); + + removeLeadingOWS(); + if (hasRemaining()) { + char c = get(); + if (c != ',') { + backout(); + throw complaint("Expected COMMA in Dictionary, found: " + format(c)); + } + removeLeadingOWS(); + if (!hasRemaining()) { + throw complaint("Found trailing COMMA in Dictionary"); + } + } + } + + return Dictionary.valueOf(result); + } + + // protected methods unit testing + + protected static DateItem parseDate(String input) { + Parser p = new Parser(input); + DateItem result = p.internalParseDate(); + p.assertEmpty("Extra characters in string parsed as Date"); + return result; + } + + protected static IntegerItem parseInteger(String input) { + Parser p = new Parser(input); + Item result = p.internalParseIntegerOrDecimal(); + if (!(result instanceof IntegerItem)) { + throw p.complaint("String parsed as Integer '" + input + "' is a Decimal"); + } else { + p.assertEmpty("Extra characters in string parsed as Integer"); + return (IntegerItem) result; + } + } + + protected static DecimalItem parseDecimal(String input) { + Parser p = new Parser(input); + Item result = p.internalParseIntegerOrDecimal(); + if (!(result instanceof DecimalItem)) { + throw p.complaint("String parsed as Decimal '" + input + "' is an Integer"); + } else { + p.assertEmpty("Extra characters in string parsed as Decimal"); + return (DecimalItem) result; + } + } + + // public instance methods + + /** + * Implementation of "Parsing a List" + * + * @return result of parse as {@link OuterList}. + * + * @see Section + * 4.2.1 of RFC 8941 + */ + public OuterList parseList() { + removeLeadingSP(); + List> result = internalParseOuterList(); + removeLeadingSP(); + assertEmpty("Extra characters in string parsed as List"); + return OuterList.valueOf(result); + } + + /** + * Implementation of "Parsing a Dictionary" + * + * @return result of parse as {@link Dictionary}. + * + * @see Section + * 4.2.2 of RFC 8941 + */ + public Dictionary parseDictionary() { + removeLeadingSP(); + Dictionary result = internalParseDictionary(); + removeLeadingSP(); + assertEmpty("Extra characters in string parsed as Dictionary"); + return result; + } + + /** + * Implementation of "Parsing an Item" + * + * @return result of parse as {@link Item}. + * + * @see Section + * 4.2.3 of RFC 8941 + */ + public Item parseItem() { + removeLeadingSP(); + Item result = internalParseItem(); + removeLeadingSP(); + assertEmpty("Extra characters in string parsed as Item"); + return result; + } + + // static public methods + + /** + * Implementation of "Parsing a List" (assuming no extra characters left in + * input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link OuterList}. + * + * @see Section + * 4.2.1 of RFC 8941 + */ + public static OuterList parseList(String input) { + Parser p = new Parser(input); + List> result = p.internalParseOuterList(); + p.assertEmpty("Extra characters in string parsed as List"); + return OuterList.valueOf(result); + } + + /** + * Implementation of "Parsing an Item Or Inner List" (assuming no extra + * characters left in input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link Item}. + * + * @see Section + * 4.2.1.1 of RFC 8941 + */ + public static Parameterizable parseItemOrInnerList(String input) { + Parser p = new Parser(input); + ListElement result = p.internalParseItemOrInnerList(); + p.assertEmpty("Extra characters in string parsed as Item or Inner List"); + return result; + } + + /** + * Implementation of "Parsing an Inner List" (assuming no extra characters + * left in input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link InnerList}. + * + * @see Section + * 4.2.1.2 of RFC 8941 + */ + public static InnerList parseInnerList(String input) { + Parser p = new Parser(input); + InnerList result = p.internalParseInnerList(); + p.assertEmpty("Extra characters in string parsed as Inner List"); + return result; + } + + /** + * Implementation of "Parsing a Dictionary" (assuming no extra characters + * left in input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link Dictionary}. + * + * @see Section + * 4.2.2 of RFC 8941 + */ + public static Dictionary parseDictionary(String input) { + Parser p = new Parser(input); + Dictionary result = p.internalParseDictionary(); + p.assertEmpty("Extra characters in string parsed as Dictionary"); + return result; + } + + /** + * Implementation of "Parsing an Item" (assuming no extra characters left in + * input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link Item}. + * + * @see Section + * 4.2.3 of RFC 8941 + */ + public static Item parseItem(String input) { + Parser p = new Parser(input); + Item result = p.parseItem(); + p.assertEmpty("Extra characters in string parsed as Item"); + return result; + } + + /** + * Implementation of "Parsing a Bare Item" (assuming no extra characters + * left in input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link Item}. + * + * @see Section + * 4.2.3.1 of RFC 8941 + */ + public static Item parseBareItem(String input) { + Parser p = new Parser(input); + Item result = p.internalParseBareItem(); + p.assertEmpty("Extra characters in string parsed as Bare Item"); + return result; + } + + /** + * Implementation of "Parsing Parameters" (assuming no extra characters left + * in input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link Parameters}. + * + * @see Section + * 4.2.3.2 of RFC 8941 + */ + public static Parameters parseParameters(String input) { + Parser p = new Parser(input); + Parameters result = p.internalParseParameters(); + p.assertEmpty("Extra characters in string parsed as Parameters"); + return result; + } + + /** + * Implementation of "Parsing a Key" (assuming no extra characters left in + * input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link String}. + * + * @see Section + * 4.2.3.3 of RFC 8941 + */ + public static String parseKey(String input) { + Parser p = new Parser(input); + String result = p.internalParseKey(); + p.assertEmpty("Extra characters in string parsed as Key"); + return result; + } + + /** + * Implementation of "Parsing an Integer or Decimal" (assuming no extra + * characters left in input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link NumberItem}. + * + * @see Section + * 4.2.4 of RFC 8941 + */ + public static NumberItem parseIntegerOrDecimal(String input) { + Parser p = new Parser(input); + NumberItem result = p.internalParseIntegerOrDecimal(); + p.assertEmpty("Extra characters in string parsed as Integer or Decimal"); + return result; + } + + /** + * Implementation of "Parsing a String" (assuming no extra characters left + * in input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link StringItem}. + * + * @see Section + * 4.2.5 of RFC 8941 + */ + public static StringItem parseString(String input) { + Parser p = new Parser(input); + StringItem result = p.internalParseString(); + p.assertEmpty("Extra characters in string parsed as String"); + return result; + } + + /** + * Implementation of "Parsing a Display String" (assuming no extra characters left + * in input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link DisplayStringItem}. + */ + public static DisplayStringItem parseDisplayString(String input) { + Parser p = new Parser(input); + DisplayStringItem result = p.internalParseDisplayString(); + p.assertEmpty("Extra characters in string parsed as String"); + return result; + } + + /** + * Implementation of "Parsing a Token" (assuming no extra characters left in + * input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link TokenItem}. + * + * @see Section + * 4.2.6 of RFC 8941 + */ + public static TokenItem parseToken(String input) { + Parser p = new Parser(input); + TokenItem result = p.internalParseToken(); + p.assertEmpty("Extra characters in string parsed as Token"); + return result; + } + + /** + * Implementation of "Parsing a Byte Sequence" (assuming no extra characters + * left in input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link ByteSequenceItem}. + * + * @see Section + * 4.2.7 of RFC 8941 + */ + public static ByteSequenceItem parseByteSequence(String input) { + Parser p = new Parser(input); + ByteSequenceItem result = p.internalParseByteSequence(); + p.assertEmpty("Extra characters in string parsed as Byte Sequence"); + return result; + } + + /** + * Implementation of "Parsing a Boolean" (assuming no extra characters left + * in input string) + * + * @param input + * {@link String} to parse. + * @return result of parse as {@link BooleanItem}. + * + * @see Section + * 4.2.8 of RFC 8941 + */ + public static BooleanItem parseBoolean(String input) { + Parser p = new Parser(input); + BooleanItem result = p.internalParseBoolean(); + p.assertEmpty("Extra characters at position %d in string parsed as Boolean: '%s'"); + return result; + } + + // utility methods on CharBuffer + + private static final char EOD = (char) -1; + + private void assertEmpty(String message) { + if (hasRemaining()) { + throw complaint(String.format(message, position(), input)); + } + } + + private void advance() { + input.position(1 + input.position()); + } + + private void backout() { + input.position(-1 + input.position()); + } + + private boolean checkNextChar(char c) { + return hasRemaining() && input.charAt(0) == c; + } + + private boolean checkNextChar(String valid) { + return hasRemaining() && valid.indexOf(input.charAt(0)) >= 0; + } + + private char get() { + return input.get(); + } + + private char getOrEOD() { + return hasRemaining() ? get() : EOD; + } + + private boolean hasRemaining() { + return input.hasRemaining(); + } + + private int length() { + return input.length(); + } + + private char peek() { + return hasRemaining() ? input.charAt(0) : EOD; + } + + private int position() { + return input.position(); + } + + private void removeLeadingSP() { + while (checkNextChar(' ')) { + advance(); + } + } + + private void removeLeadingOWS() { + while (checkNextChar(" \t")) { + advance(); + } + } + + private ParseException complaint(String message) { + return new ParseException(message, input); + } + + private ParseException complaint(String message, Throwable cause) { + return new ParseException(message, input, cause); + } + + private ParseException complaint(String message, int position, Throwable cause) { + return new ParseException(message, input, position, cause); + } + + private static boolean isHex(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + } + + private static int decodeHex(char c1, char c2) { + String lookup="0123456789abcdef"; + return (lookup.indexOf(c1) << 4) | lookup.indexOf(c2); + } + + private static String format(char c) { + String s; + if (c == 9) { + s = "HTAB"; + } else { + s = "'" + c + "'"; + } + return String.format("%s (\\u%04x)", s, (int) c); + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/StringItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/StringItem.java new file mode 100644 index 0000000..41ead65 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/StringItem.java @@ -0,0 +1,82 @@ +package io.approov.util.http.sfv; + +import java.util.Objects; + +/** + * Represents a String. + * + * @see Section + * 3.3.3 of RFC 8941 + */ +public class StringItem implements Item { + + private final String value; + private final Parameters params; + + private StringItem(String value, Parameters params) { + this.value = checkParam(Objects.requireNonNull(value, "value must not be null")); + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates a {@link StringItem} instance representing the specified + * {@code String} value. + * + * @param value + * a {@code String} value. + * @return a {@link StringItem} representing {@code value}. + */ + public static StringItem valueOf(String value) { + return new StringItem(value, Parameters.EMPTY); + } + + @Override + public StringItem withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new StringItem(this.value, params); + } + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\\' || c == '"') { + sb.append('\\'); + } + sb.append(c); + } + sb.append('"'); + params.serializeTo(sb); + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder(2 + value.length())).toString(); + } + + @Override + public String get() { + return this.value; + } + + private static String checkParam(String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c < 0x20 || c >= 0x7f) { + throw new IllegalArgumentException( + String.format("Invalid character in String at position %d: '%c' (0x%04x)", i, c, (int) c)); + } + } + return value; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/TokenItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/TokenItem.java new file mode 100644 index 0000000..8f8f8e6 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/TokenItem.java @@ -0,0 +1,77 @@ +package io.approov.util.http.sfv; + +import java.util.Objects; + +/** + * Represents a Token. + * + * @see Section + * 3.3.4 of RFC 8941 + */ +public class TokenItem implements Item { + + private final String value; + private final Parameters params; + + private TokenItem(String value, Parameters params) { + this.value = checkParam(Objects.requireNonNull(value, "value must not be null")); + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates a {@link TokenItem} instance representing the specified + * {@code String} value. + * + * @param value + * a {@code String} value. + * @return a {@link TokenItem} representing {@code value}. + */ + public static TokenItem valueOf(String value) { + return new TokenItem(value, Parameters.EMPTY); + } + + @Override + public TokenItem withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new TokenItem(this.value, params); + } + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + sb.append(this.value); + params.serializeTo(sb); + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } + + @Override + public String get() { + return this.value; + } + + private static String checkParam(String value) { + if (value.isEmpty()) { + throw new IllegalArgumentException("Token can not be empty"); + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if ((i == 0 && (c != '*' && !Utils.isAlpha(c))) || (c <= ' ' || c >= 0x7f || "\"(),;<=>?@[\\]{}".indexOf(c) >= 0)) { + throw new IllegalArgumentException( + String.format("Invalid character in Token at position %d: '%c' (0x%04x)", i, c, (int) c)); + } + } + return value; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/Type.java b/approov-service/src/main/java/io/approov/util/http/sfv/Type.java new file mode 100644 index 0000000..a6a4a36 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/Type.java @@ -0,0 +1,36 @@ +package io.approov.util.http.sfv; + +import java.util.function.Supplier; + +/** + * Base interface for Structured Data Types. + *

+ * Each type is a wrapper around the Java type it represents and which can be + * retrieved using {@link Supplier#get()}. + * + * @param + * represented Java type + * @see Section 3 of RFC + * 8941 + */ +public interface Type /*extends Supplier*/ { + + /** Backport of java.util.function.Supplier which is only available at API 24 */ + T get(); + + /** + * Serialize to an existing {@link StringBuilder}. + * + * @param sb + * where to serialize to + * @return the {@link StringBuilder} so calls can be chained. + */ + StringBuilder serializeTo(StringBuilder sb); + + /** + * Serialize. + * + * @return the serialization. + */ + String serialize(); +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/Utils.java b/approov-service/src/main/java/io/approov/util/http/sfv/Utils.java new file mode 100644 index 0000000..2a87617 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/Utils.java @@ -0,0 +1,47 @@ +package io.approov.util.http.sfv; + +import java.util.Map; +import java.util.Objects; + +/** + * Common utility methods. + */ +public class Utils { + + private Utils() { + } + + protected static boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + + protected static boolean isLcAlpha(char c) { + return (c >= 'a' && c <= 'z'); + } + + protected static boolean isAlpha(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); + } + + protected static String checkKey(String value) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException("Key can not be null or empty"); + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if ((i == 0 && (c != '*' && !isLcAlpha(c))) + || !(isLcAlpha(c) || isDigit(c) || c == '_' || c == '-' || c == '.' || c == '*')) { + throw new IllegalArgumentException( + String.format("Invalid character in key at position %d: '%c' (0x%04x)", i, c, (int) c)); + } + } + return value; + } + + protected static Map> checkKeys(Map> value) { + for (String key : Objects.requireNonNull(value, "value must not be null").keySet()) { + checkKey(key); + } + return value; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/package-info.java b/approov-service/src/main/java/io/approov/util/http/sfv/package-info.java new file mode 100644 index 0000000..42eda44 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/package-info.java @@ -0,0 +1,33 @@ +/** + * Implementation of IETF + * RFC 9651: Structured Field Values for HTTP. + *

+ * Includes a {@link io.approov.util.http.sfv.Parser} and object equivalents of the defined data types + * (see {@link io.approov.util.http.sfv.Type}). + *

+ * Here's a minimal example: + * + *


+ * {
+ *     Parser p = new Parser("a=?0, b, c; foo=bar");
+ *     Dictionary d = p.parseDictionary();
+ *     for (Map.Entry<String, Item<? extends Object>> e : d.get()) {
+ *         String key = e.getKey();
+ *         Item<? extends Object> item = e.getValue();
+ *         Object value = item.get();
+ *         Parameters params = item.getParams();
+ *         System.out.println(key + " -> " + value + (params.isEmpty() ? "" : (" (" + params.serialize() + ")")));
+ *     }
+ * }
+ * 
+ *

+ * gives: + * + *

+ * a -> false
+ * b -> true
+ * c -> true (;foo=bar)
+ * 
+ */ + +package io.approov.util.http.sfv; diff --git a/approov-service/src/main/java/io/approov/util/sig/ComponentProvider.java b/approov-service/src/main/java/io/approov/util/sig/ComponentProvider.java new file mode 100644 index 0000000..42c2b56 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/sig/ComponentProvider.java @@ -0,0 +1,245 @@ +package io.approov.util.sig; + +import java.util.List; +import java.util.regex.Pattern; + +import io.approov.util.http.sfv.Dictionary; +import io.approov.util.http.sfv.Item; +import io.approov.util.http.sfv.ListElement; +import io.approov.util.http.sfv.ParseException; +import io.approov.util.http.sfv.Parser; +import io.approov.util.http.sfv.StringItem; +import io.approov.util.http.sfv.Type; + +/** + * @author jricher + * + */ +public interface ComponentProvider { + Pattern PATTERN_WHITESPACE = Pattern.compile("[\\s\\t]*\\r\\n[\\s\\t]*"); + + // Derived component identifiers + /** The authority of the target URI for a request (Section 2.2.3). */ + String DC_AUTHORITY = "@authority"; + + /** The method used for a request (Section 2.2.1). */ + String DC_METHOD = "@method"; + + /** The absolute path portion of the target URI for a request (Section 2.2.6). */ + String DC_PATH = "@path"; + + /** The query portion of the target URI for a request (Section 2.2.7). */ + String DC_QUERY = "@query"; + + /** A parsed and encoded query parameter of the target URI for a request (Section 2.2.8). */ + String DC_QUERY_PARAM = "@query-param"; + + /** The request target (Section 2.2.5). */ + String DC_REQUEST_TARGET = "@request-target"; + + /** The scheme of the target URI for a request (Section 2.2.4). */ + String DC_SCHEME = "@scheme"; + + /** The status code for a response (Section 2.2.9). */ + String DC_STATUS = "@status"; + + /** The full target URI for a request (Section 2.2.2). */ + String DC_TARGET_URI = "@target-uri"; + + // derived, for requests + String getMethod(); + String getAuthority(); + String getScheme(); + String getTargetUri(); + String getRequestTarget(); + String getPath(); + String getQuery(); + String getQueryParam(String name); + boolean hasBody(); + + // derived, for responses + String getStatus(); + + // fields + boolean hasField(String name); + + String getField(String name); + + static String combineFieldValues(List fields) { + if (fields == null) { + return null; + } else { + StringBuilder sb = new StringBuilder(); + for (String field : fields) { + String trimmedField = field.trim(); + String replacedField = PATTERN_WHITESPACE.matcher(trimmedField).replaceAll(" "); + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(replacedField); + } + return sb.length() > 0 ? sb.toString() : null; + } + } + + default String getComponentValue(StringItem componentIdentifier) { + String baseIdentifier = componentIdentifier.get(); + if (baseIdentifier.startsWith("@")) { + // derived component + switch (baseIdentifier) { + case DC_METHOD: + return getMethod(); + case DC_AUTHORITY: + return getAuthority(); + case DC_SCHEME: + return getScheme(); + case DC_TARGET_URI: + return getTargetUri(); + case DC_REQUEST_TARGET: + return getRequestTarget(); + case DC_PATH: + return getPath(); + case DC_QUERY: + return getQuery(); + case DC_STATUS: + return getStatus(); + case DC_QUERY_PARAM: + { + if (componentIdentifier.getParams().containsKey("name")) { + Item nameParameter = componentIdentifier.getParams().get("name"); + if (nameParameter instanceof StringItem) { + String name = ((StringItem)nameParameter).get(); + return getQueryParam(name); + } else { + throw new IllegalArgumentException("Invalid Syntax: Value for 'name' parameter of " + baseIdentifier + " must be a StringItem"); + } + } else { + throw new IllegalArgumentException("'name' parameter of " + baseIdentifier + " is required"); + } + } + default: + throw new IllegalArgumentException("Unknown derived component: " + baseIdentifier); + } + } else { + if (componentIdentifier.getParams().containsKey("key")) { + Item keyParameter = componentIdentifier.getParams().get("key"); + if (keyParameter instanceof StringItem) { + try { + String fieldValue = getField(baseIdentifier); + Dictionary dictionary = Parser.parseDictionary(fieldValue); + String key = ((StringItem)keyParameter).get(); + if (dictionary.get().containsKey(key)) { + ListElement dictionaryValue = dictionary.get().get(key); + // we always re-serialize the value + return dictionaryValue.serialize(); + } else { + throw new IllegalArgumentException("Value for '" + key + "' key of dictionary " + baseIdentifier + " does not exist"); + } + } catch (ParseException e) { + throw new IllegalArgumentException("Field " + baseIdentifier + " is not a dictionary field"); + } + } else { + throw new IllegalArgumentException("Invalid Syntax: Value for 'key' parameter of field " + baseIdentifier + " must be a StringItem"); + } + } else if (componentIdentifier.getParams().containsKey("sf")) { + switch (baseIdentifier) { + case "accept": + case "accept-encoding": + case "accept-language": + case "accept-patch": + case "accept-ranges": + case "access-control-allow-headers": + case "access-control-allow-methods": + case "access-control-expose-headers": + case "access-control-request-headers": + case "allow": + case "alpn": + case "connection": + case "content-encoding": + case "content-language": + case "content-length": + case "te": + case "timing-allow-origin": + case "trailer": + case "transfer-encoding": + case "vary": + case "x-xss-protection": + case "cache-status": + case "proxy-status": + case "variant-key": + case "x-list": + case "x-list-a": + case "x-list-b": + case "accept-ch": + case "example-list": + { + try { + String fieldValue = getField(baseIdentifier); + Type sf = Parser.parseList(fieldValue); + return sf.serialize(); + } catch (ParseException e) { + throw new IllegalArgumentException("Field " + baseIdentifier + " is not a structured field"); + } + } + case "alt-svc": + case "cache-control": + case "expect-ct": + case "keep-alive": + case "pragma": + case "prefer": + case "preference-applied": + case "surrogate-control": + case "variants": + case "signature": + case "signature-input": + case "priority": + case "x-dictionary": + case "example-dict": + case "cdn-cache-control": + { + try { + String fieldValue = getField(baseIdentifier); + Type sf = Parser.parseDictionary(fieldValue); + return sf.serialize(); + } catch (ParseException e) { + throw new IllegalArgumentException("Field " + baseIdentifier + " is not a structured field"); + } + } + case "access-control-max-age": + case "access-control-allow-credentials": + case "access-control-allow-origin": + case "access-control-request-method": + case "age": + case "alt-used": + case "content-type": + case "cross-origin-resource-policy": + case "expect": + case "host": + case "origin": + case "retry-after": + case "x-content-type-options": + case "x-frame-options": + case "example-integer": + case "example-decimal": + case "example-string": + case "example-token": + case "example-bytesequence": + case "example-boolean": + { + try { + String fieldValue = getField(baseIdentifier); + Type sf = Parser.parseItem(fieldValue); + return sf.serialize(); + } catch (ParseException e) { + throw new IllegalArgumentException("Field " + baseIdentifier + " is not a structured field"); + } + } + default: + throw new IllegalArgumentException("Field " + baseIdentifier + " is not a structured field"); + } + } else { + return getField(baseIdentifier); + } + } + } +} diff --git a/approov-service/src/main/java/io/approov/util/sig/LICENSE b/approov-service/src/main/java/io/approov/util/sig/LICENSE new file mode 100644 index 0000000..13ed038 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/sig/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Bespoke Engineering + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/approov-service/src/main/java/io/approov/util/sig/SignatureBaseBuilder.java b/approov-service/src/main/java/io/approov/util/sig/SignatureBaseBuilder.java new file mode 100644 index 0000000..9c1cb75 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/sig/SignatureBaseBuilder.java @@ -0,0 +1,43 @@ +package io.approov.util.sig; + +import io.approov.util.http.sfv.StringItem; + +/** + * @author jricher + */ +public class SignatureBaseBuilder { + private final SignatureParameters sigParams; + private final ComponentProvider ctx; + + public SignatureBaseBuilder(SignatureParameters sigParams, ComponentProvider ctx) { + this.sigParams = sigParams; + this.ctx = ctx; + } + + public String createSignatureBase() { + StringBuilder base = new StringBuilder(); + + for (StringItem componentIdentifier : sigParams.getComponentIdentifiers()) { + + String componentValue = ctx.getComponentValue(componentIdentifier); + + if (componentValue != null) { + // write out the line to the base + componentIdentifier.serializeTo(base) + .append(": ") + .append(componentValue) + .append('\n'); + } else { + // FIXME: be more graceful about bailing + throw new RuntimeException("Couldn't find a value for required parameter: " + componentIdentifier.serialize()); + } + } + + // add the signature parameters line + sigParams.toComponentIdentifier().serializeTo(base) + .append(": "); + sigParams.toComponentValue().serializeTo(base); + + return base.toString(); + } +} diff --git a/approov-service/src/main/java/io/approov/util/sig/SignatureParameters.java b/approov-service/src/main/java/io/approov/util/sig/SignatureParameters.java new file mode 100644 index 0000000..2000896 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/sig/SignatureParameters.java @@ -0,0 +1,363 @@ +package io.approov.util.sig; + +//import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import io.approov.util.http.sfv.Dictionary; +import io.approov.util.http.sfv.InnerList; +import io.approov.util.http.sfv.Item; +import io.approov.util.http.sfv.ListElement; +import io.approov.util.http.sfv.NumberItem; +import io.approov.util.http.sfv.Parameters; +import io.approov.util.http.sfv.StringItem; + +/** + * Carrier class for signature parameters. + * + * @author jricher + * @author jexh + */ +public class SignatureParameters implements Cloneable { + private static final String ALG = "alg"; + private static final String CREATED = "created"; + private static final String EXPIRES = "expires"; + private static final String KEYID = "keyid"; + private static final String NONCE = "nonce"; + private static final String TAG = "tag"; + + // set this to add an extra header to the request that includes the SHA256 of the signature base + // which can be used to aid debugging on the server side to determine if there is a problem with + // the reconstruction of the signature base or the verification of the signature. + private boolean debugMode; + + private List componentIdentifiers; + + // this preserves insertion order + private Map parameters; + + /** + * Default constructor creates an empty SignatureParameters ready to be populated. + */ + public SignatureParameters() { + componentIdentifiers = new ArrayList<>(); + parameters = new LinkedHashMap<>(); + } + + /** + * Copy constructor creates a SignatureParameters instance pre-populated with a copy of all the + * component identifiers and parameters from the provided base. + * + * @param base + */ + public SignatureParameters(SignatureParameters base) { + // Items are immutable, it's fine to reference the items from the original component + // identifiers list + componentIdentifiers = new ArrayList<>(base.componentIdentifiers); + // Parameters are immutable (except for custom params with byte arrays but we ignore those) + parameters = new LinkedHashMap<>(base.parameters); + } + + /** + * @return the componentIdentifiers + */ + List getComponentIdentifiers() { + return componentIdentifiers; + } + + /** + * @return the parameters + */ + Map getParameters() { + return parameters; + } + + /** + * @param parameters the parameters to set + */ + public SignatureParameters setParameters(Map parameters) { + this.parameters = parameters; + return this; + } + + /** + * Determine if debug mode has been set for this signature parameters instance + * + * @return true is debug mode is on; false otherwise + */ + public boolean isDebugMode() { + return debugMode; + } + + /** + * Set the debug mode for this signature parameters + * + * @param debugMode true to enable; false to disable + */ + public void setDebugMode(boolean debugMode) { + this.debugMode = debugMode; + } + + /** + * @return the alg + */ + public String getAlg() { + return (String) getParameters().get(ALG); + } + + /** + * @param alg the alg to set + */ + public SignatureParameters setAlg(String alg) { + getParameters().put(ALG, alg); + return this; + } + + /** + * @return the created + */ + public Long getCreated() { + return (Long) getParameters().get(CREATED); + } + + /** + * @param created the created to set + */ + public SignatureParameters setCreated(Long created) { + getParameters().put(CREATED, created); + return this; + } + + /** + * @return the expires + */ + public Long getExpires() { + return (Long) getParameters().get(EXPIRES); + } + + /** + * @param expires the expires to set + */ + public SignatureParameters setExpires(Long expires) { + getParameters().put(EXPIRES, expires); + return this; + } + + /** + * @return the keyid + */ + public String getKeyid() { + return (String) getParameters().get(KEYID); + } + + /** + * @param keyid the keyid to set + */ + public SignatureParameters setKeyid(String keyid) { + getParameters().put(KEYID, keyid); + return this; + } + + /** + * @return the nonce + */ + public String getNonce() { + return (String) getParameters().get(NONCE); + } + + /** + * @param nonce the nonce to set + */ + public SignatureParameters setNonce(String nonce) { + getParameters().put(NONCE, nonce); + return this; + } + + public String getTag() { + return (String) getParameters().get(TAG); + } + + public SignatureParameters setTag(String tag) { + getParameters().put(TAG, tag); + return this; + } + + public Object getCustomParameter(String key) { + return getParameters().get(key); + } + + public SignatureParameters setCustomParameter(String key, Object value) { + switch (key) { + case ALG: { + String val = (String)value; + setAlg(val); + break; + } + case CREATED: { + Long val = (Long)value; + setCreated(val); + break; + } + case EXPIRES: { + Long val = (Long)value; + setExpires(val); + break; + } + case KEYID: { + String val = (String)value; + setKeyid(val); + break; + } + case NONCE: { + String val = (String)value; + setNonce(val); + break; + } + case TAG: { + String val = (String)value; + setTag(val); + break; + } + default: { + if (!Item.isItemType(value)) { + throw new IllegalArgumentException("Parameter value of unsupported type: " + value.getClass()); + } + getParameters().put(key, value); + } + } + return this; + } + public StringItem toComponentIdentifier() { + return StringItem.valueOf("@signature-params"); + } + + public InnerList toComponentValue() { + + // take a copy of the identifiers + List> identifiers = new ArrayList<>(componentIdentifiers.size()); + identifiers.addAll(componentIdentifiers); + InnerList list = InnerList.valueOf(identifiers); + + // take a copy of the parameters + Map params = new LinkedHashMap<>(getParameters()); + list = list.withParams(Parameters.valueOf(params)); + + return list; + } + + /** + * Add a component without parameters. + */ + public SignatureParameters addComponentIdentifier(String identifier) { + if (!identifier.startsWith("@")) { + componentIdentifiers.add(StringItem.valueOf(identifier.toLowerCase(Locale.US))); + } else { + componentIdentifiers.add(StringItem.valueOf(identifier)); + } + return this; + } + + /** + * Add a component with optional parameters. Field components are assumed to be + * already set to lowercase. + */ + public SignatureParameters addComponentIdentifier(StringItem identifier) { + componentIdentifiers.add(identifier); + return this; + } + + // this ignores parameters + public boolean containsComponentIdentifier(String identifier) { + for (StringItem item : componentIdentifiers) { + if (item.get().equals(identifier)) { + return true; + } + } + return false; + } + + // does not ignore parameters + public boolean containsComponentIdentifier(StringItem identifier) { + String value = identifier.get(); + Parameters params = identifier.getParams(); + for (StringItem item : componentIdentifiers) { + if (value.equals(item.get()) + && params.equals(item.getParams())) { + return true; + } + } + return false; + } + + /** + * @param signatureInput + * @param sigId + */ + public static SignatureParameters fromDictionaryEntry(Dictionary signatureInput, String sigId) { + if (signatureInput.get().containsKey(sigId)) { + ListElement item = signatureInput.get().get(sigId); + if (item instanceof InnerList) { + InnerList coveredComponents = (InnerList)item; + SignatureParameters params = new SignatureParameters(); + for (Item innerItem : coveredComponents.get()) { + params.addComponentIdentifier((StringItem)innerItem); + } + for (Map.Entry> entry : coveredComponents.getParams().entrySet()) { + String key = entry.getKey(); + switch (key) { + case ALG: { + String value = ((StringItem) entry.getValue()).get(); + params.setAlg(value); + break; + } + case CREATED: { + Long value = ((NumberItem) entry.getValue()).getAsLong(); + params.setCreated(value); + break; + } + case EXPIRES: { + Long value = ((NumberItem) entry.getValue()).getAsLong(); + params.setExpires(value); + break; + } + case KEYID: { + String value = ((StringItem) entry.getValue()).get(); + params.setKeyid(value); + break; + } + case NONCE: { + String value = ((StringItem) entry.getValue()).get(); + params.setNonce(value); + break; + } + case TAG: { + String value = ((StringItem) entry.getValue()).get(); + params.setTag(value); + break; + } + default: { + Object value = entry.getValue().get(); + params.getParameters().put(key, value); + break; + } + } + } + return params; + } else { + throw new IllegalArgumentException("Invalid syntax, identifier '" + sigId + "' must be an inner list"); + } + } else { + throw new IllegalArgumentException("Could not find identifier '" + sigId + "' in dictionary " + signatureInput.serialize()); + } + } + + //@NonNull + @Override + public String toString() { + return "SignatureParameters: " + toComponentValue().serialize(); + } +} diff --git a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceTest.java b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceTest.java new file mode 100644 index 0000000..2920bcd --- /dev/null +++ b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceTest.java @@ -0,0 +1,203 @@ +package io.approov.service.httpsurlconn; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.content.Context; + +import com.criticalblue.approovsdk.Approov; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import javax.net.ssl.HttpsURLConnection; + +import io.approov.util.sig.ComponentProvider; +import io.approov.util.sig.SignatureParameters; + +public class ApproovServiceTest { + private static final String CONFIG = "config"; + + private MockedStatic mockApproov; + private MockedStatic mockAndroidBase64; + private Context context; + + @Before + public void setUp() { + context = mock(Context.class); + mockApproov = Mockito.mockStatic(Approov.class); + mockAndroidBase64 = Mockito.mockStatic(android.util.Base64.class); + mockApproov.when(() -> Approov.initialize(context, CONFIG, "auto", null)).thenAnswer(invocation -> null); + mockApproov.when(() -> Approov.setUserProperty("approov-service-httpsurlconn")).thenAnswer(invocation -> null); + mockAndroidBase64.when(() -> android.util.Base64.decode(Mockito.anyString(), Mockito.anyInt())) + .thenAnswer(invocation -> + Base64.getDecoder().decode(invocation.getArgument(0, String.class))); + mockAndroidBase64.when(() -> android.util.Base64.encodeToString(Mockito.any(byte[].class), Mockito.anyInt())) + .thenAnswer(invocation -> { + byte[] input = invocation.getArgument(0, byte[].class); + int flags = invocation.getArgument(1, Integer.class); + Base64.Encoder encoder = Base64.getEncoder(); + if ((flags & android.util.Base64.NO_PADDING) != 0) { + encoder = encoder.withoutPadding(); + } + return encoder.encodeToString(input); + }); + ApproovService.initialize(context, CONFIG); + ApproovService.setServiceMutator(null); + } + + @After + public void tearDown() { + if (mockApproov != null) { + mockApproov.close(); + } + if (mockAndroidBase64 != null) { + mockAndroidBase64.close(); + } + } + + @Test + public void addApproovKeepsSameConnectionAndSignsWhenUrlUnchanged() throws Exception { + String requestUrl = "https://example.com/shapes"; + Approov.TokenFetchResult tokenResult = mockTokenFetchResult( + Approov.TokenFetchStatus.SUCCESS, + "approov-token-value", + null + ); + mockApproov.when(() -> Approov.fetchApproovTokenAndWait(requestUrl)).thenReturn(tokenResult); + + TestAccountMessageSigning signer = new TestAccountMessageSigning(); + signer.setDefaultFactory( + new ApproovDefaultMessageSigning.SignatureParametersFactory() + .setBaseParameters( + new SignatureParameters() + .addComponentIdentifier(ComponentProvider.DC_METHOD) + .addComponentIdentifier(ComponentProvider.DC_TARGET_URI) + ) + .setUseAccountMessageSigning() + .setAddCreated(false) + .setExpiresLifetime(0) + .setAddApproovTokenHeader(true) + .setAddApproovTraceIDHeader(false) + .addOptionalHeaders() + ); + ApproovService.setServiceMutator(signer); + + HttpsURLConnection request = newConnection(requestUrl); + request.setRequestMethod("GET"); + + HttpsURLConnection returned = ApproovService.addApproovToConnection(request); + + assertSame(request, returned); + assertEquals("approov-token-value", request.getRequestProperty("Approov-Token")); + assertNotNull(request.getRequestProperty("Signature")); + assertNotNull(request.getRequestProperty("Signature-Input")); + assertTrue(request.getRequestProperty("Signature-Input").contains("approov-token")); + } + + @Test + public void addApproovReturnsWrappedConnectionWhenQuerySubstitutionChangesUrl() throws Exception { + String requestUrl = "https://example.com/shapes?api_key=old-key"; + String substitutedUrl = "https://example.com/shapes?api_key=replaced-key"; + + Approov.TokenFetchResult tokenResult = mockTokenFetchResult( + Approov.TokenFetchStatus.SUCCESS, + "approov-token-value", + null + ); + Approov.TokenFetchResult secureStringResult = mockTokenFetchResult( + Approov.TokenFetchStatus.SUCCESS, + null, + "replaced-key" + ); + + mockApproov.when(() -> Approov.fetchApproovTokenAndWait(requestUrl)).thenReturn(tokenResult); + mockApproov.when(() -> Approov.fetchSecureStringAndWait("old-key", null)).thenReturn(secureStringResult); + + ApproovService.addSubstitutionQueryParam("api_key"); + + HttpsURLConnection request = newConnection(requestUrl); + request.setRequestMethod("GET"); + + HttpsURLConnection returned = ApproovService.addApproovToConnection(request); + + assertNotSame(request, returned); + assertTrue(returned instanceof ApproovBufferedHttpsURLConnection); + assertEquals(substitutedUrl, returned.getURL().toString()); + } + + @Test + public void addApproovUsesStatusAsTokenHeaderWhenConfigured() throws Exception { + String requestUrl = "https://example.com/shapes"; + Approov.TokenFetchResult tokenResult = mockTokenFetchResult( + Approov.TokenFetchStatus.NO_NETWORK, + "", + null + ); + mockApproov.when(() -> Approov.fetchApproovTokenAndWait(requestUrl)).thenReturn(tokenResult); + ApproovService.setUseApproovStatusIfNoToken(true); + + HttpsURLConnection request = newConnection(requestUrl); + request.setRequestMethod("GET"); + + HttpsURLConnection returned = ApproovService.addApproovToConnection(request); + + assertSame(request, returned); + assertEquals("NO_NETWORK", request.getRequestProperty("Approov-Token")); + } + + @Test + public void substituteQueryParamsReplacesConfiguredValues() throws Exception { + String requestUrl = "https://example.com/shapes?api_key=old-key"; + Approov.TokenFetchResult secureStringResult = mockTokenFetchResult( + Approov.TokenFetchStatus.SUCCESS, + null, + "replaced-key" + ); + mockApproov.when(() -> Approov.fetchSecureStringAndWait("old-key", null)).thenReturn(secureStringResult); + ApproovService.addSubstitutionQueryParam("api_key"); + + URL substituted = ApproovService.substituteQueryParams(new URL(requestUrl)); + + assertEquals("https://example.com/shapes?api_key=replaced-key", substituted.toString()); + } + + private static HttpsURLConnection newConnection(String url) throws Exception { + return (HttpsURLConnection) new URL(url).openConnection(); + } + + private static Approov.TokenFetchResult mockTokenFetchResult( + Approov.TokenFetchStatus status, + String token, + String secureString + ) { + Approov.TokenFetchResult result = mock(Approov.TokenFetchResult.class); + when(result.getStatus()).thenReturn(status); + when(result.getToken()).thenReturn(token); + when(result.getSecureString()).thenReturn(secureString); + when(result.getLoggableToken()).thenReturn(token == null ? "" : token); + when(result.getARC()).thenReturn(""); + when(result.getRejectionReasons()).thenReturn(""); + return result; + } + + private static final class TestAccountMessageSigning extends ApproovDefaultMessageSigning { + @Override + protected String getAccountMessageSignature(String message) { + return Base64.getEncoder() + .encodeToString("unit-test-signature".getBytes(StandardCharsets.UTF_8)); + } + } +}