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

Add CSTG tests #7

Merged
merged 3 commits into from
Jan 14, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ ENV UID2_E2E_API_KEY ""
ENV UID2_E2E_API_SECRET ""
ENV UID2_E2E_API_KEY_OLD ""
ENV UID2_E2E_API_SECRET_OLD ""
ENV UID2_E2E_SUBSCRIPTION_ID ""
ENV UID2_E2E_SERVER_PUBLIC_KEY ""
ENV UID2_E2E_ORIGIN ""
ENV UID2_E2E_INVALID_ORIGIN ""

ENV UID2_E2E_IDENTITY_SCOPE ""
ENV UID2_E2E_PHONE_SUPPORT ""
Expand Down
15 changes: 7 additions & 8 deletions src/test/java/app/common/HttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

public final class HttpClient {
public static final OkHttpClient RAW_CLIENT = new OkHttpClient();
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
public static final MediaType JSON = MediaType.get("application/json; charset=utf-8");

public enum HttpMethod {
GET,
Expand Down Expand Up @@ -67,12 +67,7 @@ private HttpClient() {

public static String get(String url, String bearerToken) throws Exception {
Request request = buildRequest(HttpMethod.GET, url, null, bearerToken);
try (Response response = RAW_CLIENT.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new HttpException(HttpMethod.GET, request.url().toString(), response.code(), response.message(), Objects.requireNonNull(response.body()).string());
}
return Objects.requireNonNull(response.body()).string();
}
return execute(request, HttpMethod.GET);
}

public static String get(String url) throws Exception {
Expand All @@ -81,9 +76,13 @@ public static String get(String url) throws Exception {

public static String post(String url, String body, String bearerToken) throws Exception {
Request request = buildRequest(HttpMethod.POST, url, body, bearerToken);
return execute(request, HttpMethod.POST);
}

public static String execute(Request request, HttpMethod method) throws Exception {
try (Response response = RAW_CLIENT.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new HttpException(HttpMethod.POST, request.url().toString(), response.code(), response.message(), Objects.requireNonNull(response.body()).string());
throw new HttpException(method, request.url().toString(), response.code(), response.message(), Objects.requireNonNull(response.body()).string());
}
return Objects.requireNonNull(response.body()).string();
}
Expand Down
146 changes: 142 additions & 4 deletions src/test/java/app/component/Operator.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,24 @@
import app.common.HttpClient;
import app.common.Mapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.uid2.client.*;
import com.uid2.client.IdentityScope;
import okhttp3.Request;
import okhttp3.RequestBody;

import javax.crypto.*;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.lang.reflect.Method;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.*;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.Clock;
import java.time.Instant;
import java.util.Base64;

Expand Down Expand Up @@ -52,12 +63,20 @@ public String toString() {
private record V2Envelope(String envelope, byte[] nonce) {
}

private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final String CLIENT_API_KEY = EnvUtil.getEnv("UID2_E2E_API_KEY");
private static final String CLIENT_API_SECRET = EnvUtil.getEnv("UID2_E2E_API_SECRET");
private static final String CLIENT_API_KEY_BEFORE_OPTOUT_CUTOFF = EnvUtil.getEnv("UID2_E2E_API_KEY_OLD");
private static final String CLIENT_API_SECRET_BEFORE_OPTOUT_CUTOFF = EnvUtil.getEnv("UID2_E2E_API_SECRET_OLD");
private static final String CLIENT_SIDE_TOKEN_GENERATE_SUBSCRIPTION_ID = EnvUtil.getEnv("UID2_E2E_SUBSCRIPTION_ID");
private static final String CLIENT_SIDE_TOKEN_GENERATE_SERVER_PUBLIC_KEY = EnvUtil.getEnv("UID2_E2E_SERVER_PUBLIC_KEY");
private static final String CLIENT_SIDE_TOKEN_GENERATE_ORIGIN = EnvUtil.getEnv("UID2_E2E_ORIGIN");
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do both valid and invalid origins have to be passed in dynamically? Can they be hardcoded instead?

private static final String CLIENT_SIDE_TOKEN_GENERATE_INVALID_ORIGIN = EnvUtil.getEnv("UID2_E2E_INVALID_ORIGIN");
private static final IdentityScope IDENTITY_SCOPE = IdentityScope.valueOf(EnvUtil.getEnv("UID2_E2E_IDENTITY_SCOPE"));
private static final int TIMESTAMP_LENGTH = 8;
private static final int PUBLIC_KEY_PREFIX_LENGTH = 9;
private static final int AUTHENTICATION_TAG_LENGTH_BITS = 128;
private static final int IV_BYTES = 12;
private static final String TC_STRING = "CPhJRpMPhJRpMABAMBFRACBoALAAAEJAAIYgAKwAQAKgArABAAqAAA";

private final Type type;
Expand Down Expand Up @@ -182,6 +201,125 @@ public JsonNode v2TokenGenerateUsingPayload(String payload, boolean asOldPartici
return v2DecryptEncryptedResponse(encryptedResponse, envelope.nonce(), getClientApiSecret(asOldParticipant));
}

public JsonNode v2ClientSideTokenGenerate(String requestBody, boolean useValidOrigin) throws Exception {
final byte[] serverPublicKeyBytes = base64ToByteArray(CLIENT_SIDE_TOKEN_GENERATE_SERVER_PUBLIC_KEY.substring(PUBLIC_KEY_PREFIX_LENGTH));
Copy link
Collaborator

Choose a reason for hiding this comment

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

Not a big issue, but final isn't really needed for local variables

Copy link

Choose a reason for hiding this comment

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

I'm a fan because it reduces cognitive overhead, no need to ask: "is this variable intentionally mutable / going to be reassigned later?"

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yep that's fine too, I don't have very strong opinions on either


final PublicKey serverPublicKey = KeyFactory.getInstance("EC")
.generatePublic(new X509EncodedKeySpec(serverPublicKeyBytes));

final KeyPair keyPair = generateKeyPair();
final SecretKey sharedSecret = generateSharedSecret(serverPublicKey, keyPair);

final JsonObject cstgEnvelope = createCstgEnvelope(requestBody, CLIENT_SIDE_TOKEN_GENERATE_SUBSCRIPTION_ID, keyPair.getPublic(), sharedSecret);

final Request.Builder requestBuilder = new Request.Builder()
.url(getBaseUrl() + "/v2/token/client-generate")
.addHeader("Origin", useValidOrigin ? CLIENT_SIDE_TOKEN_GENERATE_ORIGIN : CLIENT_SIDE_TOKEN_GENERATE_INVALID_ORIGIN)
.post(RequestBody.create(cstgEnvelope.toString(), HttpClient.JSON));

final String encryptedResponse = HttpClient.execute(requestBuilder.build(), HttpClient.HttpMethod.POST);
final byte[] decryptedResponse = decrypt(base64ToByteArray(encryptedResponse), sharedSecret);

return Mapper.OBJECT_MAPPER.readTree(decryptedResponse);
}

private static KeyPair generateKeyPair() {
final KeyPairGenerator keyPairGenerator;
try {
keyPairGenerator = KeyPairGenerator.getInstance("EC");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
final ECGenParameterSpec ecParameterSpec = new ECGenParameterSpec("secp256r1");
try {
keyPairGenerator.initialize(ecParameterSpec);
} catch (InvalidAlgorithmParameterException e) {
throw new RuntimeException(e);
}
return keyPairGenerator.genKeyPair();
}

Copy link

Choose a reason for hiding this comment

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

As @gmsdelmundo mentioned, any way to share this code with Operator (test) code?

private static SecretKey generateSharedSecret(PublicKey serverPublicKey, KeyPair clientKeypair) {
try {
final KeyAgreement ka = KeyAgreement.getInstance("ECDH");
ka.init(clientKeypair.getPrivate());
ka.doPhase(serverPublicKey, true);
return new SecretKeySpec(ka.generateSecret(), "AES");
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException(e);
}
}

private static JsonObject createCstgEnvelope(String request, String subscriptionId, PublicKey clientPublicKey, SecretKey sharedSecret) {
final long now = Clock.systemUTC().millis();

final byte[] iv = new byte[IV_BYTES];
SECURE_RANDOM.nextBytes(iv);

final JsonArray aad = new JsonArray();
aad.add(now);

final byte[] payload = encrypt(request.getBytes(StandardCharsets.UTF_8),
iv,
aad.toString().getBytes(StandardCharsets.UTF_8),
sharedSecret);

final JsonObject body = new JsonObject();

body.addProperty("payload", byteArrayToBase64(payload));
body.addProperty("iv", byteArrayToBase64(iv));
body.addProperty("public_key", byteArrayToBase64(clientPublicKey.getEncoded()));
body.addProperty("timestamp", now);
body.addProperty("subscription_id", subscriptionId);

return body;
}

private static byte[] encrypt(byte[] plaintext, byte[] iv, byte[] aad, SecretKey key) {
final Cipher cipher;
try {
cipher = Cipher.getInstance("AES/GCM/NoPadding");
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new RuntimeException(e);
}
final GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(AUTHENTICATION_TAG_LENGTH_BITS, iv);
try {
cipher.init(Cipher.ENCRYPT_MODE, key, gcmParameterSpec);
} catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
throw new RuntimeException(e);
}

cipher.updateAAD(aad);

try {
return cipher.doFinal(plaintext);
} catch (IllegalBlockSizeException | BadPaddingException e) {
throw new RuntimeException(e);
}
}

public byte[] decrypt(byte[] ciphertext, SecretKey key) {
final Cipher cipher;
try {
cipher = Cipher.getInstance("AES/GCM/NoPadding");
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new RuntimeException(e);
}

final GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(AUTHENTICATION_TAG_LENGTH_BITS, ciphertext, 0, IV_BYTES);
try {
cipher.init(Cipher.DECRYPT_MODE, key, gcmParameterSpec);
} catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
throw new RuntimeException(e);
}

try {
return cipher.doFinal(ciphertext, IV_BYTES, ciphertext.length - IV_BYTES);
} catch (IllegalBlockSizeException | BadPaddingException e) {
throw new RuntimeException(e);
}
}

public TokenRefreshResponse v2TokenRefresh(IdentityTokens identity) {
return publisherClient.refreshToken(identity);
}
Expand Down Expand Up @@ -242,7 +380,7 @@ private V2Envelope v2CreateEnvelope(String payload, String secret) throws Except

int nonceLength = 8;
byte[] nonce = new byte[nonceLength];
new SecureRandom().nextBytes(nonce);
SECURE_RANDOM.nextBytes(nonce);

byte[] payloadBytes = payload.getBytes(StandardCharsets.UTF_8);

Expand Down Expand Up @@ -283,11 +421,11 @@ private byte[] encryptGDM(byte[] b, byte[] secretBytes) throws Exception {
return (byte[]) encryptGDMMethod.invoke(clazz, b, null, secretBytes);
}

private byte[] base64ToByteArray(String str) {
private static byte[] base64ToByteArray(String str) {
return Base64.getDecoder().decode(str);
}

private String byteArrayToBase64(byte[] b) {
private static String byteArrayToBase64(byte[] b) {
return Base64.getEncoder().encodeToString(b);
}
}
18 changes: 18 additions & 0 deletions src/test/java/suite/operator/TestData.java
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,24 @@ public static Set<Arguments> identityMapBatchEmailArgsBadPolicy() {
return args;
}

public static Set<Arguments> clientSideTokenGenerateArgs() {
final Set<List<String>> inputs = new HashSet<>();

inputs.add(List.of("email hash", "{\"email_hash\":\"eVvLS/Vg+YZ6+z3i0NOpSXYyQAfEXqCZ7BTpAjFUBUc=\"}"));

if (PHONE_SUPPORT) {
inputs.add(List.of("phone hash", "{\"phone_hash\":\"eVvLS/Vg+YZ6+z3i0NOpSXYyQAfEXqCZ7BTpAjFUBUc=\"}"));
}

final Set<Arguments> args = new HashSet<>();
for (var operator : getPublicOperators()) {
for (var input : inputs) {
args.add(Arguments.of(input.get(0), operator, operator.getName(), input.get(1)));
}
}
return args;
}

public static Set<Arguments> tokenGenerateEmailArgsBadPolicy() {
Set<Operator> operators = getPublicOperators();
Set<List<String>> inputs = Set.of(
Expand Down
34 changes: 34 additions & 0 deletions src/test/java/suite/operator/V2ApiOperatorPublicOnlyTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,40 @@ public void testV2IdentityMapBadPolicy(String label, Operator operator, String o
assertThat(response.at("/status").asText()).isEqualTo("success");
}

@ParameterizedTest(name = "/v2/token/client-generate - {0} - {2}")
@MethodSource({
"suite.operator.TestData#clientSideTokenGenerateArgs",
})
public void testV2ClientSideTokenGenerate(String label, Operator operator, String operatorName, String payload) throws Exception {
if (isPrivateOperator(operator)) {
return;
}

final JsonNode response = operator.v2ClientSideTokenGenerate(payload, true);

assertThat(response.get("status").asText()).isEqualTo("success");
}

@ParameterizedTest(name = "/v2/token/client-generate - INVALID ORIGIN - {0} - {2}")
@MethodSource({
"suite.operator.TestData#clientSideTokenGenerateArgs",
})
public void testV2ClientSideTokenGenerateInvalidOrigin(String label, Operator operator, String operatorName, String payload) {
if (isPrivateOperator(operator)) {
return;
}

HttpClient.HttpException e = assertThrows(HttpClient.HttpException.class, () -> {
JsonNode response = operator.v2ClientSideTokenGenerate(payload, false);
});

assertAll(
() -> assertThat(e.getCode()).isEqualTo(403),
() -> assertThat(e.getResponseJson().get("status").asText()).isEqualTo("invalid_http_origin"),
() -> assertThat(e.getResponseJson().get("message").asText()).isEqualTo("unexpected http origin")
);
}

private boolean isPrivateOperator(Operator operator) {
return operator.getType() == Operator.Type.PRIVATE;
}
Expand Down