extendBackupVoucher(final Account account, final Account.BackupVoucher backupVoucher) {
+ return accountsManager.updateAsync(account, a -> {
+ final Account.BackupVoucher newPayment = backupVoucher;
+ final Account.BackupVoucher existingPayment = a.getBackupVoucher();
+ a.setBackupVoucher(merge(existingPayment, newPayment));
+ }).thenRun(Util.NOOP);
}
private static Account.BackupVoucher merge(@Nullable final Account.BackupVoucher prev,
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppleDeviceCheckConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppleDeviceCheckConfiguration.java
new file mode 100644
index 000000000..5f1b8c5a1
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppleDeviceCheckConfiguration.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.configuration;
+
+import java.time.Duration;
+
+/**
+ * Configuration for Apple DeviceCheck
+ *
+ * @param production Whether this is for production or sandbox attestations
+ * @param teamId The teamId to validate attestations against
+ * @param bundleId The bundleId to validation attestations against
+ */
+public record AppleDeviceCheckConfiguration(
+ boolean production,
+ String teamId,
+ String bundleId) {}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DeviceCheckConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DeviceCheckConfiguration.java
new file mode 100644
index 000000000..42ebdfb1f
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DeviceCheckConfiguration.java
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.configuration;
+
+import java.time.Duration;
+
+/**
+ * Configuration for Device Check operations
+ *
+ * @param backupRedemptionDuration How long to grant backup access for redemptions via device check
+ * @param backupRedemptionLevel What backup level to grant redemptions via device check
+ */
+public record DeviceCheckConfiguration(Duration backupRedemptionDuration, long backupRedemptionLevel) {}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java
index 2eb8644b6..54bfb93a8 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java
@@ -48,6 +48,8 @@ public class DynamoDbTables {
private final AccountsTableConfiguration accounts;
+ private final Table appleDeviceChecks;
+ private final Table appleDeviceCheckPublicKeys;
private final Table backups;
private final Table clientPublicKeys;
private final Table clientReleases;
@@ -74,6 +76,8 @@ public class DynamoDbTables {
public DynamoDbTables(
@JsonProperty("accounts") final AccountsTableConfiguration accounts,
+ @JsonProperty("appleDeviceChecks") final Table appleDeviceChecks,
+ @JsonProperty("appleDeviceCheckPublicKeys") final Table appleDeviceCheckPublicKeys,
@JsonProperty("backups") final Table backups,
@JsonProperty("clientPublicKeys") final Table clientPublicKeys,
@JsonProperty("clientReleases") final Table clientReleases,
@@ -99,6 +103,8 @@ public class DynamoDbTables {
@JsonProperty("verificationSessions") final Table verificationSessions) {
this.accounts = accounts;
+ this.appleDeviceChecks = appleDeviceChecks;
+ this.appleDeviceCheckPublicKeys = appleDeviceCheckPublicKeys;
this.backups = backups;
this.clientPublicKeys = clientPublicKeys;
this.clientReleases = clientReleases;
@@ -130,6 +136,18 @@ public class DynamoDbTables {
return accounts;
}
+ @NotNull
+ @Valid
+ public Table getAppleDeviceChecks() {
+ return appleDeviceChecks;
+ }
+
+ @NotNull
+ @Valid
+ public Table getAppleDeviceCheckPublicKeys() {
+ return appleDeviceCheckPublicKeys;
+ }
+
@NotNull
@Valid
public Table getBackups() {
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckController.java
new file mode 100644
index 000000000..52605edc1
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckController.java
@@ -0,0 +1,293 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.controllers;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import io.dropwizard.auth.Auth;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PUT;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.io.IOException;
+import java.time.Clock;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Locale;
+import org.glassfish.jersey.server.ManagedAsync;
+import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
+import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
+import org.whispersystems.textsecuregcm.limits.RateLimiters;
+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.util.SystemMapper;
+import org.whispersystems.websocket.auth.ReadOnly;
+
+/**
+ * Process platform device attestations.
+ *
+ * Device attestations allow clients that can prove that they are running a signed signal build on valid Apple hardware.
+ * Currently, this is only used to allow beta builds to access backup functionality, since in-app purchases are not
+ * available iOS TestFlight builds.
+ */
+@Path("/v1/devicecheck")
+@io.swagger.v3.oas.annotations.tags.Tag(name = "DeviceCheck")
+public class DeviceCheckController {
+
+ private final Clock clock;
+ private final BackupAuthManager backupAuthManager;
+ private final AppleDeviceCheckManager deviceCheckManager;
+ private final RateLimiters rateLimiters;
+ private final long backupRedemptionLevel;
+ private final Duration backupRedemptionDuration;
+
+ public DeviceCheckController(
+ final Clock clock,
+ final BackupAuthManager backupAuthManager,
+ final AppleDeviceCheckManager deviceCheckManager,
+ final RateLimiters rateLimiters,
+ final long backupRedemptionLevel,
+ final Duration backupRedemptionDuration) {
+ this.clock = clock;
+ this.backupAuthManager = backupAuthManager;
+ this.deviceCheckManager = deviceCheckManager;
+ this.backupRedemptionLevel = backupRedemptionLevel;
+ this.backupRedemptionDuration = backupRedemptionDuration;
+ this.rateLimiters = rateLimiters;
+ }
+
+ public record ChallengeResponse(
+ @Schema(description = "A challenge to use when generating attestations or assertions")
+ String challenge) {}
+
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/attest")
+ @Operation(summary = "Fetch an attest challenge", description = """
+ Retrieve a challenge to use in an attestation, which should be provided at `PUT /v1/devicecheck/attest`. To
+ produce the clientDataHash for [attestKey](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:))
+ take the SHA256 of the UTF-8 bytes of the returned challenge.
+
+ Repeat calls to retrieve a challenge may return the same challenge until it is used in a `PUT`. Callers should
+ have a single outstanding challenge at any given time.
+ """)
+ @ApiResponse(responseCode = "200", description = "The response body includes a challenge")
+ @ApiResponse(responseCode = "429", description = "Ratelimited.")
+ @ManagedAsync
+ public ChallengeResponse attestChallenge(@ReadOnly @Auth AuthenticatedDevice authenticatedDevice)
+ throws RateLimitExceededException {
+ rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)
+ .validate(authenticatedDevice.getAccount().getUuid());
+
+ return new ChallengeResponse(deviceCheckManager.createChallenge(
+ AppleDeviceCheckManager.ChallengeType.ATTEST,
+ authenticatedDevice.getAccount()));
+ }
+
+ @PUT
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ @Path("/attest")
+ @Operation(summary = "Register a keyId", description = """
+ Register a keyId with an attestation, which can be used to generate assertions from this account.
+
+ The attestation should use the SHA-256 of a challenge retrieved at `GET /v1/devicecheck/attest` as the
+ `clientDataHash`
+
+ Registration is idempotent, and you should retry network errors with the same challenge as suggested by [device
+ check](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:)#discussion),
+ as long as your challenge has not expired (410). Even if your challenge is expired, you may continue to retry with
+ your original keyId (and a fresh challenge).
+ """)
+ @ApiResponse(responseCode = "204", description = "The keyId was successfully added to the account")
+ @ApiResponse(responseCode = "410", description = "There was no challenge associated with the account. It may have expired.")
+ @ApiResponse(responseCode = "401", description = "The attestation could not be verified")
+ @ApiResponse(responseCode = "413", description = "There are too many unique keyIds associated with this account. This is an unrecoverable error.")
+ @ApiResponse(responseCode = "409", description = "The provided keyId has already been registered to a different account")
+ @ManagedAsync
+ public void attest(
+ @ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
+
+ @Valid
+ @NotNull
+ @Parameter(description = "The keyId, encoded with padded url-safe base64")
+ @QueryParam("keyId") final String keyId,
+
+ @RequestBody(description = "The attestation data, created by [attestKey](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:))")
+ @NotNull final byte[] attestation) {
+
+ try {
+ deviceCheckManager.registerAttestation(authenticatedDevice.getAccount(), parseKeyId(keyId), attestation);
+ } catch (TooManyKeysException e) {
+ throw new WebApplicationException(Response.status(413).build());
+ } catch (ChallengeNotFoundException e) {
+ throw new WebApplicationException(Response.status(410).build());
+ } catch (DeviceCheckVerificationFailedException e) {
+ throw new WebApplicationException(e.getMessage(), Response.status(401).build());
+ } catch (DuplicatePublicKeyException e) {
+ throw new WebApplicationException(Response.status(409).build());
+ }
+ }
+
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/assert")
+ @Operation(summary = "Fetch an assert challenge", description = """
+ Retrieve a challenge to use in an attestation, which must be provided at `POST /v1/devicecheck/assert`. To produce
+ the `clientDataHash` for [generateAssertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:)),
+ construct the request you intend to `POST` and include the returned challenge as the "challenge"
+ field. Serialize the request as JSON and take the SHA256 of the request, as described [here](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity#Assert-your-apps-validity-as-necessary).
+ Note that the JSON body provided to the PUT must exactly match the input to the `clientDataHash` (field order,
+ whitespace, etc matters)
+
+ Repeat calls to retrieve a challenge may return the same challenge until it is used in a `POST`. Callers should
+ attempt to only have a single outstanding challenge at any given time.
+ """)
+ @ApiResponse(responseCode = "200", description = "The response body includes a challenge")
+ @ApiResponse(responseCode = "429", description = "Ratelimited.")
+ @ManagedAsync
+ public ChallengeResponse assertChallenge(
+ @ReadOnly @Auth AuthenticatedDevice authenticatedDevice,
+
+ @Parameter(schema = @Schema(description = "The type of action you will make an assertion for",
+ allowableValues = {"backup"},
+ implementation = String.class))
+ @QueryParam("action") Action action) throws RateLimitExceededException {
+ rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)
+ .validate(authenticatedDevice.getAccount().getUuid());
+ return new ChallengeResponse(
+ deviceCheckManager.createChallenge(toChallengeType(action),
+ authenticatedDevice.getAccount()));
+ }
+
+ @POST
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ @Path("/assert")
+ @Operation(summary = "Perform an attested action", description = """
+ Specify some action to take on the account via the request field. The request must exactly match the request you
+ provide when [generating the assertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:)).
+ The request must include a challenge previously retrieved from `GET /v1/devicecheck/assert`.
+
+ Each assertion increments the counter associated with the client's device key. This method enforces that no
+ assertion with a counter lower than a counter we've already seen is allowed to execute. If a client issues
+ multiple requests concurrently, or if they retry a request that had an indeterminate outcome, it's possible that
+ the request will not be accepted because the server has already stored the updated counter. In this case the
+ request may return 401, and the client should generate a fresh assert for the request.
+ """)
+ @ApiResponse(responseCode = "204", description = "The assertion was valid and the corresponding action was executed")
+ @ApiResponse(responseCode = "404", description = "The provided keyId was not found")
+ @ApiResponse(responseCode = "410", description = "There was no challenge associated with the account. It may have expired.")
+ @ApiResponse(responseCode = "401", description = "The assertion could not be verified")
+ @ManagedAsync
+ public void assertion(
+ @ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
+
+ @Valid
+ @NotNull
+ @Parameter(description = "The keyId, encoded with padded url-safe base64")
+ @QueryParam("keyId") final String keyId,
+
+ @Valid
+ @NotNull
+ @Parameter(description = """
+ The asserted JSON request data, encoded as a string in padded url-safe base64. This must exactly match the
+ request you use when generating the assertion (including field ordering, whitespace, etc).
+ """,
+ schema = @Schema(implementation = AssertionRequest.class))
+ @QueryParam("request") final DeviceCheckController.AssertionRequestWrapper request,
+
+ @RequestBody(description = "The assertion created by [generateAssertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:))")
+ @NotNull final byte[] assertion) {
+
+ try {
+ deviceCheckManager.validateAssert(
+ authenticatedDevice.getAccount(),
+ parseKeyId(keyId),
+ toChallengeType(request.assertionRequest().action()),
+ request.assertionRequest().challenge(),
+ request.rawJson(),
+ assertion);
+ } catch (ChallengeNotFoundException e) {
+ throw new WebApplicationException(Response.status(410).build());
+ } catch (DeviceCheckVerificationFailedException e) {
+ throw new WebApplicationException(e.getMessage(), Response.status(401).build());
+ } catch (DeviceCheckKeyIdNotFoundException | RequestReuseException e) {
+ throw new WebApplicationException(Response.status(404).build());
+ }
+
+ // The request assertion was validated, execute it
+ switch (request.assertionRequest().action()) {
+ case BACKUP -> backupAuthManager.extendBackupVoucher(
+ authenticatedDevice.getAccount(),
+ new Account.BackupVoucher(backupRedemptionLevel, clock.instant().plus(backupRedemptionDuration)))
+ .join();
+ }
+ }
+
+ public enum Action {
+ BACKUP;
+
+ @JsonCreator
+ public static Action fromString(final String action) {
+ for (final Action a : Action.values()) {
+ if (a.name().toLowerCase(Locale.ROOT).equals(action)) {
+ return a;
+ }
+ }
+ throw new IllegalArgumentException("Invalid action: " + action);
+ }
+ }
+
+ public record AssertionRequest(
+ @Schema(description = "The challenge retrieved at `GET /v1/devicecheck/assert`")
+ String challenge,
+ @Schema(description = "The type of action you'd like to perform with this assert",
+ allowableValues = {"backup"}, implementation = String.class)
+ Action action) {}
+
+ /*
+ * Parses the base64 encoded AssertionRequest, but preserves the rawJson as well
+ */
+ public record AssertionRequestWrapper(AssertionRequest assertionRequest, byte[] rawJson) {
+
+ public static AssertionRequestWrapper fromString(String requestBase64) throws IOException {
+ final byte[] requestJson = Base64.getUrlDecoder().decode(requestBase64);
+ final AssertionRequest requestData = SystemMapper.jsonMapper().readValue(requestJson, AssertionRequest.class);
+ return new AssertionRequestWrapper(requestData, requestJson);
+ }
+ }
+
+
+ private static AppleDeviceCheckManager.ChallengeType toChallengeType(final Action action) {
+ return switch (action) {
+ case BACKUP -> AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION;
+ };
+ }
+
+ private static byte[] parseKeyId(final String base64KeyId) {
+ try {
+ return Base64.getUrlDecoder().decode(base64KeyId);
+ } catch (IllegalArgumentException e) {
+ throw new WebApplicationException(Response.status(422).entity(e.getMessage()).build());
+ }
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
index b7975cbb6..05c8347c6 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
@@ -57,6 +57,7 @@ public class RateLimiters extends BaseRateLimiters {
WAIT_FOR_TRANSFER_ARCHIVE("waitForTransferArchive", true, new RateLimiterConfig(10, Duration.ofSeconds(30))),
RECORD_DEVICE_TRANSFER_REQUEST("recordDeviceTransferRequest", true, new RateLimiterConfig(10, Duration.ofMillis(100))),
WAIT_FOR_DEVICE_TRANSFER_REQUEST("waitForDeviceTransferRequest", true, new RateLimiterConfig(10, Duration.ofMillis(100))),
+ DEVICE_CHECK_CHALLENGE("deviceCheckChallenge", true, new RateLimiterConfig(10, Duration.ofMinutes(1))),
;
private final String id;
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java
new file mode 100644
index 000000000..409afbb9d
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java
@@ -0,0 +1,265 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.storage.devicecheck;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.webauthn4j.appattest.DeviceCheckManager;
+import com.webauthn4j.appattest.authenticator.DCAppleDevice;
+import com.webauthn4j.appattest.authenticator.DCAppleDeviceImpl;
+import com.webauthn4j.appattest.data.DCAssertionParameters;
+import com.webauthn4j.appattest.data.DCAssertionRequest;
+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 com.webauthn4j.verifier.exception.MaliciousCounterValueException;
+import com.webauthn4j.verifier.exception.VerificationException;
+import io.lettuce.core.RedisException;
+import io.lettuce.core.SetArgs;
+import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.List;
+import java.util.UUID;
+import javax.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
+import org.whispersystems.textsecuregcm.limits.RateLimiters;
+import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;
+import org.whispersystems.textsecuregcm.storage.Account;
+
+/**
+ * Register Apple DeviceCheck App Attestations and verify the corresponding assertions.
+ *
+ * @see ...
+ * @see ...
+ */
+public class AppleDeviceCheckManager {
+
+ private static final Logger logger = LoggerFactory.getLogger(AppleDeviceCheckManager.class);
+
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+ private static final int CHALLENGE_LENGTH = 16;
+
+ // How long issued challenges last in redis
+ @VisibleForTesting
+ static final Duration CHALLENGE_TTL = Duration.ofHours(1);
+
+ // How many distinct device keys we're willing to accept for a single Account
+ @VisibleForTesting
+ static final int MAX_DEVICE_KEYS = 100;
+
+ private final AppleDeviceChecks appleDeviceChecks;
+ private final FaultTolerantRedisClusterClient redisClient;
+ private final DeviceCheckManager deviceCheckManager;
+ private final String teamId;
+ private final String bundleId;
+
+ public AppleDeviceCheckManager(
+ AppleDeviceChecks appleDeviceChecks,
+ FaultTolerantRedisClusterClient redisClient,
+ DeviceCheckManager deviceCheckManager,
+ String teamId,
+ String bundleId) {
+ this.appleDeviceChecks = appleDeviceChecks;
+ this.redisClient = redisClient;
+ this.deviceCheckManager = deviceCheckManager;
+ this.teamId = teamId;
+ this.bundleId = bundleId;
+ }
+
+ /**
+ * Attestations and assertions have independent challenges.
+ *
+ * Challenges are tied to their purpose to mitigate replay attacks
+ */
+ public enum ChallengeType {
+ ATTEST,
+ ASSERT_BACKUP_REDEMPTION
+ }
+
+ /**
+ * Register a key and attestation data for an account
+ *
+ * @param account The account this keyId should be associated with
+ * @param keyId The device's keyId
+ * @param attestBlob The device's attestation
+ * @throws ChallengeNotFoundException No issued challenge found for the account
+ * @throws DeviceCheckVerificationFailedException The provided attestation could not be verified
+ * @throws TooManyKeysException The account has registered too many unique keyIds
+ * @throws DuplicatePublicKeyException The keyId has already been used with another account
+ */
+ public void registerAttestation(final Account account, final byte[] keyId, final byte[] attestBlob)
+ throws TooManyKeysException, ChallengeNotFoundException, DeviceCheckVerificationFailedException, DuplicatePublicKeyException {
+
+ final List existingKeys = appleDeviceChecks.keyIds(account);
+ if (existingKeys.stream().anyMatch(x -> MessageDigest.isEqual(x, keyId))) {
+ // We already have the key, so no need to continue
+ return;
+ }
+
+ if (existingKeys.size() >= MAX_DEVICE_KEYS) {
+ // This is best-effort, since we don't check the number of keys transactionally. We just don't want to allow
+ // the keys for an account to grow arbitrarily large
+ throw new TooManyKeysException();
+ }
+
+ final String redisChallengeKey = challengeKey(ChallengeType.ATTEST, account.getUuid());
+ final String challenge = redisClient.withCluster(cluster -> cluster.sync().get(redisChallengeKey));
+ if (challenge == null) {
+ throw new ChallengeNotFoundException();
+ }
+
+ final byte[] clientDataHash = sha256(challenge.getBytes(StandardCharsets.UTF_8));
+ final DCAttestationRequest dcAttestationRequest = new DCAttestationRequest(keyId, attestBlob, clientDataHash);
+ final DCAttestationData dcAttestationData;
+ try {
+ dcAttestationData = deviceCheckManager.validate(dcAttestationRequest,
+ new DCAttestationParameters(new DCServerProperty(teamId, bundleId, new DefaultChallenge(challenge))));
+ } catch (VerificationException e) {
+ logger.info("Failed to verify attestation", e);
+ throw new DeviceCheckVerificationFailedException(e);
+ }
+ appleDeviceChecks.storeAttestation(account, keyId, createDcAppleDevice(dcAttestationData));
+ removeChallenge(redisChallengeKey);
+ }
+
+ private static DCAppleDeviceImpl createDcAppleDevice(final DCAttestationData dcAttestationData) {
+ final AttestationObject attestationObject = dcAttestationData.getAttestationObject();
+ if (attestationObject == null || attestationObject.getAuthenticatorData().getAttestedCredentialData() == null) {
+ throw new IllegalArgumentException("Signed and validated attestation missing expected data");
+ }
+ return new DCAppleDeviceImpl(
+ attestationObject.getAuthenticatorData().getAttestedCredentialData(),
+ attestationObject.getAttestationStatement(),
+ attestationObject.getAuthenticatorData().getSignCount(),
+ attestationObject.getAuthenticatorData().getExtensions());
+ }
+
+ /**
+ * Validate that a request came from an Apple device signed with a key already registered to the account
+ *
+ * @param account The requesting account
+ * @param keyId The key used to generate the assertion
+ * @param challengeType The {@link ChallengeType} of the assertion, which must match the challenge returned by
+ * {@link AppleDeviceCheckManager#createChallenge}
+ * @param challenge A challenge that was embedded in the supplied request
+ * @param request The request that the client asserted
+ * @param assertion The assertion from the client
+ * @throws DeviceCheckKeyIdNotFoundException The provided keyId was never registered with the account
+ * @throws ChallengeNotFoundException No issued challenge found for the account
+ * @throws DeviceCheckVerificationFailedException The provided assertion could not be verified
+ * @throws RequestReuseException The signed counter on the assertion was lower than a previously
+ * received assertion
+ */
+ public void validateAssert(
+ final Account account,
+ final byte[] keyId,
+ final ChallengeType challengeType,
+ final String challenge,
+ final byte[] request,
+ final byte[] assertion)
+ throws ChallengeNotFoundException, DeviceCheckVerificationFailedException, DeviceCheckKeyIdNotFoundException, RequestReuseException {
+
+ final String redisChallengeKey = challengeKey(challengeType, account.getUuid());
+ final String storedChallenge = redisClient.withCluster(cluster -> cluster.sync().get(redisChallengeKey));
+ if (storedChallenge == null) {
+ throw new ChallengeNotFoundException();
+ }
+ if (!MessageDigest.isEqual(
+ storedChallenge.getBytes(StandardCharsets.UTF_8),
+ challenge.getBytes(StandardCharsets.UTF_8))) {
+ throw new DeviceCheckVerificationFailedException("Provided challenge did not match stored challenge");
+ }
+
+ final DCAppleDevice appleDevice = appleDeviceChecks.lookup(account, keyId)
+ .orElseThrow(DeviceCheckKeyIdNotFoundException::new);
+ final DCAssertionRequest dcAssertionRequest = new DCAssertionRequest(keyId, assertion, sha256(request));
+ final DCAssertionParameters dcAssertionParameters =
+ new DCAssertionParameters(new DCServerProperty(teamId, bundleId, new DefaultChallenge(request)), appleDevice);
+
+ try {
+ deviceCheckManager.validate(dcAssertionRequest, dcAssertionParameters);
+ } catch (MaliciousCounterValueException e) {
+ // We will only accept assertions that have a sign count greater than the last assertion we saw. Step 5 here:
+ // https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server#Verify-the-assertion
+ throw new RequestReuseException("Sign count from request less than stored sign count");
+ } catch (VerificationException e) {
+ logger.info("Failed to validate DeviceCheck assert", e);
+ throw new DeviceCheckVerificationFailedException(e);
+ }
+
+ // Store the updated sign count, so we can check the next assertion (step 6)
+ appleDeviceChecks.updateCounter(account, keyId, appleDevice.getCounter());
+ removeChallenge(redisChallengeKey);
+ }
+
+ /**
+ * Create a challenge that can be used in an attestation or assertion
+ *
+ * @param challengeType The type of the challenge
+ * @param account The account that will use the challenge
+ * @return The challenge to be included as part of an attestation or assertion
+ */
+ public String createChallenge(final ChallengeType challengeType, final Account account)
+ throws RateLimitExceededException {
+ final UUID accountIdentifier = account.getUuid();
+
+ final String challengeKey = challengeKey(challengeType, accountIdentifier);
+ return redisClient.withCluster(cluster -> {
+ final RedisAdvancedClusterCommands commands = cluster.sync();
+
+ // Sets the new challenge if and only if there isn't already one stored for the challenge key; returns the existing
+ // challenge if present or null if no challenge was previously set.
+ final String proposedChallenge = generateChallenge();
+ @Nullable final String existingChallenge =
+ commands.setGet(challengeKey, proposedChallenge, SetArgs.Builder.nx().ex(CHALLENGE_TTL));
+
+ if (existingChallenge != null) {
+ // If the key was already set, make sure we extend the TTL. This is racy because the key could disappear or have
+ // been updated since the get returned, but it's fine. In the former case, this is a noop. In the latter
+ // case we may slightly extend the TTL from after it was set, but that's also no big deal.
+ commands.expire(challengeKey, CHALLENGE_TTL);
+ }
+
+ return existingChallenge != null ? existingChallenge : proposedChallenge;
+ });
+ }
+
+ private void removeChallenge(final String challengeKey) {
+ try {
+ redisClient.useCluster(cluster -> cluster.sync().del(challengeKey));
+ } catch (RedisException e) {
+ logger.debug("failed to remove attest challenge from redis, will let it expire via TTL");
+ }
+ }
+
+ @VisibleForTesting
+ static String challengeKey(final ChallengeType challengeType, final UUID accountIdentifier) {
+ return "device_check::" + challengeType.name() + "::" + accountIdentifier.toString();
+ }
+
+ private static String generateChallenge() {
+ final byte[] challenge = new byte[CHALLENGE_LENGTH];
+ SECURE_RANDOM.nextBytes(challenge);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(challenge);
+ }
+
+ private static byte[] sha256(byte[] bytes) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(bytes);
+ } catch (final NoSuchAlgorithmException e) {
+ throw new AssertionError("All Java implementations are required to support SHA-256", e);
+ }
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckTrustAnchor.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckTrustAnchor.java
new file mode 100644
index 000000000..76193c8f1
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckTrustAnchor.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.storage.devicecheck;
+
+import com.webauthn4j.anchor.TrustAnchorRepository;
+import com.webauthn4j.data.attestation.authenticator.AAGUID;
+import com.webauthn4j.util.CertificateUtil;
+import com.webauthn4j.verifier.attestation.trustworthiness.certpath.DefaultCertPathTrustworthinessVerifier;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * A {@link com.webauthn4j.verifier.attestation.trustworthiness.certpath.CertPathTrustworthinessVerifier} for validating
+ * x5 certificate chains, pinned with apple's well known static device check root certificate.
+ */
+public class AppleDeviceCheckTrustAnchor extends DefaultCertPathTrustworthinessVerifier {
+
+ // The location of a PEM encoded certificate for Apple's DeviceCheck root certificate
+ // https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem
+ private static String APPLE_DEVICE_CHECK_ROOT_CERT_RESOURCE_NAME = "apple_device_check.pem";
+
+ public AppleDeviceCheckTrustAnchor() {
+ super(new StaticTrustAnchorRepository(loadDeviceCheckRootCert()));
+ }
+
+ private record StaticTrustAnchorRepository(X509Certificate rootCert) implements TrustAnchorRepository {
+
+ @Override
+ public Set find(final AAGUID aaguid) {
+ return Collections.singleton(new TrustAnchor(rootCert, null));
+ }
+
+ @Override
+ public Set find(final byte[] attestationCertificateKeyIdentifier) {
+ return Collections.singleton(new TrustAnchor(rootCert, null));
+ }
+ }
+
+ private static X509Certificate loadDeviceCheckRootCert() {
+ try (InputStream stream = AppleDeviceCheckTrustAnchor.class.getResourceAsStream(
+ APPLE_DEVICE_CHECK_ROOT_CERT_RESOURCE_NAME)) {
+ if (stream == null) {
+ throw new IllegalArgumentException("Resource not found: " + APPLE_DEVICE_CHECK_ROOT_CERT_RESOURCE_NAME);
+ }
+ return CertificateUtil.generateX509Certificate(stream);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecks.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecks.java
new file mode 100644
index 000000000..7033fce7b
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecks.java
@@ -0,0 +1,304 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.storage.devicecheck;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.webauthn4j.appattest.authenticator.DCAppleDevice;
+import com.webauthn4j.appattest.authenticator.DCAppleDeviceImpl;
+import com.webauthn4j.appattest.data.attestation.statement.AppleAppAttestAttestationStatement;
+import com.webauthn4j.converter.AttestedCredentialDataConverter;
+import com.webauthn4j.converter.util.ObjectConverter;
+import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData;
+import com.webauthn4j.data.attestation.statement.AttestationStatement;
+import com.webauthn4j.data.extension.authenticator.AuthenticationExtensionsAuthenticatorOutputs;
+import com.webauthn4j.data.extension.authenticator.RegistrationExtensionAuthenticatorOutput;
+import java.security.PublicKey;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.whispersystems.textsecuregcm.storage.Account;
+import org.whispersystems.textsecuregcm.util.AttributeValues;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+import software.amazon.awssdk.services.dynamodb.model.CancellationReason;
+import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
+import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
+import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
+import software.amazon.awssdk.services.dynamodb.model.Put;
+import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
+import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;
+import software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest;
+import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;
+import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
+
+/**
+ * Store DeviceCheck attestations along with accounts, so they can be retrieved later to validate assertions.
+ *
+ * Callers associate a keyId and attestation with an account, and then use the corresponding key to make potentially
+ * many attested requests (assertions). Each assertion increments the counter associated with the key.
+ *
+ * Callers can associate more than one keyId/attestation with an account (for example, they may get a new device).
+ * However, each keyId must only be registered for a single account.
+ *
+ * @implNote We use a second table keyed on the public key to enforce uniqueness.
+ */
+public class AppleDeviceChecks {
+
+ // B: uuid, primary key
+ public static final String KEY_ACCOUNT_UUID = "U";
+ // B: key id, sort key. The key id is the SHA256 of the X9.62 uncompressed point format of the public key
+ public static final String KEY_PUBLIC_KEY_ID = "KID";
+ // N: counter, the number of asserts signed by the public key (updates on every assert)
+ private static final String ATTR_COUNTER = "C";
+ // B: attestedCredentialData
+ private static final String ATTR_CRED_DATA = "CD";
+ // B: attestationStatement, CBOR
+ private static final String ATTR_STATEMENT = "S";
+ // B: authenticatorExtensions, CBOR
+ private static final String ATTR_AUTHENTICATOR_EXTENSIONS = "AE";
+
+ // B: public key bytes, primary key for the public key table
+ public static final String KEY_PUBLIC_KEY = "PK";
+
+ private static final String CONDITIONAL_CHECK_FAILED = "ConditionalCheckFailed";
+
+ private final DynamoDbClient dynamoDbClient;
+ private final String deviceCheckTableName;
+ private final String publicKeyConstraintTableName;
+ private final ObjectConverter objectConverter;
+
+ public AppleDeviceChecks(
+ final DynamoDbClient dynamoDbClient,
+ final ObjectConverter objectConverter,
+ final String deviceCheckTableName,
+ final String publicKeyConstraintTableName) {
+ this.dynamoDbClient = dynamoDbClient;
+ this.objectConverter = objectConverter;
+ this.deviceCheckTableName = deviceCheckTableName;
+ this.publicKeyConstraintTableName = publicKeyConstraintTableName;
+ }
+
+ /**
+ * Retrieve DeviceCheck keyIds
+ *
+ * @param account The account to fetch keyIds for
+ * @return A list of keyIds currently associated with the account
+ */
+ public List keyIds(final Account account) {
+ return dynamoDbClient.queryPaginator(QueryRequest.builder()
+ .tableName(deviceCheckTableName)
+ .keyConditionExpression("#aci = :aci")
+ .expressionAttributeNames(Map.of("#aci", KEY_ACCOUNT_UUID, "#kid", KEY_PUBLIC_KEY_ID))
+ .expressionAttributeValues(Map.of(":aci", AttributeValues.fromUUID(account.getUuid())))
+ .projectionExpression("#kid")
+ .build())
+ .items()
+ .stream()
+ .flatMap(item -> getByteArray(item, KEY_PUBLIC_KEY_ID).stream())
+ .toList();
+ }
+
+ /**
+ * Register an attestation for a keyId with an account. The attestation can later be retrieved via {@link #lookup}. If
+ * the provided keyId is already registered with the account and is more up to date, no update will occur and this
+ * method will return false.
+ *
+ * @param account The account to store the registration
+ * @param keyId The keyId to associate with the account
+ * @param appleDevice Attestation information to store
+ * @return true if the attestation was stored, false if the keyId already had an attestation
+ * @throws DuplicatePublicKeyException If a different account has already registered this public key
+ */
+ public boolean storeAttestation(final Account account, final byte[] keyId, final DCAppleDevice appleDevice)
+ throws DuplicatePublicKeyException {
+ try {
+ dynamoDbClient.transactWriteItems(TransactWriteItemsRequest.builder().transactItems(
+
+ // Register the public key and associated data with the account
+ TransactWriteItem.builder().put(Put.builder()
+ .tableName(deviceCheckTableName)
+ .item(toItem(account, keyId, appleDevice))
+ // The caller should have done a non-transactional read to verify we didn't already have this keyId, but a
+ // race is possible. It's fine to wipe out an existing key (should be identical), as long as we don't
+ // lower the signed count associated with the key.
+ .conditionExpression("attribute_not_exists(#counter) OR #counter <= :counter")
+ .expressionAttributeNames(Map.of("#counter", ATTR_COUNTER))
+ .expressionAttributeValues(Map.of(":counter", AttributeValues.n(appleDevice.getCounter())))
+ .build()).build(),
+
+ // Enforce uniqueness on the supplied public key
+ TransactWriteItem.builder().put(Put.builder()
+ .tableName(publicKeyConstraintTableName)
+ .item(Map.of(
+ KEY_PUBLIC_KEY, AttributeValues.fromByteArray(extractPublicKey(appleDevice).getEncoded()),
+ KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())
+ ))
+ // Enforces public key uniqueness, as described in https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server#Store-the-public-key-and-receipt
+ .conditionExpression("attribute_not_exists(#pk) or #aci = :aci")
+ .expressionAttributeNames(Map.of("#aci", KEY_ACCOUNT_UUID, "#pk", KEY_PUBLIC_KEY))
+ .expressionAttributeValues(Map.of(":aci", AttributeValues.fromUUID(account.getUuid())))
+ .build()).build()).build());
+ return true;
+
+ } catch (TransactionCanceledException e) {
+ final CancellationReason updateCancelReason = e.cancellationReasons().get(0);
+ if (conditionalCheckFailed(updateCancelReason)) {
+ // The provided attestation is older than the one we already have stored
+ return false;
+ }
+ final CancellationReason publicKeyCancelReason = e.cancellationReasons().get(1);
+ if (conditionalCheckFailed(publicKeyCancelReason)) {
+ throw new DuplicatePublicKeyException();
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Retrieve the device attestation information previous registered with the account
+ *
+ * @param account The account that registered the keyId
+ * @param keyId The keyId that was registered
+ * @return Device attestation information that can be used to validate an assertion
+ */
+ public Optional lookup(final Account account, final byte[] keyId) {
+ final GetItemResponse item = dynamoDbClient.getItem(GetItemRequest.builder()
+ .tableName(deviceCheckTableName)
+ .key(Map.of(
+ KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
+ KEY_PUBLIC_KEY_ID, AttributeValues.fromByteArray(keyId))).build());
+ return item.hasItem() ? Optional.of(fromItem(item.item())) : Optional.empty();
+ }
+
+ /**
+ * Attempt to increase the signed counter to the newCounter value. This method enforces that the counter increases
+ * monotonically, if the new value is less than the existing counter, no update occurs and the method returns false.
+ *
+ * @param account The account the keyId is registered to
+ * @param keyId The keyId to update
+ * @param newCounter The new counter value
+ * @return true if the counter was updated, false if the stored counter was larger than newCounter
+ */
+ public boolean updateCounter(final Account account, final byte[] keyId, final long newCounter) {
+ try {
+ dynamoDbClient.updateItem(UpdateItemRequest.builder()
+ .tableName(deviceCheckTableName)
+ .key(Map.of(
+ KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
+ KEY_PUBLIC_KEY_ID, AttributeValues.fromByteArray(keyId)))
+ .expressionAttributeNames(Map.of("#counter", ATTR_COUNTER))
+ .expressionAttributeValues(Map.of(":counter", AttributeValues.n(newCounter)))
+ .updateExpression("SET #counter = :counter")
+ // someone could possibly race with us to update the counter. No big deal, but we shouldn't decrease the
+ // current counter
+ .conditionExpression("#counter <= :counter").build());
+ return true;
+ } catch (ConditionalCheckFailedException e) {
+ // We failed to increment the counter because it has already moved forward
+ return false;
+ }
+ }
+
+ private Map toItem(final Account account, final byte[] keyId, DCAppleDevice appleDevice) {
+ // Serialize the various data members, see: https://webauthn4j.github.io/webauthn4j/en/#deep-dive
+ final AttestedCredentialDataConverter attestedCredentialDataConverter =
+ new AttestedCredentialDataConverter(objectConverter);
+ final byte[] attestedCredentialData =
+ attestedCredentialDataConverter.convert(appleDevice.getAttestedCredentialData());
+ final byte[] attestationStatement = objectConverter.getCborConverter()
+ .writeValueAsBytes(new AttestationStatementEnvelope(appleDevice.getAttestationStatement()));
+ final long counter = appleDevice.getCounter();
+ final byte[] authenticatorExtensions = objectConverter.getCborConverter()
+ .writeValueAsBytes(appleDevice.getAuthenticatorExtensions());
+
+ return Map.of(
+ KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
+ KEY_PUBLIC_KEY_ID, AttributeValues.fromByteArray(keyId),
+ ATTR_CRED_DATA, AttributeValues.fromByteArray(attestedCredentialData),
+ ATTR_STATEMENT, AttributeValues.fromByteArray(attestationStatement),
+ ATTR_AUTHENTICATOR_EXTENSIONS, AttributeValues.fromByteArray(authenticatorExtensions),
+ ATTR_COUNTER, AttributeValues.n(counter));
+ }
+
+ private DCAppleDevice fromItem(final Map item) {
+ // Deserialize the fields stored in dynamodb, see: https://webauthn4j.github.io/webauthn4j/en/#deep-dive
+
+ final AttestedCredentialDataConverter attestedCredentialDataConverter =
+ new AttestedCredentialDataConverter(objectConverter);
+
+ final AttestedCredentialData credData = attestedCredentialDataConverter.convert(getByteArray(item, ATTR_CRED_DATA)
+ .orElseThrow(() -> new IllegalStateException("Stored device check key missing attestation credential data")));
+
+ // The attestationStatement is an interface, so we also need to encode enough type information (the format)
+ // so we know how to deserialize the statement. See https://webauthn4j.github.io/webauthn4j/en/#attestationstatement
+ final byte[] serializedStatementEnvelope = getByteArray(item, ATTR_STATEMENT)
+ .orElseThrow(() -> new IllegalStateException("Stored device check key missing attestation statement"));
+ final AttestationStatement statement = Optional.ofNullable(objectConverter.getCborConverter()
+ .readValue(serializedStatementEnvelope, AttestationStatementEnvelope.class))
+ .orElseThrow(() -> new IllegalStateException("Stored device check key missing attestation statement"))
+ .getAttestationStatement();
+
+ final long counter = AttributeValues.getLong(item, ATTR_COUNTER, 0);
+
+ final byte[] serializedExtensions = getByteArray(item, ATTR_AUTHENTICATOR_EXTENSIONS)
+ .orElseThrow(() -> new IllegalStateException("Stored device check key missing attestation extensions"));
+
+ @SuppressWarnings("unchecked") final AuthenticationExtensionsAuthenticatorOutputs extensions = objectConverter.getCborConverter()
+ .readValue(serializedExtensions, AuthenticationExtensionsAuthenticatorOutputs.class);
+
+ return new DCAppleDeviceImpl(credData, statement, counter, extensions);
+ }
+
+ private static PublicKey extractPublicKey(DCAppleDevice appleDevice) {
+ // This is the leaf public key as described here:
+ // https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server#Verify-the-attestation
+ // We know the sha256 of the public key matches the keyId, the apple webauthn verifier validates that. Step 5 here:
+ // https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide#Walking-through-the-validation-steps
+ final AppleAppAttestAttestationStatement attestationStatement = ((AppleAppAttestAttestationStatement) appleDevice.getAttestationStatement());
+ Objects.requireNonNull(attestationStatement);
+ return attestationStatement.getX5c().getEndEntityAttestationCertificate().getCertificate().getPublicKey();
+ }
+
+
+ private static boolean conditionalCheckFailed(final CancellationReason reason) {
+ return CONDITIONAL_CHECK_FAILED.equals(reason.code());
+ }
+
+ private static Optional getByteArray(Map item, String key) {
+ return AttributeValues.get(item, key).map(av -> av.b().asByteArray());
+ }
+
+ /**
+ * Wrapper that provides type information when deserializing attestation statements
+ */
+ private static class AttestationStatementEnvelope {
+
+ @JsonProperty("attStmt")
+ @JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.EXTERNAL_PROPERTY,
+ property = "fmt"
+ )
+ private AttestationStatement attestationStatement;
+
+ @JsonCreator
+ public AttestationStatementEnvelope(@JsonProperty("attStmt") AttestationStatement attestationStatement) {
+ this.attestationStatement = attestationStatement;
+ }
+
+ @JsonProperty("fmt")
+ public String getFormat() {
+ return attestationStatement.getFormat();
+ }
+
+ public AttestationStatement getAttestationStatement() {
+ return attestationStatement;
+ }
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/ChallengeNotFoundException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/ChallengeNotFoundException.java
new file mode 100644
index 000000000..0dc55052c
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/ChallengeNotFoundException.java
@@ -0,0 +1,7 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.storage.devicecheck;
+
+public class ChallengeNotFoundException extends Exception {}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckKeyIdNotFoundException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckKeyIdNotFoundException.java
new file mode 100644
index 000000000..4b53affff
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckKeyIdNotFoundException.java
@@ -0,0 +1,7 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.storage.devicecheck;
+
+public class DeviceCheckKeyIdNotFoundException extends Exception {}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckVerificationFailedException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckVerificationFailedException.java
new file mode 100644
index 000000000..436826472
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckVerificationFailedException.java
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.storage.devicecheck;
+
+public class DeviceCheckVerificationFailedException extends Exception {
+
+ public DeviceCheckVerificationFailedException(Exception cause) {
+ super(cause);
+ }
+
+ public DeviceCheckVerificationFailedException(String s) {
+ super(s);
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DuplicatePublicKeyException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DuplicatePublicKeyException.java
new file mode 100644
index 000000000..aeb2c60e9
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DuplicatePublicKeyException.java
@@ -0,0 +1,7 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.storage.devicecheck;
+
+public class DuplicatePublicKeyException extends Exception {}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/RequestReuseException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/RequestReuseException.java
new file mode 100644
index 000000000..199e05e99
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/RequestReuseException.java
@@ -0,0 +1,12 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.storage.devicecheck;
+
+public class RequestReuseException extends Exception {
+
+ public RequestReuseException(String s) {
+ super(s);
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/TooManyKeysException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/TooManyKeysException.java
new file mode 100644
index 000000000..883b919cf
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/TooManyKeysException.java
@@ -0,0 +1,7 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.storage.devicecheck;
+
+public class TooManyKeysException extends Exception {}
diff --git a/service/src/main/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple_device_check.pem b/service/src/main/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple_device_check.pem
new file mode 100644
index 000000000..4cff2277b
--- /dev/null
+++ b/service/src/main/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple_device_check.pem
@@ -0,0 +1,14 @@
+-----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-----
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckControllerTest.java
new file mode 100644
index 000000000..b4150d953
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckControllerTest.java
@@ -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));
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java
index 13e0f009c..6bb6efb66 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java
@@ -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;
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManagerTest.java
new file mode 100644
index 000000000..284ff5f45
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManagerTest.java
@@ -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 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 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 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();
+ });
+ }
+ }
+
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecksTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecksTest.java
new file mode 100644
index 000000000..59d012f0a
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecksTest.java
@@ -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 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 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);
+
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckTestUtil.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckTestUtil.java
new file mode 100644
index 000000000..e482d407e
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckTestUtil.java
@@ -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 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);
+ }
+ }
+}
diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml
index 242ca490b..a12f1b150 100644
--- a/service/src/test/resources/config/test.yml
+++ b/service/src/test/resources/config/test.yml
@@ -89,6 +89,15 @@ appleAppStore:
# An apple root cert https://www.apple.com/certificateauthority/
- MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9wtj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IWq6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKMaLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAEggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBcNplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQPy3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4FgxhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oPIQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AXUKqK1drk/NAJBzewdXUh
+appleDeviceCheck:
+ production: false
+ teamId: 0123456789
+ bundleId: bundle.name
+
+deviceCheck:
+ backupRedemptionDuration: P30D
+ backupRedemptionLevel: 201
+
dynamoDbClient:
type: local
@@ -99,6 +108,10 @@ dynamoDbTables:
phoneNumberIdentifierTableName: pni_assignment_test
usernamesTableName: usernames_test
usedLinkDeviceTokensTableName: used_link_device_tokens_test
+ appleDeviceChecks:
+ tableName: apple_device_checks_test
+ appleDeviceCheckPublicKeys:
+ tableName: apple_device_check_public_keys_test
backups:
tableName: backups_test
clientReleases:
diff --git a/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple-sample-attestation b/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple-sample-attestation
new file mode 100644
index 000000000..88e2ad5a8
Binary files /dev/null and b/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple-sample-attestation differ
diff --git a/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/webauthn4j-sample-assertion b/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/webauthn4j-sample-assertion
new file mode 100644
index 000000000..00f0e647b
Binary files /dev/null and b/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/webauthn4j-sample-assertion differ
diff --git a/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/webauthn4j-sample-attestation b/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/webauthn4j-sample-attestation
new file mode 100644
index 000000000..c9c87d132
Binary files /dev/null and b/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/webauthn4j-sample-attestation differ