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
37 changes: 37 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
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;
}
}
112 changes: 112 additions & 0 deletions src/main/java/com/fingerprint/Sealed.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.fingerprint;

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

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.Arrays;
import java.util.logging.Logger;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;

public class Sealed {
private static final Logger log = Logger.getLogger(ApiClient.class.getName());

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;
}
}

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 {
if (!Arrays.equals(Arrays.copyOf(sealed, SEAL_HEADER.length), SEAL_HEADER)) {
throw new IllegalArgumentException("Invalid sealed data header");
}

int index = 0;

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) {
log.warning(String.format("Failed to decrypt with key: %d error: %s", index, exception.getMessage()));
}

break;

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

index++;
}

throw new IllegalArgumentException("Invalid decryption keys");
}

/**
* 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
* @throws Exception if the sealed data is invalid or if the decryption keys are invalid
*/
public static EventResponse unsealEventResponse(byte[] sealed, DecryptionKey[] keys) throws Exception {
byte[] unsealed = unseal(sealed, keys);

ObjectMapper mapper = ObjectMapperUtil.getObjectMapper();

return mapper.readValue(unsealed, EventResponse.class);
}

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();
}
}

83 changes: 83 additions & 0 deletions src/test/java/com/fingerprint/SealedTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.fingerprint;

import com.fingerprint.model.EventResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

import java.util.Base64;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class SealedTest {
@Test
public void unsealEventResponseTest() throws Exception {
byte[] sealedResult = Base64.getDecoder().decode("noXc7SXO+mqeAGrvBMgObi/S0fXTpP3zupk8qFqsO/1zdtWCD169iLA3VkkZh9ICHpZ0oWRzqG0M9/TnCeKFohgBLqDp6O0zEfXOv6i5q++aucItznQdLwrKLP+O0blfb4dWVI8/aSbd4ELAZuJJxj9bCoVZ1vk+ShbUXCRZTD30OIEAr3eiG9aw00y1UZIqMgX6CkFlU9L9OnKLsNsyomPIaRHTmgVTI5kNhrnVNyNsnzt9rY7fUD52DQxJILVPrUJ1Q+qW7VyNslzGYBPG0DyYlKbRAomKJDQIkdj/Uwa6bhSTq4XYNVvbk5AJ/dGwvsVdOnkMT2Ipd67KwbKfw5bqQj/cw6bj8Cp2FD4Dy4Ud4daBpPRsCyxBM2jOjVz1B/lAyrOp8BweXOXYugwdPyEn38MBZ5oL4D38jIwR/QiVnMHpERh93jtgwh9Abza6i4/zZaDAbPhtZLXSM5ztdctv8bAb63CppLU541Kf4OaLO3QLvfLRXK2n8bwEwzVAqQ22dyzt6/vPiRbZ5akh8JB6QFXG0QJF9DejsIspKF3JvOKjG2edmC9o+GfL3hwDBiihYXCGY9lElZICAdt+7rZm5UxMx7STrVKy81xcvfaIp1BwGh/HyMsJnkE8IczzRFpLlHGYuNDxdLoBjiifrmHvOCUDcV8UvhSV+UAZtAVejdNGo5G/bz0NF21HUO4pVRPu6RqZIs/aX4hlm6iO/0Ru00ct8pfadUIgRcephTuFC2fHyZxNBC6NApRtLSNLfzYTTo/uSjgcu6rLWiNo5G7yfrM45RXjalFEFzk75Z/fu9lCJJa5uLFgDNKlU+IaFjArfXJCll3apbZp4/LNKiU35ZlB7ZmjDTrji1wLep8iRVVEGht/DW00MTok7Zn7Fv+MlxgWmbZB3BuezwTmXb/fNw==");
byte[] key = Base64.getDecoder().decode("p2PA7MGy5tx56cnyJaFZMr96BCFwZeHjZV2EqMvTq53=");

EventResponse expectedResponse = ObjectMapperUtil.getObjectMapper().readValue("{\"products\":{\"identification\":{\"data\":{\"visitorId\":\"2ZEDCZEfOfXjEmMuE3tq\",\"requestId\":\"1703067132750.Z5hutJ\",\"browserDetails\":{\"browserName\":\"Safari\",\"browserMajorVersion\":\"17\",\"browserFullVersion\":\"17.3\",\"os\":\"Mac OS X\",\"osVersion\":\"10.15.7\",\"device\":\"Other\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15\"},\"incognito\":false,\"ip\":\"::1\",\"ipLocation\":{\"accuracyRadius\":1000,\"latitude\":59.3241,\"longitude\":18.0517,\"postalCode\":\"100 05\",\"timezone\":\"Europe/Stockholm\",\"city\":{\"name\":\"Stockholm\"},\"country\":{\"code\":\"SE\",\"name\":\"Sweden\"},\"continent\":{\"code\":\"EU\",\"name\":\"Europe\"},\"subdivisions\":[{\"isoCode\":\"AB\",\"name\":\"Stockholm County\"}]},\"timestamp\":1703067136286,\"time\":\"2023-12-20T10:12:16Z\",\"url\":\"http://localhost:8080/\",\"tag\":{\"foo\":\"bar\"},\"confidence\":{\"score\":1},\"visitorFound\":true,\"firstSeenAt\":{\"global\":\"2023-12-15T12:13:55.103Z\",\"subscription\":\"2023-12-15T12:13:55.103Z\"},\"lastSeenAt\":{\"global\":\"2023-12-19T11:39:51.52Z\",\"subscription\":\"2023-12-19T11:39:51.52Z\"}}},\"botd\":{\"data\":{\"bot\":{\"result\":\"notDetected\"},\"meta\":{\"foo\":\"bar\"},\"url\":\"http://localhost:8080/\",\"ip\":\"::1\",\"time\":\"2023-12-20T10:12:13.894Z\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15\",\"requestId\":\"1703067132750.Z5hutJ\"}}}}", EventResponse.class);

EventResponse eventResponse = Sealed.unsealEventResponse(
sealedResult,
new Sealed.DecryptionKey[]{
new Sealed.DecryptionKey(
//Invalid key
Base64.getDecoder().decode("p2PA7MGy5tx56cnyJaFZMr96BCFwZeHjZV2EqMvTq54="),
Sealed.DecryptionAlgorithm.AES_256_GCM
),
new Sealed.DecryptionKey(
key,
Sealed.DecryptionAlgorithm.AES_256_GCM
)
}
);

assert eventResponse.equals(expectedResponse);
}

@Test
public void unsealEventResponseWithInvalidHeaderTest() throws Exception {
byte[] sealedResult = Base64.getDecoder().decode("noXc7xXO+mqeAGrvBMgObi/S0fXTpP3zupk8qFqsO/1zdtWCD169iLA3VkkZh9ICHpZ0oWRzqG0M9/TnCeKFohgBLqDp6O0zEfXOv6i5q++aucItznQdLwrKLP+O0blfb4dWVI8/aSbd4ELAZuJJxj9bCoVZ1vk+ShbUXCRZTD30OIEAr3eiG9aw00y1UZIqMgX6CkFlU9L9OnKLsNsyomPIaRHTmgVTI5kNhrnVNyNsnzt9rY7fUD52DQxJILVPrUJ1Q+qW7VyNslzGYBPG0DyYlKbRAomKJDQIkdj/Uwa6bhSTq4XYNVvbk5AJ/dGwvsVdOnkMT2Ipd67KwbKfw5bqQj/cw6bj8Cp2FD4Dy4Ud4daBpPRsCyxBM2jOjVz1B/lAyrOp8BweXOXYugwdPyEn38MBZ5oL4D38jIwR/QiVnMHpERh93jtgwh9Abza6i4/zZaDAbPhtZLXSM5ztdctv8bAb63CppLU541Kf4OaLO3QLvfLRXK2n8bwEwzVAqQ22dyzt6/vPiRbZ5akh8JB6QFXG0QJF9DejsIspKF3JvOKjG2edmC9o+GfL3hwDBiihYXCGY9lElZICAdt+7rZm5UxMx7STrVKy81xcvfaIp1BwGh/HyMsJnkE8IczzRFpLlHGYuNDxdLoBjiifrmHvOCUDcV8UvhSV+UAZtAVejdNGo5G/bz0NF21HUO4pVRPu6RqZIs/aX4hlm6iO/0Ru00ct8pfadUIgRcephTuFC2fHyZxNBC6NApRtLSNLfzYTTo/uSjgcu6rLWiNo5G7yfrM45RXjalFEFzk75Z/fu9lCJJa5uLFgDNxlU+IaFjArfXJCll3apbZp4/LNKiU35ZlB7ZmjDTrji1wLep8iRVVEGht/DW00MTok7Zn7Fv+MlxgWmbZB3BuezwTmXb/fNw==");
byte[] key = Base64.getDecoder().decode("p2PA7MGy5tx56cnyJaFZMr96BCFwZeHjZV2EqMvTq53=");

Exception thrown = assertThrows(Exception.class, () -> Sealed.unsealEventResponse(
sealedResult,
new Sealed.DecryptionKey[]{
new Sealed.DecryptionKey(
//Invalid key
Base64.getDecoder().decode("p2PA7MGy5tx56cnyJaFZMr96BCFwZeHjZV2EqMvTq54="),
Sealed.DecryptionAlgorithm.AES_256_GCM
),
new Sealed.DecryptionKey(
key,
Sealed.DecryptionAlgorithm.AES_256_GCM
)
}
));

assertEquals("Invalid sealed data header", thrown.getMessage());
}

@Test
public void unsealEventResponseWithInvalidKeysTest() throws Exception {
byte[] sealedResult = Base64.getDecoder().decode("noXc7SXO+mqeAGrvBMgObi/S0fXTpP3zupk8qFqsO/1zdtWCD169iLA3VkkZh9ICHpZ0oWRzqG0M9/TnCeKFohgBLqDp6O0zEfXOv6i5q++aucItznQdLwrKLP+O0blfb4dWVI8/aSbd4ELAZuJJxj9bCoVZ1vk+ShbUXCRZTD30OIEAr3eiG9aw00y1UZIqMgX6CkFlU9L9OnKLsNsyomPIaRHTmgVTI5kNhrnVNyNsnzt9rY7fUD52DQxJILVPrUJ1Q+qW7VyNslzGYBPG0DyYlKbRAomKJDQIkdj/Uwa6bhSTq4XYNVvbk5AJ/dGwvsVdOnkMT2Ipd67KwbKfw5bqQj/cw6bj8Cp2FD4Dy4Ud4daBpPRsCyxBM2jOjVz1B/lAyrOp8BweXOXYugwdPyEn38MBZ5oL4D38jIwR/QiVnMHpERh93jtgwh9Abza6i4/zZaDAbPhtZLXSM5ztdctv8bAb63CppLU541Kf4OaLO3QLvfLRXK2n8bwEwzVAqQ22dyzt6/vPiRbZ5akh8JB6QFXG0QJF9DejsIspKF3JvOKjG2edmC9o+GfL3hwDBiihYXCGY9lElZICAdt+7rZm5UxMx7STrVKy81xcvfaIp1BwGh/HyMsJnkE8IczzRFpLlHGYuNDxdLoBjiifrmHvOCUDcV8UvhSV+UAZtAVejdNGo5G/bz0NF21HUO4pVRPu6RqZIs/aX4hlm6iO/0Ru00ct8pfadUIgRcephTuFC2fHyZxNBC6NApRtLSNLfzYTTo/uSjgcu6rLWiNo5G7yfrM45RXjalFEFzk75Z/fu9lCJJa5uLFgDNKlU+IaFjArfXJCll3apbZp4/LNKiU35ZlB7ZmjDTrji1wLep8iRVVEGht/DW00MTok7Zn7Fv+MlxgWmbZB3BuezwTmXb/fNw==");

Exception thrown = assertThrows(Exception.class, () -> Sealed.unsealEventResponse(
sealedResult,
new Sealed.DecryptionKey[]{
new Sealed.DecryptionKey(
//Invalid key
Base64.getDecoder().decode("p2PA7MGy5tx56cnyJaFZMr96BCFwZeHjZV2EqMvTq54="),
Sealed.DecryptionAlgorithm.AES_256_GCM
),
new Sealed.DecryptionKey(
ilfa marked this conversation as resolved.
Show resolved Hide resolved
Base64.getDecoder().decode("p2PA7MGy5tx56cnyJacZMr96BCFwZeHjZV2EqMvTq54="),
Sealed.DecryptionAlgorithm.AES_256_GCM
)
}
));

assertEquals("Invalid decryption keys", thrown.getMessage());
}
}
Loading
Loading