Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: add method for decoding sealed results #27

Merged
merged 11 commits into from
Feb 14, 2024
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,43 @@ public class FingerprintApiExample {
}
```

## Sealed results

This SDK provides utility methods for decoding [sealed results](https://dev.fingerprint.com/docs/sealed-client-results).
```java
package com.fingerprint.example;

import com.fingerprint.Sealed;
import com.fingerprint.model.EventResponse;

import java.util.Base64;

public class SealedResults {
public static void main(String... args) throws Exception {
// Sealed result from the frontend.
String SEALED_RESULT = System.getenv("BASE64_SEALED_RESULT");

// Base64 encoded key generated in the dashboard.
String SEALED_KEY = System.getenv("BASE64_KEY");

final EventResponse event = Sealed.unsealEventResponse(
Base64.getDecoder().decode(SEALED_RESULT),
// You can provide more than one key to support key rotation. The SDK will try to decrypt the result with each key.
new Sealed.DecryptionKey[]{
new Sealed.DecryptionKey(
Base64.getDecoder().decode(SEALED_KEY),
Sealed.DecryptionAlgorithm.AES_256_GCM
)
}
);

// Do something with unsealed response, e.g: send it back to the frontend.
}
}

```
To learn more, refer to example located in [src/examples/java/com/fingerprint/example/SealedResults.java](src/examples/java/com/fingerprint/example/SealedResults.java).

## Documentation for API Endpoints

All URIs are relative to *https://api.fpjs.io*
Expand Down Expand Up @@ -313,6 +350,11 @@ Authentication schemes defined for the API:
- **Location**: URL query string


## Documentation for sealed results

- [Sealed](docs/Sealed.md)
- [DecryptionKey](docs/DecryptionKey.md)

## Recommendation

It's recommended to create an instance of `ApiClient` per thread in a multithreaded environment to avoid any potential issues.
Expand Down
10 changes: 10 additions & 0 deletions docs/DecryptionKey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# DecryptionKey

## Properties

| Name | Type | Description | Notes |
|---------------|-------------------------|-----------------------------------------------------------------------------------|-------|
| **Key** | **byte[]** | Key generated in dashboard that will be used to decrypt sealed result | |
| **Algorithm** | **DecryptionAlgorithm** | Algorithm to use for decryption. Currently only "AES_256_GCM" value is supported. | |


12 changes: 12 additions & 0 deletions docs/Sealed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Sealed

## **UnsealEventsResponse**
> EventResponse unsealEventResponse(sealed []byte, keys DecryptionKey[])

Decrypts the sealed response with provided keys.
### Required Parameters

| Name | Type | Description | Notes |
|------------|---------------------|------------------------------------------------------------------------------------------|-------|
| **sealed** | **byte[]** | Base64 encoded sealed data | |
| **keys** | **DecryptionKey[]** | Decryption keys. The SDK will try to decrypt the result with each key until it succeeds. | |
3 changes: 2 additions & 1 deletion scripts/generate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ echo "VERSION: $VERSION"
sed -i "s/artifactVersion: .*/artifactVersion: $VERSION/g" config.yaml
sed -i "s/^VERSION=.*/VERSION='$VERSION'/g" ./scripts/generate.sh

rm -f pom.xml README.md build.gradle settings.gradle gradle.properties ./docs/* ./src/main/java/com/fingerprint/model/*
rm -f pom.xml README.md build.gradle settings.gradle gradle.properties ./src/main/java/com/fingerprint/model/*
find ./docs -type f ! -name "DecryptionKey.md" ! -name "Sealed.md" -exec rm {} +

java -jar ./bin/generator.jar generate -c config.yaml -g java --library jersey2 -i res/fingerprint-server-api.yaml --skip-validate-spec -o . -t template

Expand Down
27 changes: 27 additions & 0 deletions src/examples/java/com/fingerprint/example/EnvUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.fingerprint.example;

import io.github.cdimascio.dotenv.Dotenv;

import java.io.File;

public class EnvUtil {
private Dotenv dotenv;

public EnvUtil() {
// Load variables from .env if present, host environment variables still take precedence if present
File envFile = new File(".env");
if (envFile.exists()) {
dotenv = Dotenv.configure().load();
} else {
System.out.println(".env file not found. Skipping dotenv loading.");
}
}

public String getEnv(String key) {
String value = System.getenv(key);
if (value == null && dotenv != null) {
value = dotenv.get(key);
}
return value;
}
}
38 changes: 5 additions & 33 deletions src/examples/java/com/fingerprint/example/FunctionalTests.java
Original file line number Diff line number Diff line change
@@ -1,46 +1,18 @@
package com.fingerprint.example;

import com.fingerprint.api.FingerprintApi;
import com.fingerprint.model.EventResponse;
import com.fingerprint.model.Response;
import com.fingerprint.sdk.ApiClient;
import com.fingerprint.sdk.ApiException;
import com.fingerprint.sdk.Configuration;
import io.github.cdimascio.dotenv.Dotenv;
import java.io.File;

public class FunctionalTests {
public static void main(String... args) {
EnvUtil envUtil = new EnvUtil();

// Load variables from .env if present, host environment variables still take precedence if present
File envFile = new File(".env");
Dotenv dotenv = null;
if (envFile.exists()) {
dotenv = Dotenv.configure().load();
} else {
System.out.println(".env file not found. Skipping dotenv loading.");
}

String FPJS_API_SECRET = System.getenv("FPJS_API_SECRET");
if (FPJS_API_SECRET == null && dotenv != null) {
FPJS_API_SECRET = dotenv.get("FPJS_API_SECRET");
}

String FPJS_VISITOR_ID = System.getenv("FPJS_VISITOR_ID");
if (FPJS_VISITOR_ID == null && dotenv != null) {
FPJS_VISITOR_ID = dotenv.get("FPJS_VISITOR_ID");
}

String FPJS_REQUEST_ID = System.getenv("FPJS_REQUEST_ID");
if (FPJS_REQUEST_ID == null && dotenv != null) {
FPJS_REQUEST_ID = dotenv.get("FPJS_REQUEST_ID");
}

String FPJS_API_REGION = System.getenv("FPJS_API_REGION");
if (FPJS_API_REGION == null && dotenv != null) {
FPJS_API_REGION = dotenv.get("FPJS_API_REGION");
}

String FPJS_API_SECRET = envUtil.getEnv("FPJS_API_SECRET");
String FPJS_VISITOR_ID = envUtil.getEnv("FPJS_VISITOR_ID");
String FPJS_REQUEST_ID = envUtil.getEnv("FPJS_REQUEST_ID");
String FPJS_API_REGION = envUtil.getEnv("FPJS_API_REGION");

// Create a new instance of the API client
ApiClient client = Configuration.getDefaultApiClient(FPJS_API_SECRET, FPJS_API_REGION != null ? FPJS_API_REGION : "us");
Expand Down
28 changes: 28 additions & 0 deletions src/examples/java/com/fingerprint/example/SealedResults.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.fingerprint.example;

import com.fingerprint.Sealed;
import com.fingerprint.model.EventResponse;

import java.util.Base64;

public class SealedResults {
public static void main(String... args) throws Exception {
EnvUtil envUtil = new EnvUtil();

String SEALED_RESULT = envUtil.getEnv("BASE64_SEALED_RESULT");
String SEALED_KEY = envUtil.getEnv("BASE64_KEY");

final EventResponse event = Sealed.unsealEventResponse(
Base64.getDecoder().decode(SEALED_RESULT),
new Sealed.DecryptionKey[]{
new Sealed.DecryptionKey(
Base64.getDecoder().decode(SEALED_KEY),
Sealed.DecryptionAlgorithm.AES_256_GCM
)
}
);

System.out.println(event);
System.exit(0);
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/fingerprint/ObjectMapperUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.fingerprint;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

public class ObjectMapperUtil {
public static ObjectMapper getObjectMapper() {
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.registerModule(new JavaTimeModule());

return mapper;
}
}
158 changes: 158 additions & 0 deletions src/main/java/com/fingerprint/Sealed.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package com.fingerprint;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fingerprint.model.EventResponse;

import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;

public class Sealed {
public enum DecryptionAlgorithm {
AES_256_GCM
}

public static class DecryptionKey {
private final byte[] key;
private final DecryptionAlgorithm algorithm;

public DecryptionKey(byte[] key, DecryptionAlgorithm algorithm) {
this.key = key;
this.algorithm = algorithm;
}
}

public static class UnsealAggregateException extends Exception {
private final List<UnsealException> unsealExceptions = new ArrayList<>();

public UnsealAggregateException() {
super("Failed to unseal with all decryption keys");
}

public void addUnsealException(UnsealException exception) {
unsealExceptions.add(exception);
}

public List<UnsealException> getUnsealExceptions() {
return unsealExceptions;
}
}

public static class InvalidSealedDataException extends IllegalArgumentException {
public InvalidSealedDataException() {
super("Invalid sealed data");
}
}

public static class InvalidSealedDataHeaderException extends IllegalArgumentException {
public InvalidSealedDataHeaderException() {
super("Invalid sealed data header");
}
}

public static class UnsealException extends Exception {
public final DecryptionKey decryptionKey;

public final Exception exception;

public UnsealException(String message, DecryptionKey decryptionKey, Exception exception) {
sshelomentsev marked this conversation as resolved.
Show resolved Hide resolved
super(message);
this.decryptionKey = decryptionKey;
sshelomentsev marked this conversation as resolved.
Show resolved Hide resolved
this.exception = exception;
}
}
private static final byte[] SEAL_HEADER = new byte[]{(byte) 0x9E, (byte) 0x85, (byte) 0xDC, (byte) 0xED};
private static final int NONCE_LENGTH = 12;
private static final int AUTH_TAG_LENGTH = 16;

public static byte[] unseal(byte[] sealed, DecryptionKey[] keys) throws IllegalArgumentException, UnsealAggregateException {
if (!Arrays.equals(Arrays.copyOf(sealed, SEAL_HEADER.length), SEAL_HEADER)) {
throw new InvalidSealedDataHeaderException();
}

UnsealAggregateException aggregateException = new UnsealAggregateException();
TheUnderScorer marked this conversation as resolved.
Show resolved Hide resolved

for (DecryptionKey key : keys) {
switch (key.algorithm) {
case AES_256_GCM:
try {
return decryptAes256Gcm(Arrays.copyOfRange(sealed, SEAL_HEADER.length, sealed.length), key.key);
} catch (Exception exception) {
aggregateException.addUnsealException(
new UnsealException(
"Failed to decrypt",
key,
exception
)
);
}

break;

default:
throw new IllegalArgumentException("Invalid decryption algorithm");
}
}

throw aggregateException;
}

/**
* decrypts the sealed response with the provided keys.
*
* @param sealed Base64 encoded sealed data
* @param keys Decryption keys. The SDK will try to decrypt the result with each key until it succeeds.
* @return EventResponse
*/
public static EventResponse unsealEventResponse(byte[] sealed, DecryptionKey[] keys) throws IllegalArgumentException, UnsealAggregateException, IOException {
byte[] unsealed = unseal(sealed, keys);

ObjectMapper mapper = ObjectMapperUtil.getObjectMapper();

EventResponse value = mapper.readValue(unsealed, EventResponse.class);

if (value.getProducts() == null) {
throw new InvalidSealedDataException();
}

return value;
}

private static byte[] decryptAes256Gcm(byte[] sealedData, byte[] decryptionKey) throws Exception {
byte[] nonce = Arrays.copyOfRange(sealedData, 0, NONCE_LENGTH);
byte[] ciphertext = Arrays.copyOfRange(sealedData, NONCE_LENGTH, sealedData.length);

Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec nonceSpec = new GCMParameterSpec(Byte.SIZE * AUTH_TAG_LENGTH, nonce);
SecretKeySpec keySpec = new SecretKeySpec(decryptionKey, "AES");

cipher.init(Cipher.DECRYPT_MODE, keySpec, nonceSpec);
byte[] decryptedData = cipher.doFinal(ciphertext);

// Decompressing the decrypted data
return decompress(decryptedData);
}

private static byte[] decompress(byte[] data) throws IOException {
Inflater inflater = new Inflater(true); // true for raw deflate data
InflaterInputStream inflaterInputStream = new InflaterInputStream(new ByteArrayInputStream(data), inflater);
ByteArrayOutputStream buffer = new ByteArrayOutputStream();

int nRead;
byte[] temp = new byte[1024];
while ((nRead = inflaterInputStream.read(temp, 0, temp.length)) != -1) {
buffer.write(temp, 0, nRead);
}

return buffer.toByteArray();
}
}

Loading
Loading