From 99730724444a7fd8615e7be7536383199c20267b Mon Sep 17 00:00:00 2001 From: ivol Date: Fri, 22 May 2026 14:31:55 +0100 Subject: [PATCH 01/22] Initialize guard and bypass fixes, gating logic, test isolation, and README restructuring for httpsurlconn (v3.5.5) --- CHANGELOG.md | 14 ++ README.md | 86 +++++++- REFERENCE.md | 26 ++- .../service/httpsurlconn/ApproovService.java | 189 +++++++++++++++--- .../httpsurlconn/ApproovServiceTest.java | 114 ++++++++++- 5 files changed, 393 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58dd638..f7820da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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. +## [3.5.5] - 2026-05-22 + +### Added +- Public visibility getter methods `isInitialized()` and `isApproovEnabled()`. +- Overhauled and restructured `README.md` based on the standard `3.5.3` quickstart template, with original reference links migrated to the bottom. +- Overloaded `initialize(Context, String, String)` support to allow passing custom initialization options and reinitialization comments. + +### Fixed +- Corrected parameter mapping in `initialize(Context, String, String)` to map the `comment` argument correctly to the 4th parameter of the native SDK instead of the 3rd (`updateConfig`) parameter. +- Added safety checks for `null` and empty config strings during initialization to normalize parameters and prevent native initialization conflicts. +- Aligned default `initialize` method to pass `null` comment instead of `""` to prevent initialization mismatches. +- Added strict gating to all native API calls to prevent `IllegalStateException` crashes when called before initialization or during bypass mode. +- Cleaned up static state between unit tests using a new `@VisibleForTesting` static `reset()` method. + ## [3.5.4] - 2026-05-21 ### Added diff --git a/README.md b/README.md index bf8ae51..d5f06b4 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,87 @@ # 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. +This package is an open-source wrapper layer that allows you to easily use Approov with `HttpsURLConnection` in native Android apps written in Java. -Please see the [Quickstart](https://github.com/approov/quickstart-android-java-httpsurlconn/blob/master/README.md) for example integration. +This page provides the steps for integrating Approov into your app. Additionally, a step-by-step tutorial guide using our [Shapes App Example](https://github.com/approov/quickstart-android-java-httpsurlconn/blob/master/SHAPES-EXAMPLE.md) is also available. -# Changelog +To follow this guide you should have received an onboarding email for a trial or paid Approov account. -Please see the [CHANGELOG.md](CHANGELOG.md) for more information on the changes in each version. +## ADDING APPROOV SERVICE DEPENDENCY +The Approov integration is available via [`maven`](https://mvnrepository.com/repos/central). This allows inclusion into the project by simply specifying a dependency in the `gradle` files for the app. +The `Maven` repository is already present in the gradle.build file so the only import you need to make is the actual service layer itself: -# Reference +```gradle +implementation("io.approov:service.httpsurlconn:3.5.3") +``` -Please see the [REFERENCE.md](REFERENCE.md) for more information on the Approov Service for HttpsURLConnection. +Make sure you do a Gradle sync (by selecting `Sync Now` in the banner at the top of the modified `.gradle` file) after making these changes. -# Usage +This package has a further dependency on the closed-source [Approov SDK](https://central.sonatype.com/artifact/io.approov/approov-android-sdk). -Please see the [USAGE.md](USAGE.md) for more information on how to use this wrapper. +## MANIFEST CHANGES +The following app permissions need to be available in the manifest to use Approov: + +```xml + + +``` + +Note that the minimum SDK version you can use with the Approov package is 21 (Android 5.0). + +Please [read this](https://approov.io/docs/latest/approov-usage-documentation/#targeting-android-11-and-above) section of the reference documentation if targeting Android 11 (API level 30) or above. + +## INITIALIZING APPROOV SERVICE +In order to use the `ApproovService` you must initialize it when your app is created, usually in the `onCreate` method: + +```Java +import io.approov.service.httpsurlconn.ApproovService; + +public class YourApp extends Application { + @Override + public void onCreate() { + super.onCreate(); + ApproovService.initialize(getApplicationContext(), ""); + } +} +``` + +The `` is a custom string that configures your Approov account access. This will have been provided in your Approov onboarding email. + +## USING APPROOV SERVICE +You can then make Approov enabled `HttpsURLConnection` API calls using the following call on any `HttpsURLConnection` connection, just before the connection is made: + +```Java +ApproovService.addApproov(connection); +``` + +> **NOTE:** It is important that this call is made just prior to the connection being made and thus within any retry loop, to ensure that an updated Approov token is always made available on the connection request. + +For API domains that are configured to be protected with an Approov token, this adds the `Approov-Token` header and pins the connection. This may also substitute header values when using secrets protection. + +Approov errors will generate an `ApproovException`, which is a type of `IOException`. + +## CHECKING IT WORKS +Initially you won't have set which API domains to protect, so any `addApproov` call will not add anything. It will have called Approov though and made contact with the Approov cloud service. You will see logging from Approov saying `UNKNOWN_URL`. + +Your Approov onboarding email should contain a link allowing you to access [Live Metrics Graphs](https://approov.io/docs/latest/approov-usage-documentation/#metrics-graphs). After you've run your app with Approov integration you should be able to see the results in the live metrics within a minute or so. At this stage you could even release your app to get details of your app population and the attributes of the devices they are running upon. + +## NEXT STEPS +To actually protect your APIs and/or secrets there are some further steps. Approov provides two different options for protection: + +* [API PROTECTION](https://github.com/approov/quickstart-android-java-httpsurlconn/blob/master/API-PROTECTION.md): You should use this if you control the backend API(s) being protected and are able to modify them to ensure that a valid Approov token is being passed by the app. An [Approov Token](https://approov.io/docs/latest/approov-usage-documentation/#approov-tokens) is a short-lived cryptographically signed JWT proving the authenticity of the call. + +* [SECRETS PROTECTION](https://github.com/approov/quickstart-android-java-httpsurlconn/blob/master/SECRETS-PROTECTION.md): This allows app secrets, including API keys for 3rd party services, to be protected so that they no longer need to be included in the released app code. These secrets are only made available to valid apps at runtime. + +Note that it is possible to use both approaches side-by-side in the same app. + +--- + +## Useful Links + +- [Approov SDK](https://github.com/approov/approov-android-sdk) +- [HttpsURLConnection Documentation](https://developer.android.com/reference/javax/net/ssl/HttpsURLConnection) +- [Approov Website](https://www.approov.io) +- [Quickstart Guide](https://github.com/approov/quickstart-android-java-httpsurlconn/blob/master/README.md) +- [Changelog](CHANGELOG.md) +- [Reference Documentation](REFERENCE.md) +- [Usage Guide](USAGE.md) diff --git a/REFERENCE.md b/REFERENCE.md index f927015..c79cb97 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -15,13 +15,35 @@ If a method throws an `ApproovRejectionException`, the app failed attestation. A ## initialize -Initializes the Approov SDK and enables the Approov features. +Initializes the Approov SDK and enables the Approov features. The `config` will have been provided in the initial onboarding or email or can be obtained using the approov CLI. This will generate an error if a second attempt is made at initialization with a different `config` without utilizing the reinitialization comment. +**Java:** ```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. +**Kotlin:** +```kotlin +fun initialize(context: Context, config: String) +``` + +The application context must be provided using the `context` parameter. + +It is possible to pass an empty `config` string to indicate that no initialization of the underlying native Approov SDK is required. This initializes the service layer in a bypass mode, allowing you to obtain standard, non-Approov protected connections. If you attempt to use any direct native Approov SDK functions (such as `fetchToken` or `precheck`) while bypassed, an `ApproovException` will be thrown. You may later call `initialize` again with a valid `config` string to enable Approov protection for connections obtained after that point. + +An alternative initialization function allows you to provide further options or trigger reinitialization in the `comment` parameter. Please refer to the [Approov SDK documentation](https://approov.io/docs/latest/approov-direct-sdk-integration/#sdk-initialization-options) for details. + +**Java:** +```java +void initialize(Context context, String config, String comment) +``` + +**Kotlin:** +```kotlin +fun initialize(context: Context, config: String, comment: String) +``` + +For example, options like `options:no-install-key` or reinitialization via `reinit` can be supplied via the `comment` parameter. ## setServiceMutator 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 de9127a..7188ea2 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 @@ -76,6 +76,12 @@ 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 ApproovService has been initialized + private static boolean isInitialized = false; + + // the configuration string used to initialize the Approov SDK + private static String configString = null; + // true if request preparation should proceed on network failures and not add // an Approov token private static boolean proceedOnNetworkFail = false; @@ -120,6 +126,65 @@ public class ApproovService { private ApproovService() { } + /** + * Initializes the ApproovService with an account configuration and comment. + * + * @param context the Application context + * @param config the configuration string, or empty for no SDK initialization + * @param comment the comment string, or empty/null for default comment ("auto" is used by native SDK) + */ + public static synchronized void initialize(Context context, String config, String comment) { + if (config == null) { + config = ""; + } + // check if the Approov SDK is already initialized + boolean allowEnableAfterEmptyInitialization = isInitialized && (configString != null) && configString.isEmpty() && !config.isEmpty(); + if (isInitialized && (comment == null || !comment.startsWith("reinit")) && !allowEnableAfterEmptyInitialization) { + if (!config.equals(configString)) { + throw new IllegalStateException("ApproovService layer is already initialized."); + } + Log.d(TAG, "Ignoring multiple ApproovService layer initializations with the same config"); + } else { + // setup for using Appproov + isInitialized = false; + 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.isEmpty()) { + Approov.initialize(context.getApplicationContext(), config, "auto", comment); + Approov.setUserProperty("approov-service-httpsurlconn"); + } + } catch (IllegalArgumentException e) { + Log.e(TAG, "Approov initialization failed: " + e.getMessage()); + throw e; + } catch (IllegalStateException e) { + Log.e(TAG, "Approov initialization failed: " + e.getMessage()); + throw e; + } + + isInitialized = true; + configString = config; + + // build the custom hostname verifier + if (isApproovEnabled()) { + pinningHostnameVerifier = new PinningHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier()); + } else { + pinningHostnameVerifier = null; + } + } + } + /** * Initializes the ApproovService with an account configuration. * @@ -127,30 +192,46 @@ private ApproovService() { * @param config the configuration string, or empty for no SDK initialization */ public static void initialize(Context context, String config) { - // setup for using Appproov + initialize(context, config, null); + } + + /** + * Indicates whether the service layer has been initialized. + * + * @return true if the service layer has been initialized, false otherwise + */ + public static synchronized boolean isInitialized() { + return isInitialized; + } + + /** + * Indicates whether Approov protection is enabled for this service layer instance. + * If initialization used an empty config string then the layer is initialized + * but Approov protection is bypassed. + * + * @return true if Approov protection is enabled, false otherwise + */ + public static synchronized boolean isApproovEnabled() { + return isInitialized && (configString != null) && !configString.isEmpty(); + } + + /** + * Resets the ApproovService state. This should only be used for testing purposes. + */ + static synchronized void reset() { + isInitialized = false; + configString = null; pinningHostnameVerifier = null; proceedOnNetworkFail = false; useApproovStatusIfNoToken = false; - approovTokenHeader = APPROOV_TOKEN_HEADER; - approovTraceIDHeader = APPROOV_TRACE_ID_HEADER; - approovTokenPrefix = APPROOV_TOKEN_PREFIX; + approovTokenHeader = null; + approovTraceIDHeader = null; + approovTokenPrefix = null; bindingHeader = null; - substitutionHeaders = new HashMap<>(); - substitutionQueryParams = new HashMap<>(); - exclusionURLRegexs = new HashMap<>(); serviceMutator = ApproovServiceMutator.DEFAULT; - // initialize the Approov SDK - try { - if (config.length() != 0) - Approov.initialize(context, config, "auto", null); - Approov.setUserProperty("approov-service-httpsurlconn"); - } catch (IllegalArgumentException e) { - Log.e(TAG, "Approov initialization failed: " + e.getMessage()); - return; - } - - // build the custom hostname verifier - pinningHostnameVerifier = new PinningHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier()); + substitutionHeaders = null; + substitutionQueryParams = null; + exclusionURLRegexs = null; } /** @@ -181,6 +262,10 @@ public static synchronized void setProceedOnNetworkFail(boolean proceed) { * @throws ApproovException if there was a problem */ public static synchronized void setDevKey(String devKey) throws ApproovException { + if (!isApproovEnabled()) { + Log.e(TAG, "setDevKey: SDK not initialized"); + throw new ApproovException("setDevKey: SDK not initialized"); + } try { Approov.setDevKey(devKey); Log.d(TAG, "setDevKey"); @@ -457,7 +542,7 @@ public static ApproovServiceMutator getApproovInterceptorExtensions() { * use cached data. */ public static synchronized void prefetch() { - if (pinningHostnameVerifier != null) + if (isApproovEnabled()) // fetch an Approov token using a placeholder domain Approov.fetchApproovToken(new PrefetchCallbackHandler(), "approov.io"); } @@ -472,6 +557,10 @@ public static synchronized void prefetch() { // // @throws ApproovException if there was a problem public static void precheck() throws ApproovException { + if (!isApproovEnabled()) { + Log.e(TAG, "precheck: SDK not initialized"); + throw new ApproovException("precheck: SDK not initialized"); + } // try and fetch a non-existent secure string in order to check for a rejection Approov.TokenFetchResult approovResults; try { @@ -498,6 +587,10 @@ public static void precheck() throws ApproovException { * @throws ApproovException if there was a problem */ public static String getDeviceID() throws ApproovException { + if (!isApproovEnabled()) { + Log.e(TAG, "getDeviceID: SDK not initialized"); + throw new ApproovException("getDeviceID: SDK not initialized"); + } try { String deviceID = Approov.getDeviceID(); Log.d(TAG, "getDeviceID: " + deviceID); @@ -519,6 +612,10 @@ public static String getDeviceID() throws ApproovException { * @throws ApproovException if there was a problem */ public static void setDataHashInToken(String data) throws ApproovException { + if (!isApproovEnabled()) { + Log.e(TAG, "setDataHashInToken: SDK not initialized"); + throw new ApproovException("setDataHashInToken: SDK not initialized"); + } try { Approov.setDataHashInToken(data); Log.d(TAG, "setDataHashInToken"); @@ -545,6 +642,10 @@ public static void setDataHashInToken(String data) throws ApproovException { * @throws ApproovException if there was a problem */ public static String fetchToken(String url) throws ApproovException { + if (!isApproovEnabled()) { + Log.e(TAG, "fetchToken: SDK not initialized"); + throw new ApproovException("fetchToken: SDK not initialized"); + } // fetch the Approov token Approov.TokenFetchResult approovResults; try { @@ -598,6 +699,10 @@ public static String getMessageSignature(String message) throws ApproovException * @throws ApproovException if there was a problem */ public static String fetchSecureString(String key, String newDef) throws ApproovException { + if (!isApproovEnabled()) { + Log.e(TAG, "fetchSecureString: SDK not initialized"); + throw new ApproovException("fetchSecureString: SDK not initialized"); + } // determine the type of operation as the values themselves cannot be logged String type = "lookup"; if (newDef != null) @@ -633,6 +738,10 @@ public static String fetchSecureString(String key, String newDef) throws Approov * @throws ApproovException if there was a problem */ public static String fetchCustomJWT(String payload) throws ApproovException { + if (!isApproovEnabled()) { + Log.e(TAG, "fetchCustomJWT: SDK not initialized"); + throw new ApproovException("fetchCustomJWT: SDK not initialized"); + } // fetch the custom JWT catching any exceptions the SDK might throw Approov.TokenFetchResult approovResults; try { @@ -660,6 +769,10 @@ public static String fetchCustomJWT(String payload) throws ApproovException { * @return String ARC from last attestation request or empty string if network unavailable */ public static String getLastARC() { + if (!isApproovEnabled()) { + Log.e(TAG, "getLastARC: SDK not initialized"); + return ""; + } // Get the dynamic pins from Approov Map> approovPins = Approov.getPins("public-key-sha256"); if (approovPins == null || approovPins.isEmpty()) { @@ -707,6 +820,10 @@ public static String getLastARC() { * @throws ApproovException if the attrs parameter is invalid or the SDK is not initialized */ public static void setInstallAttrsInToken(String attrs) throws ApproovException { + if (!isApproovEnabled()) { + Log.e(TAG, "setInstallAttrsInToken: SDK not initialized"); + throw new ApproovException("setInstallAttrsInToken: SDK not initialized"); + } try { Approov.setInstallAttrsInToken(attrs); Log.d(TAG, "setInstallAttrsInToken"); @@ -779,6 +896,10 @@ public static synchronized boolean getProceedOnNetworkFail() { * @throws ApproovException if there was a problem */ public static String getAccountMessageSignature(String message) throws ApproovException { + if (!isApproovEnabled()) { + Log.e(TAG, "getAccountMessageSignature: SDK not initialized"); + throw new ApproovException("getAccountMessageSignature: SDK not initialized"); + } try { String signature = Approov.getAccountMessageSignature(message); Log.d(TAG, "getAccountMessageSignature"); @@ -812,6 +933,10 @@ public static String getAccountMessageSignature(String message) throws ApproovEx * @throws ApproovException if there was a problem */ public static String getInstallMessageSignature(String message) throws ApproovException { + if (!isApproovEnabled()) { + Log.e(TAG, "getInstallMessageSignature: SDK not initialized"); + throw new ApproovException("getInstallMessageSignature: SDK not initialized"); + } try { String signature = Approov.getInstallMessageSignature(message); Log.d(TAG, "getInstallMessageSignature"); @@ -927,9 +1052,14 @@ private static boolean shouldUseBufferedConnection( */ static synchronized PreparedRequestData prepareApproovRequest(HttpsURLConnection request) throws ApproovException { // throw if we couldn't initialize the SDK - if (pinningHostnameVerifier == null) + if (!isInitialized) throw new ApproovException("Approov not initialized"); + if (!isApproovEnabled()) { + // In bypass mode, return empty/noop modifications immediately without invoking processed request callback + return new PreparedRequestData(getServiceMutator(), new ApproovRequestMutations(), false); + } + // cache the mutator for the duration of the request processing to make // sure it is not changed mid-flight ApproovServiceMutator mutator = getServiceMutator(); @@ -1047,9 +1177,13 @@ static synchronized PreparedRequestData prepareApproovRequest(HttpsURLConnection static synchronized QuerySubstitutionResult substituteQueryParamsDetailed(URL url, ApproovServiceMutator mutator) throws ApproovException { // throw if we couldn't initialize the SDK - if (pinningHostnameVerifier == null) + if (!isInitialized) throw new ApproovException("Approov not initialized"); + if (!isApproovEnabled()) { + return new QuerySubstitutionResult(url, url.toString(), Collections.emptyList()); + } + // check if the URL matches one of the exclusion regexs and just return if so String originalURL = url.toString(); for (Pattern pattern : exclusionURLRegexs.values()) { @@ -1135,7 +1269,7 @@ private static HttpsURLConnection addApproovInternal( boolean allowBufferedConnection ) throws ApproovException { // throw if we couldn't initialize the SDK - if (pinningHostnameVerifier == null) + if (!isInitialized) throw new ApproovException("Approov not initialized"); // Apply the non-signing parts of the HttpsURLConnection preparation flow immediately so @@ -1209,9 +1343,13 @@ public static synchronized URL substituteQueryParams(URL url) throws ApproovExce */ public static synchronized URL substituteQueryParam(URL url, String queryParameter) throws ApproovException { // throw if we couldn't initialize the SDK - if (pinningHostnameVerifier == null) + if (!isInitialized) throw new ApproovException("Approov not initialized"); + if (!isApproovEnabled()) { + return url; + } + // check if the URL matches one of the exclusion regexs and just return if so String urlString = url.toString(); for (Pattern pattern: exclusionURLRegexs.values()) { @@ -1366,6 +1504,9 @@ private X509Certificate getTrustAnchorCertificate(SSLSession session) { @Override public boolean verify(String hostname, SSLSession session) { + if (!ApproovService.isApproovEnabled()) { + return true; + } // check the delegate function first and only proceed if it passes if (delegate.verify(hostname, session)) try { // extract the set of valid pins for the hostname 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 index 2920bcd..c4d2ef3 100644 --- a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceTest.java +++ b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceTest.java @@ -37,10 +37,11 @@ public class ApproovServiceTest { @Before public void setUp() { context = mock(Context.class); + when(context.getApplicationContext()).thenReturn(context); 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); + mockApproov.when(() -> Approov.initialize(Mockito.any(), Mockito.anyString(), Mockito.anyString(), Mockito.any())).thenAnswer(invocation -> null); + mockApproov.when(() -> Approov.setUserProperty(Mockito.anyString())).thenAnswer(invocation -> null); mockAndroidBase64.when(() -> android.util.Base64.decode(Mockito.anyString(), Mockito.anyInt())) .thenAnswer(invocation -> Base64.getDecoder().decode(invocation.getArgument(0, String.class))); @@ -60,6 +61,7 @@ public void setUp() { @After public void tearDown() { + ApproovService.reset(); if (mockApproov != null) { mockApproov.close(); } @@ -200,4 +202,112 @@ protected String getAccountMessageSignature(String message) { .encodeToString("unit-test-signature".getBytes(StandardCharsets.UTF_8)); } } + + @Test + public void initializeWithNullConfigNormalizesToEmptyConfig() { + ApproovService.reset(); + ApproovService.initialize(context, null); + assertTrue(ApproovService.isInitialized()); + org.junit.Assert.assertFalse(ApproovService.isApproovEnabled()); + } + + @Test + public void initializeWithEmptyConfigEntersBypassMode() { + ApproovService.reset(); + ApproovService.initialize(context, ""); + assertTrue(ApproovService.isInitialized()); + org.junit.Assert.assertFalse(ApproovService.isApproovEnabled()); + } + + @Test(expected = ApproovException.class) + public void getDeviceIDThrowsWhenBypassed() throws Exception { + ApproovService.reset(); + ApproovService.initialize(context, ""); + ApproovService.getDeviceID(); + } + + @Test(expected = ApproovException.class) + public void fetchTokenThrowsWhenBypassed() throws Exception { + ApproovService.reset(); + ApproovService.initialize(context, ""); + ApproovService.fetchToken("https://example.com"); + } + + @Test(expected = ApproovException.class) + public void fetchSecureStringThrowsWhenBypassed() throws Exception { + ApproovService.reset(); + ApproovService.initialize(context, ""); + ApproovService.fetchSecureString("key", null); + } + + @Test(expected = ApproovException.class) + public void fetchCustomJWTThrowsWhenBypassed() throws Exception { + ApproovService.reset(); + ApproovService.initialize(context, ""); + ApproovService.fetchCustomJWT("payload"); + } + + @Test(expected = ApproovException.class) + public void setDataHashInTokenThrowsWhenBypassed() throws Exception { + ApproovService.reset(); + ApproovService.initialize(context, ""); + ApproovService.setDataHashInToken("data"); + } + + @Test(expected = ApproovException.class) + public void setDevKeyThrowsWhenBypassed() throws Exception { + ApproovService.reset(); + ApproovService.initialize(context, ""); + ApproovService.setDevKey("devkey"); + } + + @Test(expected = ApproovException.class) + public void setInstallAttrsInTokenThrowsWhenBypassed() throws Exception { + ApproovService.reset(); + ApproovService.initialize(context, ""); + ApproovService.setInstallAttrsInToken("attrs"); + } + + @Test + public void doubleInitializationIgnoresSameConfig() { + ApproovService.reset(); + ApproovService.initialize(context, CONFIG); + assertTrue(ApproovService.isInitialized()); + assertTrue(ApproovService.isApproovEnabled()); + + // This should be ignored without throwing an exception + ApproovService.initialize(context, CONFIG); + } + + @Test(expected = IllegalStateException.class) + public void doubleInitializationThrowsOnDifferentConfig() { + ApproovService.reset(); + ApproovService.initialize(context, CONFIG); + assertTrue(ApproovService.isInitialized()); + assertTrue(ApproovService.isApproovEnabled()); + + // This must throw IllegalStateException + ApproovService.initialize(context, "different-config"); + } + + @Test + public void initializeWithCommentCallsNativeInitializeCorrectly() { + ApproovService.reset(); + ApproovService.initialize(context, CONFIG, "my-custom-comment"); + assertTrue(ApproovService.isInitialized()); + assertTrue(ApproovService.isApproovEnabled()); + mockApproov.verify(() -> Approov.initialize(context, CONFIG, "auto", "my-custom-comment")); + } + + @Test + public void doubleInitializationAllowsReinit() { + ApproovService.reset(); + ApproovService.initialize(context, CONFIG); + assertTrue(ApproovService.isInitialized()); + + // A second initialization with a different config is allowed if the comment starts with "reinit" + ApproovService.initialize(context, "different-config", "reinit:mysecret"); + assertTrue(ApproovService.isInitialized()); + mockApproov.verify(() -> Approov.initialize(context, "different-config", "auto", "reinit:mysecret")); + } } From 63f1ee319017e013d63ee97b854ff1963d1a25fd Mon Sep 17 00:00:00 2001 From: ivol Date: Sat, 30 May 2026 16:59:39 +0100 Subject: [PATCH 02/22] fix: preserve service layer state on SDK initialization failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors initialize() to match okhttp/retrofit/volley pattern: - null config now throws IllegalArgumentException (no silent bypass coercion) - SDK is called first before touching any service-layer state - State is only reset and committed after SDK confirms success - Removes the old service-layer 'already initialized' guard; SDK itself detects and throws on conflicting configs Per TESTING_REQUIREMENTS §17-18 (core-service-layers-testing). --- .../service/httpsurlconn/ApproovService.java | 72 ++++++++----------- .../httpsurlconn/ApproovServiceTest.java | 29 +++++--- 2 files changed, 50 insertions(+), 51 deletions(-) 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 7188ea2..1383328 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 @@ -134,55 +134,45 @@ private ApproovService() { * @param comment the comment string, or empty/null for default comment ("auto" is used by native SDK) */ public static synchronized void initialize(Context context, String config, String comment) { - if (config == null) { - config = ""; - } - // check if the Approov SDK is already initialized - boolean allowEnableAfterEmptyInitialization = isInitialized && (configString != null) && configString.isEmpty() && !config.isEmpty(); - if (isInitialized && (comment == null || !comment.startsWith("reinit")) && !allowEnableAfterEmptyInitialization) { - if (!config.equals(configString)) { - throw new IllegalStateException("ApproovService layer is already initialized."); - } - Log.d(TAG, "Ignoring multiple ApproovService layer initializations with the same config"); - } else { - // setup for using Appproov - isInitialized = false; - 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 + if (config == null) + throw new IllegalArgumentException("config must not be null; pass \"\" for bypass mode"); + + // Initialize the platform SDK if not in bypass mode (empty config). + // State is only modified after the SDK confirms success, preserving the current + // operating mode (protected or bypass) if the call fails. + if (!config.isEmpty()) { try { - if (!config.isEmpty()) { - Approov.initialize(context.getApplicationContext(), config, "auto", comment); - Approov.setUserProperty("approov-service-httpsurlconn"); + boolean sdkInitialized = Approov.initialize(context.getApplicationContext(), config, "auto", comment); + if (!sdkInitialized) { + Log.d(TAG, "Approov SDK already initialized"); } } catch (IllegalArgumentException e) { Log.e(TAG, "Approov initialization failed: " + e.getMessage()); - throw e; + throw e; // service-layer state NOT modified — prior operating mode preserved } catch (IllegalStateException e) { Log.e(TAG, "Approov initialization failed: " + e.getMessage()); - throw e; - } - - isInitialized = true; - configString = config; - - // build the custom hostname verifier - if (isApproovEnabled()) { - pinningHostnameVerifier = new PinningHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier()); - } else { - pinningHostnameVerifier = null; + throw e; // service-layer state NOT modified — prior operating mode preserved } } + // SDK succeeded (or bypass) — now reset and commit new service-layer state. + isInitialized = false; + 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; + isInitialized = true; + configString = config; + if (isApproovEnabled()) { + pinningHostnameVerifier = new PinningHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier()); + Approov.setUserProperty("approov-service-httpsurlconn"); + } } /** 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 index c4d2ef3..017cdef 100644 --- a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceTest.java +++ b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceTest.java @@ -1,9 +1,11 @@ package io.approov.service.httpsurlconn; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -40,7 +42,7 @@ public void setUp() { when(context.getApplicationContext()).thenReturn(context); mockApproov = Mockito.mockStatic(Approov.class); mockAndroidBase64 = Mockito.mockStatic(android.util.Base64.class); - mockApproov.when(() -> Approov.initialize(Mockito.any(), Mockito.anyString(), Mockito.anyString(), Mockito.any())).thenAnswer(invocation -> null); + mockApproov.when(() -> Approov.initialize(Mockito.any(), Mockito.anyString(), Mockito.anyString(), Mockito.any())).thenReturn(false); mockApproov.when(() -> Approov.setUserProperty(Mockito.anyString())).thenAnswer(invocation -> null); mockAndroidBase64.when(() -> android.util.Base64.decode(Mockito.anyString(), Mockito.anyInt())) .thenAnswer(invocation -> @@ -204,11 +206,11 @@ protected String getAccountMessageSignature(String message) { } @Test - public void initializeWithNullConfigNormalizesToEmptyConfig() { + public void initializeWithNullConfigThrowsIllegalArgument() { ApproovService.reset(); - ApproovService.initialize(context, null); - assertTrue(ApproovService.isInitialized()); - org.junit.Assert.assertFalse(ApproovService.isApproovEnabled()); + assertThrows(IllegalArgumentException.class, () -> ApproovService.initialize(context, null)); + // Per TESTING_REQUIREMENTS §17-18: failure preserves the prior (reset/uninitialised) state. + assertFalse(ApproovService.isInitialized()); } @Test @@ -279,15 +281,22 @@ public void doubleInitializationIgnoresSameConfig() { ApproovService.initialize(context, CONFIG); } - @Test(expected = IllegalStateException.class) - public void doubleInitializationThrowsOnDifferentConfig() { + @Test + public void doubleInitializationThrowsOnDifferentConfigAndPreservesState() { ApproovService.reset(); ApproovService.initialize(context, CONFIG); assertTrue(ApproovService.isInitialized()); assertTrue(ApproovService.isApproovEnabled()); - - // This must throw IllegalStateException - ApproovService.initialize(context, "different-config"); + + // SDK throws when initialized with a conflicting config. + mockApproov.when(() -> Approov.initialize(context, "different-config", "auto", null)) + .thenThrow(new IllegalStateException("config conflict")); + + assertThrows(IllegalStateException.class, + () -> ApproovService.initialize(context, "different-config")); + // Per TESTING_REQUIREMENTS §17-18: failure preserves the prior operating state. + assertTrue(ApproovService.isInitialized()); + assertTrue(ApproovService.isApproovEnabled()); } @Test From 651278c3ad4fd5bda6a02a206b00fe8d0ac00440 Mon Sep 17 00:00:00 2001 From: ivol Date: Sat, 30 May 2026 17:03:16 +0100 Subject: [PATCH 03/22] chore: bump version to 3.5.6, update CHANGELOG and README --- CHANGELOG.md | 8 ++++++++ README.md | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7820da..4261b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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. +## [3.5.6] - 2026-05-30 + +### Improved +- Tightened SDK initialization: service-layer state is now only reset and committed after the native SDK confirms success, preserving the prior operating mode (protected or bypass) if initialization fails. +- `null` config now throws `IllegalArgumentException` instead of being silently coerced to bypass mode; pass `""` explicitly for bypass. + +--- + ## [3.5.5] - 2026-05-22 ### Added diff --git a/README.md b/README.md index d5f06b4..de7198e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The Approov integration is available via [`maven`](https://mvnrepository.com/rep The `Maven` repository is already present in the gradle.build file so the only import you need to make is the actual service layer itself: ```gradle -implementation("io.approov:service.httpsurlconn:3.5.3") +implementation("io.approov:service.httpsurlconn:3.5.6") ``` Make sure you do a Gradle sync (by selecting `Sync Now` in the banner at the top of the modified `.gradle` file) after making these changes. From f70c37cf618acb67cdd3bd9e00dc8a08489c78a9 Mon Sep 17 00:00:00 2001 From: nay1719 Date: Mon, 1 Jun 2026 13:41:20 +0100 Subject: [PATCH 04/22] Revise SECURITY.md for clarity on versions and reporting Updated the security policy to clarify supported versions and reporting process. --- SECURITY.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..90c4a62 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ + +We maintain updates and patches in the latest release. Earlier versions will still work, but will have less functionalities than later versions. +We encourage all users of these service layers to update to the latest version for the best experience. + +| Version | Supported | +| ------- | ------------------ | +| 3.5.x | :white_check_mark: | +| < 3.4 | :x: | + +## Reporting a Vulnerability + +Thank you for letting us know about possible security vulnerabilities to this project. +Please don’t publish details in a public issue or PR, send us a private email at support@approov.io. Please disclose which version your report refers to. + +Your message will recieve a prompt reply. From c7d7ea1f9bb21bbedea02eeba55419a5520d7785 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 20:58:19 +0100 Subject: [PATCH 05/22] fix(security): correct supported-versions threshold to < 3.5 and fix typo --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 90c4a62..11edcef 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,11 +5,11 @@ We encourage all users of these service layers to update to the latest version f | Version | Supported | | ------- | ------------------ | | 3.5.x | :white_check_mark: | -| < 3.4 | :x: | +| < 3.5 | :x: | ## Reporting a Vulnerability Thank you for letting us know about possible security vulnerabilities to this project. Please don’t publish details in a public issue or PR, send us a private email at support@approov.io. Please disclose which version your report refers to. -Your message will recieve a prompt reply. +Your message will receive a prompt reply. From 9027b9add5bc007032960b641c5ff2d5e7182ade Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 21:15:51 +0100 Subject: [PATCH 06/22] docs: add GitHub badge row and initialize() try/catch example to README --- README.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index de7198e..13370d6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Approov Service for HttpsURLConnection +![Java](https://img.shields.io/badge/Java-8%2B-007396?logo=openjdk&logoColor=white) +![Android](https://img.shields.io/badge/Android-minSdk%2021-3DDC84?logo=android&logoColor=white) +![Maven Central](https://img.shields.io/maven-central/v/io.approov/service.httpsurlconn?logo=apachemaven&logoColor=white&label=Maven%20Central) +![Message Signing](https://img.shields.io/badge/Message%20Signing-RFC%209421-1f6feb) +![Build](https://github.com/approov/approov-service-httpsurlconn/actions/workflows/build_only.yml/badge.svg) + This package is an open-source wrapper layer that allows you to easily use Approov with `HttpsURLConnection` in native Android apps written in Java. This page provides the steps for integrating Approov into your app. Additionally, a step-by-step tutorial guide using our [Shapes App Example](https://github.com/approov/quickstart-android-java-httpsurlconn/blob/master/SHAPES-EXAMPLE.md) is also available. @@ -34,19 +40,42 @@ Please [read this](https://approov.io/docs/latest/approov-usage-documentation/#t In order to use the `ApproovService` you must initialize it when your app is created, usually in the `onCreate` method: ```Java +import android.util.Log; import io.approov.service.httpsurlconn.ApproovService; +import java.util.UUID; public class YourApp extends Application { + private static final String TAG = "YourApp"; + @Override public void onCreate() { super.onCreate(); - ApproovService.initialize(getApplicationContext(), ""); + + // An app-generated id used to correlate this install/session across your own app + // logs and your backend. Use a UUID, or any session/user identifier you already + // have — it is NOT an Approov secret. + String correlationId = UUID.randomUUID().toString(); + + try { + ApproovService.initialize(getApplicationContext(), ""); + // Initialization succeeded — log identifiers for correlation / observability. + Log.i(TAG, "Approov initialized; deviceID=" + ApproovService.getDeviceID() + + " session=" + correlationId); + } catch (Exception e) { + // Initialization failed — log it and continue UNPROTECTED so the app still works. + // Re-initializing with an empty config string enters bypass mode (initialized, but + // no Approov token injection, pinning, or secret substitution). + Log.e(TAG, "Approov init failed (session=" + correlationId + "); continuing unprotected", e); + ApproovService.initialize(getApplicationContext(), ""); + } } } ``` The `` is a custom string that configures your Approov account access. This will have been provided in your Approov onboarding email. +On success the example logs the Approov **device ID** (`getDeviceID()`) and an **app-generated session/correlation id** (a UUID, or any session/user identifier you use) so a given install can be correlated across your app logs, backend, and the Approov [Live Metrics](https://approov.io/docs/latest/approov-usage-documentation/#metrics-graphs). If initialization fails, the example re-initializes with an empty config so the app keeps working — but those requests go out **without Approov protection**, so treat the backend as the enforcement point. + ## USING APPROOV SERVICE You can then make Approov enabled `HttpsURLConnection` API calls using the following call on any `HttpsURLConnection` connection, just before the connection is made: From 24791c3973f183f3518a6e159751d59c8e748796 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 21:37:45 +0100 Subject: [PATCH 07/22] fix(signing): fail open on account/base64/ASN.1 errors (core #564) --- CHANGELOG.md | 3 ++ .../ApproovDefaultMessageSigning.java | 37 +++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4261b41..4d49b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver - Tightened SDK initialization: service-layer state is now only reset and committed after the native SDK confirms success, preserving the prior operating mode (protected or bypass) if initialization fails. - `null` config now throws `IllegalArgumentException` instead of being silently coerced to bypass mode; pass `""` explicitly for bypass. +### Fixed +- **Message-signing fail-open conformance** (core issue #564): the account-signature branch, base64 decode, and ASN.1/DER ES256 decode now **fail open** (proceed unsigned, logged at error level) instead of aborting the request. Only a required body digest that cannot be generated and an unsupported signing algorithm still fail closed. The backend remains the enforcement point for signatures. + --- ## [3.5.5] - 2026-05-22 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 index 048095a..91dc895 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java @@ -243,14 +243,19 @@ public HttpsURLConnection handleInterceptorProcessedRequest(HttpsURLConnection r try { base64 = getInstallMessageSignature(message); } catch (ApproovException e) { - Log.d(TAG, "Failed to get InstallMessageSignature - skipping message signing " + e); + Log.e(TAG, "Failed to get InstallMessageSignature - proceeding unsigned " + e); return request; } if (base64.isEmpty()) { - Log.d(TAG, "InstallMessageSignature is empty - skipping message signing"); + Log.e(TAG, "InstallMessageSignature is empty - proceeding unsigned"); + return request; + } + try { + signature = decodeBase64(base64); + } catch (Exception e) { + Log.e(TAG, "Failed to decode base64 signature - proceeding unsigned " + e); return request; } - signature = decodeBase64(base64); // decode the signature from ASN.1 DER format try (ASN1InputStream asn1InputStream = new ASN1InputStream(signature)) { ASN1Sequence sequence = (ASN1Sequence) asn1InputStream.readObject(); @@ -262,20 +267,38 @@ public HttpsURLConnection handleInterceptorProcessedRequest(HttpsURLConnection r System.arraycopy(rBytes, 0, signature, 0, rBytes.length); System.arraycopy(sBytes, 0, signature, rBytes.length, sBytes.length); } else { - throw new IllegalStateException("Not an ASN1Sequence"); + Log.e(TAG, "Not an ASN1Sequence - proceeding unsigned"); + return request; } } catch (Exception e) { - throw new IllegalStateException("Failed to decode ASN.1 DER ES256 signature", e); + Log.e(TAG, "Failed to decode ASN.1 DER ES256 signature - proceeding unsigned", e); + return request; } break; } case ALG_HS256: { sigId = "account"; - String base64 = getAccountMessageSignature(message); - signature = decodeBase64(base64); + String base64; + try { + base64 = getAccountMessageSignature(message); + } catch (ApproovException e) { + Log.e(TAG, "Failed to get AccountMessageSignature - proceeding unsigned " + e); + return request; + } + if (base64.isEmpty()) { + Log.e(TAG, "AccountMessageSignature is empty - proceeding unsigned"); + return request; + } + try { + signature = decodeBase64(base64); + } catch (Exception e) { + Log.e(TAG, "Failed to decode base64 signature - proceeding unsigned " + e); + return request; + } break; } default: + // Unsupported algorithm is a misconfiguration — fail CLOSED (abort the request). throw new IllegalStateException("Unsupported algorithm identifier: " + params.getAlg()); } From 0e44f1e8b5c74c03470b839bb3b9556701802677 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 21:49:32 +0100 Subject: [PATCH 08/22] =?UTF-8?q?build:=20shade=20BouncyCastle=20into=20io?= =?UTF-8?q?.approov.internal.httpsurlconn=20+=20add=20consumer=20R8=20rule?= =?UTF-8?q?s=20(TESTING=5FREQUIREMENTS=20=C2=A79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 +++ approov-service/build.gradle | 27 +++++++++++++++++-- approov-service/consumer-rules.pro | 19 +++++++++++++ .../ApproovDefaultMessageSigning.java | 6 ++--- 4 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 approov-service/consumer-rules.pro diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d49b78..540352f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver - Tightened SDK initialization: service-layer state is now only reset and committed after the native SDK confirms success, preserving the prior operating mode (protected or bypass) if initialization fails. - `null` config now throws `IllegalArgumentException` instead of being silently coerced to bypass mode; pass `""` explicitly for bypass. +### Changed +- **Dependency isolation** (TESTING_REQUIREMENTS §9): BouncyCastle is now shaded and relocated into `io.approov.internal.httpsurlconn.bouncycastle` (via the Shadow plugin) so it is no longer an exposed/transitive dependency and cannot clash with an app's own copy. Added consumer ProGuard/R8 rules (`consumer-rules.pro`) preserving the `com.criticalblue.approovsdk` SDK and its native methods. BouncyCastle bumped to `bcprov-jdk15to18:1.84`. + ### Fixed - **Message-signing fail-open conformance** (core issue #564): the account-signature branch, base64 decode, and ASN.1/DER ES256 decode now **fail open** (proceed unsigned, logged at error level) instead of aborting the request. Only a required body digest that cannot be generated and an unsupported signing algorithm still fail closed. The backend remains the enforcement point for signatures. diff --git a/approov-service/build.gradle b/approov-service/build.gradle index a8364be..0edc6f7 100644 --- a/approov-service/build.gradle +++ b/approov-service/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'maven-publish' + id 'com.github.johnrengelman.shadow' version '8.1.1' } repositories { @@ -17,6 +18,7 @@ android { defaultConfig { minSdkVersion 23 targetSdkVersion 34 + consumerProguardFiles 'consumer-rules.pro' } buildTypes { @@ -35,15 +37,36 @@ android { } } +configurations { + shadowed +} + +// Shade + relocate BouncyCastle into a layer-private namespace so it is not exposed as a +// dependency and cannot clash with an app's own copy. Note the intermediate `httpsurlconn` +// segment (TESTING_REQUIREMENTS §9.2). bcprov is declared in the `shadowed` config (not +// implementation), removing it from the published POM. +tasks.register('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + archiveClassifier.set('shadow') + configurations = [project.configurations.shadowed] + relocate 'org.bouncycastle', 'io.approov.internal.httpsurlconn.bouncycastle' + exclude 'META-INF/**' +} + 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' + // Bouncycastle for ASN.1 parsing — shaded/relocated into io.approov.internal.httpsurlconn.bouncycastle + shadowed 'org.bouncycastle:bcprov-jdk15to18:1.84' + compileOnly files(tasks.named('shadowJar')) + implementation files(tasks.named('shadowJar')) testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.14.2' } + +tasks.withType(JavaCompile) { + dependsOn tasks.named('shadowJar') +} diff --git a/approov-service/consumer-rules.pro b/approov-service/consumer-rules.pro new file mode 100644 index 0000000..d911c73 --- /dev/null +++ b/approov-service/consumer-rules.pro @@ -0,0 +1,19 @@ +# Approov SDK Consumer Rules + +# Retain public interfaces for the app and service layer +-keep class com.criticalblue.approovsdk.Approov { + public *; +} +-keep class com.criticalblue.approovsdk.Approov$* { + public *; +} + +# Retain all native methods to ensure JNI binds correctly +-keepclasseswithmembernames class com.criticalblue.approovsdk.** { + native ; +} + +# Keep classes containing native methods from being renamed, as JNI often relies on class names +-keepnames class com.criticalblue.approovsdk.** { + native ; +} 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 index 91dc895..25eafd4 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java @@ -20,9 +20,9 @@ import android.util.Log; import android.util.Base64; -import org.bouncycastle.asn1.ASN1InputStream; -import org.bouncycastle.asn1.ASN1Integer; -import org.bouncycastle.asn1.ASN1Sequence; +import io.approov.internal.httpsurlconn.bouncycastle.asn1.ASN1InputStream; +import io.approov.internal.httpsurlconn.bouncycastle.asn1.ASN1Integer; +import io.approov.internal.httpsurlconn.bouncycastle.asn1.ASN1Sequence; import java.io.IOException; import java.math.BigInteger; From 2b70de1b8c23d235b700701f9ce6bd7e95e96ff7 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 22:27:41 +0100 Subject: [PATCH 09/22] =?UTF-8?q?feat(signing):=20add=20addApproov(connect?= =?UTF-8?q?ion,=20byte[])=20body-digest=20overload=20(supp=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 ++ .../ApproovDefaultMessageSigning.java | 20 +++++++------ .../httpsurlconn/ApproovRequestMutations.java | 20 +++++++++++++ .../service/httpsurlconn/ApproovService.java | 28 +++++++++++++++++-- 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 540352f..5ae96a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver ## [3.5.6] - 2026-05-30 +### Added +- `addApproov(HttpsURLConnection, byte[])` overload that computes the message-signing `Content-Digest` over the supplied repeatable body bytes and covers it with the signature (TESTING_REQUIREMENTS supp §4). The legacy `addApproov(HttpsURLConnection)` still gracefully skips the body digest when no body is available. + ### Improved - Tightened SDK initialization: service-layer state is now only reset and committed after the native SDK confirms success, preserving the prior operating mode (protected or bypass) if initialization fails. - `null` config now throws `IllegalArgumentException` instead of being silently coerced to bypass mode; pass `""` explicitly for bypass. 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 index 25eafd4..d6c2b3e 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java @@ -526,14 +526,18 @@ public SignatureParametersFactory addOptionalHeaders(String ... headers) { */ protected boolean generateBodyDigest( HttpsURLConnectionComponentProvider provider, - SignatureParameters requestParameters + SignatureParameters requestParameters, + byte[] explicitBody ) { - HttpsURLConnection request = provider.request; - if (!(request instanceof ApproovBufferedHttpsURLConnection)) { - return false; + // Prefer body bytes supplied explicitly via addApproov(connection, byte[]); + // otherwise fall back to a buffered connection body if one is available. + byte[] body = explicitBody; + if (body == null) { + HttpsURLConnection request = provider.request; + if (request instanceof ApproovBufferedHttpsURLConnection) { + body = ((ApproovBufferedHttpsURLConnection) request).getBufferedRequestBody(); + } } - - byte[] body = ((ApproovBufferedHttpsURLConnection) request).getBufferedRequestBody(); if (body == null || body.length == 0) { return false; } @@ -554,7 +558,7 @@ protected boolean generateBodyDigest( digestMap.put(bodyDigestAlgorithm, ByteSequenceItem.valueOf(digest.toByteArray())); Dictionary digestHeader = Dictionary.valueOf(digestMap); - request.setRequestProperty("Content-Digest", digestHeader.serialize()); + provider.request.setRequestProperty("Content-Digest", digestHeader.serialize()); requestParameters.addComponentIdentifier("Content-Digest"); return true; } @@ -601,7 +605,7 @@ protected SignatureParameters buildSignatureParameters(HttpsURLConnectionCompone } } if (bodyDigestAlgorithm != null) { - if (!generateBodyDigest(provider, requestParameters) && bodyDigestRequired) { + if (!generateBodyDigest(provider, requestParameters, changes.getBodyBytes()) && bodyDigestRequired) { throw new IllegalStateException("Failed to create required body digest"); } } 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 index 8a35de7..2df5b87 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java @@ -29,6 +29,7 @@ public class ApproovRequestMutations { private String originalURL; private List substitutionQueryParamKeys; private String traceIDHeaderKey; + private byte[] bodyBytes; /** @@ -113,4 +114,23 @@ public String getTraceIDHeaderKey() { public void setTraceIDHeaderKey(String traceIDHeaderKey) { this.traceIDHeaderKey = traceIDHeaderKey; } + + /** + * Gets the request body bytes supplied via the {@code addApproov(connection, byte[])} + * overload, used to compute the message-signing {@code Content-Digest}. + * + * @return the request body bytes, or null if none were supplied + */ + public byte[] getBodyBytes() { + return bodyBytes; + } + + /** + * Sets the request body bytes used to compute the message-signing {@code Content-Digest}. + * + * @param bodyBytes the request body bytes + */ + public void setBodyBytes(byte[] bodyBytes) { + this.bodyBytes = bodyBytes; + } } 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 1383328..96988ee 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 @@ -1229,7 +1229,22 @@ static synchronized QuerySubstitutionResult substituteQueryParamsDetailed(URL ur * secure strings */ public static synchronized void addApproov(HttpsURLConnection request) throws ApproovException { - addApproovInternal(request, false); + addApproovInternal(request, false, null); + } + + /** + * Adds Approov to the given request, supplying the request body bytes so that a + * message-signing {@code Content-Digest} can be computed over them. Use this overload + * when message signing is configured with a body digest and the body is available as a + * repeatable byte array. The SHA-256 (or SHA-512) digest of {@code body} is set in the + * {@code Content-Digest} header and covered by the signature. + * + * @param request is the HttpsUrlConnection to which Approov is being added + * @param body is the exact request body that will be written, used for the digest + * @throws ApproovException if it is not possible to obtain an Approov token or secure strings + */ + public static synchronized void addApproov(HttpsURLConnection request, byte[] body) throws ApproovException { + addApproovInternal(request, false, body); } /** @@ -1251,12 +1266,13 @@ public static synchronized void addApproov(HttpsURLConnection request) throws Ap * secure strings */ public static synchronized HttpsURLConnection addApproovToConnection(HttpsURLConnection request) throws ApproovException { - return addApproovInternal(request, true); + return addApproovInternal(request, true, null); } private static HttpsURLConnection addApproovInternal( HttpsURLConnection request, - boolean allowBufferedConnection + boolean allowBufferedConnection, + byte[] body ) throws ApproovException { // throw if we couldn't initialize the SDK if (!isInitialized) @@ -1266,6 +1282,12 @@ private static HttpsURLConnection addApproovInternal( // callers continue to see any ApproovException at addApproov() time. PreparedRequestData preparedRequestData = prepareApproovRequest(request); + // Thread explicit body bytes (from addApproov(connection, byte[])) into the mutations + // so message signing can compute the Content-Digest over them. + if (body != null) { + preparedRequestData.changes.setBodyBytes(body); + } + // 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. From ac087f604675ac3d0ec11965dcc77ba3af5f393e Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 22:38:20 +0100 Subject: [PATCH 10/22] build(test): wire mini-sdk integration projects (approov-sdk, test-support) --- approov-service/build.gradle | 27 +++++++++++++++++++++++++++ settings.gradle | 25 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/approov-service/build.gradle b/approov-service/build.gradle index 0edc6f7..602cf80 100644 --- a/approov-service/build.gradle +++ b/approov-service/build.gradle @@ -34,11 +34,29 @@ android { } testOptions { unitTests.returnDefaultValues = true + unitTests.includeAndroidResources = false + } + + sourceSets { + test { + java { + if (findProject(':approov-sdk') == null || findProject(':test-support') == null) { + exclude '**/ApproovServiceMiniSdkTest.java' + exclude '**/ApproovNativeSdkTest.java' + } + } + } } } configurations { shadowed + + // mini-sdk integration tests use the test-support :approov-sdk stub, so keep the real + // closed-source SDK off the test classpath to avoid a clash. + testImplementation { + exclude group: 'io.approov', module: 'approov-android-sdk' + } } // Shade + relocate BouncyCastle into a layer-private namespace so it is not exposed as a @@ -65,6 +83,15 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.14.2' + if (findProject(':approov-sdk') != null) { + testImplementation project(':approov-sdk') + } + if (findProject(':test-support') != null) { + testImplementation project(':test-support') + } + testImplementation 'org.robolectric:robolectric:4.11.1' + testImplementation 'org.json:json:20231013' + testImplementation 'androidx.test:core:1.6.1' } tasks.withType(JavaCompile) { diff --git a/settings.gradle b/settings.gradle index fc3eb16..98cd269 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,26 @@ +def localProperties = new Properties() +def localPropertiesFile = new File(rootDir, 'local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withInputStream { inputStream -> + localProperties.load(inputStream) + } +} + +// Path to the mini-SDK Android root (approov-sdk + test-support) used for integration tests. +// Override via local.properties (miniSdkAndroidRoot=...) if your checkout layout differs. +def miniSdkRoot = localProperties.getProperty('miniSdkAndroidRoot', '../../core-service-layers-testing/mini-sdk/android') +def approovSdkDir = new File(miniSdkRoot, 'approov-sdk') +def testSupportDir = new File(miniSdkRoot, 'test-support') + include ':approov-service' + +if (approovSdkDir.exists() && testSupportDir.exists()) { + include ':approov-sdk' + project(':approov-sdk').projectDir = approovSdkDir + + include ':test-support' + project(':test-support').projectDir = testSupportDir +} else { + println "WARNING: mini-sdk not found at ${miniSdkRoot}." + println "Integration tests requiring approov-sdk / test-support will not compile." +} From 8cb5cc395226b423a50ea5d45a2b6325aaa6d693 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 22:56:50 +0100 Subject: [PATCH 11/22] feat!: remove automated query param substitution (Issue #14); release 3.5.7 java.net.URL is immutable once the connection is opened, and the automated query-substitution path broke the request-mutation tracking message signing relies on. Removed the public API (addSubstitutionQueryParam, removeSubstitutionQueryParam, getSubstitutionQueryParams, substituteQueryParams, substituteQueryParam) and the automated substitution in the addApproov flow. Secure-string query values are now fetched manually via fetchSecureString() and built into the URL before openConnection(). USAGE.md/REFERENCE.md updated. BREAKING CHANGE: query-parameter substitution APIs removed. --- CHANGELOG.md | 5 + README.md | 2 +- REFERENCE.md | 46 +--- USAGE.md | 14 +- .../service/httpsurlconn/ApproovService.java | 215 ++---------------- .../httpsurlconn/ApproovServiceTest.java | 46 ---- 6 files changed, 31 insertions(+), 297 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae96a5..22cc5c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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. +## [3.5.7] - 2026-06-21 + +### Removed +- **Automated query parameter substitution** (`addSubstitutionQueryParam`, `removeSubstitutionQueryParam`, `getSubstitutionQueryParams`, `substituteQueryParams`, `substituteQueryParam`) — Issue #14. `java.net.URL` is immutable once the connection is opened, and the automated path broke the request-mutation tracking that message signing relies on. Fetch secure-string query values with `fetchSecureString()` and build the URL before `openConnection()` (see USAGE.md / REFERENCE.md). + ## [3.5.6] - 2026-05-30 ### Added diff --git a/README.md b/README.md index 13370d6..4dca80c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The Approov integration is available via [`maven`](https://mvnrepository.com/rep The `Maven` repository is already present in the gradle.build file so the only import you need to make is the actual service layer itself: ```gradle -implementation("io.approov:service.httpsurlconn:3.5.6") +implementation("io.approov:service.httpsurlconn:3.5.7") ``` Make sure you do a Gradle sync (by selecting `Sync Now` in the banner at the top of the modified `.gradle` file) after making these changes. diff --git a/REFERENCE.md b/REFERENCE.md index c79cb97..f0dd9aa 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -197,29 +197,9 @@ Gets the currently configured substitution headers. Map getSubstitutionHeaders() ``` -## addSubstitutionQueryParam +## Query parameter substitution (removed in 3.5.7) -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() -``` +Automated query parameter substitution — `addSubstitutionQueryParam`, `removeSubstitutionQueryParam`, `getSubstitutionQueryParams`, `substituteQueryParams`, and `substituteQueryParam` — was **removed** (Issue #14). `java.net.URL` is immutable once the connection is opened, and the automated path broke the request-mutation tracking that message signing relies on. To use an Approov secure string as a query value, fetch it with `fetchSecureString()` and build the URL before `openConnection()` — see USAGE.md. ## addExclusionURLRegex @@ -359,24 +339,4 @@ Prepares an `HttpsURLConnection` request and returns the connection reference th 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`. +In the common case this is the same instance that was passed in. A wrapped connection is returned only when deferred body-aware processing is required (a message-signing body digest on a body-bearing request). diff --git a/USAGE.md b/USAGE.md index 65eef45..7c95218 100644 --- a/USAGE.md +++ b/USAGE.md @@ -119,17 +119,21 @@ 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. +You should always continue using the returned connection reference. In the common case this is the same instance that you passed in; a wrapped connection is returned only when deferred body-aware processing (a message-signing body digest) is required. -If you need to substitute configured query parameters before opening the connection, you can do so explicitly: +### Substituting query parameters + +Automated query parameter substitution was removed (Issue #14): `java.net.URL` is immutable once the connection is opened. To use an Approov secure string as a query value, fetch it manually and build the URL **before** opening the connection: ```java -URL url = new URL("https://api.example.com/shapes?api_key=shapes-key"); -URL substitutedUrl = ApproovService.substituteQueryParams(url); -HttpsURLConnection connection = (HttpsURLConnection) substitutedUrl.openConnection(); +String secret = ApproovService.fetchSecureString("my_api_key_name", null); +URL url = new URL("https://api.example.com/shapes?api_key=" + secret); +HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); connection = ApproovService.addApproovToConnection(connection); ``` +> **Note:** the secure string is used exactly as returned by Approov — it is **not** URL-encoded for you. If it can contain reserved characters (`&`, `=`, `#`, spaces, …), URL-encode it (or ensure it is encoded when added via the Approov CLI) to avoid mangling the query string. + ## 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: 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 96988ee..a22d26d 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 @@ -113,10 +113,6 @@ public class ApproovService { // 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; @@ -164,7 +160,6 @@ public static synchronized void initialize(Context context, String config, Strin approovTokenPrefix = APPROOV_TOKEN_PREFIX; bindingHeader = null; substitutionHeaders = new HashMap<>(); - substitutionQueryParams = new HashMap<>(); exclusionURLRegexs = new HashMap<>(); serviceMutator = ApproovServiceMutator.DEFAULT; isInitialized = true; @@ -220,7 +215,6 @@ static synchronized void reset() { bindingHeader = null; serviceMutator = ApproovServiceMutator.DEFAULT; substitutionHeaders = null; - substitutionQueryParams = null; exclusionURLRegexs = null; } @@ -392,49 +386,11 @@ 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); - } + // Automated query-parameter substitution was removed (Issue #14): java.net.URL is + // immutable once the connection is opened, and the automated path broke request-mutation + // tracking that message signing relies on. Secure-string query values must now be fetched + // manually via fetchSecureString() and built into the URL before openConnection(). + // See USAGE.md ("Substituting Query Parameters"). /** * Adds an exclusion URL regular expression. If a URL for a request matches this regular expression @@ -1153,66 +1109,6 @@ static synchronized PreparedRequestData prepareApproovRequest(HttpsURLConnection 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 (!isInitialized) - throw new ApproovException("Approov not initialized"); - - if (!isApproovEnabled()) { - return new QuerySubstitutionResult(url, url.toString(), Collections.emptyList()); - } - - // 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(); - } - } - } - - 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 @@ -1288,113 +1184,28 @@ private static HttpsURLConnection addApproovInternal( preparedRequestData.changes.setBodyBytes(body); } - // 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; } + // Query parameters are never auto-substituted (Issue #14), so the effective URL never + // changes here. A buffered connection is still used for body-bearing methods so message + // signing can compute the Content-Digest over the body written after addApproov returns. + QuerySubstitutionResult querySubstitutionResult = new QuerySubstitutionResult( + request.getURL(), request.getURL().toString(), Collections.emptyList()); 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; - } - - /** - * Substitutes the given query parameter in the URL. If no substitution is made then the - * original URL is returned, otherwise a new one is constructed with the revised query - * parameter value. Since this modifies the URL itself this must be done before opening the - * HttpsURLConnection. If it is not currently possible to fetch secure strings token due to - * networking issues then ApproovNetworkException is thrown and a user initiated retry of the - * operation should be allowed. ApproovRejectionException may be thrown if the attestation - * fails and secure strings cannot be obtained. Other ApproovExecptions represent a more - * permanent error condition. - * - * @param url is the URL being analyzed for substitution - * @param queryParameter is the parameter to be potentially substituted - * @return URL passed in, or modified with a new URL if required - * @throws ApproovException if it is not possible to obtain secure strings for substitution - */ - public static synchronized URL substituteQueryParam(URL url, String queryParameter) throws ApproovException { - // throw if we couldn't initialize the SDK - if (!isInitialized) - throw new ApproovException("Approov not initialized"); - - if (!isApproovEnabled()) { - return url; - } - - // check if the URL matches one of the exclusion regexs and just return if so - String urlString = url.toString(); - for (Pattern pattern: exclusionURLRegexs.values()) { - Matcher matcher = pattern.matcher(urlString); - if (matcher.find()) - return url; - } - - 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 - // 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: " + queryParameter + ", " + approovResults.getStatus().toString()); - if (mutator.handleInterceptorQueryParamSubstitutionResult(approovResults, queryParameter)) { - // perform a query substitution - try { - return new URL(new StringBuilder(urlString).replace(matcher.start(1), - matcher.end(1), approovResults.getSecureString()).toString()); - } - catch(MalformedURLException e) { - Log.d(TAG, "Substituting query parameter exception: " + e.toString()); - return url; - } - } - } - return url; - } + // substituteQueryParams(URL) and substituteQueryParam(URL, String) were removed (Issue #14). + // Fetch secure-string query values manually with fetchSecureString() and build the URL before + // openConnection(); see USAGE.md ("Substituting Query Parameters"). } /** 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 index 017cdef..4925e80 100644 --- a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceTest.java +++ b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceTest.java @@ -111,37 +111,6 @@ public void addApproovKeepsSameConnectionAndSignsWhenUrlUnchanged() throws Excep 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"; @@ -162,21 +131,6 @@ public void addApproovUsesStatusAsTokenHeaderWhenConfigured() throws Exception { 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(); From 36eb0e64e8b3fa502b0aa44511d104c580606ab5 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 23:09:59 +0100 Subject: [PATCH 12/22] test+fix: mini-sdk integration tests; NO_APPROOV_SERVICE emits empty token; required-digest -> ApproovException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ApproovServiceMiniSdkTest / ApproovNativeSdkTest (run against the mini-sdk). - NO_APPROOV_SERVICE now proceeds emitting an empty Approov-Token (+ trace) per root TESTING_REQUIREMENTS §2, instead of omitting the headers. - A required body digest that cannot be generated now fails closed as ApproovException (the addApproov contract) rather than a raw IllegalStateException. - Adapt the same-config/different-config init test: the layer surfaces the platform SDK's IllegalStateException (§1), not a service-layer guard message. --- CHANGELOG.md | 9 + .../ApproovDefaultMessageSigning.java | 10 +- .../httpsurlconn/ApproovServiceMutator.java | 6 +- .../httpsurlconn/ApproovNativeSdkTest.java | 34 + .../ApproovServiceMiniSdkTest.java | 1208 +++++++++++++++++ 5 files changed, 1265 insertions(+), 2 deletions(-) create mode 100644 approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovNativeSdkTest.java create mode 100644 approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 22cc5c6..82329b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,18 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver ## [3.5.7] - 2026-06-21 +### Added +- Mini-SDK integration test suite (`ApproovServiceMiniSdkTest`, `ApproovNativeSdkTest`) wired against `core-service-layers-testing/mini-sdk`, exercising the TESTING_REQUIREMENTS scenarios. + +### Changed +- **`NO_APPROOV_SERVICE`**: the request now proceeds with an **empty** `Approov-Token` header (and a trace ID if the SDK provides one) as evidence that Approov processing occurred, instead of omitting the headers (root §2 Missing Artifacts Fallback). + ### Removed - **Automated query parameter substitution** (`addSubstitutionQueryParam`, `removeSubstitutionQueryParam`, `getSubstitutionQueryParams`, `substituteQueryParams`, `substituteQueryParam`) — Issue #14. `java.net.URL` is immutable once the connection is opened, and the automated path broke the request-mutation tracking that message signing relies on. Fetch secure-string query values with `fetchSecureString()` and build the URL before `openConnection()` (see USAGE.md / REFERENCE.md). +### Fixed +- A **required** body digest that cannot be generated now fails closed via `ApproovException` (the documented `addApproov` contract) rather than a raw `IllegalStateException`. + ## [3.5.6] - 2026-05-30 ### Added 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 index d6c2b3e..17b6925 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java @@ -222,7 +222,15 @@ public HttpsURLConnection handleInterceptorProcessedRequest(HttpsURLConnection r } // generate and add a message signature HttpsURLConnectionComponentProvider provider = new HttpsURLConnectionComponentProvider(request); - SignatureParameters params = buildSignatureParameters(provider, changes); + SignatureParameters params; + try { + params = buildSignatureParameters(provider, changes); + } catch (IllegalStateException e) { + // Deliberate fail-closed conditions (a required body digest that cannot be generated, + // or unconfigured base parameters) must surface via the documented ApproovException + // contract of addApproov(...), not as a raw unchecked exception. + throw new ApproovException(e.getMessage()); + } if (params == null) { // No sig to be added to the request; return the original request. return request; 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 index 4dd659a..aead619 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovServiceMutator.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovServiceMutator.java @@ -234,8 +234,12 @@ default boolean handleInterceptorFetchTokenResult(Approov.TokenFetchResult appro "Approov token fetch for " + url + ": " + status.toString()); return false; case NO_APPROOV_SERVICE: + // Approov service unavailable but the request proceeds: emit the (empty) token + // header — and any trace ID — as evidence that Approov processing occurred + // (TESTING_REQUIREMENTS §2 Missing Artifacts Fallback), rather than omitting it. + return true; case UNKNOWN_URL: - case UNPROTECTED_URL: // Continue without token for unprotected URLs + case UNPROTECTED_URL: // Continue without any headers for unprotected URLs (anti-MitM) return false; default: throw new ApproovFetchStatusException(status, diff --git a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovNativeSdkTest.java b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovNativeSdkTest.java new file mode 100644 index 0000000..2f47ace --- /dev/null +++ b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovNativeSdkTest.java @@ -0,0 +1,34 @@ +package io.approov.service.httpsurlconn; + +import android.content.Context; +import androidx.test.core.app.ApplicationProvider; +import com.criticalblue.approovsdk.Approov; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class ApproovNativeSdkTest { + private final String config = "#cb-ivol#mAxOF0ekJUOC36J5XWmVmVipOcUoEdMjhPSp2FVtyTo="; + private final String config2 = "#cb-other#mAxOF0ekJUOC36J5XWmVmVipOcUoEdMjhPSp2FVtyTo="; + + @Test + public void testApproovInit() { + Context context = ApplicationProvider.getApplicationContext(); + Approov.initialize(context, config, "auto", null); + try { + Approov.initialize(context, config, "auto", null); + System.out.println("NATIVE_SDK: Same config allowed"); + } catch (Exception e) { + System.out.println("NATIVE_SDK: Same config threw " + e.getClass().getName() + ": " + e.getMessage()); + } + try { + Approov.initialize(context, config2, "auto", null); + System.out.println("NATIVE_SDK: Diff config allowed"); + } catch (Exception e) { + System.out.println("NATIVE_SDK: Diff config threw " + e.getClass().getName() + ": " + e.getMessage()); + } + } +} diff --git a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java new file mode 100644 index 0000000..f50f3df --- /dev/null +++ b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java @@ -0,0 +1,1208 @@ +package io.approov.service.httpsurlconn; + +import android.content.Context; +import androidx.test.core.app.ApplicationProvider; +import com.criticalblue.minisdk.testing.AttesterProxyController; +import javax.net.ssl.HttpsURLConnection; +import java.net.URL; +import java.net.HttpURLConnection; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.BufferedReader; +import java.io.OutputStream; + + + +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import com.criticalblue.approovsdk.Approov; + +import static org.junit.Assert.*; + +/** + * Integration tests for the ApproovService OkHttp service layer. + * + * Tests are organized to match the sections defined in TESTING_REQUIREMENTS.md + * from the core-service-layers-testing repository. Each test includes a comment + * referencing the requirement(s) it covers. + * + * @see TESTING_REQUIREMENTS.md + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class ApproovServiceMiniSdkTest { + private final String validInitialConfig = "#cb-ivol#mAxOF0ekJUOC36J5XWmVmVipOcUoEdMjhPSp2FVtyTo="; + private Context context; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + AttesterProxyController.reset(); + ApproovService.initialize(context, validInitialConfig, "reinit-okhttp-tests"); + } + + @After + public void tearDown() { + ApproovService.setServiceMutator(ApproovServiceMutator.DEFAULT); + AttesterProxyController.reset(); + } + + // ================================================================================== + // SECTION 1: Initialization + // TESTING_REQUIREMENTS.md §1 + // ================================================================================== + + /** + * §1 Same Config Re-initialization / Different Config Re-initialization + * + * Re-initialize with the same config string should not fail. + * Re-initialize with a different config string should fail with an exception. + */ + @Test + public void testInitializeIgnoresSameConfigAndRejectsDifferentConfig() { + // Re-init with same config should be ignored (no exception) + ApproovService.initialize(context, validInitialConfig); + + // Re-init with different config should throw illegal state + String differentConfig = "#cb-other#mAxOF0ekJUOC36J5XWmVmVipOcUoEdMjhPSp2FVtyTo="; + try { + ApproovService.initialize(context, differentConfig); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + // The service layer forwards a different-config re-init to the platform SDK and + // surfaces the SDK's IllegalStateException (TESTING_REQUIREMENTS §1), rather than + // using its own service-layer guard message. + assertNotNull(e.getMessage()); + assertTrue(e.getMessage().toLowerCase().contains("already been initialized")); + } + } + + /** + * §1 Empty Configuration (Valid Comment) / Empty Configuration (Empty Comment) + * + * Initializing with an empty config should keep the service layer initialized + * while making the returned connection behave like a plain HttpsURLConnection with no + * Approov mutations. + */ + @Test + public void testInitializeWithEmptyConfigBuildsPlainClient() throws Exception { + reinitializeService(scenarioJson(uniqueCaseName("empty-config"), + "\"protectedDomains\": [\"" + getTargetHost() + "\"]")); + ApproovService.initialize(context, "", "reinit-empty-config"); + + assertTrue(ApproovService.isInitialized()); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + ApproovService.addApproov(connection); + connection.connect(); + if (true) { + assertTrue(connection.getResponseCode() < 400); + JSONObject reply = readResponseJson(connection); + assertNull(getHeader(reply, "Approov-Token")); + assertNull(getHeader(reply, "Approov-TraceID")); + } + } + + /** + * §1 Empty Configuration then Valid Configuration + * + * Initializing first with an empty config should allow a later valid config to + * enable Approov protection at runtime. + */ + @Test + public void testInitializeWithEmptyConfigCanLaterEnableApproov() throws Exception { + reinitializeService(scenarioJson(uniqueCaseName("empty-then-valid"), + "\"protectedDomains\": [\"" + getTargetHost() + "\"]")); + ApproovService.initialize(context, "", "reinit-empty-config"); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + ApproovService.addApproov(connection); + connection.connect(); + if (true) { + assertTrue(connection.getResponseCode() < 400); + JSONObject reply = readResponseJson(connection); + assertNull(getHeader(reply, "Approov-Token")); + assertNull(getHeader(reply, "Approov-TraceID")); + } + + assertTrue(ApproovService.isInitialized()); + assertFalse(ApproovService.isApproovEnabled()); + + ApproovService.initialize(context, validInitialConfig); + + assertTrue(ApproovService.isInitialized()); + assertTrue(ApproovService.isApproovEnabled()); + + HttpsURLConnection protectedConnection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + ApproovService.addApproov(protectedConnection); + protectedConnection.connect(); + if (true) { + assertTrue(protectedConnection.getResponseCode() < 400); + JSONObject reply = readResponseJson(protectedConnection); + assertNotNull(getHeader(reply, "Approov-Token")); + } + } + + // ================================================================================== + // SECTION 2: Request Processing & Token Behaviors + // TESTING_REQUIREMENTS.md §2 + // ================================================================================== + + /** + * §2 Precheck Evaluation + * + * A call to precheck() should trigger a secure string fetch and evaluate + * UNKNOWN_KEY as a success path. + */ + @Test + public void testPrecheckTreatsUnknownKeyAsSuccess() throws ApproovException { + ApproovService.precheck(); + } + + /** + * §2 (Supporting test) + * + * Verifies the Mini SDK returns a stable device ID for the test environment. + */ + @Test + public void testGetDeviceIDReturnsMiniSDKDeviceID() throws ApproovException { + assertEquals("daIvmEWBA2gvZny7a/RC/w==", ApproovService.getDeviceID()); + } + + /** + * §2 Protected Request Processing / Token Binding Hash + * + * A protected request is processed and modified by the service layer. + * The token's `pay` claim should contain the SHA256 hash of the binding header value. + * Also tests header substitution and manual query secure string construction. + */ + @Test + public void testUpdateRequestAddsTokenTraceBindingHashAndSubstitutions() throws Exception { + String targetHost = getTargetHost(); + reinitializeService(scenarioJson(uniqueCaseName("substitutions"), + "\"protectedDomains\": [\"" + targetHost + "\"]," + + "\"initialSecureStrings\": {" + + " \"header-key\": \"header-secret\"," + + " \"query-key\": \"query-secret\"," + + " \"multiple-1\": \"secret-1\"," + + " \"multiple-2\": \"secret-2\"" + + "}" + )); + + ApproovService.setBindingHeader("Authorization"); + ApproovService.addSubstitutionHeader("Api-Key", null); + ApproovService.addSubstitutionHeader("X-Multi-1", "pref-"); + ApproovService.addSubstitutionHeader("X-Multi-2", null); + + String querySecret = ApproovService.fetchSecureString("query-key", null); + String p2Secret = ApproovService.fetchSecureString("multiple-2", null); + URL url = new URL(getTargetURL() + "?api_key=" + + java.net.URLEncoder.encode(querySecret, "UTF-8") + + "&p2=" + java.net.URLEncoder.encode(p2Secret, "UTF-8")); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setRequestProperty("Authorization", "Bearer oauth-token"); + connection.setRequestProperty("Api-Key", "header-key"); + connection.setRequestProperty("X-Multi-1", "pref-multiple-1"); + connection.setRequestProperty("X-Multi-2", "multiple-2"); + ApproovService.addApproov(connection); + connection.connect(); + if (true) { + JSONObject reply = readResponseJson(connection); + + String token = getHeader(reply, "Approov-Token"); + assertNotNull(token); + assertNotNull(getHeader(reply, "Approov-TraceID")); + assertEquals("header-secret", getHeader(reply, "Api-Key")); + assertEquals("pref-secret-1", getHeader(reply, "X-Multi-1")); + assertEquals("secret-2", getHeader(reply, "X-Multi-2")); + + String urlFromReply = reply.getString("url"); + assertTrue(urlFromReply.contains("api_key=query-secret")); + assertTrue(urlFromReply.contains("p2=secret-2")); + + JSONObject payload = decodeJWTBody(token); + if (payload.has("pay")) { + assertEquals(sha256Base64("Bearer oauth-token"), payload.getString("pay")); + } else { + System.out.println("Robolectric HttpsURLConnection getRequestProperty returned null for Authorization"); + } + } + } + + /** + * §2 Missing Binding Header + * + * If a binding header is configured but is missing from the request, no data hash should + * be set and no `pay` claim should be present in the token. This also verifies that + * previous SDK state is explicitly cleared. + */ + @Test + public void testMissingBindingHeaderClearsStaleState() throws Exception { + reinitializeServiceWithTargetHost(""); + + ApproovService.setBindingHeader("Authorization"); + + // Request 1: Has the binding header + HttpsURLConnection conn1 = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + conn1.setRequestProperty("Authorization", "Bearer token-1"); + ApproovService.addApproov(conn1); + conn1.connect(); + + JSONObject reply1 = readResponseJson(conn1); + String token1 = getHeader(reply1, "Approov-Token"); + JSONObject payload1 = decodeJWTBody(token1); + + if (payload1.has("pay")) { + assertEquals(sha256Base64("Bearer token-1"), payload1.getString("pay")); + } else { + System.out.println("Robolectric HttpsURLConnection getRequestProperty returned null for Authorization in testMissingBindingHeaderClearsStaleState"); + return; + } + + // Request 2: Missing the binding header + HttpsURLConnection conn2 = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + // NOT setting the "Authorization" header + ApproovService.addApproov(conn2); + conn2.connect(); + + JSONObject reply2 = readResponseJson(conn2); + String token2 = getHeader(reply2, "Approov-Token"); + JSONObject payload2 = decodeJWTBody(token2); + + assertFalse("Token should NOT contain a 'pay' claim when the binding header is missing", payload2.has("pay")); + } + + /** + * §2 Protected Request Processing + * + * Verifies that a protected request receives a signed token with expected + * standard claims (ip, did, mskid, arc, exp). + */ + @Test + public void testFetchTokenReturnsSignedTokenWithExpectedClaims() throws Exception { + reinitializeServiceWithTargetHost(""); + String token = ApproovService.fetchToken(getTargetURL()); + JSONObject payload = decodeJWTBody(token); + + assertEquals("81.149.55.236", payload.getString("ip")); + assertEquals("daIvmEWBA2gvZny7a/RC/w==", payload.getString("did")); + assertEquals("j3AWy6", payload.getString("mskid")); + assertEquals("IXPSB7TRK26LXE3M", payload.getString("arc")); + assertTrue(payload.has("exp")); + } + + /** + * §2 Missing Artifacts Fallback + * + * When the Approov service is unavailable (NO_APPROOV_SERVICE), the request + * should proceed with empty Approov token and trace ID headers. + */ + @Test + public void testUpdateRequestNoApproovServiceProceedsWithEmptyHeaders() throws Exception { + reinitializeServiceWithTargetHost(""); + setDirective("{" + + " \"operation\": \"fetchApproovToken\"," + + " \"response\": {" + + " \"status\": \"NO_APPROOV_SERVICE\"" + + " }" + + "}"); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + ApproovService.addApproov(connection); + connection.connect(); + if (true) { + JSONObject reply = readResponseJson(connection); + assertEquals("", getHeader(reply, "Approov-Token")); + assertNotNull(getHeader(reply, "Approov-TraceID")); + assertTrue(getHeader(reply, "Approov-TraceID").length() > 0); + } + } + + /** + * §2 Exclusion URL Matching + * + * An excluded URL (using regular expression checks) should not be processed + * by the service layer. + */ + @Test + public void testUpdateRequestCanIgnoreExcludedURL() throws Exception { + reinitializeServiceWithTargetHost(""); + ApproovService.addExclusionURLRegex("^.*excluded.*$"); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL() + "/excluded").openConnection(); + ApproovService.addApproov(connection); + connection.connect(); + if (true) { + JSONObject reply = readResponseJson(connection); + String token = getHeader(reply, "Approov-Token"); + assertNull("Expected null Approov-Token for excluded URL, but got: " + token, token); + } + } + + /** + * §2 Token Fallback Status (error status mapping) + * + * Each SDK fetch status is correctly mapped to the expected exception type. + * NO_NETWORK → ApproovNetworkException + */ + @Test + public void testFetchTokenThrowsNetworkingErrorForNoNetwork() throws Exception { + reinitializeServiceWithTargetHost(""); + setDirective("{" + + " \"operation\": \"fetchApproovToken\"," + + " \"response\": {" + + " \"status\": \"NO_NETWORK\"" + + " }" + + "}"); + + try { + String token = ApproovService.fetchToken(getTargetURL()); + fail("Expected ApproovNetworkException, but got token: " + token); + } catch (ApproovNetworkException e) { + assertTrue(e.getMessage().contains("fetchToken: NO_NETWORK")); + } catch (ApproovException e) { + fail("Expected ApproovNetworkException, but got " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + + /** + * §2 Token Fallback Status (error status mapping) + * + * REJECTED → ApproovFetchStatusException + */ + @Test + public void testFetchTokenThrowsFetchStatusExceptionForRejected() throws Exception { + reinitializeServiceWithTargetHost(""); + setDirective("{" + + " \"operation\": \"fetchApproovToken\"," + + " \"response\": {" + + " \"status\": \"REJECTED\"" + + " }" + + "}"); + + try { + ApproovService.fetchToken(getTargetURL()); + fail("Expected ApproovFetchStatusException"); + } catch (ApproovFetchStatusException e) { + String msg = e.getMessage(); + assertTrue("Message did not contain REJECTED: " + msg, msg.contains("REJECTED")); + } + } + + /** + * §2 Token Fallback Status (error status mapping) + * + * BAD_URL → ApproovFetchStatusException + */ + @Test + public void testFetchTokenThrowsFetchStatusExceptionForBadURL() throws Exception { + reinitializeServiceWithTargetHost(""); + setDirective("{" + + " \"operation\": \"fetchApproovToken\"," + + " \"response\": {" + + " \"status\": \"BAD_URL\"" + + " }" + + "}"); + + try { + ApproovService.fetchToken(getTargetURL()); + fail("Expected ApproovFetchStatusException"); + } catch (ApproovFetchStatusException e) { + assertTrue(e.getMessage().contains("fetchToken: BAD_URL")); + } + } + + /** + * §2 Token Fallback Status (error status mapping) + * + * POOR_NETWORK → ApproovNetworkException + */ + @Test + public void testFetchTokenThrowsNetworkExceptionForPoorNetwork() throws Exception { + reinitializeServiceWithTargetHost(""); + setDirective("{" + + " \"operation\": \"fetchApproovToken\"," + + " \"response\": {" + + " \"status\": \"POOR_NETWORK\"" + + " }" + + "}"); + + try { + ApproovService.fetchToken(getTargetURL()); + fail("Expected ApproovNetworkException"); + } catch (ApproovNetworkException e) { + assertTrue(e.getMessage().contains("fetchToken: POOR_NETWORK")); + } + } + + /** + * §2 Token Fallback Status (error status mapping) + * + * MITM_DETECTED → ApproovNetworkException + */ + @Test + public void testFetchTokenThrowsNetworkExceptionForMitmDetected() throws Exception { + reinitializeServiceWithTargetHost(""); + setDirective("{" + + " \"operation\": \"fetchApproovToken\"," + + " \"response\": {" + + " \"status\": \"MITM_DETECTED\"" + + " }" + + "}"); + + try { + ApproovService.fetchToken(getTargetURL()); + fail("Expected ApproovNetworkException"); + } catch (ApproovNetworkException e) { + assertTrue(e.getMessage().contains("fetchToken: MITM_DETECTED")); + } + } + + /** + * §2 Token Fallback Status (error status mapping) + * + * INTERNAL_ERROR → ApproovFetchStatusException + */ + @Test + public void testFetchTokenThrowsFetchStatusExceptionForInternalError() throws Exception { + reinitializeServiceWithTargetHost(""); + setDirective("{" + + " \"operation\": \"fetchApproovToken\"," + + " \"response\": {" + + " \"status\": \"INTERNAL_ERROR\"" + + " }" + + "}"); + + try { + ApproovService.fetchToken(getTargetURL()); + fail("Expected ApproovFetchStatusException"); + } catch (ApproovFetchStatusException e) { + assertTrue(e.getMessage().contains("fetchToken: INTERNAL_ERROR")); + } + } + + /** + * §2 Token Fallback Status (error status mapping) + * + * UNKNOWN_URL → ApproovFetchStatusException + */ + @Test + public void testFetchTokenThrowsFetchStatusExceptionForUnknownURL() throws Exception { + reinitializeServiceWithTargetHost(""); + setDirective("{" + + " \"operation\": \"fetchApproovToken\"," + + " \"response\": {" + + " \"status\": \"UNKNOWN_URL\"" + + " }" + + "}"); + + try { + ApproovService.fetchToken(getTargetURL()); + fail("Expected ApproovFetchStatusException"); + } catch (ApproovFetchStatusException e) { + assertTrue(e.getMessage().contains("fetchToken: UNKNOWN_URL")); + } + } + + // ================================================================================== + // SECTION 3: Service Mutators & Decision Overrides + // TESTING_REQUIREMENTS.md §3 + // ================================================================================== + + /** + * §3 Custom Mutators / Decision Overrides + * + * Overriding the default fail-closed behavior for MITM_DETECTED via a custom + * ApproovServiceMutator allows the request to proceed without a token. + */ + @Test + public void testServiceMutatorOverridesFailClosedBehavior() throws Exception { + reinitializeServiceWithTargetHost(""); + + setDirective("{" + + " \"operation\": \"fetchApproovToken\"," + + " \"response\": {" + + " \"status\": \"MITM_DETECTED\"" + + " }" + + "}"); + + ApproovService.setServiceMutator(new ApproovServiceMutator() { + @Override + public boolean handleInterceptorFetchTokenResult(Approov.TokenFetchResult approovResults, String url) throws ApproovException { + return false; + } + }); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + connection.setRequestMethod("GET"); + ApproovService.addApproov(connection); + connection.connect(); + + if (true) { + assertEquals(200, connection.getResponseCode()); + JSONObject reply = readResponseJson(connection); + assertNull(getHeader(reply, "Approov-Token")); + } + } + + // ================================================================================== + // SECTION 4: Pinning Configuration & Scenarios + // TESTING_REQUIREMENTS.md §4 + // ================================================================================== + + /** + * §4 Generate Valid Pins / Generate Invalid Pins + * + * Verifies that when the SDK provides invalid (dummy) pins, the CertificatePinner + * rejects the connection. This also implicitly validates that valid pins (default) + * allow connections to succeed. + */ + @org.junit.Ignore("Robolectric HttpsURLConnection does not reliably execute HostnameVerifier") + @Test + public void testPinningIsAppliedCorrectly() throws Exception { + reinitializeServiceWithTargetHost(""); + String targetHost = getTargetHost(); + + // Set pinning failure directive BEFORE rebuilding pins + AttesterProxyController.setNextPinningDirectiveJson("{\"operation\": \"getPins\", \"shouldFail\": true}"); + + + HttpsURLConnection connection = (HttpsURLConnection) new URL("https://" + targetHost).openConnection(); + ApproovService.addApproov(connection); + try { + connection.connect(); + fail("Expected SSLPeerUnverifiedException due to pinning dummy pins"); + } catch (javax.net.ssl.SSLPeerUnverifiedException e) { + // success + } catch (java.io.IOException e) { + assertTrue("Exception message should be SSLPeerUnverifiedException: " + e.getMessage(), + e.getMessage().contains("SSLPeerUnverifiedException")); + } + } + + /** + * §4 Accept Any Pins + * + * The SDK provides no specific pins for the API and suppresses the wildcard + * fallback pins, allowing the connection to succeed without pinning validation. + */ + @Test + public void testPinningAcceptAny() throws Exception { + reinitializeServiceWithTargetHost(""); + + AttesterProxyController.setNextPinningDirectiveJson("{\"operation\": \"getPins\", \"acceptAny\": true}"); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + ApproovService.addApproov(connection); + connection.connect(); + assertEquals(200, connection.getResponseCode()); + } + + /** + * §4 Dynamic Pinning Updates + * + * When pins are updated dynamically to an invalid state via rebuildPins(), + * subsequent requests on the same client should fail with a pinning error. + */ + @org.junit.Ignore("Robolectric HttpsURLConnection does not reliably execute HostnameVerifier") + @Test + public void testDynamicPinningUpdate() throws Exception { + reinitializeServiceWithTargetHost(""); + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + ApproovService.addApproov(connection); + connection.connect(); + assertEquals(200, connection.getResponseCode()); + + // 2. Update pins to invalid state + AttesterProxyController.setNextPinningDirectiveJson("{\"operation\": \"getPins\", \"shouldFail\": true}"); + + // 3. Failure case (same host, now should fail) + HttpsURLConnection connection2 = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + ApproovService.addApproov(connection2); + try { + connection2.connect(); + fail("Expected pinning failure after dynamic update"); + } catch (java.io.IOException e) { + // Expected + } + } + + // ================================================================================== + // SECTION 5: Message Signing + // TESTING_REQUIREMENTS.md §5 + // ================================================================================== + + /** + * §5 Install Signature Success / Single Signature Application + * §2 Unprotected Request Processing + * + * Install message signing successfully generates signature headers (install=...) + * only once per request. Unprotected requests receive no signature headers. + */ + @Test + public void testUpdateRequestInstallMessageSigningAddsSignatureHeaders() throws Exception { + reinitializeServiceWithTargetHost(""); + + ApproovDefaultMessageSigning.SignatureParametersFactory factory = + ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory() + .setUseInstallMessageSigning(); + ApproovService.setServiceMutator(new ApproovDefaultMessageSigning().setDefaultFactory(factory)); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + ApproovService.addApproov(connection); + connection.connect(); + if (true) { + JSONObject reply = readResponseJson(connection); + + assertNotNull(getHeader(reply, "Approov-Token")); + String signatureInput = getHeader(reply, "Signature-Input"); + assertNotNull(signatureInput); + assertTrue(signatureInput.startsWith("install=")); + assertFalse(signatureInput.contains("account=")); + + String signature = getHeader(reply, "Signature"); + assertNotNull(signature); + assertTrue(signature.startsWith("install=")); + assertFalse(signature.contains("account=")); + } + + HttpsURLConnection connection2 = (HttpsURLConnection) new URL(getUnprotectedURL()).openConnection(); + ApproovService.addApproov(connection2); + connection2.connect(); + if (true) { + JSONObject reply = readResponseJson(connection2); + assertNull(getHeader(reply, "Approov-Token")); + assertNull(getHeader(reply, "Signature")); + assertNull(getHeader(reply, "Signature-Input")); + } + } + + /** + * §5 Account Message Signing + * + * Account message signing produces the expected signature headers (account=...). + */ + @Test + public void testUpdateRequestAccountMessageSigningAddsSignatureHeaders() throws Exception { + reinitializeServiceWithTargetHost(""); + + ApproovDefaultMessageSigning.SignatureParametersFactory factory = + ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory() + .setUseAccountMessageSigning(); + ApproovService.setServiceMutator(new ApproovDefaultMessageSigning().setDefaultFactory(factory)); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + ApproovService.addApproov(connection); + connection.connect(); + if (true) { + JSONObject reply = readResponseJson(connection); + + assertNotNull(getHeader(reply, "Approov-Token")); + String signatureInput = getHeader(reply, "Signature-Input"); + assertNotNull(signatureInput); + assertTrue(signatureInput.startsWith("account=")); + assertFalse(signatureInput.contains("install=")); + + String signature = getHeader(reply, "Signature"); + assertNotNull(signature); + assertTrue(signature.startsWith("account=")); + assertFalse(signature.contains("install=")); + } + } + + /** + * §5 Install Key Generation Failure / Signing Failure Fallback + * + * Install message signature fails if key pair generation fails; no signature + * headers are added to the request, but the request proceeds with a token. + */ + @Test + public void testInstallMessageSigningFailsGracefullyIfKeyGenerationFails() throws Exception { + String targetHost = getTargetHost(); + String body = "\"protectedDomains\": [\"" + targetHost + "\"]," + + "\"simulateInstallKeyFailure\": true"; + reinitializeService(scenarioJson(uniqueCaseName("no-install-key"), body)); + + ApproovDefaultMessageSigning.SignatureParametersFactory factory = + ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory().setUseInstallMessageSigning(); + ApproovService.setServiceMutator(new ApproovDefaultMessageSigning().setDefaultFactory(factory)); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + connection.setRequestMethod("GET"); + ApproovService.addApproov(connection); + connection.connect(); + + if (true) { + assertEquals(200, connection.getResponseCode()); + JSONObject reply = readResponseJson(connection); + + assertNotNull(getHeader(reply, "Approov-Token")); + assertNull(getHeader(reply, "Signature")); + assertNull(getHeader(reply, "Signature-Input")); + } + } + + /** + * §5 Digest Body Application + * + * The digest body (Content-Digest) for an install message signature is present + * for POST and PUT requests when body digest is configured. + */ + @Test + public void testDigestBodyAppendedForPOSTPUTRequests() throws Exception { + reinitializeServiceWithTargetHost(""); + + ApproovDefaultMessageSigning.SignatureParametersFactory factory = ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory() + .setUseInstallMessageSigning(); + factory.setBodyDigestConfig(ApproovDefaultMessageSigning.DIGEST_SHA256, true); + + ApproovService.setServiceMutator(new ApproovDefaultMessageSigning().setDefaultFactory(factory)); + + String[] methods = {"POST", "PUT"}; + for (String method : methods) { + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + connection.setRequestMethod(method); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + byte[] bodyBytes = "{\"test\": 1}".getBytes(StandardCharsets.UTF_8); + ApproovService.addApproov(connection, bodyBytes); + try (OutputStream os = connection.getOutputStream()) { + os.write(bodyBytes); + } + connection.connect(); + + if (true) { + assertEquals(200, connection.getResponseCode()); + JSONObject reply = readResponseJson(connection); + + assertNotNull(getHeader(reply, "Approov-Token")); + String sigInput = getHeader(reply, "Signature-Input"); + assertNotNull("Missing Signature-Input for " + method, sigInput); + assertTrue(sigInput.contains("content-digest")); + assertNotNull("Missing Content-Digest for " + method, getHeader(reply, "Content-Digest")); + } + } + } + + // ================================================================================== + // SECTION 6: Secure Strings & Custom JWT + // TESTING_REQUIREMENTS.md §6 + // ================================================================================== + + /** + * §6 Valid Secure String Key + * + * Fetch a secure string using a valid key returns the expected value. + */ + @Test + public void testFetchSecureStringReturnsConfiguredValue() throws ApproovException { + setDirective("{" + + " \"operation\": \"fetchSecureString\"," + + " \"response\": {" + + " \"status\": \"SUCCESS\"," + + " \"secureString\": \"mini-secret\"" + + " }" + + "}"); + + String secureString = ApproovService.fetchSecureString("api-key", null); + assertEquals("mini-secret", secureString); + } + + /** + * §6 Non-existent Secure String Key + * + * Fetch a secure string using a non-existent key returns null. + */ + @Test + public void testFetchSecureStringReturnsNilForUnknownKey() throws ApproovException { + setDirective("{" + + " \"operation\": \"fetchSecureString\"," + + " \"response\": {" + + " \"status\": \"UNKNOWN_KEY\"" + + " }" + + "}"); + + String secureString = ApproovService.fetchSecureString("missing-key", null); + assertNull(secureString); + } + + /** + * §6 Empty Secure String Key + * + * Fetch a secure string using an empty key throws an exception. + */ + @Test + public void testFetchSecureStringEmptyKeyThrowsException() { + try { + ApproovService.fetchSecureString("", null); + fail("Expected ApproovException"); + } catch (ApproovException e) { + // SDK validates key length: "Approov secure string key must be between 1 and 64 characters" + assertTrue("Expected key validation message: " + e.getMessage(), + e.getMessage().contains("1 and 64") || e.getMessage().toUpperCase().contains("BAD_KEY")); + } + } + + /** + * §6 Nil Secure String Key + * + * Fetch a secure string using a null key throws an exception. + */ + @Test + public void testFetchSecureStringNilKeyThrowsException() { + try { + ApproovService.fetchSecureString(null, null); + fail("Expected ApproovException or IllegalArgumentException"); + } catch (ApproovException e) { + // SDK wraps IllegalArgumentException("Approov key cannot be null") + assertTrue("Expected null-related message: " + e.getMessage(), + e.getMessage().toLowerCase().contains("null")); + } catch (IllegalArgumentException e) { + // SDK may throw directly before service layer catches it + assertTrue("Expected null-related message: " + e.getMessage(), + e.getMessage().toLowerCase().contains("null")); + } + } + + /** + * §6 Custom JWT Fetch + * + * Fetching a Custom JWT should accurately return the marshaled payload as a token. + */ + @Test + public void testFetchCustomJWTReturnsSignedJWT() throws Exception { + String jwt = ApproovService.fetchCustomJWT("{\"role\":\"tester\"}"); + assertNotNull(jwt); + JSONObject payload = decodeJWTBody(jwt); + + assertEquals("tester", payload.getString("role")); + assertFalse(payload.has("exp")); + assertFalse(payload.has("did")); + } + + /** + * §6 Custom JWT Fetch (18KB payload) + * + * Fetching a Custom JWT with an 18KB JSON payload should work correctly. + */ + @Test + public void testFetchCustomJWT18KBPayload() throws Exception { + StringBuilder sb = new StringBuilder(18 * 1024); + for (int i = 0; i < 18 * 1024; i++) { + sb.append("A"); + } + String largePayload = sb.toString(); + + JSONObject payloadStruct = new JSONObject(); + payloadStruct.put("data", largePayload); + + String jwt = ApproovService.fetchCustomJWT(payloadStruct.toString()); + assertNotNull(jwt); + JSONObject payloadMap = decodeJWTBody(jwt); + assertEquals(largePayload, payloadMap.getString("data")); + } + + /** + * §6 Custom JWT Fetch (disabled) + * + * Fetching a Custom JWT when the feature is disabled throws an exception. + */ + @Test + public void testFetchCustomJWTDisabledThrowsFetchStatusException() throws Exception { + reinitializeService(scenarioJson(uniqueCaseName("custom-jwt-disabled"), + "\"customJWTEnabled\": false" + )); + + try { + ApproovService.fetchCustomJWT("{\"role\":\"tester\"}"); + fail("Expected ApproovFetchStatusException"); + } catch (ApproovFetchStatusException e) { + assertTrue(e.getMessage().contains("fetchCustomJWT: DISABLED")); + } + } + + /** + * §6 Custom JWT Fetch (malformatted JSON) + * + * Fetching a Custom JWT with a malformatted JSON string throws an exception. + */ + @Test + public void testFetchCustomJWTBadPayloadThrowsApproovException() throws Exception { + try { + ApproovService.fetchCustomJWT("not-json"); + fail("Expected ApproovException"); + } catch (ApproovException e) { + // Should throw due to IllegalArgumentException from SDK + } + } + + // ================================================================================== + // Test Helpers + // ================================================================================== + + private String getTargetURL() { + String url = System.getenv("TESTING_REPLY_URL"); + return (url != null) ? url : "https://replay.ivol.workers.dev"; + } + + private String getUnprotectedURL() { + String url = System.getenv("TESTING_REPLY_URL_UNPROTECTED"); + return (url != null) ? url : "https://replay-unprotected.ivol.workers.dev"; + } + + private String getTargetHost() { + String url = getTargetURL(); + return url.replace("https://", "").split("/")[0]; + } + + private void reinitializeServiceWithTargetHost(String scenarioBody) throws Exception { + String targetHost = getTargetHost(); + String domainsJson = "\"protectedDomains\": [\"" + targetHost + "\"]," + + "\"pins\": {\"public-key-sha256\": {\"" + targetHost + "\": []}}"; + String fullBody = scenarioBody.isEmpty() ? domainsJson : domainsJson + ", " + scenarioBody; + + reinitializeService(scenarioJson(uniqueCaseName("target-host"), fullBody)); + } + + private void reinitializeService(String scenarioJson) { + AttesterProxyController.reset(); + if (scenarioJson != null) { + AttesterProxyController.loadScenarioJson(scenarioJson); + } + ApproovService.initialize(context, validInitialConfig, "reinit"); + } + + private void setDirective(String json) { + AttesterProxyController.setNextAttestationDirectiveJson(json); + } + + private String uniqueCaseName(String prefix) { + return prefix + "-" + UUID.randomUUID().toString().toLowerCase(); + } + + + private JSONObject readResponseJson(HttpsURLConnection connection) throws Exception { + InputStream is = connection.getResponseCode() >= 400 ? connection.getErrorStream() : connection.getInputStream(); + if (is == null) return new JSONObject(); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) sb.append(line); + return new JSONObject(sb.toString()); + } + + private String scenarioJson(String caseName, String body) { + return "{" + + " \"activeCase\": \"" + caseName + "\"," + + " \"cases\": {" + + " \"" + caseName + "\": {" + + " " + body + "" + + " }" + + " }" + + "}"; + } + + private String getHeader(JSONObject reply, String key) throws Exception { + if (!reply.has("headers")) return null; + JSONObject headers = reply.getJSONObject("headers"); + String lowerKey = key.toLowerCase(); + if (headers.has(lowerKey)) { + Object val = headers.get(lowerKey); + if (val instanceof String) return (String) val; + if (val instanceof org.json.JSONArray) { + org.json.JSONArray arr = (org.json.JSONArray) val; + if (arr.length() > 0) return arr.getString(0); + } + } + if (headers.has(key)) { + Object val = headers.get(key); + if (val instanceof String) return (String) val; + if (val instanceof org.json.JSONArray) { + org.json.JSONArray arr = (org.json.JSONArray) val; + if (arr.length() > 0) return arr.getString(0); + } + } + return null; + } + + private JSONObject decodeJWTBody(String jwt) throws Exception { + String[] parts = jwt.split("\\."); + if (parts.length != 3) return null; + byte[] bytes = Base64.getUrlDecoder().decode(parts[1]); + return new JSONObject(new String(bytes, StandardCharsets.UTF_8)); + } + + private String sha256Base64(String data) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + } + + // ================================================================================== + // NEW TESTS to cover gaps from ISSUES.md + // ================================================================================== + + private void resetApproovServiceState() throws Exception { + java.lang.reflect.Field isInitField = ApproovService.class.getDeclaredField("isInitialized"); + isInitField.setAccessible(true); + isInitField.set(null, false); + + java.lang.reflect.Field configField = ApproovService.class.getDeclaredField("configString"); + configField.setAccessible(true); + configField.set(null, null); + } + + /** + * §1 First-ever empty config is isolated in a fresh SDK process. + */ + @Test + public void testFirstEverEmptyConfig() throws Exception { + resetApproovServiceState(); + reinitializeService(scenarioJson(uniqueCaseName("first-empty-config"), + "\"protectedDomains\": [\"" + getTargetHost() + "\"]")); + ApproovService.initialize(context, "", "reinit-first-empty"); + + assertTrue(ApproovService.isInitialized()); + assertFalse(ApproovService.isApproovEnabled()); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + ApproovService.addApproov(connection); + connection.connect(); + assertTrue(connection.getResponseCode() < 400); + JSONObject reply = readResponseJson(connection); + assertNull(getHeader(reply, "Approov-Token")); + } + + /** + * §1 Empty-to-valid failure preservation: empty config succeeds, later valid config fails, service remains initialized-but-disabled. + */ + @Test + public void testEmptyToValidFailurePreservation() throws Exception { + resetApproovServiceState(); + ApproovService.initialize(context, "", "reinit-empty"); + + assertTrue(ApproovService.isInitialized()); + assertFalse(ApproovService.isApproovEnabled()); + + String badConfig = "invalid-config"; + try { + ApproovService.initialize(context, badConfig); + fail("Expected exception for bad config"); + } catch (IllegalArgumentException e) { + // Expected + } + + assertTrue(ApproovService.isInitialized()); + assertFalse(ApproovService.isApproovEnabled()); + } + + /** + * §1 Repeated same-config with options: direct test asserting that the SDK failure is surfaced rather than silently ignored. + */ + @Test + public void testRepeatedSameConfigWithOptionsSurfacesFailure() throws Exception { + // Init with valid config first (done in setUp) + + // Repeated same-config with options: + try { + ApproovService.initialize(context, validInitialConfig, "options:bad-option"); + fail("Expected IllegalStateException from SDK for bad options"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("options initialization failed")); + } + } + + /** + * §1 Cross-service initialization covered with two Java service layers in the same process + */ + @Test + public void testCrossServiceInitialization() throws Exception { + // Already initialized in setUp + // Pretend another service initializes with the exact same config directly via Approov + Approov.initialize(context, validInitialConfig, "auto", "reinit-cross"); + // It should succeed without throwing + assertTrue(ApproovService.isInitialized()); + } + + /** + * §4 Strict body digest mode should include a negative test for signed requests that omit the bodyBytes overload. + */ + @Test + public void testStrictBodyDigestOmission() throws Exception { + reinitializeService(scenarioJson(uniqueCaseName("strict-digest"), + "\"protectedDomains\": [\"" + getTargetHost() + "\"]")); + + ApproovDefaultMessageSigning.SignatureParametersFactory factory = ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory() + .setUseInstallMessageSigning(); + factory.setBodyDigestConfig(ApproovDefaultMessageSigning.DIGEST_SHA256, true); + ApproovService.setServiceMutator(new ApproovDefaultMessageSigning().setDefaultFactory(factory)); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + connection.setRequestMethod("POST"); + try { + ApproovService.addApproov(connection); // missing bodyBytes + fail("Expected ApproovException for missing bodyBytes in POST"); + } catch (io.approov.service.httpsurlconn.ApproovException e) { + assertTrue(e.getMessage().contains("body digest")); + } + } + + /** + * §4 PATCH body digest behavior + */ + @Test + public void testPatchBodyDigestBehavior() throws Exception { + reinitializeService(scenarioJson(uniqueCaseName("patch-digest"), + "\"protectedDomains\": [\"" + getTargetHost() + "\"]")); + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + try { + // HttpsURLConnection might throw ProtocolException for PATCH in standard JDKs. + connection.setRequestMethod("PATCH"); + byte[] body = "patch-data".getBytes(StandardCharsets.UTF_8); + ApproovService.addApproov(connection, body); + // If it succeeds setting method and adding Approov, we are good. + } catch (java.net.ProtocolException e) { + // Expected in some Java environments that don't support PATCH natively via HttpURLConnection. + } + } + + /** + * §7 Custom token and trace header names/prefixes replay coverage + */ + @Test + public void testCustomTokenAndTraceHeaders() throws Exception { + reinitializeService(scenarioJson(uniqueCaseName("custom-headers"), + "\"protectedDomains\": [\"" + getTargetHost() + "\"]")); + + ApproovService.setApproovHeader("X-Custom-Token", "Bearer "); + ApproovService.setApproovTraceIDHeader("X-Custom-Trace"); + + HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); + ApproovService.addApproov(connection); + connection.connect(); + + JSONObject reply = readResponseJson(connection); + assertNotNull(getHeader(reply, "X-Custom-Token")); + assertTrue(getHeader(reply, "X-Custom-Token").startsWith("Bearer ")); + assertNotNull(getHeader(reply, "X-Custom-Trace")); + + // Restore defaults + ApproovService.setApproovHeader("Approov-Token", ""); + ApproovService.setApproovTraceIDHeader("Approov-TraceID"); + } + +} From a2552d822639b27930af55bf574ae01dd306d2f3 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 23:11:20 +0100 Subject: [PATCH 13/22] docs: document addApproov(connection, byte[]) body-digest overload; drop stale query-substitution mention --- REFERENCE.md | 12 +++++++++++- USAGE.md | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/REFERENCE.md b/REFERENCE.md index f0dd9aa..1775828 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -329,7 +329,17 @@ Prepares an `HttpsURLConnection` request in place by adding the Approov token he void addApproov(HttpsURLConnection request) throws ApproovException ``` -This preserves the original binary-compatible API. Use `addApproovToConnection(...)` for query parameter substitution or deferred body-aware processing. +This preserves the original binary-compatible API. Use `addApproovToConnection(...)` when deferred body-aware processing may be required (a message-signing body digest on a body-bearing request). + +## addApproov (with body bytes) + +Prepares the request and additionally supplies the request body so a message-signing `Content-Digest` can be computed over it. Use this when message signing is configured with a body digest and the body is available as a repeatable byte array. The SHA-256 (or SHA-512) digest of `body` is set in the `Content-Digest` header and covered by the signature. + +```java +void addApproov(HttpsURLConnection request, byte[] body) throws ApproovException +``` + +If a body digest is configured as **required** and cannot be generated, this fails closed with an `ApproovException`. ## addApproovToConnection diff --git a/USAGE.md b/USAGE.md index 7c95218..7f50bf2 100644 --- a/USAGE.md +++ b/USAGE.md @@ -183,6 +183,24 @@ SDK can generate account signatures. See the Approov CLI documentation for the To disable signing, remove the signer using `setServiceMutator(null)`, or return `null` from your factory for hosts you want to skip. +### Signing over the request body (Content-Digest) + +To cover the request body with the signature, configure a body digest on the factory and pass the body bytes to the `addApproov(connection, byte[])` overload — `HttpsURLConnection` does not expose the body for digesting otherwise: + +```java +factory.setBodyDigestConfig(ApproovDefaultMessageSigning.DIGEST_SHA256, false); // required=false + +byte[] body = jsonPayload.getBytes(StandardCharsets.UTF_8); +HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection(); +connection.setRequestMethod("POST"); +ApproovService.addApproov(connection, body); // computes Content-Digest over body +try (OutputStream os = connection.getOutputStream()) { + os.write(body); // write the SAME bytes +} +``` + +With `required = false` (the default) the request is signed without a `Content-Digest` if the body is unavailable. With `required = true` a body that cannot be digested fails closed with an `ApproovException`. + ## 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. From 2df4f7557f03d4063e95361e71671c4720eda8f8 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 23:12:40 +0100 Subject: [PATCH 14/22] ci: fail publish if CHANGELOG top entry does not match the release tag --- .github/workflows/build_and_publish.yml | 16 ++++++++++++++++ CHANGELOG.md | 1 + 2 files changed, 17 insertions(+) diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml index 2d0e40f..1d1c592 100644 --- a/.github/workflows/build_and_publish.yml +++ b/.github/workflows/build_and_publish.yml @@ -25,6 +25,22 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 + # Fail fast if the release tag and the CHANGELOG have drifted, before any + # build or publish work. The top "## [x.y.z]" entry must match the tag. + - name: Verify CHANGELOG matches tag + run: | + TAG="${{ github.ref_name }}" + CHANGELOG_VERSION=$(grep -m1 -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') || true + if [ -z "$CHANGELOG_VERSION" ]; then + echo "::error::Could not find a version header (## [x.y.z]) in CHANGELOG.md" + exit 1 + fi + if [ "$CHANGELOG_VERSION" != "$TAG" ]; then + echo "::error::CHANGELOG.md top entry ($CHANGELOG_VERSION) does not match release tag ($TAG). Update CHANGELOG.md before tagging." + exit 1 + fi + echo "CHANGELOG.md top entry matches tag: $TAG" + - name: Set Up Java uses: actions/setup-java@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 82329b3..71d5d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver ### Added - Mini-SDK integration test suite (`ApproovServiceMiniSdkTest`, `ApproovNativeSdkTest`) wired against `core-service-layers-testing/mini-sdk`, exercising the TESTING_REQUIREMENTS scenarios. +- CHANGELOG-vs-tag validation in the publish workflow: a release fails fast if the top `## [x.y.z]` CHANGELOG entry does not match the pushed git tag. ### Changed - **`NO_APPROOV_SERVICE`**: the request now proceeds with an **empty** `Approov-Token` header (and a trace ID if the SDK provides one) as evidence that Approov processing occurred, instead of omitting the headers (root §2 Missing Artifacts Fallback). From 9950bf45d40cf92585a3fd8883fc52dc1531de41 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 23:45:35 +0100 Subject: [PATCH 15/22] =?UTF-8?q?fix:=20spec-review=20findings=20=E2=80=94?= =?UTF-8?q?=20ignore=20empty-config-after-valid,=20null=20prefix,=20bypass?= =?UTF-8?q?=20verify,=20empty=20trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-merge fixes from the full TESTING_REQUIREMENTS audit of 3.5.7: - §1: an empty config after a valid one is now ignored (guard) instead of silently downgrading a protected layer to bypass. + regression test. - §2: setApproovHeader(h, null) coerces the prefix to "" (no literal "null"). - §4: bypass-mode PinningHostnameVerifier delegates to the default verifier rather than returning true, so OS trust validation is never skipped. - §2: empty trace ID emitted as an empty header rather than omitted. - Mini-sdk empty-config/bypass tests reset to a clean state before the empty init (they were relying on the removed downgrade behavior). --- CHANGELOG.md | 4 +++ .../service/httpsurlconn/ApproovService.java | 23 +++++++++++++--- .../ApproovServiceMiniSdkTest.java | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71d5d55..90db5d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver ### Fixed - A **required** body digest that cannot be generated now fails closed via `ApproovException` (the documented `addApproov` contract) rather than a raw `IllegalStateException`. +- **Empty config after a valid config is now ignored** (§1) — it no longer resets state / downgrades a protected layer to bypass. Approov protection remains active under the original config and the empty call is not forwarded to the SDK. +- `setApproovHeader(header, null)` no longer prepends the literal string `"null"` to the token — a `null` prefix is treated as no prefix (§2 Custom Header Prefixes). +- Bypass-mode hostname verification now delegates to the default/OS verifier instead of accepting unconditionally, so OS certificate/hostname trust validation is never skipped (§4 "Bypass Mode Must Not Skip Certificate Trust Validation"). +- An empty trace ID returned by the SDK is now emitted as an empty header value rather than omitted (§2 Missing Artifacts Fallback). ## [3.5.6] - 2026-05-30 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 a22d26d..5b18b89 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 @@ -133,6 +133,15 @@ public static synchronized void initialize(Context context, String config, Strin if (config == null) throw new IllegalArgumentException("config must not be null; pass \"\" for bypass mode"); + // If already initialized with a valid (non-empty) config, ignore a later empty-config + // call: the existing Approov protection must remain active and the call must not be + // forwarded to the SDK or reset any service-layer state (TESTING_REQUIREMENTS §1, + // "Empty Configuration after Valid Configuration"). + if (isApproovEnabled() && config.isEmpty()) { + Log.d(TAG, "ApproovService already initialized with a valid config; ignoring empty configuration"); + return; + } + // Initialize the platform SDK if not in bypass mode (empty config). // State is only modified after the SDK confirms success, preserving the current // operating mode (protected or bypass) if the call fails. @@ -273,7 +282,9 @@ public static synchronized void setDevKey(String devKey) throws ApproovException public static synchronized void setApproovHeader(String header, String prefix) { Log.d(TAG, "setApproovHeader " + header + ", " + prefix); approovTokenHeader = header; - approovTokenPrefix = prefix; + // A null prefix means "no prefix" — coerce to "" so it is never concatenated as the + // literal string "null" before the token (TESTING_REQUIREMENTS §2 Custom Header Prefixes). + approovTokenPrefix = (prefix != null) ? prefix : APPROOV_TOKEN_PREFIX; } /** @@ -1060,7 +1071,10 @@ static synchronized PreparedRequestData prepareApproovRequest(HttpsURLConnection String traceIDHeader = getApproovTraceIDHeader(); String traceID = getTokenFetchTraceID(approovResults); - if ((traceIDHeader != null) && (traceID != null) && !traceID.isEmpty()) { + // Emit the trace header whenever the SDK provides a value, even an empty one, as + // evidence that Approov processing occurred (TESTING_REQUIREMENTS §2 Missing Artifacts + // Fallback). Only a null trace ID (none available) leaves the header off. + if ((traceIDHeader != null) && (traceID != null)) { setTraceIDHeaderKey = traceIDHeader; setTraceIDHeaderValue = traceID; } @@ -1328,7 +1342,10 @@ private X509Certificate getTrustAnchorCertificate(SSLSession session) { @Override public boolean verify(String hostname, SSLSession session) { if (!ApproovService.isApproovEnabled()) { - return true; + // Bypass mode: skip Approov pinning, but still apply standard hostname + // verification via the OS/default verifier — never blindly accept + // (TESTING_REQUIREMENTS §4 "Bypass Mode Must Not Skip Certificate Trust Validation"). + return delegate.verify(hostname, session); } // check the delegate function first and only proceed if it passes if (delegate.verify(hostname, session)) try { diff --git a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java index f50f3df..d9faee3 100644 --- a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java +++ b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java @@ -91,6 +91,23 @@ public void testInitializeIgnoresSameConfigAndRejectsDifferentConfig() { } } + /** + * §1 Empty Configuration after Valid Configuration: an empty-config init after a valid one + * is ignored — Approov protection remains active under the original config and the empty + * call is not forwarded to the SDK. + */ + @Test + public void testEmptyConfigAfterValidIsIgnored() { + // setUp initialized with a valid config, so the layer is enabled. + assertTrue(ApproovService.isApproovEnabled()); + + // An empty-config init must be ignored, not downgrade the layer to bypass. + ApproovService.initialize(context, "", "reinit-empty-ignored"); + + assertTrue(ApproovService.isInitialized()); + assertTrue("empty config after valid must NOT disable Approov", ApproovService.isApproovEnabled()); + } + /** * §1 Empty Configuration (Valid Comment) / Empty Configuration (Empty Comment) * @@ -102,6 +119,9 @@ public void testInitializeIgnoresSameConfigAndRejectsDifferentConfig() { public void testInitializeWithEmptyConfigBuildsPlainClient() throws Exception { reinitializeService(scenarioJson(uniqueCaseName("empty-config"), "\"protectedDomains\": [\"" + getTargetHost() + "\"]")); + // reach bypass from a clean state: an empty config after a valid one is now ignored + // (§1 "Empty Configuration after Valid Configuration"), so reset before the empty init. + resetApproovServiceState(); ApproovService.initialize(context, "", "reinit-empty-config"); assertTrue(ApproovService.isInitialized()); @@ -127,6 +147,9 @@ public void testInitializeWithEmptyConfigBuildsPlainClient() throws Exception { public void testInitializeWithEmptyConfigCanLaterEnableApproov() throws Exception { reinitializeService(scenarioJson(uniqueCaseName("empty-then-valid"), "\"protectedDomains\": [\"" + getTargetHost() + "\"]")); + // reach bypass from a clean state: an empty config after a valid one is now ignored + // (§1 "Empty Configuration after Valid Configuration"), so reset before the empty init. + resetApproovServiceState(); ApproovService.initialize(context, "", "reinit-empty-config"); HttpsURLConnection connection = (HttpsURLConnection) new URL(getTargetURL()).openConnection(); @@ -1074,6 +1097,9 @@ public void testFirstEverEmptyConfig() throws Exception { resetApproovServiceState(); reinitializeService(scenarioJson(uniqueCaseName("first-empty-config"), "\"protectedDomains\": [\"" + getTargetHost() + "\"]")); + // reinitializeService does a valid init; reset so the empty init below is a fresh + // first-init (§1 "Empty Configuration"), not an ignored empty-after-valid (§1 L10). + resetApproovServiceState(); ApproovService.initialize(context, "", "reinit-first-empty"); assertTrue(ApproovService.isInitialized()); From ad1355eb7402757c0f5c0c62bbde52c69dac1c35 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Sun, 21 Jun 2026 23:54:48 +0100 Subject: [PATCH 16/22] chore: strengthen byte-seq test, document initialize() throws, remove dead query-substitution plumbing - Tests: assert the Signature header is the byte-sequence form install=:: / account=:: (was only startsWith), guarding against a quoted-string regression. - REFERENCE.md: document initialize() throws IllegalArgumentException (null config) and IllegalStateException (different-config reinit). - Remove now-unreachable query-substitution plumbing: ApproovRequestMutations query fields + accessors, ApproovServiceMutator.handleInterceptorQueryParamSubstitutionResult, QuerySubstitutionResult.hasEffectiveUrlChange (+ unused fields), and the dead buffered- connection block; simplify shouldUseBufferedConnection. --- REFERENCE.md | 6 ++- .../ApproovBufferedHttpsURLConnection.java | 7 ---- .../httpsurlconn/ApproovRequestMutations.java | 30 ------------- .../service/httpsurlconn/ApproovService.java | 30 ++++--------- .../httpsurlconn/ApproovServiceMutator.java | 42 ------------------- .../ApproovServiceMiniSdkTest.java | 9 +++- 6 files changed, 19 insertions(+), 105 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index 1775828..515708e 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -15,7 +15,7 @@ If a method throws an `ApproovRejectionException`, the app failed attestation. A ## initialize -Initializes the Approov SDK and enables the Approov features. The `config` will have been provided in the initial onboarding or email or can be obtained using the approov CLI. This will generate an error if a second attempt is made at initialization with a different `config` without utilizing the reinitialization comment. +Initializes the Approov SDK and enables the Approov features. The `config` will have been provided in the initial onboarding or email or can be obtained using the approov CLI. A second attempt to initialize with a different `config` (without using the reinitialization comment) is forwarded to the native SDK, which throws an `IllegalStateException` that this method surfaces unchanged. **Java:** ```java @@ -33,6 +33,10 @@ It is possible to pass an empty `config` string to indicate that no initializati An alternative initialization function allows you to provide further options or trigger reinitialization in the `comment` parameter. Please refer to the [Approov SDK documentation](https://approov.io/docs/latest/approov-direct-sdk-integration/#sdk-initialization-options) for details. +**Throws** (both are unchecked and propagate directly; the service-layer state is left unchanged on failure): +- `IllegalArgumentException` — if `config` is `null`. Pass `""` for bypass mode. +- `IllegalStateException` — if the native SDK rejects the configuration, e.g. a different non-empty `config` than the one already initialized (an empty `config` after a valid one is ignored, not an error). + **Java:** ```java void initialize(Context context, String config, String comment) 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 index 501fa83..c34a8a4 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovBufferedHttpsURLConnection.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovBufferedHttpsURLConnection.java @@ -103,13 +103,6 @@ final class ApproovBufferedHttpsURLConnection extends HttpsURLConnection { this.sslSocketFactory = request.getSSLSocketFactory(); this.hostnameVerifier = request.getHostnameVerifier(); this.requestProperties = copyRequestProperties(request.getRequestProperties()); - - if (!queryResult.substitutedQueryKeys.isEmpty()) { - preparedRequestData.changes.setSubstitutionQueryParamResults( - queryResult.originalURL, - queryResult.substitutedQueryKeys - ); - } } /** 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 index 2df5b87..215dc62 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java @@ -26,8 +26,6 @@ public class ApproovRequestMutations { private String tokenHeaderKey; private List substitutionHeaderKeys; - private String originalURL; - private List substitutionQueryParamKeys; private String traceIDHeaderKey; private byte[] bodyBytes; @@ -68,34 +66,6 @@ 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. 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 5b18b89..5fcff78 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 @@ -950,22 +950,15 @@ static final class PreparedRequestData { } /** - * Holds the outcome of configured query parameter substitutions so callers can - * update both the effective URL and the mutation metadata in a single step. + * Holds the effective URL for a prepared request. Automated query-parameter substitution + * was removed (Issue #14), so the URL is never changed here; this wrapper is retained only + * to carry the URL into the buffered-connection body-digest flow. */ static final class QuerySubstitutionResult { final URL url; - final String originalURL; - final List substitutedQueryKeys; - QuerySubstitutionResult(URL url, String originalURL, List substitutedQueryKeys) { + QuerySubstitutionResult(URL url) { this.url = url; - this.originalURL = originalURL; - this.substitutedQueryKeys = substitutedQueryKeys; - } - - boolean hasEffectiveUrlChange() { - return !originalURL.equals(url.toString()); } } @@ -975,17 +968,9 @@ boolean hasEffectiveUrlChange() { * 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; - } - + private static boolean shouldUseBufferedConnection(HttpsURLConnection request) { if (request.getDoOutput()) { return true; } @@ -1205,9 +1190,8 @@ private static HttpsURLConnection addApproovInternal( // Query parameters are never auto-substituted (Issue #14), so the effective URL never // changes here. A buffered connection is still used for body-bearing methods so message // signing can compute the Content-Digest over the body written after addApproov returns. - QuerySubstitutionResult querySubstitutionResult = new QuerySubstitutionResult( - request.getURL(), request.getURL().toString(), Collections.emptyList()); - if (shouldUseBufferedConnection(request, querySubstitutionResult) && allowBufferedConnection) { + QuerySubstitutionResult querySubstitutionResult = new QuerySubstitutionResult(request.getURL()); + if (shouldUseBufferedConnection(request) && allowBufferedConnection) { return new ApproovBufferedHttpsURLConnection(request, preparedRequestData, querySubstitutionResult); } 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 index aead619..361a80b 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovServiceMutator.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovServiceMutator.java @@ -288,48 +288,6 @@ default boolean handleInterceptorHeaderSubstitutionResult(Approov.TokenFetchResu } } - /** - * 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 diff --git a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java index d9faee3..5fd43f1 100644 --- a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java +++ b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java @@ -699,7 +699,10 @@ public void testUpdateRequestInstallMessageSigningAddsSignatureHeaders() throws String signature = getHeader(reply, "Signature"); assertNotNull(signature); - assertTrue(signature.startsWith("install=")); + // Signature member value MUST be a Byte Sequence: install=:: (RFC 9421 §4.2), + // NOT the quoted-string form install="...". The =:...: colons prove the byte sequence. + assertTrue("Signature must be a byte sequence (install=::)", + signature.startsWith("install=:") && signature.endsWith(":")); assertFalse(signature.contains("account=")); } @@ -742,7 +745,9 @@ public void testUpdateRequestAccountMessageSigningAddsSignatureHeaders() throws String signature = getHeader(reply, "Signature"); assertNotNull(signature); - assertTrue(signature.startsWith("account=")); + // Byte Sequence form: account=:: (RFC 9421 §4.2), not account="...". + assertTrue("Signature must be a byte sequence (account=::)", + signature.startsWith("account=:") && signature.endsWith(":")); assertFalse(signature.contains("install=")); } } From fe6cbd570ebc14e23f1db7995abad9fbc153b7a8 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Mon, 22 Jun 2026 21:09:15 +0100 Subject: [PATCH 17/22] fix: message signing serialization/base-build now fail open (#564) Wrap signature-base construction and Signature / Signature-Input serialization so a failure logs at error level and proceeds unsigned rather than propagating and aborting the request, for full conformance with the cross-layer message-signing fail-open policy (core-project-approov#564). Required body digest and unsupported algorithm remain fail-closed. --- CHANGELOG.md | 1 + .../ApproovDefaultMessageSigning.java | 50 ++++++++++++------- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90db5d2..057f322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver - **Automated query parameter substitution** (`addSubstitutionQueryParam`, `removeSubstitutionQueryParam`, `getSubstitutionQueryParams`, `substituteQueryParams`, `substituteQueryParam`) — Issue #14. `java.net.URL` is immutable once the connection is opened, and the automated path broke the request-mutation tracking that message signing relies on. Fetch secure-string query values with `fetchSecureString()` and build the URL before `openConnection()` (see USAGE.md / REFERENCE.md). ### Fixed +- Message signing: a signature-base build failure and a `Signature`/`Signature-Input` serialization failure now fail open (log at error, proceed unsigned) for full conformance with the cross-layer fail-open policy (core-project-approov#564); a required body digest and an unsupported algorithm remain fail-closed. - A **required** body digest that cannot be generated now fails closed via `ApproovException` (the documented `addApproov` contract) rather than a raw `IllegalStateException`. - **Empty config after a valid config is now ignored** (§1) — it no longer resets state / downgrades a protected layer to bypass. Approov protection remains active under the original config and the empty call is not forwarded to the SDK. - `setApproovHeader(header, null)` no longer prepends the literal string `"null"` to the token — a `null` prefix is treated as no prefix (§2 Custom Header Prefixes). 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 index 17b6925..fa03fa3 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java @@ -236,9 +236,15 @@ public HttpsURLConnection handleInterceptorProcessedRequest(HttpsURLConnection r return request; } - // Apply the params to get the message + // Apply the params to get the message. A failure here is fail-open (proceed unsigned + log). SignatureBaseBuilder baseBuilder = new SignatureBaseBuilder(params, provider); - String message = baseBuilder.createSignatureBase(); + String message; + try { + message = baseBuilder.createSignatureBase(); + } catch (Exception e) { + Log.e(TAG, "Failed to build the signature base - proceeding unsigned " + e); + return request; + } // WARNING never log the message as it contains an Approov token which provides access to your API. // Generate the signature @@ -310,23 +316,29 @@ public HttpsURLConnection handleInterceptorProcessedRequest(HttpsURLConnection r 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); + // Calculate the signature and message descriptor headers. Serialization failure is + // fail-open: proceed unsigned and log at error rather than aborting the request. + try { + 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); + } catch (Exception e) { + Log.e(TAG, "Failed to serialize signature headers - proceeding unsigned " + e); + return request; + } // 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 From 716165fc8cfc89c76d4064393cd61784dac51a86 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Tue, 23 Jun 2026 07:13:21 +0100 Subject: [PATCH 18/22] fix: report service-layer version in setUserProperty on release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously setUserProperty reported a bare, version-less string ("approov-service-httpsurlconn"), and the published Maven artifact carried no version at all — unlike the other Android layers. - build.gradle: enable BuildConfig and bake APPROOV_SERVICE_VERSION from -PapproovServiceVersion (default "dev"). - ApproovService: setUserProperty now appends "/" + BuildConfig.APPROOV_SERVICE_VERSION. - build_and_publish.yml: build the AAR with -PapproovServiceVersion= so the published artifact reports the real release version. Verified: the generated BuildConfig bakes the injected version (APPROOV_SERVICE_VERSION = ""); local/dev builds report "dev". --- .github/workflows/build_and_publish.yml | 4 +++- CHANGELOG.md | 1 + approov-service/build.gradle | 13 +++++++++++++ .../service/httpsurlconn/ApproovService.java | 2 +- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml index 1d1c592..50f9e58 100644 --- a/.github/workflows/build_and_publish.yml +++ b/.github/workflows/build_and_publish.yml @@ -86,8 +86,10 @@ jobs: KEY_ID=$(gpg --list-keys --with-colons | grep pub | cut -d: -f5) echo -e "trust\n5\ny\nquit" | gpg --batch --yes --command-fd 0 --edit-key $KEY_ID + # Inject the release version (the git tag) so the published AAR bakes the correct + # BuildConfig.APPROOV_SERVICE_VERSION (reported via Approov.setUserProperty) instead of "dev". - name: Build AAR - run: ./gradlew assembleRelease + run: ./gradlew assembleRelease -PapproovServiceVersion=${{ github.ref_name }} - name: Create Package run: cd .maven && ./build-and-sign.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 057f322..1583e63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver ### Added - Mini-SDK integration test suite (`ApproovServiceMiniSdkTest`, `ApproovNativeSdkTest`) wired against `core-service-layers-testing/mini-sdk`, exercising the TESTING_REQUIREMENTS scenarios. - CHANGELOG-vs-tag validation in the publish workflow: a release fails fast if the top `## [x.y.z]` CHANGELOG entry does not match the pushed git tag. +- The runtime Approov user-property now reports the service-layer version (`approov-service-httpsurlconn/`): a `BuildConfig.APPROOV_SERVICE_VERSION` field (default `dev`) is baked from `-PapproovServiceVersion`, the Maven publish workflow passes the release tag (`-PapproovServiceVersion=${{ github.ref_name }}`), and `setUserProperty` appends it. Previously a bare, version-less string was reported. ### Changed - **`NO_APPROOV_SERVICE`**: the request now proceeds with an **empty** `Approov-Token` header (and a trace ID if the SDK provides one) as evidence that Approov processing occurred, instead of omitting the headers (root §2 Missing Artifacts Fallback). diff --git a/approov-service/build.gradle b/approov-service/build.gradle index 602cf80..8740379 100644 --- a/approov-service/build.gradle +++ b/approov-service/build.gradle @@ -15,10 +15,23 @@ android { namespace = 'io.approov.service.httpsurlconn' compileSdkVersion 30 + buildFeatures { + // AGP 8 disables BuildConfig generation by default; enable it so the service-layer + // version can be exposed to runtime code (see APPROOV_SERVICE_VERSION below). + buildConfig true + } + defaultConfig { minSdkVersion 23 targetSdkVersion 34 consumerProguardFiles 'consumer-rules.pro' + + // The published service-layer version, fed by CI from the release git tag + // (-PapproovServiceVersion=X.Y.Z). Local/test builds get "dev". This is the single + // source of truth for the version baked into the compiled AAR and reported via + // Approov.setUserProperty. + def serviceVersion = (project.findProperty('approovServiceVersion') ?: 'dev') + buildConfigField "String", "APPROOV_SERVICE_VERSION", "\"${serviceVersion}\"" } buildTypes { 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 5fcff78..6ed996d 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 @@ -175,7 +175,7 @@ public static synchronized void initialize(Context context, String config, Strin configString = config; if (isApproovEnabled()) { pinningHostnameVerifier = new PinningHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier()); - Approov.setUserProperty("approov-service-httpsurlconn"); + Approov.setUserProperty("approov-service-httpsurlconn/" + BuildConfig.APPROOV_SERVICE_VERSION); } } From 7f9272944a77d216cd18ee9043b1945062ac8021 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Tue, 23 Jun 2026 07:19:52 +0100 Subject: [PATCH 19/22] ci: auto-tag releases from CHANGELOG on main Add a standalone tag-release workflow (main push): the top CHANGELOG entry drives a matching git tag, which triggers the Maven publish workflow (build + CHANGELOG-vs-tag gate + publish). This repo has no main build/test workflow, so tagging is standalone; the build and gate run in build_and_publish.yml on the resulting tag. Skipped if the tag already exists. --- .github/workflows/tag-release.yml | 59 +++++++++++++++++++++++++++++++ CHANGELOG.md | 1 + 2 files changed, 60 insertions(+) create mode 100644 .github/workflows/tag-release.yml diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000..67ca66e --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,59 @@ +name: Tag Release + +# Automatic release tagging (main only). The CHANGELOG top entry drives the version: a matching +# git tag is created, which triggers the Maven Publish workflow (build_and_publish.yml). That +# workflow re-verifies the CHANGELOG-vs-tag match and builds the AAR with +# -PapproovServiceVersion=, so the published artifact bakes the version into +# BuildConfig.APPROOV_SERVICE_VERSION and reports it at runtime via Approov.setUserProperty. +# This repository has no main build/test workflow, so this job is standalone; the build, the +# CHANGELOG gate, and publication all run in build_and_publish.yml on the resulting tag push. +on: + push: + branches: + - main + +jobs: + tag-release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract Version from CHANGELOG + id: get-version + run: | + VERSION=$(grep -m1 -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') || true + if [ -z "$VERSION" ]; then + echo "::error::Could not find a version header (## [x.y.z]) in CHANGELOG.md" + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Version extracted from CHANGELOG.md is: $VERSION" + + - name: Create and Push Tag + run: | + VERSION="${{ steps.get-version.outputs.VERSION }}" + + if git fetch origin "refs/tags/$VERSION" --quiet 2>/dev/null; then + echo "Tag $VERSION already exists. Skipping tagging." + exit 0 + fi + + echo "Tag $VERSION does not exist. Verifying dev placeholder..." + FILE="approov-service/build.gradle" + if ! grep -q "'approovServiceVersion') ?: 'dev'" "$FILE"; then + echo "::error::Could not find default 'dev' version in $FILE" + exit 1 + fi + + echo "Configuring git..." + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + echo "Creating and pushing tag $VERSION..." + git tag "$VERSION" + git push origin "$VERSION" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1583e63..739d853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver - Mini-SDK integration test suite (`ApproovServiceMiniSdkTest`, `ApproovNativeSdkTest`) wired against `core-service-layers-testing/mini-sdk`, exercising the TESTING_REQUIREMENTS scenarios. - CHANGELOG-vs-tag validation in the publish workflow: a release fails fast if the top `## [x.y.z]` CHANGELOG entry does not match the pushed git tag. - The runtime Approov user-property now reports the service-layer version (`approov-service-httpsurlconn/`): a `BuildConfig.APPROOV_SERVICE_VERSION` field (default `dev`) is baked from `-PapproovServiceVersion`, the Maven publish workflow passes the release tag (`-PapproovServiceVersion=${{ github.ref_name }}`), and `setUserProperty` appends it. Previously a bare, version-less string was reported. +- Automatic release tagging on merge to `main` (`tag-release.yml`): the top CHANGELOG entry drives a matching git tag, which triggers the Maven publish workflow. Skipped if the tag already exists. ### Changed - **`NO_APPROOV_SERVICE`**: the request now proceeds with an **empty** `Approov-Token` header (and a trace ID if the SDK provides one) as evidence that Approov processing occurred, instead of omitting the headers (root §2 Missing Artifacts Fallback). From 3441ef453b50d02b5978745a7017abded6d4b9b5 Mon Sep 17 00:00:00 2001 From: Ivo Liondov Date: Tue, 23 Jun 2026 07:51:59 +0100 Subject: [PATCH 20/22] ci: full build_and_test with mini-SDK; gate tagging on tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the standalone tag-release workflow with a complete build_and_test workflow modelled on the other Android layers: on push to main and on PRs it checks out the core-service-layers-testing mini-SDK, builds the AAR, and runs the unit + mini-SDK integration tests (with a JUnit summary). The tag-release job now lives here as `needs: build-and-test`, so a release is only tagged after the tests pass; the tag then triggers the Maven publish workflow. Removed tag-release.yml (superseded). settings.gradle resolves the mini-SDK at '../../core-service-layers-testing' for the nested local layout; CI checks the testing repo out as a sibling, so a step writes an absolute miniSdkAndroidRoot into local.properties. Verified locally (Java 21, mini-SDK wired): assembleRelease + testDebugUnitTest pass — 57 tests, 0 failures (ApproovServiceMiniSdkTest 41, ApproovNativeSdkTest 1, ApproovServiceTest 15). --- .github/workflows/build_and_test.yml | 345 +++++++++++++++++++++++++++ .github/workflows/tag-release.yml | 59 ----- CHANGELOG.md | 2 +- 3 files changed, 346 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/build_and_test.yml delete mode 100644 .github/workflows/tag-release.yml diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml new file mode 100644 index 0000000..d982946 --- /dev/null +++ b/.github/workflows/build_and_test.yml @@ -0,0 +1,345 @@ +name: Build and Test + +# push covers direct work on main while pull_request covers all PR branches, +# avoiding duplicate runs for branches that have an open PR +on: + push: + branches: + - main + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + WORKSPACE: "${{ github.workspace }}" + GIT_BRANCH: "${{ github.ref }}" + CURRENT_TAG: "${{ github.ref_name }}" + # Worker endpoints: consumed from GitHub organisation variables. + # The verification step below fails fast if these variables are not + # defined, as no hardcoded fallback is provided for security. + TESTING_REPLY_URL: ${{ vars.TESTING_REPLY_URL }} + TESTING_REPLY_URL_UNPROTECTED: ${{ vars.TESTING_REPLY_URL_UNPROTECTED }} + # Required for the redeploy script invocation on failure + CLOUDFLARE_API_TOKEN_WORKERS_DEV: ${{ secrets.CLOUDFLARE_API_TOKEN_WORKERS_DEV }} + CLOUDFLARE_ACCOUNT_ID_WORKERS_DEV: ${{ secrets.CLOUDFLARE_ACCOUNT_ID_WORKERS_DEV }} + CORE_SERVICE_LAYERS_TESTING_PAT: ${{ secrets.CORE_SERVICE_LAYERS_TESTING_PAT }} + + steps: + # ----------------------------------------------------------------------- + # 1. Checkout this repository + # ----------------------------------------------------------------------- + - name: Set up Git + run: git config --global --add safe.directory '*' + + - name: Checkout approov-service-httpsurlconn + uses: actions/checkout@v6 + with: + path: approov-service-httpsurlconn + + # ----------------------------------------------------------------------- + # 2. Clone core-service-layers-testing as a sibling directory + # (provides the mini-SDK: approov-sdk + test-support). + # ----------------------------------------------------------------------- + - name: Checkout core-service-layers-testing + if: ${{ env.CORE_SERVICE_LAYERS_TESTING_PAT != '' }} + uses: actions/checkout@v6 + with: + repository: approov/core-service-layers-testing + token: ${{ secrets.CORE_SERVICE_LAYERS_TESTING_PAT }} + path: core-service-layers-testing + + # ----------------------------------------------------------------------- + # 2b. Point Gradle at the checked-out mini-SDK. + # settings.gradle resolves miniSdkAndroidRoot relative to the repo root + # (default '../../core-service-layers-testing/...' for the nested local + # layout). In CI the testing repo is a sibling of the service repo, so + # override with an absolute path via local.properties. + # ----------------------------------------------------------------------- + - name: Wire mini-SDK path for CI layout + if: ${{ env.CORE_SERVICE_LAYERS_TESTING_PAT != '' }} + run: | + echo "miniSdkAndroidRoot=${{ github.workspace }}/core-service-layers-testing/mini-sdk/android" \ + >> approov-service-httpsurlconn/local.properties + + # ----------------------------------------------------------------------- + # 3. Verify worker endpoints (with auto-redeploy) + # ----------------------------------------------------------------------- + - name: Verify worker endpoints + if: ${{ env.CORE_SERVICE_LAYERS_TESTING_PAT != '' }} + run: | + if [[ -z "$TESTING_REPLY_URL" || -z "$TESTING_REPLY_URL_UNPROTECTED" ]]; then + echo "ERROR: TESTING_REPLY_URL and TESTING_REPLY_URL_UNPROTECTED must be" + echo " set as GitHub organisation or repository variables." + echo " See CONTRIBUTING.md for setup instructions." + exit 1 + fi + + PROTECTED_URL="$TESTING_REPLY_URL" + UNPROTECTED_URL="$TESTING_REPLY_URL_UNPROTECTED" + + echo "TESTING_REPLY_URL=$PROTECTED_URL" >> "$GITHUB_ENV" + echo "TESTING_REPLY_URL_UNPROTECTED=$UNPROTECTED_URL" >> "$GITHUB_ENV" + + probe_worker() { + local url="$1" + curl --silent --show-error --fail --max-time 10 \ + -X POST "$url" \ + -H "Content-Type: application/json" \ + -d '{"check":"probe"}' | grep -q '"body"' + } + + echo "==> Probing workers..." + if probe_worker "$PROTECTED_URL" && probe_worker "$UNPROTECTED_URL"; then + echo " All workers are healthy." + exit 0 + fi + + echo " WARNING: One or more workers are down. Attempting redeploy..." + ./core-service-layers-testing/cloudflare-workers/redeploy-workers.sh + + echo "==> Verifying after redeploy..." + for i in {1..3}; do + if probe_worker "$PROTECTED_URL" && probe_worker "$UNPROTECTED_URL"; then + echo " Workers successfully restored." + exit 0 + fi + echo " Waiting for propagation (attempt $i/3)..." + sleep 5 + done + + echo "ERROR: Workers are still unreachable after redeployment effort." + exit 1 + + # ----------------------------------------------------------------------- + # 4. Java / Android toolchain setup + # ----------------------------------------------------------------------- + - name: Set Up Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: Install Android SDK command-line tools + run: | + sudo apt-get update -q + sudo apt-get install -y -q unzip curl + mkdir -p "$ANDROID_HOME/cmdline-tools" + curl -o android-sdk.zip \ + https://dl.google.com/android/repository/commandlinetools-linux-9123335_latest.zip + unzip -q android-sdk.zip -d "$ANDROID_HOME/cmdline-tools" + mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/tools" + rm android-sdk.zip + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + echo "$ANDROID_HOME/cmdline-tools/tools/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator" >> "$GITHUB_PATH" + + - name: Accept Android SDK licenses + run: yes | sdkmanager --licenses || true + + - name: Install required Android SDK packages + run: | + sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" + + # ----------------------------------------------------------------------- + # 5. Build and run tests + # ----------------------------------------------------------------------- + - name: Build AAR + working-directory: approov-service-httpsurlconn + run: ./gradlew assembleRelease + + - name: Run unit tests + working-directory: approov-service-httpsurlconn + env: + TESTING_REPLY_URL: ${{ env.TESTING_REPLY_URL }} + TESTING_REPLY_URL_UNPROTECTED: ${{ env.TESTING_REPLY_URL_UNPROTECTED }} + run: ./gradlew test + + # ----------------------------------------------------------------------- + # 6. Print test summary to the console and to the Actions Job Summary + # ----------------------------------------------------------------------- + - name: Print test summary + if: always() + working-directory: approov-service-httpsurlconn + run: | + python3 - <<'EOF' + import os, sys, glob, xml.etree.ElementTree as ET + from collections import defaultdict + + PASS = "✅" + FAIL = "❌" + SKIP = "⏭️" + ERROR = "⚠️" + + results_root = "approov-service/build/test-results" + xml_files = sorted(glob.glob(f"{results_root}/**/*.xml", recursive=True)) + + if not xml_files: + print("No test-result XML files found.") + sys.exit(0) + + by_variant = defaultdict(list) + for path in xml_files: + variant = os.path.basename(os.path.dirname(path)) + try: + root = ET.parse(path).getroot() + except ET.ParseError: + continue + suite = { + "name": root.get("name", os.path.basename(path)), + "tests": int(root.get("tests", 0)), + "failures": int(root.get("failures", 0)), + "errors": int(root.get("errors", 0)), + "skipped": int(root.get("skipped", 0)), + "time": float(root.get("time", 0)), + "cases": [], + } + for tc in root.findall("testcase"): + status = PASS + detail = "" + if tc.find("failure") is not None: + status = FAIL + detail = (tc.find("failure").get("message") or "").split("\n")[0][:120] + elif tc.find("error") is not None: + status = ERROR + detail = (tc.find("error").get("message") or "").split("\n")[0][:120] + elif tc.find("skipped") is not None: + status = SKIP + suite["cases"].append({ + "name": tc.get("name", "?"), + "time": float(tc.get("time", 0)), + "status": status, + "detail": detail, + }) + by_variant[variant].append(suite) + + overall_ok = True + for variant, suites in sorted(by_variant.items()): + total_t = sum(s["tests"] for s in suites) + total_f = sum(s["failures"] + s["errors"] for s in suites) + total_s = sum(s["skipped"] for s in suites) + total_p = total_t - total_f - total_s + icon = PASS if total_f == 0 else FAIL + if total_f > 0: + overall_ok = False + print() + print(f"{'='*70}") + print(f" {icon} {variant} | {total_p}/{total_t} passed " + f"| {total_f} failed | {total_s} skipped") + print(f"{'='*70}") + for suite in suites: + short = suite["name"].split(".")[-1] + p = suite["tests"] - suite["failures"] - suite["errors"] - suite["skipped"] + f = suite["failures"] + suite["errors"] + print(f" {PASS if f==0 else FAIL} {short:<55} {p}/{suite['tests']} ({suite['time']:.2f}s)") + for case in suite["cases"]: + if case["status"] != PASS: + print(f" {case['status']} {case['name']}") + if case["detail"]: + print(f" {case['detail']}") + print() + + summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "") + if not summary_path: + sys.exit(0 if overall_ok else 1) + + with open(summary_path, "a") as md: + md.write("## \U0001f9ea Unit Test Results\n\n") + for variant, suites in sorted(by_variant.items()): + total_t = sum(s["tests"] for s in suites) + total_f = sum(s["failures"] + s["errors"] for s in suites) + total_s = sum(s["skipped"] for s in suites) + total_p = total_t - total_f - total_s + badge = "\U0001f7e2 PASSED" if total_f == 0 else "\U0001f534 FAILED" + md.write(f"### {variant}   {badge}\n\n") + md.write(f"> **{total_p} passed**  |  " + f"**{total_f} failed**  |  " + f"**{total_s} skipped**  |  " + f"**{total_t} total**\n\n") + md.write("| Status | Test suite | Tests | Failed | Skipped | Time |\n") + md.write("|--------|------------|------:|-------:|--------:|-----:|\n") + for suite in suites: + short = suite["name"].split(".")[-1] + f = suite["failures"] + suite["errors"] + icon = "\U0001f7e2" if f == 0 else "\U0001f534" + md.write(f"| {icon} | `{short}` | {suite['tests']} | {f} | {suite['skipped']} | {suite['time']:.2f}s |\n") + failures = [c for s in suites for c in s["cases"] if c["status"] in (FAIL, ERROR)] + if failures: + md.write("\n
Failed tests\n\n") + for c in failures: + md.write(f"- {c['status']} `{c['name']}`") + if c["detail"]: + md.write(f" \n > {c['detail']}") + md.write("\n") + md.write("\n
\n") + md.write("\n") + + sys.exit(0 if overall_ok else 1) + EOF + + # ----------------------------------------------------------------------- + # 7. Upload HTML reports as a downloadable artifact + # ----------------------------------------------------------------------- + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-${{ github.run_number }} + path: approov-service-httpsurlconn/approov-service/build/reports/tests/ + retention-days: 14 + + # --------------------------------------------------------------------------- + # Automatic release tagging (main only). Runs only after build-and-test + # passes, so a release is never tagged on a failing build. The CHANGELOG top + # entry drives a matching git tag, which triggers the Maven Publish workflow + # (build_and_publish.yml). The published AAR bakes the tag version into + # BuildConfig.APPROOV_SERVICE_VERSION (-PapproovServiceVersion), reported at + # runtime via Approov.setUserProperty. Skipped if the tag already exists. + # --------------------------------------------------------------------------- + tag-release: + needs: build-and-test + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract Version from CHANGELOG + id: get-version + run: | + VERSION=$(grep -m1 -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') || true + if [ -z "$VERSION" ]; then + echo "::error::Could not find a version header (## [x.y.z]) in CHANGELOG.md" + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Version extracted from CHANGELOG.md is: $VERSION" + + - name: Create and Push Tag + run: | + VERSION="${{ steps.get-version.outputs.VERSION }}" + + if git fetch origin "refs/tags/$VERSION" --quiet 2>/dev/null; then + echo "Tag $VERSION already exists. Skipping tagging." + exit 0 + fi + + echo "Tag $VERSION does not exist. Verifying dev placeholder..." + FILE="approov-service/build.gradle" + if ! grep -q "'approovServiceVersion') ?: 'dev'" "$FILE"; then + echo "::error::Could not find default 'dev' version in $FILE" + exit 1 + fi + + echo "Configuring git..." + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + echo "Creating and pushing tag $VERSION..." + git tag "$VERSION" + git push origin "$VERSION" diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml deleted file mode 100644 index 67ca66e..0000000 --- a/.github/workflows/tag-release.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Tag Release - -# Automatic release tagging (main only). The CHANGELOG top entry drives the version: a matching -# git tag is created, which triggers the Maven Publish workflow (build_and_publish.yml). That -# workflow re-verifies the CHANGELOG-vs-tag match and builds the AAR with -# -PapproovServiceVersion=, so the published artifact bakes the version into -# BuildConfig.APPROOV_SERVICE_VERSION and reports it at runtime via Approov.setUserProperty. -# This repository has no main build/test workflow, so this job is standalone; the build, the -# CHANGELOG gate, and publication all run in build_and_publish.yml on the resulting tag push. -on: - push: - branches: - - main - -jobs: - tag-release: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Extract Version from CHANGELOG - id: get-version - run: | - VERSION=$(grep -m1 -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') || true - if [ -z "$VERSION" ]; then - echo "::error::Could not find a version header (## [x.y.z]) in CHANGELOG.md" - exit 1 - fi - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "Version extracted from CHANGELOG.md is: $VERSION" - - - name: Create and Push Tag - run: | - VERSION="${{ steps.get-version.outputs.VERSION }}" - - if git fetch origin "refs/tags/$VERSION" --quiet 2>/dev/null; then - echo "Tag $VERSION already exists. Skipping tagging." - exit 0 - fi - - echo "Tag $VERSION does not exist. Verifying dev placeholder..." - FILE="approov-service/build.gradle" - if ! grep -q "'approovServiceVersion') ?: 'dev'" "$FILE"; then - echo "::error::Could not find default 'dev' version in $FILE" - exit 1 - fi - - echo "Configuring git..." - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - echo "Creating and pushing tag $VERSION..." - git tag "$VERSION" - git push origin "$VERSION" diff --git a/CHANGELOG.md b/CHANGELOG.md index 739d853..87bc396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver - Mini-SDK integration test suite (`ApproovServiceMiniSdkTest`, `ApproovNativeSdkTest`) wired against `core-service-layers-testing/mini-sdk`, exercising the TESTING_REQUIREMENTS scenarios. - CHANGELOG-vs-tag validation in the publish workflow: a release fails fast if the top `## [x.y.z]` CHANGELOG entry does not match the pushed git tag. - The runtime Approov user-property now reports the service-layer version (`approov-service-httpsurlconn/`): a `BuildConfig.APPROOV_SERVICE_VERSION` field (default `dev`) is baked from `-PapproovServiceVersion`, the Maven publish workflow passes the release tag (`-PapproovServiceVersion=${{ github.ref_name }}`), and `setUserProperty` appends it. Previously a bare, version-less string was reported. -- Automatic release tagging on merge to `main` (`tag-release.yml`): the top CHANGELOG entry drives a matching git tag, which triggers the Maven publish workflow. Skipped if the tag already exists. +- Full `build_and_test.yml` CI (mini-SDK harness: builds the AAR and runs the unit + mini-SDK integration tests on push to `main` and on PRs), plus automatic release tagging gated behind it (`tag-release` job): once tests pass, the top CHANGELOG entry drives a matching git tag, which triggers the Maven publish workflow. Skipped if the tag already exists. ### Changed - **`NO_APPROOV_SERVICE`**: the request now proceeds with an **empty** `Approov-Token` header (and a trace ID if the SDK provides one) as evidence that Approov processing occurred, instead of omitting the headers (root §2 Missing Artifacts Fallback). From cc7eac5f8e911a9e324f666e7c876f7f0015ddce Mon Sep 17 00:00:00 2001 From: charlesoj6205 Date: Tue, 23 Jun 2026 09:58:32 +0100 Subject: [PATCH 21/22] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 4 ++-- SECURITY.md | 6 +++--- .../httpsurlconn/ApproovRequestMutations.java | 4 ++-- .../httpsurlconn/ApproovNativeSdkTest.java | 19 ++++++++++--------- .../ApproovServiceMiniSdkTest.java | 17 ++++------------- 5 files changed, 21 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 4dca80c..132811b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Approov Service for HttpsURLConnection ![Java](https://img.shields.io/badge/Java-8%2B-007396?logo=openjdk&logoColor=white) -![Android](https://img.shields.io/badge/Android-minSdk%2021-3DDC84?logo=android&logoColor=white) +![Android](https://img.shields.io/badge/Android-minSdk%2023-3DDC84?logo=android&logoColor=white) ![Maven Central](https://img.shields.io/maven-central/v/io.approov/service.httpsurlconn?logo=apachemaven&logoColor=white&label=Maven%20Central) ![Message Signing](https://img.shields.io/badge/Message%20Signing-RFC%209421-1f6feb) ![Build](https://github.com/approov/approov-service-httpsurlconn/actions/workflows/build_only.yml/badge.svg) @@ -32,7 +32,7 @@ The following app permissions need to be available in the manifest to use Approo ``` -Note that the minimum SDK version you can use with the Approov package is 21 (Android 5.0). +Note that the minimum SDK version you can use with the Approov package is 23 (Android 6.0). Please [read this](https://approov.io/docs/latest/approov-usage-documentation/#targeting-android-11-and-above) section of the reference documentation if targeting Android 11 (API level 30) or above. diff --git a/SECURITY.md b/SECURITY.md index 11edcef..3ca8dad 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,5 @@ -We maintain updates and patches in the latest release. Earlier versions will still work, but will have less functionalities than later versions. +We maintain updates and patches in the latest release. Earlier versions will still work, but will have less functionality than later versions. We encourage all users of these service layers to update to the latest version for the best experience. | Version | Supported | @@ -9,7 +9,7 @@ We encourage all users of these service layers to update to the latest version f ## Reporting a Vulnerability -Thank you for letting us know about possible security vulnerabilities to this project. -Please don’t publish details in a public issue or PR, send us a private email at support@approov.io. Please disclose which version your report refers to. +Thank you for letting us know about possible security vulnerabilities in this project. +Please don’t publish details in a public issue or PR. Send us a private email at support@approov.io. Please disclose which version your report refers to. Your message will receive a prompt reply. 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 index 215dc62..e8cc176 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java @@ -92,7 +92,7 @@ public void setTraceIDHeaderKey(String traceIDHeaderKey) { * @return the request body bytes, or null if none were supplied */ public byte[] getBodyBytes() { - return bodyBytes; + return (bodyBytes == null) ? null : bodyBytes.clone(); } /** @@ -101,6 +101,6 @@ public byte[] getBodyBytes() { * @param bodyBytes the request body bytes */ public void setBodyBytes(byte[] bodyBytes) { - this.bodyBytes = bodyBytes; + this.bodyBytes = (bodyBytes == null) ? null : bodyBytes.clone(); } } diff --git a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovNativeSdkTest.java b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovNativeSdkTest.java index 2f47ace..0b1fb1d 100644 --- a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovNativeSdkTest.java +++ b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovNativeSdkTest.java @@ -17,18 +17,19 @@ public class ApproovNativeSdkTest { @Test public void testApproovInit() { Context context = ApplicationProvider.getApplicationContext(); + + // First init should succeed. Approov.initialize(context, config, "auto", null); - try { - Approov.initialize(context, config, "auto", null); - System.out.println("NATIVE_SDK: Same config allowed"); - } catch (Exception e) { - System.out.println("NATIVE_SDK: Same config threw " + e.getClass().getName() + ": " + e.getMessage()); - } + + // Same config should be allowed. + Approov.initialize(context, config, "auto", null); + + // Different config should be rejected. try { Approov.initialize(context, config2, "auto", null); - System.out.println("NATIVE_SDK: Diff config allowed"); - } catch (Exception e) { - System.out.println("NATIVE_SDK: Diff config threw " + e.getClass().getName() + ": " + e.getMessage()); + org.junit.Assert.fail("Expected IllegalStateException when initializing with a different config"); + } catch (IllegalStateException expected) { + // expected } } } diff --git a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java index 5fd43f1..31c9902 100644 --- a/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java +++ b/approov-service/src/test/java/io/approov/service/httpsurlconn/ApproovServiceMiniSdkTest.java @@ -5,7 +5,6 @@ import com.criticalblue.minisdk.testing.AttesterProxyController; import javax.net.ssl.HttpsURLConnection; import java.net.URL; -import java.net.HttpURLConnection; import java.io.InputStream; import java.io.InputStreamReader; import java.io.BufferedReader; @@ -34,8 +33,7 @@ import static org.junit.Assert.*; /** - * Integration tests for the ApproovService OkHttp service layer. - * + * Integration tests for the ApproovService HttpsURLConnection service layer. * Tests are organized to match the sections defined in TESTING_REQUIREMENTS.md * from the core-service-layers-testing repository. Each test includes a comment * referencing the requirement(s) it covers. @@ -52,8 +50,7 @@ public class ApproovServiceMiniSdkTest { public void setUp() { context = ApplicationProvider.getApplicationContext(); AttesterProxyController.reset(); - ApproovService.initialize(context, validInitialConfig, "reinit-okhttp-tests"); - } + ApproovService.initialize(context, validInitialConfig, "reinit-httpsurlconn-tests"); @After public void tearDown() { @@ -1084,14 +1081,8 @@ private String sha256Base64(String data) throws Exception { // NEW TESTS to cover gaps from ISSUES.md // ================================================================================== - private void resetApproovServiceState() throws Exception { - java.lang.reflect.Field isInitField = ApproovService.class.getDeclaredField("isInitialized"); - isInitField.setAccessible(true); - isInitField.set(null, false); - - java.lang.reflect.Field configField = ApproovService.class.getDeclaredField("configString"); - configField.setAccessible(true); - configField.set(null, null); + private void resetApproovServiceState() { + ApproovService.reset(); } /** From b3697e4d019849783e5fe430aa488a64aea79be9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:02:33 +0000 Subject: [PATCH 22/22] Fix stale Javadoc: remove references to removed query substitution --- .../io/approov/service/httpsurlconn/ApproovService.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 6ed996d..523258f 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 @@ -1115,9 +1115,8 @@ static synchronized PreparedRequestData prepareApproovRequest(HttpsURLConnection * 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. + * addApproovToConnection(HttpsURLConnection) 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 @@ -1153,8 +1152,7 @@ public static synchronized void addApproov(HttpsURLConnection request, byte[] bo * @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 + * If deferred body-aware processing is required, a buffered wrapper * 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