Add DeviceCheck API for iOS Testflight backup enablement

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

View File

@ -92,6 +92,14 @@ appleAppStore:
productIdToLevel: {}
appleRootCerts: []
appleDeviceCheck:
production: false
teamId: 0123456789
bundleId: bundle.name
deviceCheck:
backupRedemptionDuration: P30D
backupRedemptionLevel: 201
dynamoDbClient:
region: us-west-2 # AWS Region
@ -103,6 +111,10 @@ dynamoDbTables:
phoneNumberIdentifierTableName: Example_Accounts_PhoneNumberIdentifiers
usernamesTableName: Example_Accounts_Usernames
usedLinkDeviceTokensTableName: Example_Accounts_UsedLinkDeviceTokens
appleDeviceChecks:
tableName: Example_AppleDeviceChecks
appleDeviceCheckPublicKeys:
tableName: Example_AppleDeviceCheckPublicKeys
backups:
tableName: Example_Backups
clientReleases:

View File

@ -15,6 +15,7 @@
<java-uuid-generator.version>5.1.0</java-uuid-generator.version>
<google-androidpublisher.version>v3-rev20241016-2.0.0</google-androidpublisher.version>
<storekit.version>3.2.0</storekit.version>
<webauthn4j.version>0.28.0.RELEASE</webauthn4j.version>
</properties>
<dependencies>
@ -35,6 +36,11 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.webauthn4j</groupId>
<artifactId>webauthn4j-appattest</artifactId>
<version>${webauthn4j.version}</version>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-jaxrs2-jakarta</artifactId>

View File

@ -16,6 +16,7 @@ import java.util.Map;
import org.whispersystems.textsecuregcm.attachments.TusConfiguration;
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
import org.whispersystems.textsecuregcm.configuration.AppleAppStoreConfiguration;
import org.whispersystems.textsecuregcm.configuration.AppleDeviceCheckConfiguration;
import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.AwsCredentialsProviderFactory;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
@ -25,6 +26,7 @@ import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
import org.whispersystems.textsecuregcm.configuration.ClientReleaseConfiguration;
import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration;
import org.whispersystems.textsecuregcm.configuration.DefaultAwsCredentialsFactory;
import org.whispersystems.textsecuregcm.configuration.DeviceCheckConfiguration;
import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration;
import org.whispersystems.textsecuregcm.configuration.DogstatsdConfiguration;
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientFactory;
@ -96,6 +98,16 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private AppleAppStoreConfiguration appleAppStore;
@NotNull
@Valid
@JsonProperty
private AppleDeviceCheckConfiguration appleDeviceCheck;
@NotNull
@Valid
@JsonProperty
private DeviceCheckConfiguration deviceCheck;
@NotNull
@Valid
@JsonProperty
@ -359,6 +371,14 @@ public class WhisperServerConfiguration extends Configuration {
return appleAppStore;
}
public AppleDeviceCheckConfiguration getAppleDeviceCheck() {
return appleDeviceCheck;
}
public DeviceCheckConfiguration getDeviceCheck() {
return deviceCheck;
}
public DynamoDbClientFactory getDynamoDbClientConfiguration() {
return dynamoDbClient;
}

View File

@ -8,6 +8,7 @@ import static com.codahale.metrics.MetricRegistry.name;
import static java.util.Objects.requireNonNull;
import com.google.common.collect.Lists;
import com.webauthn4j.appattest.DeviceCheckManager;
import io.dropwizard.auth.AuthDynamicFeature;
import io.dropwizard.auth.AuthFilter;
import io.dropwizard.auth.AuthValueFactoryProvider;
@ -114,6 +115,7 @@ import org.whispersystems.textsecuregcm.controllers.CallRoutingController;
import org.whispersystems.textsecuregcm.controllers.CallRoutingControllerV2;
import org.whispersystems.textsecuregcm.controllers.CertificateController;
import org.whispersystems.textsecuregcm.controllers.ChallengeController;
import org.whispersystems.textsecuregcm.controllers.DeviceCheckController;
import org.whispersystems.textsecuregcm.controllers.DeviceController;
import org.whispersystems.textsecuregcm.controllers.DirectoryV2Controller;
import org.whispersystems.textsecuregcm.controllers.DonationController;
@ -213,6 +215,9 @@ import org.whispersystems.textsecuregcm.storage.AccountLockManager;
import org.whispersystems.textsecuregcm.storage.AccountPrincipalSupplier;
import org.whispersystems.textsecuregcm.storage.Accounts;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager;
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckTrustAnchor;
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceChecks;
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
import org.whispersystems.textsecuregcm.storage.ClientPublicKeys;
import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager;
@ -789,6 +794,20 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
cdn3RemoteStorageManager,
clock);
final AppleDeviceChecks appleDeviceChecks = new AppleDeviceChecks(
dynamoDbClient,
DeviceCheckManager.createObjectConverter(),
config.getDynamoDbTables().getAppleDeviceChecks().getTableName(),
config.getDynamoDbTables().getAppleDeviceCheckPublicKeys().getTableName());
final DeviceCheckManager deviceCheckManager = new DeviceCheckManager(new AppleDeviceCheckTrustAnchor());
deviceCheckManager.getAttestationDataValidator().setProduction(config.getAppleDeviceCheck().production());
final AppleDeviceCheckManager appleDeviceCheckManager = new AppleDeviceCheckManager(
appleDeviceChecks,
cacheCluster,
deviceCheckManager,
config.getAppleDeviceCheck().teamId(),
config.getAppleDeviceCheck().bundleId());
final DynamicConfigTurnRouter configTurnRouter = new DynamicConfigTurnRouter(dynamicConfigurationManager);
MaxMindDatabaseManager geoIpCityDatabaseManager = new MaxMindDatabaseManager(
@ -1092,6 +1111,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
zkAuthOperations, callingGenericZkSecretParams, clock),
new ChallengeController(rateLimitChallengeManager, challengeConstraintChecker),
new DeviceController(accountsManager, clientPublicKeysManager, rateLimiters, config.getMaxDevices()),
new DeviceCheckController(clock, backupAuthManager, appleDeviceCheckManager, rateLimiters,
config.getDeviceCheck().backupRedemptionLevel(),
config.getDeviceCheck().backupRedemptionDuration()),
new DirectoryV2Controller(directoryV2CredentialsGenerator),
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
ReceiptCredentialPresentation::new),

View File

@ -235,13 +235,24 @@ public class BackupAuthManager {
.withDescription("receipt serial is already redeemed")
.asRuntimeException();
}
return accountsManager.updateAsync(account, a -> {
final Account.BackupVoucher newPayment = new Account.BackupVoucher(receiptLevel, receiptExpiration);
final Account.BackupVoucher existingPayment = a.getBackupVoucher();
a.setBackupVoucher(merge(existingPayment, newPayment));
});
})
.thenRun(Util.NOOP);
return extendBackupVoucher(account, new Account.BackupVoucher(receiptLevel, receiptExpiration));
});
}
/**
* Extend the duration of the backup voucher on an account.
*
* @param account The account to update
* @param backupVoucher The backup voucher to apply to this account
* @return A future that completes once the account has been updated to have at least the level and expiration
* in the provided voucher.
*/
public CompletableFuture<Void> 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,

View File

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

View File

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

View File

@ -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() {

View File

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

View File

@ -57,6 +57,7 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
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;

View File

@ -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 <a href="https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity">...</a>
* @see <a
* href="https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server">...</a>
*/
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.
* <p>
* 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<byte[]> 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<String, String> 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);
}
}
}

View File

@ -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<TrustAnchor> find(final AAGUID aaguid) {
return Collections.singleton(new TrustAnchor(rootCert, null));
}
@Override
public Set<TrustAnchor> 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);
}
}
}

View File

@ -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.
* <p>
* 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.
* <p>
* 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<byte[]> 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<DCAppleDevice> 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<String, AttributeValue> 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<String, AttributeValue> 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<RegistrationExtensionAuthenticatorOutput> 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<byte[]> getByteArray(Map<String, AttributeValue> 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;
}
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

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

View File

@ -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 {}

View File

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

View File

@ -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 {}

View File

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

View File

@ -0,0 +1,225 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import org.glassfish.jersey.server.ServerProperties;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager;
import org.whispersystems.textsecuregcm.storage.devicecheck.ChallengeNotFoundException;
import org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckKeyIdNotFoundException;
import org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckVerificationFailedException;
import org.whispersystems.textsecuregcm.storage.devicecheck.DuplicatePublicKeyException;
import org.whispersystems.textsecuregcm.storage.devicecheck.RequestReuseException;
import org.whispersystems.textsecuregcm.storage.devicecheck.TooManyKeysException;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
@ExtendWith(DropwizardExtensionsSupport.class)
class DeviceCheckControllerTest {
private final static Duration REDEMPTION_DURATION = Duration.ofDays(5);
private final static long REDEMPTION_LEVEL = 201L;
private final static BackupAuthManager backupAuthManager = mock(BackupAuthManager.class);
private final static AppleDeviceCheckManager appleDeviceCheckManager = mock(AppleDeviceCheckManager.class);
private final static RateLimiters rateLimiters = mock(RateLimiters.class);
private final static Clock clock = TestClock.pinned(Instant.EPOCH);
private static final ResourceExtension resources = ResourceExtension.builder()
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
.addProvider(AuthHelper.getAuthFilter())
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))
.addProvider(new CompletionExceptionMapper())
.addResource(new GrpcStatusRuntimeExceptionMapper())
.addProvider(new RateLimitExceededExceptionMapper())
.setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new DeviceCheckController(clock, backupAuthManager, appleDeviceCheckManager, rateLimiters,
REDEMPTION_LEVEL, REDEMPTION_DURATION))
.build();
@BeforeEach
public void setUp() {
reset(backupAuthManager);
reset(appleDeviceCheckManager);
reset(rateLimiters);
when(rateLimiters.forDescriptor(any())).thenReturn(mock(RateLimiter.class));
}
@ParameterizedTest
@EnumSource(AppleDeviceCheckManager.ChallengeType.class)
public void createChallenge(AppleDeviceCheckManager.ChallengeType challengeType) throws RateLimitExceededException {
when(appleDeviceCheckManager.createChallenge(eq(challengeType), any()))
.thenReturn("TestChallenge");
WebTarget target = resources.getJerseyTest()
.target("v1/devicecheck/%s".formatted(switch (challengeType) {
case ATTEST -> "attest";
case ASSERT_BACKUP_REDEMPTION -> "assert";
}));
if (challengeType == AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION) {
target = target.queryParam("action", "backup");
}
final DeviceCheckController.ChallengeResponse challenge = target
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get(DeviceCheckController.ChallengeResponse.class);
assertThat(challenge.challenge()).isEqualTo("TestChallenge");
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void createChallengeRateLimited(boolean create) throws RateLimitExceededException {
final RateLimiter rateLimiter = mock(RateLimiter.class);
when(rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)).thenReturn(rateLimiter);
doThrow(new RateLimitExceededException(Duration.ofSeconds(1L))).when(rateLimiter).validate(any(UUID.class));
final String path = "v1/devicecheck/%s".formatted(create ? "assert" : "attest");
final Response response = resources.getJerseyTest()
.target(path)
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get();
assertThat(response.getStatus()).isEqualTo(429);
}
@Test
public void failedAttestValidation()
throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {
final String errorMessage = "a test error message";
final byte[] keyId = TestRandomUtil.nextBytes(16);
final byte[] attestation = TestRandomUtil.nextBytes(32);
doThrow(new DeviceCheckVerificationFailedException(errorMessage)).when(appleDeviceCheckManager)
.registerAttestation(any(), eq(keyId), eq(attestation));
final Response response = resources.getJerseyTest()
.target("v1/devicecheck/attest")
.queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId))
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.entity(attestation, MediaType.APPLICATION_OCTET_STREAM));
assertThat(response.getStatus()).isEqualTo(401);
assertThat(response.getMediaType()).isEqualTo(MediaType.APPLICATION_JSON_TYPE);
assertThat(response.readEntity(Map.class).get("message")).isEqualTo(errorMessage);
}
@Test
public void failedAssertValidation()
throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, DeviceCheckKeyIdNotFoundException, RequestReuseException {
final String errorMessage = "a test error message";
final byte[] keyId = TestRandomUtil.nextBytes(16);
final byte[] assertion = TestRandomUtil.nextBytes(32);
final String challenge = "embeddedChallenge";
final String request = """
{"action": "backup", "challenge": "embeddedChallenge"}
""";
doThrow(new DeviceCheckVerificationFailedException(errorMessage)).when(appleDeviceCheckManager)
.validateAssert(any(), eq(keyId), eq(AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION), eq(challenge), eq(request.getBytes()), eq(assertion));
final Response response = resources.getJerseyTest()
.target("v1/devicecheck/assert")
.queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId))
.queryParam("request", Base64.getUrlEncoder().encodeToString(request.getBytes(StandardCharsets.UTF_8)))
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.post(Entity.entity(assertion, MediaType.APPLICATION_OCTET_STREAM));
assertThat(response.getStatus()).isEqualTo(401);
assertThat(response.getMediaType()).isEqualTo(MediaType.APPLICATION_JSON_TYPE);
assertThat(response.readEntity(Map.class).get("message")).isEqualTo(errorMessage);
}
@Test
public void registerKey()
throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {
final byte[] keyId = TestRandomUtil.nextBytes(16);
final byte[] attestation = TestRandomUtil.nextBytes(32);
final Response response = resources.getJerseyTest()
.target("v1/devicecheck/attest")
.queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId))
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.entity(attestation, MediaType.APPLICATION_OCTET_STREAM));
assertThat(response.getStatus()).isEqualTo(204);
verify(appleDeviceCheckManager, times(1))
.registerAttestation(any(), eq(keyId), eq(attestation));
}
@Test
public void checkAssertion()
throws DeviceCheckKeyIdNotFoundException, DeviceCheckVerificationFailedException, ChallengeNotFoundException, RequestReuseException {
final byte[] keyId = TestRandomUtil.nextBytes(16);
final byte[] assertion = TestRandomUtil.nextBytes(32);
final String challenge = "embeddedChallenge";
final String request = """
{"action": "backup", "challenge": "embeddedChallenge"}
""";
when(backupAuthManager.extendBackupVoucher(any(), eq(new Account.BackupVoucher(
REDEMPTION_LEVEL,
clock.instant().plus(REDEMPTION_DURATION)))))
.thenReturn(CompletableFuture.completedFuture(null));
final Response response = resources.getJerseyTest()
.target("v1/devicecheck/assert")
.queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId))
.queryParam("request", Base64.getUrlEncoder().encodeToString(request.getBytes(StandardCharsets.UTF_8)))
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.post(Entity.entity(assertion, MediaType.APPLICATION_OCTET_STREAM));
assertThat(response.getStatus()).isEqualTo(204);
verify(appleDeviceCheckManager, times(1)).validateAssert(
any(),
eq(keyId),
eq(AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION),
eq(challenge),
eq(request.getBytes(StandardCharsets.UTF_8)),
eq(assertion));
}
}

View File

@ -10,6 +10,7 @@ import java.util.List;
import org.whispersystems.textsecuregcm.backup.BackupsDb;
import org.whispersystems.textsecuregcm.scheduler.JobScheduler;
import org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples;
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceChecks;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
@ -398,6 +399,28 @@ public final class DynamoDbExtensionSchema {
.attributeName(VerificationSessions.KEY_KEY)
.attributeType(ScalarAttributeType.S)
.build()),
List.of(), List.of()),
APPLE_DEVICE_CHECKS("apple_device_check",
AppleDeviceChecks.KEY_ACCOUNT_UUID,
AppleDeviceChecks.KEY_PUBLIC_KEY_ID,
List.of(AttributeDefinition.builder()
.attributeName(AppleDeviceChecks.KEY_ACCOUNT_UUID)
.attributeType(ScalarAttributeType.B)
.build(),
AttributeDefinition.builder()
.attributeName(AppleDeviceChecks.KEY_PUBLIC_KEY_ID)
.attributeType(ScalarAttributeType.B)
.build()),
List.of(), List.of()),
APPLE_DEVICE_CHECKS_KEY_CONSTRAINT("apple_device_check_key_constraint",
AppleDeviceChecks.KEY_PUBLIC_KEY,
null,
List.of(AttributeDefinition.builder()
.attributeName(AppleDeviceChecks.KEY_PUBLIC_KEY)
.attributeType(ScalarAttributeType.B)
.build()),
List.of(), List.of());
private final String tableName;

View File

@ -0,0 +1,237 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage.devicecheck;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import com.webauthn4j.appattest.DeviceCheckManager;
import com.webauthn4j.appattest.authenticator.DCAppleDevice;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.function.Supplier;
import java.util.stream.IntStream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.DynamoDbExtension;
import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;
import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import org.whispersystems.textsecuregcm.util.Util;
class AppleDeviceCheckManagerTest {
private static final UUID ACI = UUID.randomUUID();
@RegisterExtension
static final RedisClusterExtension CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
@RegisterExtension
static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS,
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT);
private final TestClock clock = TestClock.pinned(Instant.now());
private AppleDeviceChecks appleDeviceChecks;
private Account account;
private AppleDeviceCheckManager appleDeviceCheckManager;
@BeforeEach
void setupDeviceChecks() {
clock.pin(Instant.now());
account = mock(Account.class);
when(account.getUuid()).thenReturn(ACI);
final DeviceCheckManager deviceCheckManager = DeviceCheckTestUtil.appleDeviceCheckManager();
appleDeviceChecks = new AppleDeviceChecks(DYNAMO_DB_EXTENSION.getDynamoDbClient(),
DeviceCheckManager.createObjectConverter(),
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS.tableName(),
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT.tableName());
appleDeviceCheckManager = new AppleDeviceCheckManager(appleDeviceChecks, CLUSTER_EXTENSION.getRedisCluster(),
deviceCheckManager, DeviceCheckTestUtil.SAMPLE_TEAM_ID, DeviceCheckTestUtil.SAMPLE_BUNDLE_ID);
}
@Test
public void missingChallengeAttest() {
assertThatExceptionOfType(ChallengeNotFoundException.class).isThrownBy(() ->
appleDeviceCheckManager.registerAttestation(account,
DeviceCheckTestUtil.SAMPLE_KEY_ID,
DeviceCheckTestUtil.SAMPLE_ATTESTATION));
}
@Test
public void missingChallengeAssert() {
assertThatExceptionOfType(ChallengeNotFoundException.class).isThrownBy(() ->
appleDeviceCheckManager.validateAssert(account,
DeviceCheckTestUtil.SAMPLE_KEY_ID,
AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE,
DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8),
DeviceCheckTestUtil.SAMPLE_ASSERTION));
}
@Test
public void tooManyKeys() throws DuplicatePublicKeyException {
final DCAppleDevice dcAppleDevice = DeviceCheckTestUtil.sampleDevice();
// Fill the table with a bunch of keyIds
final List<byte[]> keyIds = IntStream
.range(0, AppleDeviceCheckManager.MAX_DEVICE_KEYS - 1)
.mapToObj(i -> TestRandomUtil.nextBytes(16)).toList();
for (byte[] keyId : keyIds) {
appleDeviceChecks.storeAttestation(account, keyId, dcAppleDevice);
}
// We're allowed 1 more key for this account
assertThatNoException().isThrownBy(() -> registerAttestation(account));
// a new key should be rejected
assertThatExceptionOfType(TooManyKeysException.class).isThrownBy(() ->
appleDeviceCheckManager.registerAttestation(account,
TestRandomUtil.nextBytes(16),
DeviceCheckTestUtil.SAMPLE_ATTESTATION));
// we can however accept an existing key
assertThatNoException().isThrownBy(() -> registerAttestation(account, false));
}
@Test
public void duplicateKeys() {
assertThatNoException().isThrownBy(() -> registerAttestation(account));
final Account duplicator = mock(Account.class);
when(duplicator.getUuid()).thenReturn(UUID.randomUUID());
// Both accounts use the attestation keyId, the second registration should fail
assertThatExceptionOfType(DuplicatePublicKeyException.class)
.isThrownBy(() -> registerAttestation(duplicator));
}
@Test
public void fetchingChallengeRefreshesTtl() throws RateLimitExceededException {
final String challenge =
appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account);
final String redisKey = AppleDeviceCheckManager.challengeKey(AppleDeviceCheckManager.ChallengeType.ATTEST,
account.getUuid());
final String storedChallenge = CLUSTER_EXTENSION.getRedisCluster()
.withCluster(cluster -> cluster.sync().get(redisKey));
assertThat(storedChallenge).isEqualTo(challenge);
final Supplier<Long> ttl = () -> CLUSTER_EXTENSION.getRedisCluster()
.withCluster(cluster -> cluster.sync().ttl(redisKey));
// Wait until the TTL visibly changes (~1sec)
while (ttl.get() >= AppleDeviceCheckManager.CHALLENGE_TTL.toSeconds()) {
Util.sleep(100);
}
// Our TTL fetch needs to happen before the TTL ticks down to make sure the TTL was actually refreshed. So it must
// happen within 1 second. This should be plenty of time, but allow a few retries in case we get very unlucky.
final boolean ttlRefreshed = IntStream.range(0, 5)
.mapToObj(i -> {
assertThatNoException()
.isThrownBy(
() -> appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account));
return ttl.get() == AppleDeviceCheckManager.CHALLENGE_TTL.toSeconds();
})
.anyMatch(detectedRefresh -> detectedRefresh);
assertThat(ttlRefreshed).isTrue();
assertThat(appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account))
.isEqualTo(challenge);
}
@Test
public void validateAssertion() {
assertThatNoException().isThrownBy(() -> registerAttestation(account));
// The sign counter should be 0 since we've made no attests
assertThat(appleDeviceChecks.lookup(account, DeviceCheckTestUtil.SAMPLE_KEY_ID).get().getCounter())
.isEqualTo(0L);
// Rig redis to return our sample challenge for the assert
final String assertChallengeKey = AppleDeviceCheckManager.challengeKey(
AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION,
account.getUuid());
CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster ->
cluster.sync().set(assertChallengeKey, DeviceCheckTestUtil.SAMPLE_CHALLENGE));
assertThatNoException().isThrownBy(() ->
appleDeviceCheckManager.validateAssert(
account,
DeviceCheckTestUtil.SAMPLE_KEY_ID,
AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE,
DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8),
DeviceCheckTestUtil.SAMPLE_ASSERTION));
CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster ->
assertThat(cluster.sync().get(assertChallengeKey)).isNull());
// the sign counter should now be 1 (read from our sample assert)
assertThat(appleDeviceChecks.lookup(account, DeviceCheckTestUtil.SAMPLE_KEY_ID).get().getCounter())
.isEqualTo(1L);
}
@Test
public void assertionCounterMovesBackwards() {
assertThatNoException().isThrownBy(() -> registerAttestation(account));
// force set the sign counter for our keyId to be larger than the sign counter in our sample assert (1)
appleDeviceChecks.updateCounter(account, DeviceCheckTestUtil.SAMPLE_KEY_ID, 2);
// Rig redis to return our sample challenge for the assert
CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster -> cluster.sync().set(
AppleDeviceCheckManager.challengeKey(
AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION,
account.getUuid()),
DeviceCheckTestUtil.SAMPLE_CHALLENGE));
assertThatExceptionOfType(RequestReuseException.class).isThrownBy(() ->
appleDeviceCheckManager.validateAssert(
account,
DeviceCheckTestUtil.SAMPLE_KEY_ID,
AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE,
DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8),
DeviceCheckTestUtil.SAMPLE_ASSERTION));
}
private void registerAttestation(final Account account)
throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {
registerAttestation(account, true);
}
private void registerAttestation(final Account account, boolean assertChallengeRemoved)
throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {
final String attestChallengeKey = AppleDeviceCheckManager.challengeKey(
AppleDeviceCheckManager.ChallengeType.ATTEST,
account.getUuid());
CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster ->
cluster.sync().set(attestChallengeKey, DeviceCheckTestUtil.SAMPLE_CHALLENGE));
try (MockedStatic<Instant> mocked = mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) {
mocked.when(Instant::now).thenReturn(DeviceCheckTestUtil.SAMPLE_TIME);
appleDeviceCheckManager.registerAttestation(account,
DeviceCheckTestUtil.SAMPLE_KEY_ID,
DeviceCheckTestUtil.SAMPLE_ATTESTATION);
}
if (assertChallengeRemoved) {
CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster -> {
// should be deleted once the attestation is registered
assertThat(cluster.sync().get(attestChallengeKey)).isNull();
});
}
}
}

View File

@ -0,0 +1,125 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage.devicecheck;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.webauthn4j.appattest.DeviceCheckManager;
import com.webauthn4j.appattest.authenticator.DCAppleDevice;
import java.util.List;
import java.util.UUID;
import java.util.stream.IntStream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.DynamoDbExtension;
import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
class AppleDeviceChecksTest {
private static final UUID ACI = UUID.randomUUID();
@RegisterExtension
static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS,
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT);
private AppleDeviceChecks deviceChecks;
private Account account;
@BeforeEach
void setupDeviceChecks() {
account = mock(Account.class);
when(account.getUuid()).thenReturn(ACI);
deviceChecks = new AppleDeviceChecks(DYNAMO_DB_EXTENSION.getDynamoDbClient(),
DeviceCheckManager.createObjectConverter(),
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS.tableName(),
DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT.tableName());
}
@Test
public void testSerde() throws DuplicatePublicKeyException {
final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();
final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId();
assertThat(deviceChecks.storeAttestation(account, keyId, appleDevice)).isTrue();
assertThat(deviceChecks.keyIds(account)).containsExactly(keyId);
final DCAppleDevice deserialized = deviceChecks.lookup(account, keyId).orElseThrow();
assertThat(deserialized.getClass()).isEqualTo(appleDevice.getClass());
assertThat(deserialized.getAttestationStatement().getFormat())
.isEqualTo(appleDevice.getAttestationStatement().getFormat());
assertThat(deserialized.getAttestationStatement().getClass())
.isEqualTo(appleDevice.getAttestationStatement().getClass());
assertThat(deserialized.getAttestedCredentialData().getCredentialId())
.isEqualTo(appleDevice.getAttestedCredentialData().getCredentialId());
assertThat(deserialized.getAttestedCredentialData().getCOSEKey())
.isEqualTo(appleDevice.getAttestedCredentialData().getCOSEKey());
assertThat(deserialized.getAttestedCredentialData().getAaguid())
.isEqualTo(appleDevice.getAttestedCredentialData().getAaguid());
assertThat(deserialized.getAuthenticatorExtensions().getExtensions())
.containsExactlyEntriesOf(appleDevice.getAuthenticatorExtensions().getExtensions());
assertThat(deserialized.getCounter())
.isEqualTo(appleDevice.getCounter());
}
@Test
public void duplicateKeys() throws DuplicatePublicKeyException {
final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();
final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId();
final Account dupliateAccount = mock(Account.class);
when(dupliateAccount.getUuid()).thenReturn(UUID.randomUUID());
deviceChecks.storeAttestation(account, keyId, appleDevice);
// Storing same key with a different account fails
assertThatExceptionOfType(DuplicatePublicKeyException.class)
.isThrownBy(() -> deviceChecks.storeAttestation(dupliateAccount, keyId, appleDevice));
// Storing the same key with the same account is fine
assertThatNoException().isThrownBy(() -> deviceChecks.storeAttestation(account, keyId, appleDevice));
}
@Test
public void multipleKeys() throws DuplicatePublicKeyException {
final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();
final List<byte[]> keyIds = IntStream.range(0, 10).mapToObj(i -> TestRandomUtil.nextBytes(16)).toList();
for (byte[] keyId : keyIds) {
// The keyId should typically match the device attestation, but we don't check that at this layer
assertThat(deviceChecks.storeAttestation(account, keyId, appleDevice)).isTrue();
assertThat(deviceChecks.lookup(account, keyId)).isNotEmpty();
}
final List<byte[]> actual = deviceChecks.keyIds(account);
assertThat(actual).containsExactlyInAnyOrderElementsOf(keyIds);
}
@Test
public void updateCounter() throws DuplicatePublicKeyException {
final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();
final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId();
assertThat(appleDevice.getCounter()).isEqualTo(0L);
deviceChecks.storeAttestation(account, keyId, appleDevice);
assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(0L);
assertThat(deviceChecks.updateCounter(account, keyId, 2)).isTrue();
assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(2L);
// Should not update since the counter is stale
assertThat(deviceChecks.updateCounter(account, keyId, 1)).isFalse();
assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(2L);
}
}

View File

@ -0,0 +1,134 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage.devicecheck;
import static org.mockito.Mockito.mockStatic;
import com.webauthn4j.appattest.DeviceCheckManager;
import com.webauthn4j.appattest.authenticator.DCAppleDevice;
import com.webauthn4j.appattest.authenticator.DCAppleDeviceImpl;
import com.webauthn4j.appattest.data.DCAttestationData;
import com.webauthn4j.appattest.data.DCAttestationParameters;
import com.webauthn4j.appattest.data.DCAttestationRequest;
import com.webauthn4j.appattest.server.DCServerProperty;
import com.webauthn4j.data.attestation.AttestationObject;
import com.webauthn4j.data.client.challenge.DefaultChallenge;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Base64;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
public class DeviceCheckTestUtil {
// https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem
private static final String APPLE_APP_ATTEST_ROOT = """
-----BEGIN CERTIFICATE-----
MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
oyFraWVIyd/dganmrduC1bmTBGwD
-----END CERTIFICATE-----
""";
// Sample attestation from apple docs:
// https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide#Example-setup
final static String APPLE_SAMPLE_TEAM_ID = "0352187391";
final static String APPLE_SAMPLE_BUNDLE_ID = "com.apple.example_app_attest";
final static String APPLE_SAMPLE_CHALLENGE = "test_server_challenge";
final static byte[] APPLE_SAMPLE_KEY_ID = Base64.getDecoder().decode("bSrEhF8TIzIvWSPwvZ0i2+UOBre4ASH84rK15m6emNY=");
final static byte[] APPLE_SAMPLE_ATTESTATION = loadBinaryResource("apple-sample-attestation");
// Leaf certificate in apple sample attestation expires 2024-04-20
final static Instant APPLE_SAMPLE_TIME = Instant.parse("2024-04-19T00:00:00.00Z");
// Sample attestation from webauthn4j:
// https://github.com/webauthn4j/webauthn4j/blob/6b7a8f8edce4ab589c49ecde8740873ab96c4218/webauthn4j-appattest/src/test/java/com/webauthn4j/appattest/DeviceCheckManagerTest.java#L126
final static String SAMPLE_TEAM_ID = "8YE23NZS57";
final static String SAMPLE_BUNDLE_ID = "com.kayak.travel";
final static byte[] SAMPLE_KEY_ID = Base64.getDecoder().decode("VnfqjSp0rWyyqNhrfh+9/IhLIvXuYTPAmJEVQwl4dko=");
final static String SAMPLE_CHALLENGE = "1234567890abcdefgh"; // same challenge used for the attest and assert
final static byte[] SAMPLE_ASSERTION = loadBinaryResource("webauthn4j-sample-assertion");
final static byte[] SAMPLE_ATTESTATION = loadBinaryResource("webauthn4j-sample-attestation");
// Leaf certificate in sample attestation expires 2020-09-30
final static Instant SAMPLE_TIME = Instant.parse("2020-09-28T00:00:00Z");
public static DeviceCheckManager appleDeviceCheckManager() {
return new DeviceCheckManager(new AppleDeviceCheckTrustAnchor());
}
public static DCAppleDevice sampleDevice() {
final byte[] clientDataHash = sha256(SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8));
return validate(SAMPLE_CHALLENGE, clientDataHash, SAMPLE_KEY_ID, SAMPLE_ATTESTATION, SAMPLE_TEAM_ID,
SAMPLE_BUNDLE_ID, SAMPLE_TIME);
}
public static DCAppleDevice appleSampleDevice() {
// Note: the apple example provides the clientDataHash (typically the SHA256 of the challenge), NOT the challenge,
// despite them referring to the value as a challenge
final byte[] clientDataHash = APPLE_SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8);
return validate(APPLE_SAMPLE_CHALLENGE, clientDataHash, APPLE_SAMPLE_KEY_ID, APPLE_SAMPLE_ATTESTATION,
APPLE_SAMPLE_TEAM_ID, APPLE_SAMPLE_BUNDLE_ID, APPLE_SAMPLE_TIME);
}
private static DCAppleDevice validate(final String challengePlainText, final byte[] clientDataHash,
final byte[] keyId, final byte[] attestation, final String teamId, final String bundleId, final Instant now) {
final DCAttestationRequest dcAttestationRequest = new DCAttestationRequest(keyId, attestation, clientDataHash);
final DCAttestationData dcAttestationData;
try (final MockedStatic<Instant> instantMock = mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) {
instantMock.when(Instant::now).thenReturn(now);
dcAttestationData = appleDeviceCheckManager().validate(dcAttestationRequest, new DCAttestationParameters(
new DCServerProperty(
teamId, bundleId,
new DefaultChallenge(challengePlainText.getBytes(StandardCharsets.UTF_8)))));
}
final AttestationObject attestationObject = dcAttestationData.getAttestationObject();
return new DCAppleDeviceImpl(
attestationObject.getAuthenticatorData().getAttestedCredentialData(),
attestationObject.getAttestationStatement(),
attestationObject.getAuthenticatorData().getSignCount(),
attestationObject.getAuthenticatorData().getExtensions());
}
private static byte[] sha256(byte[] bytes) {
final MessageDigest sha256;
try {
sha256 = MessageDigest.getInstance("SHA-256");
} catch (final NoSuchAlgorithmException e) {
// All Java implementations are required to support SHA-256
throw new AssertionError(e);
}
return sha256.digest(bytes);
}
private static byte[] loadBinaryResource(final String resourceName) {
try (InputStream stream = DeviceCheckTestUtil.class.getResourceAsStream(resourceName)) {
if (stream == null) {
throw new IllegalArgumentException("Resource not found: " + resourceName);
}
return stream.readAllBytes();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}

View File

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