Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/message signing #6

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 4 additions & 20 deletions approov-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,14 @@ plugins {
repositories {
mavenCentral()
google()
jcenter()
maven { url "https://jitpack.io" }
}

group = 'com.github.approov'

android {
compileSdkVersion 30

compileSdkVersion 34
namespace 'io.approov.service.okhttp'
defaultConfig {
minSdkVersion 21
targetSdkVersion 28
targetSdkVersion 34
}

buildTypes {
Expand All @@ -34,18 +30,6 @@ android {

dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.github.approov:approov-android-sdk:3.2.2'
implementation 'io.approov:approov-android-sdk:3.3.0'
}

afterEvaluate {
publishing {
publications {
release(MavenPublication) {
from components.release
groupId = 'com.github.approov'
artifactId = 'approov-service-okhttp'
version = '3.2.2'
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package io.approov.service.okhttp;

import java.util.ArrayList;
import okhttp3.Request;

/* Add the following interfaces */
interface MessageSigningConfig {
String getSigningMessage();
String getTargetHeaderName();
String generateTargetHeaderValue(String messageSignature);
}

interface MessageSigningConfigFactory {
MessageSigningConfig generateMessageSigningConfig(Request request, String approovTokenHeader);
}

/* message signing configuration
* This class is used to configure the message signing feature. The message signature can be computed based on the
Copy link
Contributor

@jexh jexh Jan 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment needs to be updated to reflect the changes to the class. It should also explain what the message signing behaviour is, which bits are included in the message, the order and any transformation that is performed (b64 encode the body).

* request URL, the headers specified in signedHeaders in the order in which they are listed and, optionally, the
* body of the message. The signature will be added to the request headers using the header name specified in header.
* You can have multiple configurations for different domains and a default '*' configuration that will be used if no
* specific configuration is found for a domain.
*/
public final class DefaultMessageSigningConfigFactory implements MessageSigningConfigFactory {
// the name of the header that will be used to send the message signature
private String targetHeader;
// the list of headers to include in the message to be signed, in the order they should be added
private ArrayList<String> signedHeaders;

// constructor
public ApproovMessageSigningConfig(String targetHeader) {
if (targetHeader == null || targetHeader.isEmpty())
throw new IllegalArgumentException("The target header must be specified");
this.targetHeader = targetHeader;
this.signedHeaders = new ArrayList<>();
}

/* Get/set methods */

/* Get target header */
public String getTargetHeader() {
return targetHeader;
}

/* Add a header to the list of signed headers: NOTE the sequence of headers DOES matter */
public DefaultMessageSigningConfigFactory addSignedHeader(String header) {
if (header == null || header.isEmpty())
throw new IllegalArgumentException("The header must be specified");
signedHeaders.add(header);
return this;
}

MessageSigningConfig generateMessageSigningConfig(Request request, String approovTokenHeader){
List<String> usedHeaders = new ArrayList<>();
StringBuilder message = new StringBuilder();
// 1. Add the Method to the message
message.append(request.method());
message.append("\n");
// 2. add the URL to the message, followed by a newline
message.append(request.url()); // TODO make sure this includes all the URL params if there are any
message.append("\n");
// 3. add the Approov token header to the message
List<String> values = request.headers(approovTokenHeader); // make sure the okhtp lookup works whatever the case used on the header name
if values == null || values.isEmpty() {
throw new IllegalArgumentException("provided request does not include the Approov token header");
}
usedHeaders.add(approovTokenHeader.toLowerCase())
for (String value : values) {
message.append(approovTokenHeader.toLowerCase()).append(":");
if (value != null) {
message.append(value);
}
message.append("\n");
}

// 4. add the required headers to the message as 'headername:headervalue', where the headername is in
// lowercase
if (messageSigningConfig.getSignedHeaders() != null) {
for (String header : messageSigningConfig.signedHeaders) {
// add one headername:headervalue\n entry for each header value to be included in the signature
List<String> values = request.headers(header);
if (values != null && values.size() > 0) {
usedHeaders.add(approovTokenHeader.toLowerCase())
for (String value : values) {
message.append(header.toLowerCase()).append(":");
if (value != null) {
message.append(value);
}
message.append("\n");
}
}
}
}

// add the body to the message
okhttp3.RequestBody body = request.body();
if (body != null && !body.isOneShot()) { // we can't support one-shot bodies without making a copy - we probably need to do that extra work.
Buffer buffer = new Buffer();
body.writeTo(buffer);
// need to convert the contents of the buffer to b64 - using readUtf8 may still cause serious problems in the message signing code if it contains control characters (or most problematic NULLs)
message.append(buffer.readUtf8());
}
return new DefaultMessageSigningConfig(targetHeader, usedHeaders, message.String());
}
}

public class DefaultMessageSigningConfig implements MessageSigningConfig
// the name of the header that will be used to send the message signature
private String targetHeader;
// the list of headers with counts that are expected by the server and were also included in the message to be signed
private List<String> usedHeaders;
// the message to be signed
private String message;

DefaultMessageSigningConfig(String targetHeader, String usedHeaders, String message) {
this.targeHeader = targetHeader;
this.usedHeaders = usedHeaders;
this.message = message;
}

public String getTargetHeaderName(){ return targetHeader }
public String getSigningMessage() { return usedHeadersSpec }
public String generateTargetHeaderValue(String messageSignature) {
// create a JSON object of the following form:
// {
// "accountSig":"messageSignature.String()",
// "headers":usedHeaders list as JSON list of strings
// }
// base 64 the JSON
String b64HeaderValue = ""
return b64HeaderValue;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okio.Buffer;

// ApproovService provides a mediation layer to the Approov SDK itself
public class ApproovService {
Expand Down Expand Up @@ -82,6 +83,9 @@ public class ApproovService {
// set of URL regexs that should be excluded from any Approov protection, mapped to the compiled Pattern
private static Map<String, Pattern> exclusionURLRegexs = null;

// message signing configurations mapped to a domain
private static MessageSigningConfigFactory messageSigningConfigFactory = null;

/**
* Construction is disallowed as this is a static only class.
*/
Expand Down Expand Up @@ -118,6 +122,19 @@ public static void initialize(Context context, String config) {
}
}

/**
* Sets the message signing configuration. If this is set, then it will be used to generate a message from each request
* which is then signed and the signature data is added to a header on the request sent to the server.
*
* To unset message signing, call this function as setMessageSigning(null)
*
* @param factory is the MessageSigningConfigFactory that is used by the interceptor to determine request specific
* message signing properties.
*/
public static synchronized void setMessageSigning(MessageSigningConfigFactory factory) throws ApproovException {
messageSigningConfigFactory = factory
}

/**
* Sets a flag indicating if the network interceptor should proceed anyway if it is
* not possible to obtain an Approov token due to a networking failure. If this is set
Expand Down Expand Up @@ -166,7 +183,7 @@ public static synchronized void setDevKey(String devKey) throws ApproovException
* @param header is the header to place the Approov token on
* @param prefix is any prefix String for the Approov token header
*/
public static synchronized void setApproovHeader(String header, String prefix) {
public static synchronized void setApproovHeader(String header, String prefix) throws ApproovException {
Log.d(TAG, "setApproovHeader " + header + ", " + prefix);
approovTokenHeader = header;
approovTokenPrefix = prefix;
Expand Down Expand Up @@ -396,7 +413,7 @@ public static void setDataHashInToken(String data) throws ApproovException {
* is not possible to use the networking interception to add the token. This will
* likely require network access so may take some time to complete. If the attestation fails
* for any reason then an ApproovException is thrown. This will be ApproovNetworkException for
* networking issues wher a user initiated retry of the operation should be allowed. Note that
* networking issues where a user initiated retry of the operation should be allowed. Note that
* the returned token should NEVER be cached by your app, you should call this function when
* it is needed.
*
Expand Down Expand Up @@ -432,6 +449,7 @@ else if (approovResults.getStatus() != Approov.TokenFetchStatus.SUCCESS)
return approovResults.getToken();
}


/**
* Gets the signature for the given message. This uses an account specific message signing key that is
* transmitted to the SDK after a successful fetch if the facility is enabled for the account. Note
Expand Down Expand Up @@ -629,7 +647,7 @@ public static synchronized OkHttpClient getOkHttpClient() {
Log.d(TAG, "Building new Approov OkHttpClient");
ApproovTokenInterceptor interceptor = new ApproovTokenInterceptor(approovTokenHeader,
approovTokenPrefix, bindingHeader, proceedOnNetworkFail, substitutionHeaders,
substitutionQueryParams, exclusionURLRegexs);
substitutionQueryParams, exclusionURLRegexs, messageSigningConfigFactory);
okHttpClient = okHttpBuilder.certificatePinner(pinBuilder.build()).addInterceptor(interceptor).build();
} else {
// if the ApproovService was not initialized then we can't add Approov capabilities
Expand Down Expand Up @@ -688,6 +706,9 @@ class ApproovTokenInterceptor implements Interceptor {
// set of URL regexs that should be excluded from any Approov protection, mapped to the compiled Pattern
private Map<String, Pattern> exclusionURLRegexs;

// message signing configuration
private MessageSigningConfigFactory messageSigningConfigFactory;

/**
* Constructs a new interceptor that adds Approov tokens and substitute headers or query
* parameters.
Expand All @@ -699,10 +720,12 @@ class ApproovTokenInterceptor implements Interceptor {
* @param substitutionHeaders is the map of secure string substitution headers mapped to any required prefixes
* @param substitutionQueryParams is the set of query parameter key names subject to substitution
* @param exclusionURLRegexs specifies regexs of URLs that should be excluded
* @paraf messageSigningConfigFactory FIXME
*/
public ApproovTokenInterceptor(String approovTokenHeader, String approovTokenPrefix, String bindingHeader,
boolean proceedOnNetworkFail, Map<String, String> substitutionHeaders,
Set<String> substitutionQueryParams, Map<String, Pattern> exclusionURLRegexs) {
Set<String> substitutionQueryParams, Map<String, Pattern> exclusionURLRegexs,
MessageSigningConfigFactory messageSigningConfigFactory) {
this.approovTokenHeader = approovTokenHeader;
this.approovTokenPrefix = approovTokenPrefix;
this.bindingHeader = bindingHeader;
Expand All @@ -719,6 +742,7 @@ public ApproovTokenInterceptor(String approovTokenHeader, String approovTokenPre
}
}
this.exclusionURLRegexs = new HashMap<>(exclusionURLRegexs);
this.messageSigningConfigFactory = messageSigningConfigFactory;
}

@Override
Expand All @@ -738,6 +762,8 @@ public Response intercept(Chain chain) throws IOException {

// request an Approov token for the domain
String host = request.url().host();

// fetch the Approov token for the domain
Approov.TokenFetchResult approovResults = Approov.fetchApproovTokenAndWait(host);

// provide information about the obtained token or error (note "approov token -check" can
Expand Down Expand Up @@ -766,16 +792,16 @@ public Response intercept(Chain chain) throws IOException {
// we successfully obtained a token so add it to the header for the request
request = request.newBuilder().header(approovTokenHeader, approovTokenPrefix + approovResults.getToken()).build();
else if ((approovResults.getStatus() == Approov.TokenFetchStatus.NO_NETWORK) ||
(approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) ||
(approovResults.getStatus() == Approov.TokenFetchStatus.MITM_DETECTED)) {
(approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) ||
(approovResults.getStatus() == Approov.TokenFetchStatus.MITM_DETECTED)) {
// we are unable to get an Approov token due to network conditions so the request can
// be retried by the user later - unless this is overridden
if (!proceedOnNetworkFail)
throw new ApproovNetworkException("Approov token fetch for " + host + ": " + approovResults.getStatus().toString());
}
else if ((approovResults.getStatus() != Approov.TokenFetchStatus.NO_APPROOV_SERVICE) &&
(approovResults.getStatus() != Approov.TokenFetchStatus.UNKNOWN_URL) &&
(approovResults.getStatus() != Approov.TokenFetchStatus.UNPROTECTED_URL))
(approovResults.getStatus() != Approov.TokenFetchStatus.UNKNOWN_URL) &&
(approovResults.getStatus() != Approov.TokenFetchStatus.UNPROTECTED_URL))
// we have failed to get an Approov token with a more serious permanent error
throw new ApproovException("Approov token fetch for " + host + ": " + approovResults.getStatus().toString());

Expand Down Expand Up @@ -862,6 +888,16 @@ else if (approovResults.getStatus() != Approov.TokenFetchStatus.UNKNOWN_KEY)
}
}

// if message signing is enabled, add the signature header to the request
if (messageSigningConfigFactory != null) {
MessageSigningConfig messageSigningConfig = messageSigningConfigFactory.generateMessageSigningConfig(request, approovTokenHeader);
Log.d(TAG, "Signing message with configuration: " + messageSigningConfig); // add a useful toString on DefaultMessageSigningConfig
String signature = ApproovService.getMessageSignature(messageSigningConfig.getMessage());
String signatureHeaderName = messageSigningConfig.getTargetHeaderName();
String signatureHeaderValue = messageSigningConfig.generateTargetHeaderValue(signature);
request = request.newBuilder().header(signatureHeaderName, signatureHeaderValue).build();
}

// proceed with the rest of the chain
return chain.proceed(request);
}
Expand Down
3 changes: 1 addition & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.1'
classpath 'com.android.tools.build:gradle:8.1.1' // Update to the latest version
}
}

allprojects {
repositories {
mavenCentral()
google()
maven { url "https://jitpack.io" }
jcenter()
}
}
Expand Down
Binary file modified gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
5 changes: 3 additions & 2 deletions gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip

Loading