diff --git a/approov-service/build.gradle b/approov-service/build.gradle index 7d9eec2..1dcff22 100644 --- a/approov-service/build.gradle +++ b/approov-service/build.gradle @@ -15,8 +15,8 @@ android { compileSdkVersion 30 defaultConfig { - minSdkVersion 21 - targetSdkVersion 28 + minSdkVersion 23 + targetSdkVersion 34 } buildTypes { @@ -34,5 +34,8 @@ android { dependencies { 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' } diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java new file mode 100644 index 0000000..c32eb9b --- /dev/null +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovDefaultMessageSigning.java @@ -0,0 +1,661 @@ +// +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package io.approov.service.httpsurlconn; + +import android.util.Log; +import android.util.Base64; + +import org.bouncycastle.asn1.ASN1InputStream; +import org.bouncycastle.asn1.ASN1Integer; +import org.bouncycastle.asn1.ASN1Sequence; + +import java.io.IOException; +import java.math.BigInteger; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.net.ssl.HttpsURLConnection; + +import io.approov.util.http.sfv.ByteSequenceItem; +import io.approov.util.http.sfv.Dictionary; +import io.approov.util.sig.ComponentProvider; +import io.approov.util.sig.SignatureBaseBuilder; +import io.approov.util.sig.SignatureParameters; +import io.approov.util.http.sfv.StringItem; + + +/** + * Provides a base implementation of message signing for Approov when using + * httpsurlconn requests. This class provides mechanisms to configure and apply + * message signatures to HTTP requests based on specified parameters and + * algorithms. + */ +public class ApproovDefaultMessageSigning implements ApproovServiceMutator { + // logging tag + private static final String TAG = "ApproovMsgSign"; + + /** + * Constant for the SHA-256 digest algorithm (used for body digests). + */ + public static final String DIGEST_SHA256 = "sha-256"; + + /** + * Constant for the SHA-512 digest algorithm (used for body digests). + */ + public static final String DIGEST_SHA512 = "sha-512"; + + /** + * Constant for the ECDSA P-256 with SHA-256 algorithm (used when signing with install private key). + */ + public final static String ALG_ES256 = "ecdsa-p256-sha256"; + + /** + * Constant for the HMAC with SHA-256 algorithm (used when signing with the account signing key). + */ + public final static String ALG_HS256 = "hmac-sha256"; + + /** + * The default factory for generating signature parameters. + */ + protected SignatureParametersFactory defaultFactory; + + /** + * A map of host-specific factories for generating signature parameters. + */ + protected final Map hostFactories; + + /** + * Constructs an instance of {@code ApproovDefaultMessageSigning}. + */ + public ApproovDefaultMessageSigning() { + hostFactories = new HashMap<>(); + } + + /** + * Sets the default factory for generating signature parameters. + * + * @param factory The factory to set as the default. + * @return The current instance for method chaining. + */ + public ApproovDefaultMessageSigning setDefaultFactory(SignatureParametersFactory factory) { + this.defaultFactory = factory; + return this; + } + + /** + * Associates a specific host with a factory for generating signature parameters. + * + * @param hostName The host name. + * @param factory The factory to associate with the host. + * @return The current instance for method chaining. + */ + public ApproovDefaultMessageSigning putHostFactory(String hostName, SignatureParametersFactory factory) { + this.hostFactories.put(hostName, factory); + return this; + } + + /** + * Builds the signature parameters for a given request. + * + * @param provider The component provider for the request. + * @param changes The request mutations to apply. + * @return The generated {@link SignatureParameters}, or {@code null} if no factory is available. + */ + protected SignatureParameters buildSignatureParameters( + HttpsURLConnectionComponentProvider provider, + ApproovRequestMutations changes + ) { + SignatureParametersFactory factory = hostFactories.get(provider.getAuthority()); + if (factory == null) { + factory = defaultFactory; + if (factory == null) { + return null; + } + } + return factory.buildSignatureParameters(provider, changes); + } + + /** + * Retrieves an install message signature for the supplied message. + * + * @param message The message to be signed. + * @return The base64-encoded ASN.1 DER signature. + * @throws ApproovException If signing is unavailable. + */ + protected String getInstallMessageSignature(String message) throws ApproovException { + return ApproovService.getInstallMessageSignature(message); + } + + /** + * Retrieves an account message signature for the supplied message. + * + * @param message The message to be signed. + * @return The base64-encoded signature. + * @throws ApproovException If signing is unavailable. + */ + protected String getAccountMessageSignature(String message) throws ApproovException { + return ApproovService.getAccountMessageSignature(message); + } + + /** + * Decodes a base64-encoded signature value. + * + * @param base64 The signature bytes encoded as base64. + * @return The decoded bytes. + */ + protected byte[] decodeBase64(String base64) { + return Base64.decode(base64, Base64.NO_WRAP); + } + + /** + * Converts one part, encoded as an ASN1Integer, of an ASN.1 DER encoded ES256 signature to a byte array of + * exactly 32 bytes. Throws IllegalArgumentException if this is not possible. + * + * @param bytesAsASN1Integer The ASN1Integer to convert. + * @return A byte array of length 32, containing the raw bytes of the signature part. + * @throws IllegalArgumentException if the ASN1Integer is not representing a 32 byte array. + */ + private static byte[] to32ByteArray(ASN1Integer bytesAsASN1Integer) { + BigInteger bytesAsBigInteger = bytesAsASN1Integer.getValue(); + byte[] bytes = bytesAsBigInteger.toByteArray(); + byte[] bytes32; + if (bytes.length < 32) { + bytes32 = new byte[32]; + System.arraycopy(bytes, 0, bytes32, 32 - bytes.length, bytes.length); + } else if (bytes.length == 32) { + bytes32 = bytes; + } else if (bytes.length == 33 && bytes[0] == 0) { + bytes32 = new byte[32]; + System.arraycopy(bytes, 1, bytes32, 0, 32); + } else { + throw new IllegalArgumentException("Not an ASN.1 DER ES256 signature part"); + } + return bytes32; + } + + /** + * Adds message signature to requests that have passed through the Approov + * interceptor. The request is only modified to include message signature + * headers if an ApproovToken has been added to the request and if there is + * a defined SignatureParameter factory for the request. + * + * @param request The original HTTP request. + * @param changes The request mutations that were applied by the Approov interceptor. + * @return The processed HTTP request with the signature headers added. + * @throws ApproovException If an error occurs during processing. + */ + @Override + public HttpsURLConnection handleInterceptorProcessedRequest(HttpsURLConnection request, ApproovRequestMutations changes) throws ApproovException { + if (changes == null || changes.getTokenHeaderKey() == null) { + // the request doesn't have an Approov token, so we don't need to sign it + return request; + } + // generate and add a message signature + HttpsURLConnectionComponentProvider provider = new HttpsURLConnectionComponentProvider(request); + SignatureParameters params = buildSignatureParameters(provider, changes); + if (params == null) { + // No sig to be added to the request; return the original request. + return request; + } + + // Apply the params to get the message + SignatureBaseBuilder baseBuilder = new SignatureBaseBuilder(params, provider); + String message = baseBuilder.createSignatureBase(); + // WARNING never log the message as it contains an Approov token which provides access to your API. + + // Generate the signature + String sigId; + byte[] signature; + switch (params.getAlg()) { + case ALG_ES256: { + sigId = "install"; + String base64; + try { + base64 = getInstallMessageSignature(message); + } catch (ApproovException e) { + Log.d(TAG, "Failed to get InstallMessageSignature - skipping message signing " + e); + return request; + } + if (base64.isEmpty()) { + Log.d(TAG, "InstallMessageSignature is empty - skipping message signing"); + return request; + } + signature = decodeBase64(base64); + // decode the signature from ASN.1 DER format + try (ASN1InputStream asn1InputStream = new ASN1InputStream(signature)) { + ASN1Sequence sequence = (ASN1Sequence) asn1InputStream.readObject(); + if (sequence instanceof ASN1Sequence) { + // Combine r and s into a single byte array + byte[] rBytes = to32ByteArray((ASN1Integer) sequence.getObjectAt(0)); + byte[] sBytes = to32ByteArray((ASN1Integer) sequence.getObjectAt(1)); + signature = new byte[rBytes.length + sBytes.length]; + System.arraycopy(rBytes, 0, signature, 0, rBytes.length); + System.arraycopy(sBytes, 0, signature, rBytes.length, sBytes.length); + } else { + throw new IllegalStateException("Not an ASN1Sequence"); + } + } catch (Exception e) { + throw new IllegalStateException("Failed to decode ASN.1 DER ES256 signature", e); + } + break; + } + case ALG_HS256: { + sigId = "account"; + String base64 = getAccountMessageSignature(message); + signature = decodeBase64(base64); + break; + } + default: + throw new IllegalStateException("Unsupported algorithm identifier: " + params.getAlg()); + } + + // --------------- Header handling --------------- + + // Calculate the signature and message descriptor headers + // Correct (byte sequence item): + String sigHeader = Dictionary.valueOf(Map.of( + sigId, ByteSequenceItem.valueOf(signature))).serialize(); + String sigInputHeader = Dictionary.valueOf(Map.of( + sigId, params.toComponentValue())).serialize(); + + // HttpURLConnection doesn't have a removeHeader function + // Instead we tracj what we're setting and ensure we don't set the same header twice which would cause issues with signing + request.setRequestProperty("Signature", sigHeader); + request.setRequestProperty("Signature-Input", sigInputHeader); + + // Debugging - log the message and signature-related headers + // WARNING never log the message in production code as it contains the Approov token which allows API access + // Log.d(TAG, "Message Value - Signature Message: " + message); + // Log.d(TAG, "Message Header - Signature: " + sigHeader); + // Log.d(TAG, "Message Header Signature-Input: " + sigInputHeader); + + if (params.isDebugMode()) { + try { + MessageDigest digestBuilder = MessageDigest.getInstance("SHA-256"); + byte[] digest = digestBuilder.digest(message.getBytes(StandardCharsets.UTF_8)); + String digestHeader = Dictionary.valueOf(Map.of( + DIGEST_SHA256, ByteSequenceItem.valueOf(digest))).serialize(); + request.setRequestProperty("Signature-Base-Digest", digestHeader); + } catch (NoSuchAlgorithmException e) { + Log.d(TAG, "Failed to get digest algorithm - no debug entry " + e); + } + } + return request; + } + + /** + * Generates a default {@link SignatureParametersFactory} with predefined settings. + * + * @return A new instance of {@link SignatureParametersFactory}. + */ + public static SignatureParametersFactory generateDefaultSignatureParametersFactory() { + return generateDefaultSignatureParametersFactory(null); + } + + /** + * Generates a default {@link SignatureParametersFactory} with optional base parameters. + * + * @param baseParametersOverride The base parameters to override, or {@code null} to use defaults. + * @return A new instance of {@link SignatureParametersFactory}. + */ + public static SignatureParametersFactory generateDefaultSignatureParametersFactory( + SignatureParameters baseParametersOverride + ) { + // default expiry seconds - must encompass worst case request retry + // time and clock skew + long defaultExpiresLifetime = 15; + SignatureParameters baseParameters; + if (baseParametersOverride != null) { + baseParameters = baseParametersOverride; + } else { + baseParameters = new SignatureParameters() + .addComponentIdentifier(ComponentProvider.DC_METHOD) + .addComponentIdentifier(ComponentProvider.DC_TARGET_URI) + ; + } + return new SignatureParametersFactory() + .setBaseParameters(baseParameters) + .setUseInstallMessageSigning() + .setAddCreated(true) + .setExpiresLifetime(defaultExpiresLifetime) + .setAddApproovTokenHeader(true) + .setAddApproovTraceIDHeader(true) + .addOptionalHeaders("Authorization", "Content-Length", "Content-Type") + .setBodyDigestConfig(DIGEST_SHA256, false) + ; + } + + /** + * Factory class for creating pre-request {@link SignatureParameters} with + * configurable settings. Each request passed to the factory builds a new + * SignatureParameters instance based on the configured settings and + * specific for the request. + */ + public static class SignatureParametersFactory { + protected SignatureParameters baseParameters; + protected String bodyDigestAlgorithm; + protected boolean bodyDigestRequired; + protected boolean useAccountMessageSigning; + protected boolean addCreated; + protected long expiresLifetime; + protected boolean addApproovTokenHeader; + protected boolean addApproovTraceIDHeader; + protected List optionalHeaders; + + /** + * Sets the base parameters for the factory. + * + * @param baseParameters The base parameters to set. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setBaseParameters(SignatureParameters baseParameters) { + this.baseParameters = baseParameters; + return this; + } + + /** + * Configures the body digest settings for the factory. + * + * @param bodyDigestAlgorithm The digest algorithm to use, or {@code null} to disable. + * @param required Whether the body digest is required. + * @return The current instance for method chaining. + * @throws IllegalArgumentException If an unsupported algorithm is specified. + */ + public SignatureParametersFactory setBodyDigestConfig(String bodyDigestAlgorithm, boolean required) { + if (bodyDigestAlgorithm == null) { + required = false; + } else if (!bodyDigestAlgorithm.equals(DIGEST_SHA256) + && !bodyDigestAlgorithm.equals(DIGEST_SHA512)) { + throw new IllegalArgumentException("Unsupported body digest algorithm: " + bodyDigestAlgorithm); + } + this.bodyDigestAlgorithm = bodyDigestAlgorithm; + this.bodyDigestRequired = required; + return this; + } + + /** + * Configures the factory to use device message signing. + * + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setUseInstallMessageSigning() { + this.useAccountMessageSigning = false; + return this; + } + + /** + * Configures the factory to use account message signing. + * + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setUseAccountMessageSigning() { + this.useAccountMessageSigning = true; + return this; + } + + /** + * Sets whether the "created" field should be added to the signature parameters. + * + * @param addCreated Whether to add the "created" field. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setAddCreated(boolean addCreated) { + this.addCreated = addCreated; + return this; + } + + /** + * Sets the expiration lifetime for the signature parameters. Only a + * value >0 will cause the expires attribute to be added to the + * SignatureParameters for a request. + * + * @param expiresLifetime The expiration lifetime in seconds, if <=0 + * no expiration is added. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setExpiresLifetime(long expiresLifetime) { + this.expiresLifetime = expiresLifetime; + return this; + } + + /** + * Sets whether the Approov token header should be added to the signature parameters. + * + * @param addApproovTokenHeader Whether to add the Approov token header. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setAddApproovTokenHeader(boolean addApproovTokenHeader) { + this.addApproovTokenHeader = addApproovTokenHeader; + return this; + } + + /** + * Sets whether the optional Approov traceID header should be added to the signature + * parameters. + * + * @param addApproovTraceIDHeader Whether to add the Approov traceID header. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory setAddApproovTraceIDHeader(boolean addApproovTraceIDHeader) { + this.addApproovTraceIDHeader = addApproovTraceIDHeader; + return this; + } + + /** + * Adds optional headers to the signature parameters. Headers + * configured as optional are added to the generated + * SignatureParameters if the target request includes the header + * otherwise they are ignored. + * + * @param headers The headers to add. + * @return The current instance for method chaining. + */ + public SignatureParametersFactory addOptionalHeaders(String ... headers) { + if (this.optionalHeaders == null) { + this.optionalHeaders = new ArrayList<>(Arrays.asList(headers)); + } else { + this.optionalHeaders.addAll(Arrays.asList(headers)); + } + return this; + } + + /** + * Generates a body digest for the request if possible. + * + * @param provider The component provider for the request. + * @param requestParameters The signature parameters to update. + * @return {@code true} if the body digest was successfully generated, {@code false} otherwise. + */ + protected boolean generateBodyDigest( + HttpsURLConnectionComponentProvider provider, + SignatureParameters requestParameters + ) { + // HttpsURLConnection does not expose request body bytes for digesting here. + return false; + } + + + /** + * Builds the signature parameters for a given request. + * + * @param provider The component provider for the request. + * @param changes The request mutations to apply. + * @return The generated {@link SignatureParameters}. + * @throws IllegalStateException If required parameters cannot be generated. + */ + protected SignatureParameters buildSignatureParameters(HttpsURLConnectionComponentProvider provider, ApproovRequestMutations changes) { + SignatureParameters requestParameters = new SignatureParameters(baseParameters); + if (useAccountMessageSigning) { + requestParameters.setAlg(ALG_HS256); + } else { + requestParameters.setAlg(ALG_ES256); + } + if (addCreated || expiresLifetime > 0) { + long currentTime = System.currentTimeMillis() / 1000; + if (addCreated) { + requestParameters.setCreated(currentTime); + } + if (expiresLifetime > 0) { + requestParameters.setExpires(currentTime + expiresLifetime); + } + } + if (addApproovTokenHeader) { + requestParameters.addComponentIdentifier(changes.getTokenHeaderKey()); + } + if (addApproovTraceIDHeader && changes.getTraceIDHeaderKey() != null) { + requestParameters.addComponentIdentifier(changes.getTraceIDHeaderKey()); + } + for (String headerName: optionalHeaders) { + if (provider.hasField(headerName)) { + requestParameters.addComponentIdentifier(headerName); + } + } + if (bodyDigestAlgorithm != null) { + if (!generateBodyDigest(provider, requestParameters) && bodyDigestRequired) { + throw new IllegalStateException("Failed to create required body digest"); + } + } + return requestParameters; + } + } + + /** + * HttpURLConnectionComponentProvider implements the ComponentProvider interface for HttpURLConnection requests. + */ + protected static final class HttpsURLConnectionComponentProvider implements ComponentProvider { + private HttpsURLConnection request; + + private java.net.URL url; + + /** + * Constructs an instance of {@code OkHttpComponentProvider}. + * + * @param request The OkHttp request to wrap. + */ + HttpsURLConnectionComponentProvider(HttpsURLConnection request) { + this.request = request; + this.url = request.getURL(); + } + + @Override + public String getMethod() { + return request.getRequestMethod(); + } + + @Override + public String getAuthority() { + return url.getHost(); + } + + @Override + public String getScheme() { return url.getProtocol(); } + + @Override + public String getTargetUri() { + // Use URI canonical form to avoid subtle encoding differences in signed target-uri. + try { + return url.toURI().toString(); + } catch (Exception e) { + return url.toString(); + } + } + + @Override + public String getRequestTarget() { + try { + URI uri = url.toURI(); + String path = uri.getRawPath() == null ? "" : uri.getRawPath(); + String query = uri.getRawQuery(); + return (query == null || query.isEmpty()) ? path : path + "?" + query; + } catch (Exception e){ + String path = (url.getPath() == null) ? "" : url.getPath(); + String query = url.getQuery(); + return (query == null || query.isEmpty()) ? path : path + "?" + query; + } + } + + @Override + public String getPath() { + try { + URI uri = url.toURI(); + return uri.getRawPath(); + } catch (Exception e) { + return url.getPath(); + } + } + + @Override + public String getQuery() { + try { + URI uri = url.toURI(); + return uri.getRawQuery(); + } catch (Exception e) { + return url.getQuery(); + } + } + + @Override + public String getQueryParam(String name) { + // Parse from raw query to preserve encoded bytes exactly as used by signature construction. + String query; + try { + query = url.toURI().getRawQuery(); + } catch (Exception e) { + query = url.getQuery(); + } + if (query == null || query.isEmpty()) throw new IllegalArgumentException("Could not find query parameter named " + name); + String[] parts = query.split("&"); + String found = null; + for (String part : parts) { + int idx = part.indexOf('='); + String k = idx >= 0 ? part.substring(0, idx) : part; + String v = idx >= 0 ? part.substring(idx + 1) : ""; + if (k.equals(name)) { + if (found != null) return null; + found = v; + } + } + if (found == null) throw new IllegalArgumentException("Could not find query parameter named " + name); + return found; + } + @Override + public String getStatus() { throw new IllegalStateException("Only requests are supported"); } + + @Override + public boolean hasField(String name) { return request.getRequestProperty(name) != null; } + + @Override + public String getField(String name) { + return request.getRequestProperty(name); + } + + @Override + public boolean hasBody() { + String method = request.getRequestMethod(); + return "POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method) || "PATCH".equalsIgnoreCase(method); + } + } +} + diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovFetchStatusException.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovFetchStatusException.java new file mode 100644 index 0000000..65b8690 --- /dev/null +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovFetchStatusException.java @@ -0,0 +1,48 @@ +// +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package io.approov.service.httpsurlconn; + +import com.criticalblue.approovsdk.Approov; + +/** + * Exception raised when an Approov token fetch returns a status other than success. + */ +public class ApproovFetchStatusException extends ApproovException { + + private final Approov.TokenFetchStatus tokenFetchStatus; + + /** + * Constructs a token fetch status exception with the provided status. + * + * @param status status returned by the Approov SDK, may be {@code null} if unavailable + * @param message information describing the exception cause + */ + public ApproovFetchStatusException(Approov.TokenFetchStatus status, String message) { + super(message); + this.tokenFetchStatus = status; + } + + /** + * Retrieves the token fetch status associated with this exception. + * + * @return the status returned by the Approov SDK, or {@code null} if not provided + */ + public Approov.TokenFetchStatus getTokenFetchStatus() { + return tokenFetchStatus; + } +} \ No newline at end of file diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovInterceptorExtensions.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovInterceptorExtensions.java new file mode 100644 index 0000000..3c5adae --- /dev/null +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovInterceptorExtensions.java @@ -0,0 +1,59 @@ +// +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package io.approov.service.httpsurlconn; + +import java.net.URL; +import java.net.HttpURLConnection; + +import javax.net.ssl.HttpsURLConnection; + +/** + * ApproovInterceptorExtensions provides an interface for handling callbacks during + * the processing of network requests by Approov. It allows further modifications + * to requests after Approov has applied its changes. + * + * @deprecated Replace implementations of this interface with ApproovServiceMutator + * while changing the name of the ApproovInterceptorExtensions.processedRequest + * method to ApproovServiceMutator.handleInterceptorProcessedRequest. + */ +@Deprecated +public interface ApproovInterceptorExtensions extends ApproovServiceMutator{ + + /** + * Replace the default implementation of ApproovServiceMutator.handleInterceptorProcessedRequest + * to call the now deprecated ApproovInterceptorExtensions.processedRequest method. + * + * @param request the processed request + * @param changes the mutations applied to the request by Approov + * @return the final request to use to complete the Approov interceptor step. + * @throws ApproovException if there is an error during processing + */ + default HttpsURLConnection handleInterceptorProcessedRequest(HttpsURLConnection request, ApproovRequestMutations changes) throws ApproovException { + // call the deprecated method to maintain backwards compatibility + return processedRequest(request, changes); + } + + /** + * @deprecated Use ApproovServiceMutator.handleInterceptorProcessedRequest instead. + */ + @Deprecated + default HttpsURLConnection processedRequest(HttpsURLConnection request, ApproovRequestMutations changes) throws ApproovException { + // No further changes to the request are required + return request; + } +} \ No newline at end of file diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovNetworkException.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovNetworkException.java index 8a5f427..0d3e151 100644 --- a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovNetworkException.java +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovNetworkException.java @@ -17,9 +17,13 @@ package io.approov.service.httpsurlconn; +import com.criticalblue.approovsdk.Approov; + // ApproovNetworkException indicates an exception caused by networking conditions which is likely to be // temporary so a user initiated retry should be performed + public class ApproovNetworkException extends ApproovException { + private final Approov.TokenFetchStatus tokenFetchStatus; /** * Constructs an Approov networking exception. @@ -28,5 +32,15 @@ public class ApproovNetworkException extends ApproovException { */ public ApproovNetworkException(String message) { super(message); + this.tokenFetchStatus = null; + } + + public ApproovNetworkException(Approov.TokenFetchStatus status, String message) { + super(message); + this.tokenFetchStatus = status; + } + + public Approov.TokenFetchStatus getTokenFetchStatus() { + return tokenFetchStatus; } } diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java new file mode 100644 index 0000000..8a35de7 --- /dev/null +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovRequestMutations.java @@ -0,0 +1,116 @@ +// +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package io.approov.service.httpsurlconn; + +import java.util.List; + +/** + * ApproovRequestMutations stores information about changes made to a network request + * during Approov processing, such as token headers, substituted headers, and query parameters. + */ +public class ApproovRequestMutations { + private String tokenHeaderKey; + private List substitutionHeaderKeys; + private String originalURL; + private List substitutionQueryParamKeys; + private String traceIDHeaderKey; + + + /** + * Gets the header key used for the Approov token. + * + * @return the Approov token header key + */ + public String getTokenHeaderKey() { + return tokenHeaderKey; + } + + /** + * Sets the header key used for the Approov token. + * + * @param tokenHeaderKey the Approov token header key + */ + public void setTokenHeaderKey(String tokenHeaderKey) { + this.tokenHeaderKey = tokenHeaderKey; + } + + /** + * Gets the list of headers that were substituted with secure strings. + * + * @return the list of substituted header keys + */ + public List getSubstitutionHeaderKeys() { + return substitutionHeaderKeys; + } + + /** + * Sets the list of headers that were substituted with secure strings. + * + * @param substitutionHeaderKeys the list of substituted header keys + */ + public void setSubstitutionHeaderKeys(List substitutionHeaderKeys) { + this.substitutionHeaderKeys = substitutionHeaderKeys; + } + + /** + * Gets the original URL before any query parameter substitutions. + * + * @return the original URL + */ + public String getOriginalURL() { + return originalURL; + } + + /** + * Gets the list of query parameter keys that were substituted with secure strings. + * + * @return the list of substituted query parameter keys + */ + public List getSubstitutionQueryParamKeys() { + return substitutionQueryParamKeys; + } + + /** + * Sets the results of query parameter substitutions, including the original URL and the keys of substituted parameters. + * + * @param originalURL the original URL before substitutions + * @param substitutionQueryParamKeys the list of substituted query parameter keys + */ + public void setSubstitutionQueryParamResults(String originalURL, List substitutionQueryParamKeys) { + this.originalURL = originalURL; + this.substitutionQueryParamKeys = substitutionQueryParamKeys; + } + + /** + * Gets the header key used for the optional Approov TraceID debug header. + * + * @return the Approov TraceID header key. Null if the TraceID header was not used. + */ + public String getTraceIDHeaderKey() { + return traceIDHeaderKey; + } + + /** + * Sets the header key used for the optional Approov TraceID debug header. + * + * @param traceIDHeaderKey the Approov TraceID header key + */ + public void setTraceIDHeaderKey(String traceIDHeaderKey) { + this.traceIDHeaderKey = traceIDHeaderKey; + } +} diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovService.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovService.java index 2983129..903bf95 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 @@ -60,6 +60,10 @@ public class ApproovService { // Approov token private static boolean proceedOnNetworkFail = false; + // true if the Approov fetch status should be used as the token header value if the + // actual token fetch fails or returns an empty token + private static boolean useApproovStatusIfNoToken = false; + // header to be used to send Approov tokens private static String approovTokenHeader = null; @@ -69,6 +73,11 @@ public class ApproovService { // any header to be used for binding in Approov tokens or null if not set private static String bindingHeader = null; + // The mutator instance used to control ApproovService behaviour at key points in the flow. + // Unless set using the ApproovService.setServiceMutator() method, the default behaviour + // defined in the default implementation of ApproovServiceMutator will be used. + private static ApproovServiceMutator serviceMutator = ApproovServiceMutator.DEFAULT; + // map of headers that should have their values substituted for secure strings, mapped to their // required prefixes private static Map substitutionHeaders = null; @@ -89,15 +98,16 @@ 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 + // setup for using Approov pinningHostnameVerifier = null; proceedOnNetworkFail = false; + useApproovStatusIfNoToken = false; approovTokenHeader = APPROOV_TOKEN_HEADER; approovTokenPrefix = APPROOV_TOKEN_PREFIX; bindingHeader = null; substitutionHeaders = new HashMap<>(); exclusionURLRegexs = new HashMap<>(); - + serviceMutator = ApproovServiceMutator.DEFAULT; // initialize the Approov SDK try { if (config.length() != 0) @@ -117,7 +127,7 @@ public static void initialize(Context context, String config) { * not possible to obtain an Approov token due to a networking failure. If this is set * then your backend API can receive calls without the expected Approov token header * being added, or without header/query parameter substitutions being made. Note that - * this should be used with caution because it may allow a connection to be established + * this should be used with caution because it may allow a request to be established * before any dynamic pins have been received via Approov, thus potentially opening * the channel to a MitM. * @@ -223,7 +233,7 @@ public static synchronized void removeSubstitutionHeader(String header) { * Approov pins but without a path to update the pins until a URL is used that is not excluded. Thus * you are responsible for ensuring that there is always a possibility of calling a non-excluded * URL, or you should make an explicit call to fetchToken if there are persistent pinning failures. - * Conversely, use of those option may allow a connection to be established before any dynamic pins + * Conversely, use of those option may allow a request to be established before any dynamic pins * have been received via Approov, thus potentially opening the channel to a MitM. * * @param urlRegex is the regular expression that will be compared against URLs to exclude them @@ -252,6 +262,47 @@ public static synchronized void removeExclusionURLRegex(String urlRegex) { } } + /** + * Sets the ApproovServiceMutator instance to handle callbacks from the + * ApproovService implementation. This facility enables customization of + * ApproovService operations at key points in the configuration and + * attestation flows. It should reduce the number of times this service + * layer implementation needs to be forked in order to introduce custom + * behavior. + * + * @param mutator is the ApproovServiceMutator with callback handlers that may + * override the default behavior of the ApproovService singleton. + * Passing null to this method will reinstate the default + * behavior. + */ + public static synchronized void setServiceMutator(ApproovServiceMutator mutator) { + if (mutator == null) { + mutator = ApproovServiceMutator.DEFAULT; + } + Log.d(TAG, "Applied ApproovServiceMutator:" + mutator.toString()); + serviceMutator = mutator; + } + + + /** + * Gets the active service mutator instance that is handling callbacks from + * ApproovService. + * + * @return the service mutator instance (never null) + */ + public static synchronized ApproovServiceMutator getServiceMutator() { + return serviceMutator; + } + + /** + * @deprecated Use setServiceMutator instead + */ + @Deprecated + public static void setApproovInterceptorExtensions(ApproovServiceMutator mutator) { + setServiceMutator(mutator); + } + + /** * Prefetches in the background to lower the effective latency of a subsequent token fetch or * secure string fetch by starting the operation earlier so the subsequent fetch may be able to @@ -585,8 +636,115 @@ public static void setInstallAttrsInToken(String attrs) throws ApproovException } /** - * Adds Approov to the given connection. The Approov token is added in a header and this - * also overrides the HostnameVerifier with something that pins the connections. If a + * Gets a copy of the current exclusion URL regexs. + * + * @return Map of the exclusion regexs to their respective Patterns + */ + static synchronized Map getExclusionURLRegexs() { + return new HashMap<>(exclusionURLRegexs); + } + + /** + * Sets a flag indicating if the Approov fetch status (e.g. "NO_NETWORK", + * "MITM_DETECTED") + * should be used as the token header value if the actual token fetch fails or + * returns an empty token. + * This allows passing error condition information to the backend via the + * Approov-Token header, + * which might otherwise be empty or missing. + * + * @param shouldUse is true if the status should be used as the token value + */ + public static synchronized void setUseApproovStatusIfNoToken(boolean shouldUse) { + Log.d(TAG, "setUseApproovStatusIfNoToken " + shouldUse); + useApproovStatusIfNoToken = shouldUse; + } + /** + * Gets a flag indicating if the Approov fetch status should be used as the token header value + * if the actual token fetch fails or returns an empty token. + * + * @return true if the status should be used as the token value, false otherwise + */ + public static synchronized boolean getUseApproovStatusIfNoToken() { + return useApproovStatusIfNoToken; + } + + /** + * Gets a flag indicating if the network interceptor should proceed anyway if it is + * not possible to obtain an Approov token due to a networking failure. + * + * @return true if Approov networking fails should allow continuation, false otherwise + * @deprecated Use setServiceMutator to control this behavior + */ + @Deprecated + public static synchronized boolean getProceedOnNetworkFail() { + return proceedOnNetworkFail; + } + + /** + * Gets the signature for the given message. This uses an account specific message signing key that is + * transmitted to the SDK after a successful fetch if the facility is enabled for the account. Note + * that if the attestation failed then the signing key provided is actually random so that the + * signature will be incorrect. An Approov token should always be included in the message + * being signed and sent alongside this signature to prevent replay attacks. If no signature is + * available, because there has been no prior fetch or the feature is not enabled, then an + * ApproovException is thrown. + * + * @param message is the message whose content is to be signed + * @return String of the base64 encoded message signature + * @throws ApproovException if there was a problem + */ + public static String getAccountMessageSignature(String message) throws ApproovException { + try { + String signature = Approov.getAccountMessageSignature(message); + Log.d(TAG, "getAccountMessageSignature"); + if (signature == null) + throw new ApproovException("no account signature available"); + return signature; + } + catch (IllegalStateException e) { + throw new ApproovException("IllegalState: " + e.getMessage()); + } + catch (IllegalArgumentException e) { + throw new ApproovException("IllegalArgument: " + e.getMessage()); + } + } + + /** + * Gets the install signature for the given message. This uses an app install specific message + * signing key that is generated the first time an app launches. This signing mechanism uses an + * ECC key pair where the private key is managed by the secure element or trusted execution + * environment of the device. Where it can, Approov uses attested key pairs to perform the + * message signing. + *

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

+ * If no signature is available, because there has been no prior fetch or the feature is not + * enabled, then an ApproovException is thrown. + * + * @param message is the message whose content is to be signed + * @return String of the base64 encoded message signature in ASN.1 DER format + * @throws ApproovException if there was a problem + */ + public static String getInstallMessageSignature(String message) throws ApproovException { + try { + String signature = Approov.getInstallMessageSignature(message); + Log.d(TAG, "getInstallMessageSignature"); + if (signature == null) + throw new ApproovException("no device signature available"); + return signature; + } + catch (IllegalStateException e) { + throw new ApproovException("IllegalState: " + e.getMessage()); + } + catch (IllegalArgumentException e) { + throw new ApproovException("IllegalArgument: " + e.getMessage()); + } + } + /** + * Adds Approov to the given request. The Approov token is added in a header and this + * also overrides the HostnameVerifier with something that pins the requests. If a * binding header has been specified then its hash will be set if it is present. This function * may also substitute header values to hold secure string secrets. If it is not * currently possible to fetch an Approov token due to networking issues then @@ -594,35 +752,39 @@ public static void setInstallAttrsInToken(String attrs) throws ApproovException * be allowed. ApproovRejectionException is thrown if header substitution is being attempted and * the app fails attestation. Other ApproovExecptions represent a more permanent error condition. * - * @param connection is the HttpsUrlConnection to which Approov is being added + * @param request is the HttpsUrlConnection to which Approov is being added * @throws ApproovException if it is not possible to obtain an Approov token or secure strings */ - public static synchronized void addApproov(HttpsURLConnection connection) throws ApproovException { + public static synchronized HttpsURLConnection addApproov(HttpsURLConnection request) throws ApproovException { // throw if we couldn't initialize the SDK if (pinningHostnameVerifier == null) throw new ApproovException("Approov not initialized"); - // ensure the connection is pinned - this is done even if the URL is excluded in case - // the same domain is used for an Approov protected request and the same connection is live - connection.setHostnameVerifier(pinningHostnameVerifier); + ApproovServiceMutator mutator = getServiceMutator(); + ApproovRequestMutations requestMutations = new ApproovRequestMutations(); + List substitutedHeaderKeys = new java.util.ArrayList<>(); + + // ensure the request is pinned - this is done even if the URL is excluded in case + // the same domain is used for an Approov protected request and the same request is live + request.setHostnameVerifier(pinningHostnameVerifier); // check if the URL matches one of the exclusion regexs and just return if so - String url = connection.getURL().toString(); + String url = request.getURL().toString(); for (Pattern pattern: exclusionURLRegexs.values()) { Matcher matcher = pattern.matcher(url); if (matcher.find()) - return; + return mutator.handleInterceptorProcessedRequest(request, requestMutations); } // update the data hash based on any token binding header if it is available if (bindingHeader != null) { - String headerValue = connection.getRequestProperty(bindingHeader); + String headerValue = request.getRequestProperty(bindingHeader); if (headerValue != null) Approov.setDataHashInToken(headerValue); } // request an Approov token for the domain - String host = connection.getURL().getHost(); + String host = request.getURL().getHost(); Approov.TokenFetchResult approovResults = Approov.fetchApproovTokenAndWait(host); // provide information about the obtained token or error (note "approov token -check" can @@ -631,9 +793,11 @@ public static synchronized void addApproov(HttpsURLConnection connection) throws Log.d(TAG, "Token for " + host + ": " + approovResults.getLoggableToken()); // check the status of Approov token fetch - if (approovResults.getStatus() == Approov.TokenFetchStatus.SUCCESS) + if (approovResults.getStatus() == Approov.TokenFetchStatus.SUCCESS) { // we successfully obtained a token so add it to the header for the request - connection.addRequestProperty(approovTokenHeader, approovTokenPrefix + approovResults.getToken()); + request.addRequestProperty(approovTokenHeader, approovTokenPrefix + approovResults.getToken()); + requestMutations.setTokenHeaderKey(approovTokenHeader); + } else if ((approovResults.getStatus() == Approov.TokenFetchStatus.NO_NETWORK) || (approovResults.getStatus() == Approov.TokenFetchStatus.POOR_NETWORK) || (approovResults.getStatus() == Approov.TokenFetchStatus.MITM_DETECTED)) { @@ -653,20 +817,21 @@ else if ((approovResults.getStatus() != Approov.TokenFetchStatus.NO_APPROOV_SERV // protected by Approov and therefore potential subject to a MitM if ((approovResults.getStatus() != Approov.TokenFetchStatus.SUCCESS) && (approovResults.getStatus() != Approov.TokenFetchStatus.UNPROTECTED_URL)) - return; + return mutator.handleInterceptorProcessedRequest(request, requestMutations); // we now deal with any header substitutions, which may require further fetches but these // should be using cached results for (Map.Entry entry: substitutionHeaders.entrySet()) { String header = entry.getKey(); String prefix = entry.getValue(); - String value = connection.getRequestProperty(header); + String value = request.getRequestProperty(header); if ((value != null) && value.startsWith(prefix) && (value.length() > prefix.length())) { approovResults = Approov.fetchSecureStringAndWait(value.substring(prefix.length()), null); Log.d(TAG, "Substituting header: " + header + ", " + approovResults.getStatus().toString()); if (approovResults.getStatus() == Approov.TokenFetchStatus.SUCCESS) { // perform the header substitution - connection.setRequestProperty(header, prefix + approovResults.getSecureString()); + request.setRequestProperty(header, prefix + approovResults.getSecureString()); + substitutedHeaderKeys.add(header); } else if (approovResults.getStatus() == Approov.TokenFetchStatus.REJECTED) // if the request is rejected then we provide a special exception with additional information @@ -689,6 +854,11 @@ else if (approovResults.getStatus() != Approov.TokenFetchStatus.UNKNOWN_KEY) approovResults.getStatus().toString()); } } + + if (!substitutedHeaderKeys.isEmpty()) + requestMutations.setSubstitutionHeaderKeys(substitutedHeaderKeys); + + return mutator.handleInterceptorProcessedRequest(request, requestMutations); } /** @@ -846,7 +1016,7 @@ public boolean verify(String hostname, SSLSession session) { Log.e(TAG, "Certificate not X.509"); } - // the connection is rejected + // the request is rejected Log.w(TAG, "Pinning rejection for " + hostname); return false; } catch (SSLException e) { diff --git a/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovServiceMutator.java b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovServiceMutator.java new file mode 100644 index 0000000..3aa4432 --- /dev/null +++ b/approov-service/src/main/java/io/approov/service/httpsurlconn/ApproovServiceMutator.java @@ -0,0 +1,357 @@ +// +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package io.approov.service.httpsurlconn; + +// okhttp3.Request equivalent imports +import java.net.URL; +import java.net.HttpURLConnection; + +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +import com.criticalblue.approovsdk.Approov; + +import javax.net.ssl.HttpsURLConnection; + +/** + * ApproovServiceMutator provides an interface for modifying the behavior of + * the ApproovService class by overriding the default implementations of the + * defined callbacks. Opportunities to modify behavior are offered at key + * points in the service and attestation flows. + * + * The interface provides default implementations for all methods, so + * implementing classes can choose to override only the methods they are + * interested in. The default implementations provide standard behavior + * that is suitable for most use cases and provides backwards compatibility + * with previous versions of this Approov service layer. + */ +public interface ApproovServiceMutator { + /** + * Default mutator that provides standard behavior with no changes. + */ + public static final ApproovServiceMutator DEFAULT = new ApproovServiceMutator() { + @Override + public String toString() { + return "ApproovServiceMutator.DEFAULT"; + } + }; + + /** + * Decides how to handle the token fetch result from an + * ApproovService.precheck() operation. + * + * @param approovResults the TokenFetchResult obtained by + * ApproovService.precheck() + * @throws ApproovException The implementation can either return, taking no + * action or throw an ApproovException encoding + * the cause of the failure. + */ + @SuppressWarnings("deprecation") + default void handlePrecheckResult(Approov.TokenFetchResult approovResults) throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + String arc = approovResults.getARC(); + String rejectionReasons = approovResults.getRejectionReasons(); + switch (status) { + case REJECTED: + throw new ApproovRejectionException( + "precheck: " + status.toString() + ": " + arc + " " + rejectionReasons, arc, rejectionReasons); + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + throw new ApproovNetworkException(status, "precheck: " + status.toString()); + case SUCCESS: + case UNKNOWN_KEY: + break; + default: + throw new ApproovFetchStatusException(status, "precheck: " + status.toString()); + } + } + + /** + * Decides how to handle the token fetch result from an + * ApproovService.fetchToken() operation. + * + * @param approovResults the TokenFetchResult obtained by + * ApproovService.fetchToken() + * @throws ApproovException The implementation can either return, taking no + * action or throw an ApproovException encoding + * the cause of the failure. + */ + @SuppressWarnings("deprecation") + default void handleFetchTokenResult(Approov.TokenFetchResult approovResults) throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + switch (status) { + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + throw new ApproovNetworkException(status, "fetchToken: " + status.toString()); + case SUCCESS: + break; + default: + throw new ApproovFetchStatusException(status, "fetchToken: " + status.toString()); + } + } + + /** + * Decides how to handle the token fetch result from an + * ApproovService.fetchSecureString() operation. + * + * @param approovResults the TokenFetchResult obtained by + * ApproovService.fetchSecureString() + * @param operation the operation type ("lookup" or "definition"); "lookup" + * indicates that an existing value was requested, while + * "definition" indicates that a new value was being added + * or set + * @param key the secure string key + * @throws ApproovException The implementation can either return, taking no + * action or throw an ApproovException encoding + * the cause of the failure + */ + @SuppressWarnings("deprecation") + default void handleFetchSecureStringResult(Approov.TokenFetchResult approovResults, String operation, String key) + throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + String arc = approovResults.getARC(); + String rejectionReasons = approovResults.getRejectionReasons(); + switch (status) { + case REJECTED: + throw new ApproovRejectionException("fetchSecureString " + operation + " for " + key + ": " + + status.toString() + ": " + arc + " " + rejectionReasons, arc, rejectionReasons); + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + throw new ApproovNetworkException(status, + "fetchSecureString " + operation + " for " + key + ": " + status.toString()); + case SUCCESS: + case UNKNOWN_KEY: + break; + default: + throw new ApproovFetchStatusException(status, + "fetchSecureString " + operation + " for " + key + ": " + status.toString()); + } + } + + /** + * Decides how to handle the token fetch result from an + * ApproovService.fetchCustomJWT() operation. + * + * @param approovResults the TokenFetchResult obtained by + * ApproovService.fetchCustomJWT() + * @throws ApproovException The implementation can either return, taking no + * action or throw an ApproovException encoding + * the cause of the failure + */ + @SuppressWarnings("deprecation") + default void handleFetchCustomJWTResult(Approov.TokenFetchResult approovResults) throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + String arc = approovResults.getARC(); + String rejectionReasons = approovResults.getRejectionReasons(); + switch (status) { + case REJECTED: + throw new ApproovRejectionException( + "fetchCustomJWT: " + status.toString() + ": " + arc + " " + rejectionReasons, arc, + rejectionReasons); + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + throw new ApproovNetworkException(status, "fetchCustomJWT: " + status.toString()); + case SUCCESS: + break; + default: + throw new ApproovFetchStatusException(status, "fetchCustomJWT: " + status.toString()); + } + } + + /** + * Decides whether a request should be processed in the interceptor or not. + * Called at the start of the ApproovService interceptor processing. + * + * @param request the request property extracted from the interceptor chain + * @return true if the request should be processed by the Approov interceptor, + * false if it should be issued unchanged + * @throws ApproovException The implementation can either return to indicate the + * action described above or throw an ApproovException + * encoding the cause of the failure + */ + default boolean handleInterceptorShouldProcessConnection(HttpsURLConnection request) throws ApproovException { + if (request == null) + throw new ApproovException( + "handleInterceptorShouldProcessConnection method was passed a request that is null!"); + + // check if the URL matches one of the exclusion regexs and skip interceptor + // processing in these cases + String url = request.getURL().toString(); + for (Pattern pattern : ApproovService.getExclusionURLRegexs().values()) { + Matcher matcher = pattern.matcher(url); + if (matcher.find()) { + return false; + } + } + return true; + } + + /** + * Decides how to handle the token fetch result from a call to + * Approov.fetchApproovTokenAndWait() from within the interceptor. + * + * @param approovResults the TokenFetchResult from Approov + * @param url the URL string for which the token was requested + * @return true if processing should continue, false if request should proceed + * even though no token was obtained from the fetch + * @throws ApproovException The implementation can either return to indicate the + * action described above or throw an ApproovException + * encoding the cause of the failure + */ + @SuppressWarnings("deprecation") + default boolean handleInterceptorFetchTokenResult(Approov.TokenFetchResult approovResults, String url) + throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + switch (status) { + case SUCCESS: + return true; + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + if (ApproovService.getUseApproovStatusIfNoToken()) + return true; + if (!ApproovService.getProceedOnNetworkFail()) + throw new ApproovNetworkException(status, + "Approov token fetch for " + url + ": " + status.toString()); + return false; + case NO_APPROOV_SERVICE: + case UNKNOWN_URL: + case UNPROTECTED_URL: // Continue without token for unprotected URLs + return false; + default: + throw new ApproovFetchStatusException(status, + "Approov token fetch for " + url + ": " + status.toString()); + } + } + + /** + * Decides how to handle the token fetch result while substituting headers from + * within the interceptor. The passed fetch result to process is associated with + * a preceding call to Approov.fetchSecureStringAndWait which passed in the + * current header value (minus a prefix) as the key. This method is called once + * per header being processed for substitution. + * + * @param approovResults the TokenFetchResult from Approov + * @param header the header being substituted + * @return true if substitution should proceed, false if it should be skipped + * @throws ApproovException The implementation can either return to indicate the + * action described above or throw an ApproovException + * encoding the cause of the failure + */ + @SuppressWarnings("deprecation") + default boolean handleInterceptorHeaderSubstitutionResult(Approov.TokenFetchResult approovResults, String header) + throws ApproovException { + Approov.TokenFetchStatus status = approovResults.getStatus(); + String arc = approovResults.getARC(); + String rejectionReasons = approovResults.getRejectionReasons(); + switch (status) { + case SUCCESS: + return true; + case REJECTED: + throw new ApproovRejectionException("Header substitution for " + header + ": " + status.toString() + + ": " + arc + " " + rejectionReasons, arc, rejectionReasons); + case NO_NETWORK: + case POOR_NETWORK: + case MITM_DETECTED: + if (!ApproovService.getProceedOnNetworkFail()) + throw new ApproovNetworkException(status, + "Header substitution for " + header + ": " + status.toString()); + return false; + case UNKNOWN_KEY: + return false; + default: + throw new ApproovFetchStatusException(status, + "Header substitution for " + header + ": " + status.toString()); + } + } + + /** + * Decides how to handle the token fetch result while substituting query params + * from within the interceptor. 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 Approov has processed a network request, allowing further + * modifications. + * + * @param request the processed request + * @param changes the mutations applied to the request by Approov + * @return the final request to use to complete the Approov interceptor step. + * @throws ApproovException The implementation can either return as described + * above or throw an ApproovException encoding the + * cause of the failure + */ + default HttpsURLConnection handleInterceptorProcessedRequest(HttpsURLConnection request, ApproovRequestMutations changes) + throws ApproovException { + // Modifies request in place and returns void by default, as no further changes to the request are required + return request; + } + + /** + * Decides whether certificate pinning should be applied to a request or not. + * Called at the start of the ApproovService pinning processing. + * + * @param request the request being processed + * @return true if pinning should be applied, false to skip it + */ + default boolean handlePinningShouldProcessRequest(HttpURLConnection request) { + // By default do not skip pinning for any requests + return true; + } +} \ No newline at end of file diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/BooleanItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/BooleanItem.java new file mode 100644 index 0000000..0bcb867 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/BooleanItem.java @@ -0,0 +1,66 @@ +package io.approov.util.http.sfv; + +import java.util.Objects; + +/** + * Represents a Boolean. + * + * @see Section + * 3.3.6 of RFC 8941 + */ +public class BooleanItem implements Item { + + private final boolean value; + private final Parameters params; + + private static final BooleanItem TRUE = new BooleanItem(true, Parameters.EMPTY); + private static final BooleanItem FALSE = new BooleanItem(false, Parameters.EMPTY); + + private BooleanItem(boolean value, Parameters params) { + this.value = value; + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates a {@link BooleanItem} instance representing the specified + * {@code boolean} value. + * + * @param value + * a {@code boolean} value. + * @return a {@link BooleanItem} representing {@code value}. + */ + public static BooleanItem valueOf(boolean value) { + return value ? TRUE : FALSE; + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public BooleanItem withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new BooleanItem(this.value, params); + } + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + sb.append(value ? "?1" : "?0"); + params.serializeTo(sb); + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } + + @Override + public Boolean get() { + return value; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/ByteSequenceItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/ByteSequenceItem.java new file mode 100644 index 0000000..308b440 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/ByteSequenceItem.java @@ -0,0 +1,70 @@ +package io.approov.util.http.sfv; + +import android.util.Base64; + +import java.nio.ByteBuffer; +import java.util.Objects; + +/** + * Represents a Byte Sequence. + * + * @see Section + * 3.3.5 of RFC 8941 + */ +public class ByteSequenceItem implements Item { + + private final byte[] value; + private final Parameters params; + + private ByteSequenceItem(byte[] value, Parameters params) { + this.value = Objects.requireNonNull(value, "value must not be null"); + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates a {@link ByteSequenceItem} instance representing the specified + * {@code byte[]} value. + * + * @param value + * a {@code byte[]} value. + * @return a {@link ByteSequenceItem} representing {@code value}. + */ + public static ByteSequenceItem valueOf(byte[] value) { + return new ByteSequenceItem(value, Parameters.EMPTY); + } + + @Override + public ByteSequenceItem withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new ByteSequenceItem(this.value, params); + } + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + sb.append(':'); + sb.append(Base64.encodeToString(this.value, Base64.NO_WRAP)); + sb.append(':'); + params.serializeTo(sb); + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } + + @Override + public ByteBuffer get() { + // this returns a wrapper around a copy so that the object itself + // stays immutable + return ByteBuffer.wrap(this.value.clone()); + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/DateItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/DateItem.java new file mode 100644 index 0000000..1625029 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/DateItem.java @@ -0,0 +1,77 @@ +package io.approov.util.http.sfv; + +import java.util.Objects; + +/** + * Represents a Date. + */ +public class DateItem implements NumberItem { + + private final long value; + private final Parameters params; + + private static final long MIN = -999999999999999L; + private static final long MAX = 999999999999999L; + + private DateItem(long value, Parameters params) { + if (value < MIN || value > MAX) { + throw new IllegalArgumentException("value must be in the range from " + MIN + " to " + MAX); + } + this.value = value; + this.params = Objects.requireNonNull(params, "params must not be null"); + } + + /** + * Creates an {@link DateItem} instance representing the specified + * {@code long} value. + * + * @param value + * a {@code long} value. + * @return a {@link DateItem} representing {@code value}. + */ + public static DateItem valueOf(long value) { + return new DateItem(value, Parameters.EMPTY); + } + + @Override + public DateItem withParams(Parameters params) { + if (Objects.requireNonNull(params, "params must not be null").isEmpty()) { + return this; + } else { + return new DateItem(this.value, params); + } + } + + @Override + public Parameters getParams() { + return params; + } + + @Override + public StringBuilder serializeTo(StringBuilder sb) { + sb.append('@'); + sb.append(value); + params.serializeTo(sb); + return sb; + } + + @Override + public String serialize() { + return serializeTo(new StringBuilder()).toString(); + } + + @Override + public Long get() { + return value; + } + + @Override + public long getAsLong() { + return value; + } + + @Override + public int getDivisor() { + return 1; + } +} diff --git a/approov-service/src/main/java/io/approov/util/http/sfv/DecimalItem.java b/approov-service/src/main/java/io/approov/util/http/sfv/DecimalItem.java new file mode 100644 index 0000000..9e477fa --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/http/sfv/DecimalItem.java @@ -0,0 +1,118 @@ +package io.approov.util.http.sfv; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Represents a Decimal. + *

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

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

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

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

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

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

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

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

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


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

+ * gives: + * + *

+ * a -> false
+ * b -> true
+ * c -> true (;foo=bar)
+ * 
+ */ + +package io.approov.util.http.sfv; diff --git a/approov-service/src/main/java/io/approov/util/sig/ComponentProvider.java b/approov-service/src/main/java/io/approov/util/sig/ComponentProvider.java new file mode 100644 index 0000000..81c3648 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/sig/ComponentProvider.java @@ -0,0 +1,249 @@ +package io.approov.util.sig; + +import java.util.List; +import java.util.regex.Pattern; + +import io.approov.util.http.sfv.Dictionary; +import io.approov.util.http.sfv.Item; +import io.approov.util.http.sfv.ListElement; +import io.approov.util.http.sfv.ParseException; +import io.approov.util.http.sfv.Parser; +import io.approov.util.http.sfv.StringItem; +import io.approov.util.http.sfv.Type; + +/** + * @author jricher + * + */ +public interface ComponentProvider { + Pattern PATTERN_WHITESPACE = Pattern.compile("[\\s\\t]*\\r\\n[\\s\\t]*"); + + // Derived component identifiers + /** The authority of the target URI for a request (Section 2.2.3). */ + String DC_AUTHORITY = "@authority"; + + /** The method used for a request (Section 2.2.1). */ + String DC_METHOD = "@method"; + + /** The absolute path portion of the target URI for a request (Section 2.2.6). */ + String DC_PATH = "@path"; + + /** The query portion of the target URI for a request (Section 2.2.7). */ + String DC_QUERY = "@query"; + + /** A parsed and encoded query parameter of the target URI for a request (Section 2.2.8). */ + String DC_QUERY_PARAM = "@query-param"; + + /** The request target (Section 2.2.5). */ + String DC_REQUEST_TARGET = "@request-target"; + + /** The scheme of the target URI for a request (Section 2.2.4). */ + String DC_SCHEME = "@scheme"; + + /** The status code for a response (Section 2.2.9). */ + String DC_STATUS = "@status"; + + /** The full target URI for a request (Section 2.2.2). */ + String DC_TARGET_URI = "@target-uri"; + + // derived, for requests + String getMethod(); + String getAuthority(); + String getScheme(); + String getTargetUri(); + String getRequestTarget(); + String getPath(); + String getQuery(); + String getQueryParam(String name); + boolean hasBody(); + + // derived, for responses + String getStatus(); + + // fields + boolean hasField(String name); + + String getField(String name); + + static String combineFieldValues(List fields) { + if (fields == null) { + return null; + } else { + StringBuilder sb = new StringBuilder(); + for (String field : fields) { + String trimmedField = field.trim(); + String replacedField = PATTERN_WHITESPACE.matcher(trimmedField).replaceAll(" "); + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(replacedField); + } + return sb.length() > 0 ? sb.toString() : null; + } + } + + default String getComponentValue(StringItem componentIdentifier) { + String baseIdentifier = componentIdentifier.get(); + if (baseIdentifier.startsWith("@")) { + // derived component + switch (baseIdentifier) { + case DC_METHOD: + return getMethod(); + case DC_AUTHORITY: + return getAuthority(); + case DC_SCHEME: + return getScheme(); + case DC_TARGET_URI: + return getTargetUri(); + case DC_REQUEST_TARGET: + return getRequestTarget(); + case DC_PATH: + return getPath(); + case DC_QUERY: + return getQuery(); + case DC_STATUS: + return getStatus(); + case DC_QUERY_PARAM: + { + if (componentIdentifier.getParams().containsKey("name")) { + Item nameParameter = componentIdentifier.getParams().get("name"); + if (nameParameter instanceof StringItem) { + String name = ((StringItem)nameParameter).get(); + return getQueryParam(name); + } else { + throw new IllegalArgumentException("Invalid Syntax: Value for 'name' parameter of " + baseIdentifier + " must be a StringItem"); + } + } else { + throw new IllegalArgumentException("'name' parameter of " + baseIdentifier + " is required"); + } + } + default: + throw new IllegalArgumentException("Unknown derived component: " + baseIdentifier); + } + } else { + if (componentIdentifier.getParams().containsKey("key")) { + Item keyParameter = componentIdentifier.getParams().get("key"); + if (keyParameter instanceof StringItem) { + try { + String fieldValue = getField(baseIdentifier); + Dictionary dictionary = Parser.parseDictionary(fieldValue); + String key = ((StringItem)keyParameter).get(); + if (dictionary.get().containsKey(key)) { + ListElement dictionaryValue = dictionary.get().get(key); + // we always re-serialize the value + return dictionaryValue.serialize(); + } else { + throw new IllegalArgumentException("Value for '" + key + "' key of dictionary " + baseIdentifier + " does not exist"); + } + } catch (ParseException e) { + throw new IllegalArgumentException("Field " + baseIdentifier + " is not a dictionary field"); + } + } else { + throw new IllegalArgumentException("Invalid Syntax: Value for 'key' parameter of field " + baseIdentifier + " must be a StringItem"); + } + } else if (componentIdentifier.getParams().containsKey("sf")) { + switch (baseIdentifier) { + case "accept": + case "accept-encoding": + case "accept-language": + case "accept-patch": + case "accept-ranges": + case "access-control-allow-headers": + case "access-control-allow-methods": + case "access-control-expose-headers": + case "access-control-request-headers": + case "allow": + case "alpn": + case "connection": + case "content-encoding": + case "content-language": + case "content-length": + case "te": + case "timing-allow-origin": + case "trailer": + case "transfer-encoding": + case "vary": + case "x-xss-protection": + case "cache-status": + case "proxy-status": + case "variant-key": + case "x-list": + case "x-list-a": + case "x-list-b": + case "accept-ch": + case "example-list": + { + // List + try { + String fieldValue = getField(baseIdentifier); + Type sf = Parser.parseList(fieldValue); + return sf.serialize(); + } catch (ParseException e) { + throw new IllegalArgumentException("Field " + baseIdentifier + " is not a structured field"); + } + } + case "alt-svc": + case "cache-control": + case "expect-ct": + case "keep-alive": + case "pragma": + case "prefer": + case "preference-applied": + case "surrogate-control": + case "variants": + case "signature": + case "signature-input": + case "priority": + case "x-dictionary": + case "example-dict": + case "cdn-cache-control": + { + // Dictionary + try { + String fieldValue = getField(baseIdentifier); + Type sf = Parser.parseDictionary(fieldValue); + return sf.serialize(); + } catch (ParseException e) { + throw new IllegalArgumentException("Field " + baseIdentifier + " is not a structured field"); + } + } + case "access-control-max-age": + case "access-control-allow-credentials": + case "access-control-allow-origin": + case "access-control-request-method": + case "age": + case "alt-used": + case "content-type": + case "cross-origin-resource-policy": + case "expect": + case "host": + case "origin": + case "retry-after": + case "x-content-type-options": + case "x-frame-options": + case "example-integer": + case "example-decimal": + case "example-string": + case "example-token": + case "example-bytesequence": + case "example-boolean": + { + // Item + try { + String fieldValue = getField(baseIdentifier); + Type sf = Parser.parseItem(fieldValue); + return sf.serialize(); + } catch (ParseException e) { + throw new IllegalArgumentException("Field " + baseIdentifier + " is not a structured field"); + } + } + default: + throw new IllegalArgumentException("Field " + baseIdentifier + " is not a structured field"); + + } + } else { + return getField(baseIdentifier); + } + } + } +} diff --git a/approov-service/src/main/java/io/approov/util/sig/LICENSE b/approov-service/src/main/java/io/approov/util/sig/LICENSE new file mode 100644 index 0000000..13ed038 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/sig/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Bespoke Engineering + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/approov-service/src/main/java/io/approov/util/sig/SignatureBaseBuilder.java b/approov-service/src/main/java/io/approov/util/sig/SignatureBaseBuilder.java new file mode 100644 index 0000000..9c1cb75 --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/sig/SignatureBaseBuilder.java @@ -0,0 +1,43 @@ +package io.approov.util.sig; + +import io.approov.util.http.sfv.StringItem; + +/** + * @author jricher + */ +public class SignatureBaseBuilder { + private final SignatureParameters sigParams; + private final ComponentProvider ctx; + + public SignatureBaseBuilder(SignatureParameters sigParams, ComponentProvider ctx) { + this.sigParams = sigParams; + this.ctx = ctx; + } + + public String createSignatureBase() { + StringBuilder base = new StringBuilder(); + + for (StringItem componentIdentifier : sigParams.getComponentIdentifiers()) { + + String componentValue = ctx.getComponentValue(componentIdentifier); + + if (componentValue != null) { + // write out the line to the base + componentIdentifier.serializeTo(base) + .append(": ") + .append(componentValue) + .append('\n'); + } else { + // FIXME: be more graceful about bailing + throw new RuntimeException("Couldn't find a value for required parameter: " + componentIdentifier.serialize()); + } + } + + // add the signature parameters line + sigParams.toComponentIdentifier().serializeTo(base) + .append(": "); + sigParams.toComponentValue().serializeTo(base); + + return base.toString(); + } +} diff --git a/approov-service/src/main/java/io/approov/util/sig/SignatureParameters.java b/approov-service/src/main/java/io/approov/util/sig/SignatureParameters.java new file mode 100644 index 0000000..18be1de --- /dev/null +++ b/approov-service/src/main/java/io/approov/util/sig/SignatureParameters.java @@ -0,0 +1,363 @@ +package io.approov.util.sig; + +//import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import io.approov.util.http.sfv.Dictionary; +import io.approov.util.http.sfv.InnerList; +import io.approov.util.http.sfv.Item; +import io.approov.util.http.sfv.ListElement; +import io.approov.util.http.sfv.NumberItem; +import io.approov.util.http.sfv.Parameters; +import io.approov.util.http.sfv.StringItem; + +/** + * Carrier class for signature parameters. + * + * @author jricher + * @author jexh + */ +public class SignatureParameters implements Cloneable { + private static final String ALG = "alg"; + private static final String CREATED = "created"; + private static final String EXPIRES = "expires"; + private static final String KEYID = "keyid"; + private static final String NONCE = "nonce"; + private static final String TAG = "tag"; + + // set this to add an extra header to the request that includes the SHA256 of the signature base + // which can be used to aid debugging on the server side to determine if there is a problem with + // the reconstruction of the signature base or the verification of the signature. + private boolean debugMode; + + private List componentIdentifiers; + + // this preserves insertion order + private Map parameters; + + /** + * Default constructor creates an empty SignatureParameters ready to be populated. + */ + public SignatureParameters() { + componentIdentifiers = new ArrayList<>(); + parameters = new LinkedHashMap<>(); + } + + /** + * Copy constructor creates a SignatureParameters instance pre-populated with a copy of all the + * component identifiers and parameters from the provided base. + * + * @param base + */ + public SignatureParameters(SignatureParameters base) { + // Items are immutable, it's fine to reference the items from the original component + // identifiers list + componentIdentifiers = new ArrayList<>(base.componentIdentifiers); + // Parameters are immutable (except for custom params with byte arrays but we ignore those) + parameters = new LinkedHashMap<>(base.parameters); + } + + /** + * @return the componentIdentifiers + */ + List getComponentIdentifiers() { + return componentIdentifiers; + } + + /** + * @return the parameters + */ + Map getParameters() { + return parameters; + } + + /** + * @param parameters the parameters to set + */ + public SignatureParameters setParameters(Map parameters) { + this.parameters = parameters; + return this; + } + + /** + * Determine if debug mode has been set for this signature parameters instance + * + * @return true is debug mode is on; false otherwise + */ + public boolean isDebugMode() { + return debugMode; + } + + /** + * Set the debug mode for this signature parameters + * + * @param debugMode true to enable; false to disable + */ + public void setDebugMode(boolean debugMode) { + this.debugMode = debugMode; + } + + /** + * @return the alg + */ + public String getAlg() { + return (String) getParameters().get(ALG); + } + + /** + * @param alg the alg to set + */ + public SignatureParameters setAlg(String alg) { + getParameters().put(ALG, alg); + return this; + } + + /** + * @return the created + */ + public Long getCreated() { + return (Long) getParameters().get(CREATED); + } + + /** + * @param created the created to set + */ + public SignatureParameters setCreated(Long created) { + getParameters().put(CREATED, created); + return this; + } + + /** + * @return the expires + */ + public Long getExpires() { + return (Long) getParameters().get(EXPIRES); + } + + /** + * @param expires the expires to set + */ + public SignatureParameters setExpires(Long expires) { + getParameters().put(EXPIRES, expires); + return this; + } + + /** + * @return the keyid + */ + public String getKeyid() { + return (String) getParameters().get(KEYID); + } + + /** + * @param keyid the keyid to set + */ + public SignatureParameters setKeyid(String keyid) { + getParameters().put(KEYID, keyid); + return this; + } + + /** + * @return the nonce + */ + public String getNonce() { + return (String) getParameters().get(NONCE); + } + + /** + * @param nonce the nonce to set + */ + public SignatureParameters setNonce(String nonce) { + getParameters().put(NONCE, nonce); + return this; + } + + public String getTag() { + return (String) getParameters().get(TAG); + } + + public SignatureParameters setTag(String tag) { + getParameters().put(TAG, tag); + return this; + } + + public Object getCustomParameter(String key) { + return getParameters().get(key); + } + + public Object setCustomParameter(String key, Object value) { + switch (key) { + case ALG: { + String val = (String)value; + setAlg(val); + break; + } + case CREATED: { + Long val = (Long)value; + setCreated(val); + break; + } + case EXPIRES: { + Long val = (Long)value; + setExpires(val); + break; + } + case KEYID: { + String val = (String)value; + setKeyid(val); + break; + } + case NONCE: { + String val = (String)value; + setNonce(val); + break; + } + case TAG: { + String val = (String)value; + setTag(val); + break; + } + default: { + if (!Item.isItemType(value)) { + throw new IllegalArgumentException("Parameter value of unsupported type: " + value.getClass()); + } + getParameters().put(key, value); + } + } + return this; + } + public StringItem toComponentIdentifier() { + return StringItem.valueOf("@signature-params"); + } + + public InnerList toComponentValue() { + + // take a copy of the identifiers + List> identifiers = new ArrayList<>(componentIdentifiers.size()); + identifiers.addAll(componentIdentifiers); + InnerList list = InnerList.valueOf(identifiers); + + // take a copy of the parameters + Map params = new LinkedHashMap<>(getParameters()); + list = list.withParams(Parameters.valueOf(params)); + + return list; + } + + /** + * Add a component without parameters. + */ + public SignatureParameters addComponentIdentifier(String identifier) { + if (!identifier.startsWith("@")) { + componentIdentifiers.add(StringItem.valueOf(identifier.toLowerCase(Locale.US))); + } else { + componentIdentifiers.add(StringItem.valueOf(identifier)); + } + return this; + } + + /** + * Add a component with optional parameters. Field components are assumed to be + * already set to lowercase. + */ + public SignatureParameters addComponentIdentifier(StringItem identifier) { + componentIdentifiers.add(identifier); + return this; + } + + // this ignores parameters + public boolean containsComponentIdentifier(String identifier) { + for (StringItem item : componentIdentifiers) { + if (item.get().equals(identifier)) { + return true; + } + } + return false; + } + + // does not ignore parameters + public boolean containsComponentIdentifier(StringItem identifier) { + String value = identifier.get(); + Parameters params = identifier.getParams(); + for (StringItem item : componentIdentifiers) { + if (value.equals(identifier.get()) + && params.equals(identifier.getParams())) { + return true; + } + } + return false; + } + + /** + * @param signatureInput + * @param sigId + */ + public static SignatureParameters fromDictionaryEntry(Dictionary signatureInput, String sigId) { + if (signatureInput.get().containsKey(sigId)) { + ListElement item = signatureInput.get().get(sigId); + if (item instanceof InnerList) { + InnerList coveredComponents = (InnerList)item; + SignatureParameters params = new SignatureParameters(); + for (Item innerItem : coveredComponents.get()) { + params.addComponentIdentifier((StringItem)innerItem); + } + for (Map.Entry> entry : coveredComponents.getParams().entrySet()) { + String key = entry.getKey(); + switch (key) { + case ALG: { + String value = ((StringItem) entry.getValue()).get(); + params.setAlg(value); + break; + } + case CREATED: { + Long value = ((NumberItem) entry.getValue()).getAsLong(); + params.setCreated(value); + break; + } + case EXPIRES: { + Long value = ((NumberItem) entry.getValue()).getAsLong(); + params.setExpires(value); + break; + } + case KEYID: { + String value = ((StringItem) entry.getValue()).get(); + params.setKeyid(value); + break; + } + case NONCE: { + String value = ((StringItem) entry.getValue()).get(); + params.setNonce(value); + break; + } + case TAG: { + String value = ((StringItem) entry.getValue()).get(); + params.setTag(value); + break; + } + default: { + Object value = entry.getValue().get(); + params.getParameters().put(key, value); + break; + } + } + } + return params; + } else { + throw new IllegalArgumentException("Invalid syntax, identifier '" + sigId + "' must be an inner list"); + } + } else { + throw new IllegalArgumentException("Could not find identifier '" + sigId + "' in dictionary " + signatureInput.serialize()); + } + } + + //@NonNull + @Override + public String toString() { + return "SignatureParameters: " + toComponentValue().serialize(); + } +}