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