mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-21 06:48:07 +01:00
Add DeviceCheck API for iOS Testflight backup enablement
This commit is contained in:
committed by
ravi-signal
parent
fb6c4eca34
commit
2c163352c3
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.dropwizard.auth.AuthValueFactoryProvider;
|
||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||
import jakarta.ws.rs.client.Entity;
|
||||
import jakarta.ws.rs.client.WebTarget;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.glassfish.jersey.server.ServerProperties;
|
||||
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.ChallengeNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckKeyIdNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckVerificationFailedException;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.DuplicatePublicKeyException;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.RequestReuseException;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.TooManyKeysException;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.textsecuregcm.util.TestClock;
|
||||
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
|
||||
|
||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||
class DeviceCheckControllerTest {
|
||||
|
||||
private final static Duration REDEMPTION_DURATION = Duration.ofDays(5);
|
||||
private final static long REDEMPTION_LEVEL = 201L;
|
||||
private final static BackupAuthManager backupAuthManager = mock(BackupAuthManager.class);
|
||||
private final static AppleDeviceCheckManager appleDeviceCheckManager = mock(AppleDeviceCheckManager.class);
|
||||
private final static RateLimiters rateLimiters = mock(RateLimiters.class);
|
||||
private final static Clock clock = TestClock.pinned(Instant.EPOCH);
|
||||
|
||||
private static final ResourceExtension resources = ResourceExtension.builder()
|
||||
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
|
||||
.addProvider(AuthHelper.getAuthFilter())
|
||||
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))
|
||||
.addProvider(new CompletionExceptionMapper())
|
||||
.addResource(new GrpcStatusRuntimeExceptionMapper())
|
||||
.addProvider(new RateLimitExceededExceptionMapper())
|
||||
.setMapper(SystemMapper.jsonMapper())
|
||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||
.addResource(new DeviceCheckController(clock, backupAuthManager, appleDeviceCheckManager, rateLimiters,
|
||||
REDEMPTION_LEVEL, REDEMPTION_DURATION))
|
||||
.build();
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
reset(backupAuthManager);
|
||||
reset(appleDeviceCheckManager);
|
||||
reset(rateLimiters);
|
||||
when(rateLimiters.forDescriptor(any())).thenReturn(mock(RateLimiter.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(AppleDeviceCheckManager.ChallengeType.class)
|
||||
public void createChallenge(AppleDeviceCheckManager.ChallengeType challengeType) throws RateLimitExceededException {
|
||||
when(appleDeviceCheckManager.createChallenge(eq(challengeType), any()))
|
||||
.thenReturn("TestChallenge");
|
||||
|
||||
WebTarget target = resources.getJerseyTest()
|
||||
.target("v1/devicecheck/%s".formatted(switch (challengeType) {
|
||||
case ATTEST -> "attest";
|
||||
case ASSERT_BACKUP_REDEMPTION -> "assert";
|
||||
}));
|
||||
if (challengeType == AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION) {
|
||||
target = target.queryParam("action", "backup");
|
||||
}
|
||||
final DeviceCheckController.ChallengeResponse challenge = target
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.get(DeviceCheckController.ChallengeResponse.class);
|
||||
|
||||
assertThat(challenge.challenge()).isEqualTo("TestChallenge");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
public void createChallengeRateLimited(boolean create) throws RateLimitExceededException {
|
||||
final RateLimiter rateLimiter = mock(RateLimiter.class);
|
||||
when(rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)).thenReturn(rateLimiter);
|
||||
doThrow(new RateLimitExceededException(Duration.ofSeconds(1L))).when(rateLimiter).validate(any(UUID.class));
|
||||
|
||||
final String path = "v1/devicecheck/%s".formatted(create ? "assert" : "attest");
|
||||
|
||||
final Response response = resources.getJerseyTest()
|
||||
.target(path)
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.get();
|
||||
assertThat(response.getStatus()).isEqualTo(429);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedAttestValidation()
|
||||
throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {
|
||||
final String errorMessage = "a test error message";
|
||||
final byte[] keyId = TestRandomUtil.nextBytes(16);
|
||||
final byte[] attestation = TestRandomUtil.nextBytes(32);
|
||||
|
||||
doThrow(new DeviceCheckVerificationFailedException(errorMessage)).when(appleDeviceCheckManager)
|
||||
.registerAttestation(any(), eq(keyId), eq(attestation));
|
||||
final Response response = resources.getJerseyTest()
|
||||
.target("v1/devicecheck/attest")
|
||||
.queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId))
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.entity(attestation, MediaType.APPLICATION_OCTET_STREAM));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(response.getMediaType()).isEqualTo(MediaType.APPLICATION_JSON_TYPE);
|
||||
assertThat(response.readEntity(Map.class).get("message")).isEqualTo(errorMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedAssertValidation()
|
||||
throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, DeviceCheckKeyIdNotFoundException, RequestReuseException {
|
||||
final String errorMessage = "a test error message";
|
||||
final byte[] keyId = TestRandomUtil.nextBytes(16);
|
||||
final byte[] assertion = TestRandomUtil.nextBytes(32);
|
||||
final String challenge = "embeddedChallenge";
|
||||
final String request = """
|
||||
{"action": "backup", "challenge": "embeddedChallenge"}
|
||||
""";
|
||||
|
||||
doThrow(new DeviceCheckVerificationFailedException(errorMessage)).when(appleDeviceCheckManager)
|
||||
.validateAssert(any(), eq(keyId), eq(AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION), eq(challenge), eq(request.getBytes()), eq(assertion));
|
||||
|
||||
final Response response = resources.getJerseyTest()
|
||||
.target("v1/devicecheck/assert")
|
||||
.queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId))
|
||||
.queryParam("request", Base64.getUrlEncoder().encodeToString(request.getBytes(StandardCharsets.UTF_8)))
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.post(Entity.entity(assertion, MediaType.APPLICATION_OCTET_STREAM));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(response.getMediaType()).isEqualTo(MediaType.APPLICATION_JSON_TYPE);
|
||||
assertThat(response.readEntity(Map.class).get("message")).isEqualTo(errorMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registerKey()
|
||||
throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {
|
||||
final byte[] keyId = TestRandomUtil.nextBytes(16);
|
||||
final byte[] attestation = TestRandomUtil.nextBytes(32);
|
||||
final Response response = resources.getJerseyTest()
|
||||
.target("v1/devicecheck/attest")
|
||||
.queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId))
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.entity(attestation, MediaType.APPLICATION_OCTET_STREAM));
|
||||
assertThat(response.getStatus()).isEqualTo(204);
|
||||
verify(appleDeviceCheckManager, times(1))
|
||||
.registerAttestation(any(), eq(keyId), eq(attestation));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkAssertion()
|
||||
throws DeviceCheckKeyIdNotFoundException, DeviceCheckVerificationFailedException, ChallengeNotFoundException, RequestReuseException {
|
||||
final byte[] keyId = TestRandomUtil.nextBytes(16);
|
||||
final byte[] assertion = TestRandomUtil.nextBytes(32);
|
||||
final String challenge = "embeddedChallenge";
|
||||
final String request = """
|
||||
{"action": "backup", "challenge": "embeddedChallenge"}
|
||||
""";
|
||||
|
||||
when(backupAuthManager.extendBackupVoucher(any(), eq(new Account.BackupVoucher(
|
||||
REDEMPTION_LEVEL,
|
||||
clock.instant().plus(REDEMPTION_DURATION)))))
|
||||
.thenReturn(CompletableFuture.completedFuture(null));
|
||||
|
||||
final Response response = resources.getJerseyTest()
|
||||
.target("v1/devicecheck/assert")
|
||||
.queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId))
|
||||
.queryParam("request", Base64.getUrlEncoder().encodeToString(request.getBytes(StandardCharsets.UTF_8)))
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.post(Entity.entity(assertion, MediaType.APPLICATION_OCTET_STREAM));
|
||||
assertThat(response.getStatus()).isEqualTo(204);
|
||||
verify(appleDeviceCheckManager, times(1)).validateAssert(
|
||||
any(),
|
||||
eq(keyId),
|
||||
eq(AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION),
|
||||
eq(challenge),
|
||||
eq(request.getBytes(StandardCharsets.UTF_8)),
|
||||
eq(assertion));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import java.util.List;
|
||||
import org.whispersystems.textsecuregcm.backup.BackupsDb;
|
||||
import org.whispersystems.textsecuregcm.scheduler.JobScheduler;
|
||||
import org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceChecks;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
|
||||
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
|
||||
@@ -398,6 +399,28 @@ public final class DynamoDbExtensionSchema {
|
||||
.attributeName(VerificationSessions.KEY_KEY)
|
||||
.attributeType(ScalarAttributeType.S)
|
||||
.build()),
|
||||
List.of(), List.of()),
|
||||
|
||||
APPLE_DEVICE_CHECKS("apple_device_check",
|
||||
AppleDeviceChecks.KEY_ACCOUNT_UUID,
|
||||
AppleDeviceChecks.KEY_PUBLIC_KEY_ID,
|
||||
List.of(AttributeDefinition.builder()
|
||||
.attributeName(AppleDeviceChecks.KEY_ACCOUNT_UUID)
|
||||
.attributeType(ScalarAttributeType.B)
|
||||
.build(),
|
||||
AttributeDefinition.builder()
|
||||
.attributeName(AppleDeviceChecks.KEY_PUBLIC_KEY_ID)
|
||||
.attributeType(ScalarAttributeType.B)
|
||||
.build()),
|
||||
List.of(), List.of()),
|
||||
|
||||
APPLE_DEVICE_CHECKS_KEY_CONSTRAINT("apple_device_check_key_constraint",
|
||||
AppleDeviceChecks.KEY_PUBLIC_KEY,
|
||||
null,
|
||||
List.of(AttributeDefinition.builder()
|
||||
.attributeName(AppleDeviceChecks.KEY_PUBLIC_KEY)
|
||||
.attributeType(ScalarAttributeType.B)
|
||||
.build()),
|
||||
List.of(), List.of());
|
||||
|
||||
private final String tableName;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.storage.devicecheck;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.webauthn4j.appattest.DeviceCheckManager;
|
||||
import com.webauthn4j.appattest.authenticator.DCAppleDevice;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.IntStream;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamoDbExtension;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;
|
||||
import org.whispersystems.textsecuregcm.util.TestClock;
|
||||
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
class AppleDeviceCheckManagerTest {
|
||||
|
||||
private static final UUID ACI = UUID.randomUUID();
|
||||
|
||||
@RegisterExtension
|
||||
static final RedisClusterExtension CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
||||
|
||||
@RegisterExtension
|
||||
static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(
|
||||
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS,
|
||||
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT);
|
||||
|
||||
private final TestClock clock = TestClock.pinned(Instant.now());
|
||||
private AppleDeviceChecks appleDeviceChecks;
|
||||
private Account account;
|
||||
private AppleDeviceCheckManager appleDeviceCheckManager;
|
||||
|
||||
@BeforeEach
|
||||
void setupDeviceChecks() {
|
||||
clock.pin(Instant.now());
|
||||
account = mock(Account.class);
|
||||
when(account.getUuid()).thenReturn(ACI);
|
||||
|
||||
final DeviceCheckManager deviceCheckManager = DeviceCheckTestUtil.appleDeviceCheckManager();
|
||||
appleDeviceChecks = new AppleDeviceChecks(DYNAMO_DB_EXTENSION.getDynamoDbClient(),
|
||||
DeviceCheckManager.createObjectConverter(),
|
||||
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS.tableName(),
|
||||
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT.tableName());
|
||||
appleDeviceCheckManager = new AppleDeviceCheckManager(appleDeviceChecks, CLUSTER_EXTENSION.getRedisCluster(),
|
||||
deviceCheckManager, DeviceCheckTestUtil.SAMPLE_TEAM_ID, DeviceCheckTestUtil.SAMPLE_BUNDLE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingChallengeAttest() {
|
||||
assertThatExceptionOfType(ChallengeNotFoundException.class).isThrownBy(() ->
|
||||
appleDeviceCheckManager.registerAttestation(account,
|
||||
DeviceCheckTestUtil.SAMPLE_KEY_ID,
|
||||
DeviceCheckTestUtil.SAMPLE_ATTESTATION));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingChallengeAssert() {
|
||||
assertThatExceptionOfType(ChallengeNotFoundException.class).isThrownBy(() ->
|
||||
appleDeviceCheckManager.validateAssert(account,
|
||||
DeviceCheckTestUtil.SAMPLE_KEY_ID,
|
||||
AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE,
|
||||
DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8),
|
||||
DeviceCheckTestUtil.SAMPLE_ASSERTION));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tooManyKeys() throws DuplicatePublicKeyException {
|
||||
final DCAppleDevice dcAppleDevice = DeviceCheckTestUtil.sampleDevice();
|
||||
|
||||
// Fill the table with a bunch of keyIds
|
||||
final List<byte[]> keyIds = IntStream
|
||||
.range(0, AppleDeviceCheckManager.MAX_DEVICE_KEYS - 1)
|
||||
.mapToObj(i -> TestRandomUtil.nextBytes(16)).toList();
|
||||
for (byte[] keyId : keyIds) {
|
||||
appleDeviceChecks.storeAttestation(account, keyId, dcAppleDevice);
|
||||
}
|
||||
|
||||
// We're allowed 1 more key for this account
|
||||
assertThatNoException().isThrownBy(() -> registerAttestation(account));
|
||||
|
||||
// a new key should be rejected
|
||||
assertThatExceptionOfType(TooManyKeysException.class).isThrownBy(() ->
|
||||
appleDeviceCheckManager.registerAttestation(account,
|
||||
TestRandomUtil.nextBytes(16),
|
||||
DeviceCheckTestUtil.SAMPLE_ATTESTATION));
|
||||
|
||||
// we can however accept an existing key
|
||||
assertThatNoException().isThrownBy(() -> registerAttestation(account, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void duplicateKeys() {
|
||||
assertThatNoException().isThrownBy(() -> registerAttestation(account));
|
||||
final Account duplicator = mock(Account.class);
|
||||
when(duplicator.getUuid()).thenReturn(UUID.randomUUID());
|
||||
|
||||
// Both accounts use the attestation keyId, the second registration should fail
|
||||
assertThatExceptionOfType(DuplicatePublicKeyException.class)
|
||||
.isThrownBy(() -> registerAttestation(duplicator));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fetchingChallengeRefreshesTtl() throws RateLimitExceededException {
|
||||
final String challenge =
|
||||
appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account);
|
||||
final String redisKey = AppleDeviceCheckManager.challengeKey(AppleDeviceCheckManager.ChallengeType.ATTEST,
|
||||
account.getUuid());
|
||||
|
||||
final String storedChallenge = CLUSTER_EXTENSION.getRedisCluster()
|
||||
.withCluster(cluster -> cluster.sync().get(redisKey));
|
||||
assertThat(storedChallenge).isEqualTo(challenge);
|
||||
|
||||
final Supplier<Long> ttl = () -> CLUSTER_EXTENSION.getRedisCluster()
|
||||
.withCluster(cluster -> cluster.sync().ttl(redisKey));
|
||||
|
||||
// Wait until the TTL visibly changes (~1sec)
|
||||
while (ttl.get() >= AppleDeviceCheckManager.CHALLENGE_TTL.toSeconds()) {
|
||||
Util.sleep(100);
|
||||
}
|
||||
|
||||
// Our TTL fetch needs to happen before the TTL ticks down to make sure the TTL was actually refreshed. So it must
|
||||
// happen within 1 second. This should be plenty of time, but allow a few retries in case we get very unlucky.
|
||||
final boolean ttlRefreshed = IntStream.range(0, 5)
|
||||
.mapToObj(i -> {
|
||||
assertThatNoException()
|
||||
.isThrownBy(
|
||||
() -> appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account));
|
||||
return ttl.get() == AppleDeviceCheckManager.CHALLENGE_TTL.toSeconds();
|
||||
})
|
||||
.anyMatch(detectedRefresh -> detectedRefresh);
|
||||
assertThat(ttlRefreshed).isTrue();
|
||||
|
||||
assertThat(appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account))
|
||||
.isEqualTo(challenge);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateAssertion() {
|
||||
assertThatNoException().isThrownBy(() -> registerAttestation(account));
|
||||
|
||||
// The sign counter should be 0 since we've made no attests
|
||||
assertThat(appleDeviceChecks.lookup(account, DeviceCheckTestUtil.SAMPLE_KEY_ID).get().getCounter())
|
||||
.isEqualTo(0L);
|
||||
|
||||
// Rig redis to return our sample challenge for the assert
|
||||
final String assertChallengeKey = AppleDeviceCheckManager.challengeKey(
|
||||
AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION,
|
||||
account.getUuid());
|
||||
CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster ->
|
||||
cluster.sync().set(assertChallengeKey, DeviceCheckTestUtil.SAMPLE_CHALLENGE));
|
||||
|
||||
assertThatNoException().isThrownBy(() ->
|
||||
appleDeviceCheckManager.validateAssert(
|
||||
account,
|
||||
DeviceCheckTestUtil.SAMPLE_KEY_ID,
|
||||
AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE,
|
||||
DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8),
|
||||
DeviceCheckTestUtil.SAMPLE_ASSERTION));
|
||||
|
||||
CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster ->
|
||||
assertThat(cluster.sync().get(assertChallengeKey)).isNull());
|
||||
|
||||
// the sign counter should now be 1 (read from our sample assert)
|
||||
assertThat(appleDeviceChecks.lookup(account, DeviceCheckTestUtil.SAMPLE_KEY_ID).get().getCounter())
|
||||
.isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void assertionCounterMovesBackwards() {
|
||||
assertThatNoException().isThrownBy(() -> registerAttestation(account));
|
||||
|
||||
// force set the sign counter for our keyId to be larger than the sign counter in our sample assert (1)
|
||||
appleDeviceChecks.updateCounter(account, DeviceCheckTestUtil.SAMPLE_KEY_ID, 2);
|
||||
|
||||
// Rig redis to return our sample challenge for the assert
|
||||
CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster -> cluster.sync().set(
|
||||
AppleDeviceCheckManager.challengeKey(
|
||||
AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION,
|
||||
account.getUuid()),
|
||||
DeviceCheckTestUtil.SAMPLE_CHALLENGE));
|
||||
|
||||
assertThatExceptionOfType(RequestReuseException.class).isThrownBy(() ->
|
||||
appleDeviceCheckManager.validateAssert(
|
||||
account,
|
||||
DeviceCheckTestUtil.SAMPLE_KEY_ID,
|
||||
AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE,
|
||||
DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8),
|
||||
DeviceCheckTestUtil.SAMPLE_ASSERTION));
|
||||
}
|
||||
|
||||
private void registerAttestation(final Account account)
|
||||
throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {
|
||||
registerAttestation(account, true);
|
||||
}
|
||||
|
||||
private void registerAttestation(final Account account, boolean assertChallengeRemoved)
|
||||
throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {
|
||||
final String attestChallengeKey = AppleDeviceCheckManager.challengeKey(
|
||||
AppleDeviceCheckManager.ChallengeType.ATTEST,
|
||||
account.getUuid());
|
||||
CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster ->
|
||||
cluster.sync().set(attestChallengeKey, DeviceCheckTestUtil.SAMPLE_CHALLENGE));
|
||||
try (MockedStatic<Instant> mocked = mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) {
|
||||
mocked.when(Instant::now).thenReturn(DeviceCheckTestUtil.SAMPLE_TIME);
|
||||
appleDeviceCheckManager.registerAttestation(account,
|
||||
DeviceCheckTestUtil.SAMPLE_KEY_ID,
|
||||
DeviceCheckTestUtil.SAMPLE_ATTESTATION);
|
||||
}
|
||||
if (assertChallengeRemoved) {
|
||||
CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster -> {
|
||||
// should be deleted once the attestation is registered
|
||||
assertThat(cluster.sync().get(attestChallengeKey)).isNull();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.storage.devicecheck;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.webauthn4j.appattest.DeviceCheckManager;
|
||||
import com.webauthn4j.appattest.authenticator.DCAppleDevice;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.IntStream;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamoDbExtension;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;
|
||||
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
|
||||
|
||||
|
||||
class AppleDeviceChecksTest {
|
||||
|
||||
private static final UUID ACI = UUID.randomUUID();
|
||||
|
||||
@RegisterExtension
|
||||
static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(
|
||||
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS,
|
||||
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT);
|
||||
|
||||
private AppleDeviceChecks deviceChecks;
|
||||
private Account account;
|
||||
|
||||
@BeforeEach
|
||||
void setupDeviceChecks() {
|
||||
account = mock(Account.class);
|
||||
when(account.getUuid()).thenReturn(ACI);
|
||||
deviceChecks = new AppleDeviceChecks(DYNAMO_DB_EXTENSION.getDynamoDbClient(),
|
||||
DeviceCheckManager.createObjectConverter(),
|
||||
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS.tableName(),
|
||||
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT.tableName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerde() throws DuplicatePublicKeyException {
|
||||
final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();
|
||||
final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId();
|
||||
assertThat(deviceChecks.storeAttestation(account, keyId, appleDevice)).isTrue();
|
||||
|
||||
assertThat(deviceChecks.keyIds(account)).containsExactly(keyId);
|
||||
|
||||
final DCAppleDevice deserialized = deviceChecks.lookup(account, keyId).orElseThrow();
|
||||
assertThat(deserialized.getClass()).isEqualTo(appleDevice.getClass());
|
||||
assertThat(deserialized.getAttestationStatement().getFormat())
|
||||
.isEqualTo(appleDevice.getAttestationStatement().getFormat());
|
||||
assertThat(deserialized.getAttestationStatement().getClass())
|
||||
.isEqualTo(appleDevice.getAttestationStatement().getClass());
|
||||
assertThat(deserialized.getAttestedCredentialData().getCredentialId())
|
||||
.isEqualTo(appleDevice.getAttestedCredentialData().getCredentialId());
|
||||
assertThat(deserialized.getAttestedCredentialData().getCOSEKey())
|
||||
.isEqualTo(appleDevice.getAttestedCredentialData().getCOSEKey());
|
||||
assertThat(deserialized.getAttestedCredentialData().getAaguid())
|
||||
.isEqualTo(appleDevice.getAttestedCredentialData().getAaguid());
|
||||
assertThat(deserialized.getAuthenticatorExtensions().getExtensions())
|
||||
.containsExactlyEntriesOf(appleDevice.getAuthenticatorExtensions().getExtensions());
|
||||
assertThat(deserialized.getCounter())
|
||||
.isEqualTo(appleDevice.getCounter());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void duplicateKeys() throws DuplicatePublicKeyException {
|
||||
final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();
|
||||
final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId();
|
||||
|
||||
final Account dupliateAccount = mock(Account.class);
|
||||
when(dupliateAccount.getUuid()).thenReturn(UUID.randomUUID());
|
||||
|
||||
deviceChecks.storeAttestation(account, keyId, appleDevice);
|
||||
|
||||
// Storing same key with a different account fails
|
||||
assertThatExceptionOfType(DuplicatePublicKeyException.class)
|
||||
.isThrownBy(() -> deviceChecks.storeAttestation(dupliateAccount, keyId, appleDevice));
|
||||
|
||||
// Storing the same key with the same account is fine
|
||||
assertThatNoException().isThrownBy(() -> deviceChecks.storeAttestation(account, keyId, appleDevice));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multipleKeys() throws DuplicatePublicKeyException {
|
||||
final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();
|
||||
|
||||
final List<byte[]> keyIds = IntStream.range(0, 10).mapToObj(i -> TestRandomUtil.nextBytes(16)).toList();
|
||||
|
||||
for (byte[] keyId : keyIds) {
|
||||
// The keyId should typically match the device attestation, but we don't check that at this layer
|
||||
assertThat(deviceChecks.storeAttestation(account, keyId, appleDevice)).isTrue();
|
||||
assertThat(deviceChecks.lookup(account, keyId)).isNotEmpty();
|
||||
}
|
||||
final List<byte[]> actual = deviceChecks.keyIds(account);
|
||||
|
||||
assertThat(actual).containsExactlyInAnyOrderElementsOf(keyIds);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateCounter() throws DuplicatePublicKeyException {
|
||||
final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();
|
||||
final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId();
|
||||
|
||||
assertThat(appleDevice.getCounter()).isEqualTo(0L);
|
||||
deviceChecks.storeAttestation(account, keyId, appleDevice);
|
||||
assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(0L);
|
||||
assertThat(deviceChecks.updateCounter(account, keyId, 2)).isTrue();
|
||||
assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(2L);
|
||||
|
||||
// Should not update since the counter is stale
|
||||
assertThat(deviceChecks.updateCounter(account, keyId, 1)).isFalse();
|
||||
assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(2L);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.storage.devicecheck;
|
||||
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import com.webauthn4j.appattest.DeviceCheckManager;
|
||||
import com.webauthn4j.appattest.authenticator.DCAppleDevice;
|
||||
import com.webauthn4j.appattest.authenticator.DCAppleDeviceImpl;
|
||||
import com.webauthn4j.appattest.data.DCAttestationData;
|
||||
import com.webauthn4j.appattest.data.DCAttestationParameters;
|
||||
import com.webauthn4j.appattest.data.DCAttestationRequest;
|
||||
import com.webauthn4j.appattest.server.DCServerProperty;
|
||||
import com.webauthn4j.data.attestation.AttestationObject;
|
||||
import com.webauthn4j.data.client.challenge.DefaultChallenge;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
public class DeviceCheckTestUtil {
|
||||
|
||||
// https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem
|
||||
private static final String APPLE_APP_ATTEST_ROOT = """
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
|
||||
JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
|
||||
QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
|
||||
Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
|
||||
biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
|
||||
bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
|
||||
NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
|
||||
Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
|
||||
MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
|
||||
CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
|
||||
53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
|
||||
oyFraWVIyd/dganmrduC1bmTBGwD
|
||||
-----END CERTIFICATE-----
|
||||
""";
|
||||
|
||||
// Sample attestation from apple docs:
|
||||
// https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide#Example-setup
|
||||
final static String APPLE_SAMPLE_TEAM_ID = "0352187391";
|
||||
final static String APPLE_SAMPLE_BUNDLE_ID = "com.apple.example_app_attest";
|
||||
final static String APPLE_SAMPLE_CHALLENGE = "test_server_challenge";
|
||||
final static byte[] APPLE_SAMPLE_KEY_ID = Base64.getDecoder().decode("bSrEhF8TIzIvWSPwvZ0i2+UOBre4ASH84rK15m6emNY=");
|
||||
final static byte[] APPLE_SAMPLE_ATTESTATION = loadBinaryResource("apple-sample-attestation");
|
||||
// Leaf certificate in apple sample attestation expires 2024-04-20
|
||||
final static Instant APPLE_SAMPLE_TIME = Instant.parse("2024-04-19T00:00:00.00Z");
|
||||
|
||||
// Sample attestation from webauthn4j:
|
||||
// https://github.com/webauthn4j/webauthn4j/blob/6b7a8f8edce4ab589c49ecde8740873ab96c4218/webauthn4j-appattest/src/test/java/com/webauthn4j/appattest/DeviceCheckManagerTest.java#L126
|
||||
final static String SAMPLE_TEAM_ID = "8YE23NZS57";
|
||||
final static String SAMPLE_BUNDLE_ID = "com.kayak.travel";
|
||||
final static byte[] SAMPLE_KEY_ID = Base64.getDecoder().decode("VnfqjSp0rWyyqNhrfh+9/IhLIvXuYTPAmJEVQwl4dko=");
|
||||
final static String SAMPLE_CHALLENGE = "1234567890abcdefgh"; // same challenge used for the attest and assert
|
||||
final static byte[] SAMPLE_ASSERTION = loadBinaryResource("webauthn4j-sample-assertion");
|
||||
final static byte[] SAMPLE_ATTESTATION = loadBinaryResource("webauthn4j-sample-attestation");
|
||||
// Leaf certificate in sample attestation expires 2020-09-30
|
||||
final static Instant SAMPLE_TIME = Instant.parse("2020-09-28T00:00:00Z");
|
||||
|
||||
|
||||
public static DeviceCheckManager appleDeviceCheckManager() {
|
||||
return new DeviceCheckManager(new AppleDeviceCheckTrustAnchor());
|
||||
}
|
||||
|
||||
public static DCAppleDevice sampleDevice() {
|
||||
final byte[] clientDataHash = sha256(SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8));
|
||||
return validate(SAMPLE_CHALLENGE, clientDataHash, SAMPLE_KEY_ID, SAMPLE_ATTESTATION, SAMPLE_TEAM_ID,
|
||||
SAMPLE_BUNDLE_ID, SAMPLE_TIME);
|
||||
}
|
||||
|
||||
public static DCAppleDevice appleSampleDevice() {
|
||||
// Note: the apple example provides the clientDataHash (typically the SHA256 of the challenge), NOT the challenge,
|
||||
// despite them referring to the value as a challenge
|
||||
final byte[] clientDataHash = APPLE_SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
return validate(APPLE_SAMPLE_CHALLENGE, clientDataHash, APPLE_SAMPLE_KEY_ID, APPLE_SAMPLE_ATTESTATION,
|
||||
APPLE_SAMPLE_TEAM_ID, APPLE_SAMPLE_BUNDLE_ID, APPLE_SAMPLE_TIME);
|
||||
}
|
||||
|
||||
private static DCAppleDevice validate(final String challengePlainText, final byte[] clientDataHash,
|
||||
final byte[] keyId, final byte[] attestation, final String teamId, final String bundleId, final Instant now) {
|
||||
|
||||
final DCAttestationRequest dcAttestationRequest = new DCAttestationRequest(keyId, attestation, clientDataHash);
|
||||
|
||||
final DCAttestationData dcAttestationData;
|
||||
try (final MockedStatic<Instant> instantMock = mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) {
|
||||
instantMock.when(Instant::now).thenReturn(now);
|
||||
|
||||
dcAttestationData = appleDeviceCheckManager().validate(dcAttestationRequest, new DCAttestationParameters(
|
||||
new DCServerProperty(
|
||||
teamId, bundleId,
|
||||
new DefaultChallenge(challengePlainText.getBytes(StandardCharsets.UTF_8)))));
|
||||
}
|
||||
|
||||
final AttestationObject attestationObject = dcAttestationData.getAttestationObject();
|
||||
return new DCAppleDeviceImpl(
|
||||
attestationObject.getAuthenticatorData().getAttestedCredentialData(),
|
||||
attestationObject.getAttestationStatement(),
|
||||
attestationObject.getAuthenticatorData().getSignCount(),
|
||||
attestationObject.getAuthenticatorData().getExtensions());
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] bytes) {
|
||||
final MessageDigest sha256;
|
||||
try {
|
||||
sha256 = MessageDigest.getInstance("SHA-256");
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
// All Java implementations are required to support SHA-256
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
return sha256.digest(bytes);
|
||||
}
|
||||
|
||||
private static byte[] loadBinaryResource(final String resourceName) {
|
||||
try (InputStream stream = DeviceCheckTestUtil.class.getResourceAsStream(resourceName)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalArgumentException("Resource not found: " + resourceName);
|
||||
}
|
||||
return stream.readAllBytes();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user