Add DeviceCheck API for iOS Testflight backup enablement

This commit is contained in:
Ravi Khadiwala
2024-11-13 23:37:22 -06:00
committed by ravi-signal
parent fb6c4eca34
commit 2c163352c3
29 changed files with 1877 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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