From 2c163352c31e2a97741a160c6c46e0e5cabcf649 Mon Sep 17 00:00:00 2001 From: Ravi Khadiwala Date: Wed, 13 Nov 2024 23:37:22 -0600 Subject: [PATCH] Add DeviceCheck API for iOS Testflight backup enablement --- service/config/sample.yml | 12 + service/pom.xml | 6 + .../WhisperServerConfiguration.java | 20 ++ .../textsecuregcm/WhisperServerService.java | 22 ++ .../backup/BackupAuthManager.java | 25 +- .../AppleDeviceCheckConfiguration.java | 19 ++ .../DeviceCheckConfiguration.java | 15 + .../configuration/DynamoDbTables.java | 18 ++ .../controllers/DeviceCheckController.java | 293 +++++++++++++++++ .../textsecuregcm/limits/RateLimiters.java | 1 + .../devicecheck/AppleDeviceCheckManager.java | 265 +++++++++++++++ .../AppleDeviceCheckTrustAnchor.java | 57 ++++ .../devicecheck/AppleDeviceChecks.java | 304 ++++++++++++++++++ .../ChallengeNotFoundException.java | 7 + .../DeviceCheckKeyIdNotFoundException.java | 7 + ...eviceCheckVerificationFailedException.java | 16 + .../DuplicatePublicKeyException.java | 7 + .../devicecheck/RequestReuseException.java | 12 + .../devicecheck/TooManyKeysException.java | 7 + .../devicecheck/apple_device_check.pem | 14 + .../DeviceCheckControllerTest.java | 225 +++++++++++++ .../storage/DynamoDbExtensionSchema.java | 23 ++ .../AppleDeviceCheckManagerTest.java | 237 ++++++++++++++ .../devicecheck/AppleDeviceChecksTest.java | 125 +++++++ .../devicecheck/DeviceCheckTestUtil.java | 134 ++++++++ service/src/test/resources/config/test.yml | 13 + .../devicecheck/apple-sample-attestation | Bin 0 -> 5637 bytes .../devicecheck/webauthn4j-sample-assertion | Bin 0 -> 140 bytes .../devicecheck/webauthn4j-sample-attestation | Bin 0 -> 5368 bytes 29 files changed, 1877 insertions(+), 7 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppleDeviceCheckConfiguration.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/DeviceCheckConfiguration.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckController.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckTrustAnchor.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecks.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/ChallengeNotFoundException.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckKeyIdNotFoundException.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckVerificationFailedException.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DuplicatePublicKeyException.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/RequestReuseException.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/TooManyKeysException.java create mode 100644 service/src/main/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple_device_check.pem create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckControllerTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManagerTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecksTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckTestUtil.java create mode 100644 service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple-sample-attestation create mode 100644 service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/webauthn4j-sample-assertion create mode 100644 service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/webauthn4j-sample-attestation diff --git a/service/config/sample.yml b/service/config/sample.yml index fb60d3956..14486ddbf 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -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: diff --git a/service/pom.xml b/service/pom.xml index b26c19331..50eb2b9c9 100644 --- a/service/pom.xml +++ b/service/pom.xml @@ -15,6 +15,7 @@ 5.1.0 v3-rev20241016-2.0.0 3.2.0 + 0.28.0.RELEASE @@ -35,6 +36,11 @@ + + com.webauthn4j + webauthn4j-appattest + ${webauthn4j.version} + io.swagger.core.v3 swagger-jaxrs2-jakarta diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 2ff9ca3cd..e173ae2d0 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -16,6 +16,7 @@ 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.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 AppleAppStoreConfiguration getAppleAppStore() { return appleAppStore; } + public AppleDeviceCheckConfiguration getAppleDeviceCheck() { + return appleDeviceCheck; + } + + public DeviceCheckConfiguration getDeviceCheck() { + return deviceCheck; + } + public DynamoDbClientFactory getDynamoDbClientConfiguration() { return dynamoDbClient; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index be586a920..249c7d8ce 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -8,6 +8,7 @@ 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.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.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 void run(WhisperServerConfiguration config, Environment environment) thro 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 @@ protected void configureServer(final ServerBuilder serverBuilder) { 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), diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java index 515c32f39..07cb7af7e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java @@ -235,13 +235,24 @@ public CompletableFuture redeemReceipt( .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 extendBackupVoucher(final Account account, final Account.BackupVoucher backupVoucher) { + return accountsManager.updateAsync(account, a -> { + final Account.BackupVoucher newPayment = backupVoucher; + final Account.BackupVoucher existingPayment = a.getBackupVoucher(); + a.setBackupVoucher(merge(existingPayment, newPayment)); + }).thenRun(Util.NOOP); } private static Account.BackupVoucher merge(@Nullable final Account.BackupVoucher prev, diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppleDeviceCheckConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppleDeviceCheckConfiguration.java new file mode 100644 index 000000000..5f1b8c5a1 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppleDeviceCheckConfiguration.java @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.configuration; + +import java.time.Duration; + +/** + * Configuration for Apple DeviceCheck + * + * @param production Whether this is for production or sandbox attestations + * @param teamId The teamId to validate attestations against + * @param bundleId The bundleId to validation attestations against + */ +public record AppleDeviceCheckConfiguration( + boolean production, + String teamId, + String bundleId) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DeviceCheckConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DeviceCheckConfiguration.java new file mode 100644 index 000000000..42ebdfb1f --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DeviceCheckConfiguration.java @@ -0,0 +1,15 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.configuration; + +import java.time.Duration; + +/** + * Configuration for Device Check operations + * + * @param backupRedemptionDuration How long to grant backup access for redemptions via device check + * @param backupRedemptionLevel What backup level to grant redemptions via device check + */ +public record DeviceCheckConfiguration(Duration backupRedemptionDuration, long backupRedemptionLevel) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java index 2eb8644b6..54bfb93a8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java @@ -48,6 +48,8 @@ public Duration getExpiration() { 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 Duration getExpiration() { 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 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 AccountsTableConfiguration getAccounts() { return accounts; } + @NotNull + @Valid + public Table getAppleDeviceChecks() { + return appleDeviceChecks; + } + + @NotNull + @Valid + public Table getAppleDeviceCheckPublicKeys() { + return appleDeviceCheckPublicKeys; + } + @NotNull @Valid public Table getBackups() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckController.java new file mode 100644 index 000000000..52605edc1 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckController.java @@ -0,0 +1,293 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.controllers; + +import com.fasterxml.jackson.annotation.JsonCreator; +import io.dropwizard.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.util.Base64; +import java.util.Locale; +import org.glassfish.jersey.server.ManagedAsync; +import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.backup.BackupAuthManager; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager; +import org.whispersystems.textsecuregcm.storage.devicecheck.ChallengeNotFoundException; +import org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckKeyIdNotFoundException; +import org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckVerificationFailedException; +import org.whispersystems.textsecuregcm.storage.devicecheck.DuplicatePublicKeyException; +import org.whispersystems.textsecuregcm.storage.devicecheck.RequestReuseException; +import org.whispersystems.textsecuregcm.storage.devicecheck.TooManyKeysException; +import org.whispersystems.textsecuregcm.util.SystemMapper; +import org.whispersystems.websocket.auth.ReadOnly; + +/** + * Process platform device attestations. + *

+ * Device attestations allow clients that can prove that they are running a signed signal build on valid Apple hardware. + * Currently, this is only used to allow beta builds to access backup functionality, since in-app purchases are not + * available iOS TestFlight builds. + */ +@Path("/v1/devicecheck") +@io.swagger.v3.oas.annotations.tags.Tag(name = "DeviceCheck") +public class DeviceCheckController { + + private final Clock clock; + private final BackupAuthManager backupAuthManager; + private final AppleDeviceCheckManager deviceCheckManager; + private final RateLimiters rateLimiters; + private final long backupRedemptionLevel; + private final Duration backupRedemptionDuration; + + public DeviceCheckController( + final Clock clock, + final BackupAuthManager backupAuthManager, + final AppleDeviceCheckManager deviceCheckManager, + final RateLimiters rateLimiters, + final long backupRedemptionLevel, + final Duration backupRedemptionDuration) { + this.clock = clock; + this.backupAuthManager = backupAuthManager; + this.deviceCheckManager = deviceCheckManager; + this.backupRedemptionLevel = backupRedemptionLevel; + this.backupRedemptionDuration = backupRedemptionDuration; + this.rateLimiters = rateLimiters; + } + + public record ChallengeResponse( + @Schema(description = "A challenge to use when generating attestations or assertions") + String challenge) {} + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/attest") + @Operation(summary = "Fetch an attest challenge", description = """ + Retrieve a challenge to use in an attestation, which should be provided at `PUT /v1/devicecheck/attest`. To + produce the clientDataHash for [attestKey](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:)) + take the SHA256 of the UTF-8 bytes of the returned challenge. + + Repeat calls to retrieve a challenge may return the same challenge until it is used in a `PUT`. Callers should + have a single outstanding challenge at any given time. + """) + @ApiResponse(responseCode = "200", description = "The response body includes a challenge") + @ApiResponse(responseCode = "429", description = "Ratelimited.") + @ManagedAsync + public ChallengeResponse attestChallenge(@ReadOnly @Auth AuthenticatedDevice authenticatedDevice) + throws RateLimitExceededException { + rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE) + .validate(authenticatedDevice.getAccount().getUuid()); + + return new ChallengeResponse(deviceCheckManager.createChallenge( + AppleDeviceCheckManager.ChallengeType.ATTEST, + authenticatedDevice.getAccount())); + } + + @PUT + @Consumes(MediaType.APPLICATION_OCTET_STREAM) + @Path("/attest") + @Operation(summary = "Register a keyId", description = """ + Register a keyId with an attestation, which can be used to generate assertions from this account. + + The attestation should use the SHA-256 of a challenge retrieved at `GET /v1/devicecheck/attest` as the + `clientDataHash` + + Registration is idempotent, and you should retry network errors with the same challenge as suggested by [device + check](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:)#discussion), + as long as your challenge has not expired (410). Even if your challenge is expired, you may continue to retry with + your original keyId (and a fresh challenge). + """) + @ApiResponse(responseCode = "204", description = "The keyId was successfully added to the account") + @ApiResponse(responseCode = "410", description = "There was no challenge associated with the account. It may have expired.") + @ApiResponse(responseCode = "401", description = "The attestation could not be verified") + @ApiResponse(responseCode = "413", description = "There are too many unique keyIds associated with this account. This is an unrecoverable error.") + @ApiResponse(responseCode = "409", description = "The provided keyId has already been registered to a different account") + @ManagedAsync + public void attest( + @ReadOnly @Auth final AuthenticatedDevice authenticatedDevice, + + @Valid + @NotNull + @Parameter(description = "The keyId, encoded with padded url-safe base64") + @QueryParam("keyId") final String keyId, + + @RequestBody(description = "The attestation data, created by [attestKey](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:))") + @NotNull final byte[] attestation) { + + try { + deviceCheckManager.registerAttestation(authenticatedDevice.getAccount(), parseKeyId(keyId), attestation); + } catch (TooManyKeysException e) { + throw new WebApplicationException(Response.status(413).build()); + } catch (ChallengeNotFoundException e) { + throw new WebApplicationException(Response.status(410).build()); + } catch (DeviceCheckVerificationFailedException e) { + throw new WebApplicationException(e.getMessage(), Response.status(401).build()); + } catch (DuplicatePublicKeyException e) { + throw new WebApplicationException(Response.status(409).build()); + } + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/assert") + @Operation(summary = "Fetch an assert challenge", description = """ + Retrieve a challenge to use in an attestation, which must be provided at `POST /v1/devicecheck/assert`. To produce + the `clientDataHash` for [generateAssertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:)), + construct the request you intend to `POST` and include the returned challenge as the "challenge" + field. Serialize the request as JSON and take the SHA256 of the request, as described [here](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity#Assert-your-apps-validity-as-necessary). + Note that the JSON body provided to the PUT must exactly match the input to the `clientDataHash` (field order, + whitespace, etc matters) + + Repeat calls to retrieve a challenge may return the same challenge until it is used in a `POST`. Callers should + attempt to only have a single outstanding challenge at any given time. + """) + @ApiResponse(responseCode = "200", description = "The response body includes a challenge") + @ApiResponse(responseCode = "429", description = "Ratelimited.") + @ManagedAsync + public ChallengeResponse assertChallenge( + @ReadOnly @Auth AuthenticatedDevice authenticatedDevice, + + @Parameter(schema = @Schema(description = "The type of action you will make an assertion for", + allowableValues = {"backup"}, + implementation = String.class)) + @QueryParam("action") Action action) throws RateLimitExceededException { + rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE) + .validate(authenticatedDevice.getAccount().getUuid()); + return new ChallengeResponse( + deviceCheckManager.createChallenge(toChallengeType(action), + authenticatedDevice.getAccount())); + } + + @POST + @Consumes(MediaType.APPLICATION_OCTET_STREAM) + @Path("/assert") + @Operation(summary = "Perform an attested action", description = """ + Specify some action to take on the account via the request field. The request must exactly match the request you + provide when [generating the assertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:)). + The request must include a challenge previously retrieved from `GET /v1/devicecheck/assert`. + + Each assertion increments the counter associated with the client's device key. This method enforces that no + assertion with a counter lower than a counter we've already seen is allowed to execute. If a client issues + multiple requests concurrently, or if they retry a request that had an indeterminate outcome, it's possible that + the request will not be accepted because the server has already stored the updated counter. In this case the + request may return 401, and the client should generate a fresh assert for the request. + """) + @ApiResponse(responseCode = "204", description = "The assertion was valid and the corresponding action was executed") + @ApiResponse(responseCode = "404", description = "The provided keyId was not found") + @ApiResponse(responseCode = "410", description = "There was no challenge associated with the account. It may have expired.") + @ApiResponse(responseCode = "401", description = "The assertion could not be verified") + @ManagedAsync + public void assertion( + @ReadOnly @Auth final AuthenticatedDevice authenticatedDevice, + + @Valid + @NotNull + @Parameter(description = "The keyId, encoded with padded url-safe base64") + @QueryParam("keyId") final String keyId, + + @Valid + @NotNull + @Parameter(description = """ + The asserted JSON request data, encoded as a string in padded url-safe base64. This must exactly match the + request you use when generating the assertion (including field ordering, whitespace, etc). + """, + schema = @Schema(implementation = AssertionRequest.class)) + @QueryParam("request") final DeviceCheckController.AssertionRequestWrapper request, + + @RequestBody(description = "The assertion created by [generateAssertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:))") + @NotNull final byte[] assertion) { + + try { + deviceCheckManager.validateAssert( + authenticatedDevice.getAccount(), + parseKeyId(keyId), + toChallengeType(request.assertionRequest().action()), + request.assertionRequest().challenge(), + request.rawJson(), + assertion); + } catch (ChallengeNotFoundException e) { + throw new WebApplicationException(Response.status(410).build()); + } catch (DeviceCheckVerificationFailedException e) { + throw new WebApplicationException(e.getMessage(), Response.status(401).build()); + } catch (DeviceCheckKeyIdNotFoundException | RequestReuseException e) { + throw new WebApplicationException(Response.status(404).build()); + } + + // The request assertion was validated, execute it + switch (request.assertionRequest().action()) { + case BACKUP -> backupAuthManager.extendBackupVoucher( + authenticatedDevice.getAccount(), + new Account.BackupVoucher(backupRedemptionLevel, clock.instant().plus(backupRedemptionDuration))) + .join(); + } + } + + public enum Action { + BACKUP; + + @JsonCreator + public static Action fromString(final String action) { + for (final Action a : Action.values()) { + if (a.name().toLowerCase(Locale.ROOT).equals(action)) { + return a; + } + } + throw new IllegalArgumentException("Invalid action: " + action); + } + } + + public record AssertionRequest( + @Schema(description = "The challenge retrieved at `GET /v1/devicecheck/assert`") + String challenge, + @Schema(description = "The type of action you'd like to perform with this assert", + allowableValues = {"backup"}, implementation = String.class) + Action action) {} + + /* + * Parses the base64 encoded AssertionRequest, but preserves the rawJson as well + */ + public record AssertionRequestWrapper(AssertionRequest assertionRequest, byte[] rawJson) { + + public static AssertionRequestWrapper fromString(String requestBase64) throws IOException { + final byte[] requestJson = Base64.getUrlDecoder().decode(requestBase64); + final AssertionRequest requestData = SystemMapper.jsonMapper().readValue(requestJson, AssertionRequest.class); + return new AssertionRequestWrapper(requestData, requestJson); + } + } + + + private static AppleDeviceCheckManager.ChallengeType toChallengeType(final Action action) { + return switch (action) { + case BACKUP -> AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION; + }; + } + + private static byte[] parseKeyId(final String base64KeyId) { + try { + return Base64.getUrlDecoder().decode(base64KeyId); + } catch (IllegalArgumentException e) { + throw new WebApplicationException(Response.status(422).entity(e.getMessage()).build()); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java index b7975cbb6..05c8347c6 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java @@ -57,6 +57,7 @@ public enum For implements RateLimiterDescriptor { WAIT_FOR_TRANSFER_ARCHIVE("waitForTransferArchive", true, new RateLimiterConfig(10, Duration.ofSeconds(30))), RECORD_DEVICE_TRANSFER_REQUEST("recordDeviceTransferRequest", true, new RateLimiterConfig(10, Duration.ofMillis(100))), WAIT_FOR_DEVICE_TRANSFER_REQUEST("waitForDeviceTransferRequest", true, new RateLimiterConfig(10, Duration.ofMillis(100))), + DEVICE_CHECK_CHALLENGE("deviceCheckChallenge", true, new RateLimiterConfig(10, Duration.ofMinutes(1))), ; private final String id; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java new file mode 100644 index 000000000..409afbb9d --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java @@ -0,0 +1,265 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +import com.google.common.annotations.VisibleForTesting; +import com.webauthn4j.appattest.DeviceCheckManager; +import com.webauthn4j.appattest.authenticator.DCAppleDevice; +import com.webauthn4j.appattest.authenticator.DCAppleDeviceImpl; +import com.webauthn4j.appattest.data.DCAssertionParameters; +import com.webauthn4j.appattest.data.DCAssertionRequest; +import com.webauthn4j.appattest.data.DCAttestationData; +import com.webauthn4j.appattest.data.DCAttestationParameters; +import com.webauthn4j.appattest.data.DCAttestationRequest; +import com.webauthn4j.appattest.server.DCServerProperty; +import com.webauthn4j.data.attestation.AttestationObject; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.verifier.exception.MaliciousCounterValueException; +import com.webauthn4j.verifier.exception.VerificationException; +import io.lettuce.core.RedisException; +import io.lettuce.core.SetArgs; +import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient; +import org.whispersystems.textsecuregcm.storage.Account; + +/** + * Register Apple DeviceCheck App Attestations and verify the corresponding assertions. + * + * @see ... + * @see ... + */ +public class AppleDeviceCheckManager { + + private static final Logger logger = LoggerFactory.getLogger(AppleDeviceCheckManager.class); + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int CHALLENGE_LENGTH = 16; + + // How long issued challenges last in redis + @VisibleForTesting + static final Duration CHALLENGE_TTL = Duration.ofHours(1); + + // How many distinct device keys we're willing to accept for a single Account + @VisibleForTesting + static final int MAX_DEVICE_KEYS = 100; + + private final AppleDeviceChecks appleDeviceChecks; + private final FaultTolerantRedisClusterClient redisClient; + private final DeviceCheckManager deviceCheckManager; + private final String teamId; + private final String bundleId; + + public AppleDeviceCheckManager( + AppleDeviceChecks appleDeviceChecks, + FaultTolerantRedisClusterClient redisClient, + DeviceCheckManager deviceCheckManager, + String teamId, + String bundleId) { + this.appleDeviceChecks = appleDeviceChecks; + this.redisClient = redisClient; + this.deviceCheckManager = deviceCheckManager; + this.teamId = teamId; + this.bundleId = bundleId; + } + + /** + * Attestations and assertions have independent challenges. + *

+ * Challenges are tied to their purpose to mitigate replay attacks + */ + public enum ChallengeType { + ATTEST, + ASSERT_BACKUP_REDEMPTION + } + + /** + * Register a key and attestation data for an account + * + * @param account The account this keyId should be associated with + * @param keyId The device's keyId + * @param attestBlob The device's attestation + * @throws ChallengeNotFoundException No issued challenge found for the account + * @throws DeviceCheckVerificationFailedException The provided attestation could not be verified + * @throws TooManyKeysException The account has registered too many unique keyIds + * @throws DuplicatePublicKeyException The keyId has already been used with another account + */ + public void registerAttestation(final Account account, final byte[] keyId, final byte[] attestBlob) + throws TooManyKeysException, ChallengeNotFoundException, DeviceCheckVerificationFailedException, DuplicatePublicKeyException { + + final List existingKeys = appleDeviceChecks.keyIds(account); + if (existingKeys.stream().anyMatch(x -> MessageDigest.isEqual(x, keyId))) { + // We already have the key, so no need to continue + return; + } + + if (existingKeys.size() >= MAX_DEVICE_KEYS) { + // This is best-effort, since we don't check the number of keys transactionally. We just don't want to allow + // the keys for an account to grow arbitrarily large + throw new TooManyKeysException(); + } + + final String redisChallengeKey = challengeKey(ChallengeType.ATTEST, account.getUuid()); + final String challenge = redisClient.withCluster(cluster -> cluster.sync().get(redisChallengeKey)); + if (challenge == null) { + throw new ChallengeNotFoundException(); + } + + final byte[] clientDataHash = sha256(challenge.getBytes(StandardCharsets.UTF_8)); + final DCAttestationRequest dcAttestationRequest = new DCAttestationRequest(keyId, attestBlob, clientDataHash); + final DCAttestationData dcAttestationData; + try { + dcAttestationData = deviceCheckManager.validate(dcAttestationRequest, + new DCAttestationParameters(new DCServerProperty(teamId, bundleId, new DefaultChallenge(challenge)))); + } catch (VerificationException e) { + logger.info("Failed to verify attestation", e); + throw new DeviceCheckVerificationFailedException(e); + } + appleDeviceChecks.storeAttestation(account, keyId, createDcAppleDevice(dcAttestationData)); + removeChallenge(redisChallengeKey); + } + + private static DCAppleDeviceImpl createDcAppleDevice(final DCAttestationData dcAttestationData) { + final AttestationObject attestationObject = dcAttestationData.getAttestationObject(); + if (attestationObject == null || attestationObject.getAuthenticatorData().getAttestedCredentialData() == null) { + throw new IllegalArgumentException("Signed and validated attestation missing expected data"); + } + return new DCAppleDeviceImpl( + attestationObject.getAuthenticatorData().getAttestedCredentialData(), + attestationObject.getAttestationStatement(), + attestationObject.getAuthenticatorData().getSignCount(), + attestationObject.getAuthenticatorData().getExtensions()); + } + + /** + * Validate that a request came from an Apple device signed with a key already registered to the account + * + * @param account The requesting account + * @param keyId The key used to generate the assertion + * @param challengeType The {@link ChallengeType} of the assertion, which must match the challenge returned by + * {@link AppleDeviceCheckManager#createChallenge} + * @param challenge A challenge that was embedded in the supplied request + * @param request The request that the client asserted + * @param assertion The assertion from the client + * @throws DeviceCheckKeyIdNotFoundException The provided keyId was never registered with the account + * @throws ChallengeNotFoundException No issued challenge found for the account + * @throws DeviceCheckVerificationFailedException The provided assertion could not be verified + * @throws RequestReuseException The signed counter on the assertion was lower than a previously + * received assertion + */ + public void validateAssert( + final Account account, + final byte[] keyId, + final ChallengeType challengeType, + final String challenge, + final byte[] request, + final byte[] assertion) + throws ChallengeNotFoundException, DeviceCheckVerificationFailedException, DeviceCheckKeyIdNotFoundException, RequestReuseException { + + final String redisChallengeKey = challengeKey(challengeType, account.getUuid()); + final String storedChallenge = redisClient.withCluster(cluster -> cluster.sync().get(redisChallengeKey)); + if (storedChallenge == null) { + throw new ChallengeNotFoundException(); + } + if (!MessageDigest.isEqual( + storedChallenge.getBytes(StandardCharsets.UTF_8), + challenge.getBytes(StandardCharsets.UTF_8))) { + throw new DeviceCheckVerificationFailedException("Provided challenge did not match stored challenge"); + } + + final DCAppleDevice appleDevice = appleDeviceChecks.lookup(account, keyId) + .orElseThrow(DeviceCheckKeyIdNotFoundException::new); + final DCAssertionRequest dcAssertionRequest = new DCAssertionRequest(keyId, assertion, sha256(request)); + final DCAssertionParameters dcAssertionParameters = + new DCAssertionParameters(new DCServerProperty(teamId, bundleId, new DefaultChallenge(request)), appleDevice); + + try { + deviceCheckManager.validate(dcAssertionRequest, dcAssertionParameters); + } catch (MaliciousCounterValueException e) { + // We will only accept assertions that have a sign count greater than the last assertion we saw. Step 5 here: + // https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server#Verify-the-assertion + throw new RequestReuseException("Sign count from request less than stored sign count"); + } catch (VerificationException e) { + logger.info("Failed to validate DeviceCheck assert", e); + throw new DeviceCheckVerificationFailedException(e); + } + + // Store the updated sign count, so we can check the next assertion (step 6) + appleDeviceChecks.updateCounter(account, keyId, appleDevice.getCounter()); + removeChallenge(redisChallengeKey); + } + + /** + * Create a challenge that can be used in an attestation or assertion + * + * @param challengeType The type of the challenge + * @param account The account that will use the challenge + * @return The challenge to be included as part of an attestation or assertion + */ + public String createChallenge(final ChallengeType challengeType, final Account account) + throws RateLimitExceededException { + final UUID accountIdentifier = account.getUuid(); + + final String challengeKey = challengeKey(challengeType, accountIdentifier); + return redisClient.withCluster(cluster -> { + final RedisAdvancedClusterCommands commands = cluster.sync(); + + // Sets the new challenge if and only if there isn't already one stored for the challenge key; returns the existing + // challenge if present or null if no challenge was previously set. + final String proposedChallenge = generateChallenge(); + @Nullable final String existingChallenge = + commands.setGet(challengeKey, proposedChallenge, SetArgs.Builder.nx().ex(CHALLENGE_TTL)); + + if (existingChallenge != null) { + // If the key was already set, make sure we extend the TTL. This is racy because the key could disappear or have + // been updated since the get returned, but it's fine. In the former case, this is a noop. In the latter + // case we may slightly extend the TTL from after it was set, but that's also no big deal. + commands.expire(challengeKey, CHALLENGE_TTL); + } + + return existingChallenge != null ? existingChallenge : proposedChallenge; + }); + } + + private void removeChallenge(final String challengeKey) { + try { + redisClient.useCluster(cluster -> cluster.sync().del(challengeKey)); + } catch (RedisException e) { + logger.debug("failed to remove attest challenge from redis, will let it expire via TTL"); + } + } + + @VisibleForTesting + static String challengeKey(final ChallengeType challengeType, final UUID accountIdentifier) { + return "device_check::" + challengeType.name() + "::" + accountIdentifier.toString(); + } + + private static String generateChallenge() { + final byte[] challenge = new byte[CHALLENGE_LENGTH]; + SECURE_RANDOM.nextBytes(challenge); + return Base64.getUrlEncoder().withoutPadding().encodeToString(challenge); + } + + private static byte[] sha256(byte[] bytes) { + try { + return MessageDigest.getInstance("SHA-256").digest(bytes); + } catch (final NoSuchAlgorithmException e) { + throw new AssertionError("All Java implementations are required to support SHA-256", e); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckTrustAnchor.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckTrustAnchor.java new file mode 100644 index 000000000..76193c8f1 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckTrustAnchor.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +import com.webauthn4j.anchor.TrustAnchorRepository; +import com.webauthn4j.data.attestation.authenticator.AAGUID; +import com.webauthn4j.util.CertificateUtil; +import com.webauthn4j.verifier.attestation.trustworthiness.certpath.DefaultCertPathTrustworthinessVerifier; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Set; + +/** + * A {@link com.webauthn4j.verifier.attestation.trustworthiness.certpath.CertPathTrustworthinessVerifier} for validating + * x5 certificate chains, pinned with apple's well known static device check root certificate. + */ +public class AppleDeviceCheckTrustAnchor extends DefaultCertPathTrustworthinessVerifier { + + // The location of a PEM encoded certificate for Apple's DeviceCheck root certificate + // https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem + private static String APPLE_DEVICE_CHECK_ROOT_CERT_RESOURCE_NAME = "apple_device_check.pem"; + + public AppleDeviceCheckTrustAnchor() { + super(new StaticTrustAnchorRepository(loadDeviceCheckRootCert())); + } + + private record StaticTrustAnchorRepository(X509Certificate rootCert) implements TrustAnchorRepository { + + @Override + public Set find(final AAGUID aaguid) { + return Collections.singleton(new TrustAnchor(rootCert, null)); + } + + @Override + public Set find(final byte[] attestationCertificateKeyIdentifier) { + return Collections.singleton(new TrustAnchor(rootCert, null)); + } + } + + private static X509Certificate loadDeviceCheckRootCert() { + try (InputStream stream = AppleDeviceCheckTrustAnchor.class.getResourceAsStream( + APPLE_DEVICE_CHECK_ROOT_CERT_RESOURCE_NAME)) { + if (stream == null) { + throw new IllegalArgumentException("Resource not found: " + APPLE_DEVICE_CHECK_ROOT_CERT_RESOURCE_NAME); + } + return CertificateUtil.generateX509Certificate(stream); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecks.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecks.java new file mode 100644 index 000000000..7033fce7b --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecks.java @@ -0,0 +1,304 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.webauthn4j.appattest.authenticator.DCAppleDevice; +import com.webauthn4j.appattest.authenticator.DCAppleDeviceImpl; +import com.webauthn4j.appattest.data.attestation.statement.AppleAppAttestAttestationStatement; +import com.webauthn4j.converter.AttestedCredentialDataConverter; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData; +import com.webauthn4j.data.attestation.statement.AttestationStatement; +import com.webauthn4j.data.extension.authenticator.AuthenticationExtensionsAuthenticatorOutputs; +import com.webauthn4j.data.extension.authenticator.RegistrationExtensionAuthenticatorOutput; +import java.security.PublicKey; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.util.AttributeValues; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.CancellationReason; +import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; +import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; +import software.amazon.awssdk.services.dynamodb.model.Put; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem; +import software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest; +import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +/** + * Store DeviceCheck attestations along with accounts, so they can be retrieved later to validate assertions. + *

+ * Callers associate a keyId and attestation with an account, and then use the corresponding key to make potentially + * many attested requests (assertions). Each assertion increments the counter associated with the key. + *

+ * Callers can associate more than one keyId/attestation with an account (for example, they may get a new device). + * However, each keyId must only be registered for a single account. + * + * @implNote We use a second table keyed on the public key to enforce uniqueness. + */ +public class AppleDeviceChecks { + + // B: uuid, primary key + public static final String KEY_ACCOUNT_UUID = "U"; + // B: key id, sort key. The key id is the SHA256 of the X9.62 uncompressed point format of the public key + public static final String KEY_PUBLIC_KEY_ID = "KID"; + // N: counter, the number of asserts signed by the public key (updates on every assert) + private static final String ATTR_COUNTER = "C"; + // B: attestedCredentialData + private static final String ATTR_CRED_DATA = "CD"; + // B: attestationStatement, CBOR + private static final String ATTR_STATEMENT = "S"; + // B: authenticatorExtensions, CBOR + private static final String ATTR_AUTHENTICATOR_EXTENSIONS = "AE"; + + // B: public key bytes, primary key for the public key table + public static final String KEY_PUBLIC_KEY = "PK"; + + private static final String CONDITIONAL_CHECK_FAILED = "ConditionalCheckFailed"; + + private final DynamoDbClient dynamoDbClient; + private final String deviceCheckTableName; + private final String publicKeyConstraintTableName; + private final ObjectConverter objectConverter; + + public AppleDeviceChecks( + final DynamoDbClient dynamoDbClient, + final ObjectConverter objectConverter, + final String deviceCheckTableName, + final String publicKeyConstraintTableName) { + this.dynamoDbClient = dynamoDbClient; + this.objectConverter = objectConverter; + this.deviceCheckTableName = deviceCheckTableName; + this.publicKeyConstraintTableName = publicKeyConstraintTableName; + } + + /** + * Retrieve DeviceCheck keyIds + * + * @param account The account to fetch keyIds for + * @return A list of keyIds currently associated with the account + */ + public List keyIds(final Account account) { + return dynamoDbClient.queryPaginator(QueryRequest.builder() + .tableName(deviceCheckTableName) + .keyConditionExpression("#aci = :aci") + .expressionAttributeNames(Map.of("#aci", KEY_ACCOUNT_UUID, "#kid", KEY_PUBLIC_KEY_ID)) + .expressionAttributeValues(Map.of(":aci", AttributeValues.fromUUID(account.getUuid()))) + .projectionExpression("#kid") + .build()) + .items() + .stream() + .flatMap(item -> getByteArray(item, KEY_PUBLIC_KEY_ID).stream()) + .toList(); + } + + /** + * Register an attestation for a keyId with an account. The attestation can later be retrieved via {@link #lookup}. If + * the provided keyId is already registered with the account and is more up to date, no update will occur and this + * method will return false. + * + * @param account The account to store the registration + * @param keyId The keyId to associate with the account + * @param appleDevice Attestation information to store + * @return true if the attestation was stored, false if the keyId already had an attestation + * @throws DuplicatePublicKeyException If a different account has already registered this public key + */ + public boolean storeAttestation(final Account account, final byte[] keyId, final DCAppleDevice appleDevice) + throws DuplicatePublicKeyException { + try { + dynamoDbClient.transactWriteItems(TransactWriteItemsRequest.builder().transactItems( + + // Register the public key and associated data with the account + TransactWriteItem.builder().put(Put.builder() + .tableName(deviceCheckTableName) + .item(toItem(account, keyId, appleDevice)) + // The caller should have done a non-transactional read to verify we didn't already have this keyId, but a + // race is possible. It's fine to wipe out an existing key (should be identical), as long as we don't + // lower the signed count associated with the key. + .conditionExpression("attribute_not_exists(#counter) OR #counter <= :counter") + .expressionAttributeNames(Map.of("#counter", ATTR_COUNTER)) + .expressionAttributeValues(Map.of(":counter", AttributeValues.n(appleDevice.getCounter()))) + .build()).build(), + + // Enforce uniqueness on the supplied public key + TransactWriteItem.builder().put(Put.builder() + .tableName(publicKeyConstraintTableName) + .item(Map.of( + KEY_PUBLIC_KEY, AttributeValues.fromByteArray(extractPublicKey(appleDevice).getEncoded()), + KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()) + )) + // Enforces public key uniqueness, as described in https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server#Store-the-public-key-and-receipt + .conditionExpression("attribute_not_exists(#pk) or #aci = :aci") + .expressionAttributeNames(Map.of("#aci", KEY_ACCOUNT_UUID, "#pk", KEY_PUBLIC_KEY)) + .expressionAttributeValues(Map.of(":aci", AttributeValues.fromUUID(account.getUuid()))) + .build()).build()).build()); + return true; + + } catch (TransactionCanceledException e) { + final CancellationReason updateCancelReason = e.cancellationReasons().get(0); + if (conditionalCheckFailed(updateCancelReason)) { + // The provided attestation is older than the one we already have stored + return false; + } + final CancellationReason publicKeyCancelReason = e.cancellationReasons().get(1); + if (conditionalCheckFailed(publicKeyCancelReason)) { + throw new DuplicatePublicKeyException(); + } + throw e; + } + } + + /** + * Retrieve the device attestation information previous registered with the account + * + * @param account The account that registered the keyId + * @param keyId The keyId that was registered + * @return Device attestation information that can be used to validate an assertion + */ + public Optional lookup(final Account account, final byte[] keyId) { + final GetItemResponse item = dynamoDbClient.getItem(GetItemRequest.builder() + .tableName(deviceCheckTableName) + .key(Map.of( + KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()), + KEY_PUBLIC_KEY_ID, AttributeValues.fromByteArray(keyId))).build()); + return item.hasItem() ? Optional.of(fromItem(item.item())) : Optional.empty(); + } + + /** + * Attempt to increase the signed counter to the newCounter value. This method enforces that the counter increases + * monotonically, if the new value is less than the existing counter, no update occurs and the method returns false. + * + * @param account The account the keyId is registered to + * @param keyId The keyId to update + * @param newCounter The new counter value + * @return true if the counter was updated, false if the stored counter was larger than newCounter + */ + public boolean updateCounter(final Account account, final byte[] keyId, final long newCounter) { + try { + dynamoDbClient.updateItem(UpdateItemRequest.builder() + .tableName(deviceCheckTableName) + .key(Map.of( + KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()), + KEY_PUBLIC_KEY_ID, AttributeValues.fromByteArray(keyId))) + .expressionAttributeNames(Map.of("#counter", ATTR_COUNTER)) + .expressionAttributeValues(Map.of(":counter", AttributeValues.n(newCounter))) + .updateExpression("SET #counter = :counter") + // someone could possibly race with us to update the counter. No big deal, but we shouldn't decrease the + // current counter + .conditionExpression("#counter <= :counter").build()); + return true; + } catch (ConditionalCheckFailedException e) { + // We failed to increment the counter because it has already moved forward + return false; + } + } + + private Map toItem(final Account account, final byte[] keyId, DCAppleDevice appleDevice) { + // Serialize the various data members, see: https://webauthn4j.github.io/webauthn4j/en/#deep-dive + final AttestedCredentialDataConverter attestedCredentialDataConverter = + new AttestedCredentialDataConverter(objectConverter); + final byte[] attestedCredentialData = + attestedCredentialDataConverter.convert(appleDevice.getAttestedCredentialData()); + final byte[] attestationStatement = objectConverter.getCborConverter() + .writeValueAsBytes(new AttestationStatementEnvelope(appleDevice.getAttestationStatement())); + final long counter = appleDevice.getCounter(); + final byte[] authenticatorExtensions = objectConverter.getCborConverter() + .writeValueAsBytes(appleDevice.getAuthenticatorExtensions()); + + return Map.of( + KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()), + KEY_PUBLIC_KEY_ID, AttributeValues.fromByteArray(keyId), + ATTR_CRED_DATA, AttributeValues.fromByteArray(attestedCredentialData), + ATTR_STATEMENT, AttributeValues.fromByteArray(attestationStatement), + ATTR_AUTHENTICATOR_EXTENSIONS, AttributeValues.fromByteArray(authenticatorExtensions), + ATTR_COUNTER, AttributeValues.n(counter)); + } + + private DCAppleDevice fromItem(final Map item) { + // Deserialize the fields stored in dynamodb, see: https://webauthn4j.github.io/webauthn4j/en/#deep-dive + + final AttestedCredentialDataConverter attestedCredentialDataConverter = + new AttestedCredentialDataConverter(objectConverter); + + final AttestedCredentialData credData = attestedCredentialDataConverter.convert(getByteArray(item, ATTR_CRED_DATA) + .orElseThrow(() -> new IllegalStateException("Stored device check key missing attestation credential data"))); + + // The attestationStatement is an interface, so we also need to encode enough type information (the format) + // so we know how to deserialize the statement. See https://webauthn4j.github.io/webauthn4j/en/#attestationstatement + final byte[] serializedStatementEnvelope = getByteArray(item, ATTR_STATEMENT) + .orElseThrow(() -> new IllegalStateException("Stored device check key missing attestation statement")); + final AttestationStatement statement = Optional.ofNullable(objectConverter.getCborConverter() + .readValue(serializedStatementEnvelope, AttestationStatementEnvelope.class)) + .orElseThrow(() -> new IllegalStateException("Stored device check key missing attestation statement")) + .getAttestationStatement(); + + final long counter = AttributeValues.getLong(item, ATTR_COUNTER, 0); + + final byte[] serializedExtensions = getByteArray(item, ATTR_AUTHENTICATOR_EXTENSIONS) + .orElseThrow(() -> new IllegalStateException("Stored device check key missing attestation extensions")); + + @SuppressWarnings("unchecked") final AuthenticationExtensionsAuthenticatorOutputs extensions = objectConverter.getCborConverter() + .readValue(serializedExtensions, AuthenticationExtensionsAuthenticatorOutputs.class); + + return new DCAppleDeviceImpl(credData, statement, counter, extensions); + } + + private static PublicKey extractPublicKey(DCAppleDevice appleDevice) { + // This is the leaf public key as described here: + // https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server#Verify-the-attestation + // We know the sha256 of the public key matches the keyId, the apple webauthn verifier validates that. Step 5 here: + // https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide#Walking-through-the-validation-steps + final AppleAppAttestAttestationStatement attestationStatement = ((AppleAppAttestAttestationStatement) appleDevice.getAttestationStatement()); + Objects.requireNonNull(attestationStatement); + return attestationStatement.getX5c().getEndEntityAttestationCertificate().getCertificate().getPublicKey(); + } + + + private static boolean conditionalCheckFailed(final CancellationReason reason) { + return CONDITIONAL_CHECK_FAILED.equals(reason.code()); + } + + private static Optional getByteArray(Map item, String key) { + return AttributeValues.get(item, key).map(av -> av.b().asByteArray()); + } + + /** + * Wrapper that provides type information when deserializing attestation statements + */ + private static class AttestationStatementEnvelope { + + @JsonProperty("attStmt") + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXTERNAL_PROPERTY, + property = "fmt" + ) + private AttestationStatement attestationStatement; + + @JsonCreator + public AttestationStatementEnvelope(@JsonProperty("attStmt") AttestationStatement attestationStatement) { + this.attestationStatement = attestationStatement; + } + + @JsonProperty("fmt") + public String getFormat() { + return attestationStatement.getFormat(); + } + + public AttestationStatement getAttestationStatement() { + return attestationStatement; + } + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/ChallengeNotFoundException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/ChallengeNotFoundException.java new file mode 100644 index 000000000..0dc55052c --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/ChallengeNotFoundException.java @@ -0,0 +1,7 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +public class ChallengeNotFoundException extends Exception {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckKeyIdNotFoundException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckKeyIdNotFoundException.java new file mode 100644 index 000000000..4b53affff --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckKeyIdNotFoundException.java @@ -0,0 +1,7 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +public class DeviceCheckKeyIdNotFoundException extends Exception {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckVerificationFailedException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckVerificationFailedException.java new file mode 100644 index 000000000..436826472 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckVerificationFailedException.java @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +public class DeviceCheckVerificationFailedException extends Exception { + + public DeviceCheckVerificationFailedException(Exception cause) { + super(cause); + } + + public DeviceCheckVerificationFailedException(String s) { + super(s); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DuplicatePublicKeyException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DuplicatePublicKeyException.java new file mode 100644 index 000000000..aeb2c60e9 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DuplicatePublicKeyException.java @@ -0,0 +1,7 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +public class DuplicatePublicKeyException extends Exception {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/RequestReuseException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/RequestReuseException.java new file mode 100644 index 000000000..199e05e99 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/RequestReuseException.java @@ -0,0 +1,12 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +public class RequestReuseException extends Exception { + + public RequestReuseException(String s) { + super(s); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/TooManyKeysException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/TooManyKeysException.java new file mode 100644 index 000000000..883b919cf --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/TooManyKeysException.java @@ -0,0 +1,7 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +public class TooManyKeysException extends Exception {} diff --git a/service/src/main/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple_device_check.pem b/service/src/main/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple_device_check.pem new file mode 100644 index 000000000..4cff2277b --- /dev/null +++ b/service/src/main/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple_device_check.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw +JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK +QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa +Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv +biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y +bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh +NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au +Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw +CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn +53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV +oyFraWVIyd/dganmrduC1bmTBGwD +-----END CERTIFICATE----- diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckControllerTest.java new file mode 100644 index 000000000..b4150d953 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckControllerTest.java @@ -0,0 +1,225 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.controllers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.dropwizard.auth.AuthValueFactoryProvider; +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import io.dropwizard.testing.junit5.ResourceExtension; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.glassfish.jersey.server.ServerProperties; +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.backup.BackupAuthManager; +import org.whispersystems.textsecuregcm.limits.RateLimiter; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper; +import org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper; +import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager; +import org.whispersystems.textsecuregcm.storage.devicecheck.ChallengeNotFoundException; +import org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckKeyIdNotFoundException; +import org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckVerificationFailedException; +import org.whispersystems.textsecuregcm.storage.devicecheck.DuplicatePublicKeyException; +import org.whispersystems.textsecuregcm.storage.devicecheck.RequestReuseException; +import org.whispersystems.textsecuregcm.storage.devicecheck.TooManyKeysException; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.util.SystemMapper; +import org.whispersystems.textsecuregcm.util.TestClock; +import org.whispersystems.textsecuregcm.util.TestRandomUtil; + +@ExtendWith(DropwizardExtensionsSupport.class) +class DeviceCheckControllerTest { + + private final static Duration REDEMPTION_DURATION = Duration.ofDays(5); + private final static long REDEMPTION_LEVEL = 201L; + private final static BackupAuthManager backupAuthManager = mock(BackupAuthManager.class); + private final static AppleDeviceCheckManager appleDeviceCheckManager = mock(AppleDeviceCheckManager.class); + private final static RateLimiters rateLimiters = mock(RateLimiters.class); + private final static Clock clock = TestClock.pinned(Instant.EPOCH); + + private static final ResourceExtension resources = ResourceExtension.builder() + .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) + .addProvider(AuthHelper.getAuthFilter()) + .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class)) + .addProvider(new CompletionExceptionMapper()) + .addResource(new GrpcStatusRuntimeExceptionMapper()) + .addProvider(new RateLimitExceededExceptionMapper()) + .setMapper(SystemMapper.jsonMapper()) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addResource(new DeviceCheckController(clock, backupAuthManager, appleDeviceCheckManager, rateLimiters, + REDEMPTION_LEVEL, REDEMPTION_DURATION)) + .build(); + + @BeforeEach + public void setUp() { + reset(backupAuthManager); + reset(appleDeviceCheckManager); + reset(rateLimiters); + when(rateLimiters.forDescriptor(any())).thenReturn(mock(RateLimiter.class)); + } + + @ParameterizedTest + @EnumSource(AppleDeviceCheckManager.ChallengeType.class) + public void createChallenge(AppleDeviceCheckManager.ChallengeType challengeType) throws RateLimitExceededException { + when(appleDeviceCheckManager.createChallenge(eq(challengeType), any())) + .thenReturn("TestChallenge"); + + WebTarget target = resources.getJerseyTest() + .target("v1/devicecheck/%s".formatted(switch (challengeType) { + case ATTEST -> "attest"; + case ASSERT_BACKUP_REDEMPTION -> "assert"; + })); + if (challengeType == AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION) { + target = target.queryParam("action", "backup"); + } + final DeviceCheckController.ChallengeResponse challenge = target + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .get(DeviceCheckController.ChallengeResponse.class); + + assertThat(challenge.challenge()).isEqualTo("TestChallenge"); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void createChallengeRateLimited(boolean create) throws RateLimitExceededException { + final RateLimiter rateLimiter = mock(RateLimiter.class); + when(rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)).thenReturn(rateLimiter); + doThrow(new RateLimitExceededException(Duration.ofSeconds(1L))).when(rateLimiter).validate(any(UUID.class)); + + final String path = "v1/devicecheck/%s".formatted(create ? "assert" : "attest"); + + final Response response = resources.getJerseyTest() + .target(path) + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .get(); + assertThat(response.getStatus()).isEqualTo(429); + } + + @Test + public void failedAttestValidation() + throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException { + final String errorMessage = "a test error message"; + final byte[] keyId = TestRandomUtil.nextBytes(16); + final byte[] attestation = TestRandomUtil.nextBytes(32); + + doThrow(new DeviceCheckVerificationFailedException(errorMessage)).when(appleDeviceCheckManager) + .registerAttestation(any(), eq(keyId), eq(attestation)); + final Response response = resources.getJerseyTest() + .target("v1/devicecheck/attest") + .queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId)) + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .put(Entity.entity(attestation, MediaType.APPLICATION_OCTET_STREAM)); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getMediaType()).isEqualTo(MediaType.APPLICATION_JSON_TYPE); + assertThat(response.readEntity(Map.class).get("message")).isEqualTo(errorMessage); + } + + @Test + public void failedAssertValidation() + throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, DeviceCheckKeyIdNotFoundException, RequestReuseException { + final String errorMessage = "a test error message"; + final byte[] keyId = TestRandomUtil.nextBytes(16); + final byte[] assertion = TestRandomUtil.nextBytes(32); + final String challenge = "embeddedChallenge"; + final String request = """ + {"action": "backup", "challenge": "embeddedChallenge"} + """; + + doThrow(new DeviceCheckVerificationFailedException(errorMessage)).when(appleDeviceCheckManager) + .validateAssert(any(), eq(keyId), eq(AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION), eq(challenge), eq(request.getBytes()), eq(assertion)); + + final Response response = resources.getJerseyTest() + .target("v1/devicecheck/assert") + .queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId)) + .queryParam("request", Base64.getUrlEncoder().encodeToString(request.getBytes(StandardCharsets.UTF_8))) + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .post(Entity.entity(assertion, MediaType.APPLICATION_OCTET_STREAM)); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getMediaType()).isEqualTo(MediaType.APPLICATION_JSON_TYPE); + assertThat(response.readEntity(Map.class).get("message")).isEqualTo(errorMessage); + } + + @Test + public void registerKey() + throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException { + final byte[] keyId = TestRandomUtil.nextBytes(16); + final byte[] attestation = TestRandomUtil.nextBytes(32); + final Response response = resources.getJerseyTest() + .target("v1/devicecheck/attest") + .queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId)) + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .put(Entity.entity(attestation, MediaType.APPLICATION_OCTET_STREAM)); + assertThat(response.getStatus()).isEqualTo(204); + verify(appleDeviceCheckManager, times(1)) + .registerAttestation(any(), eq(keyId), eq(attestation)); + } + + @Test + public void checkAssertion() + throws DeviceCheckKeyIdNotFoundException, DeviceCheckVerificationFailedException, ChallengeNotFoundException, RequestReuseException { + final byte[] keyId = TestRandomUtil.nextBytes(16); + final byte[] assertion = TestRandomUtil.nextBytes(32); + final String challenge = "embeddedChallenge"; + final String request = """ + {"action": "backup", "challenge": "embeddedChallenge"} + """; + + when(backupAuthManager.extendBackupVoucher(any(), eq(new Account.BackupVoucher( + REDEMPTION_LEVEL, + clock.instant().plus(REDEMPTION_DURATION))))) + .thenReturn(CompletableFuture.completedFuture(null)); + + final Response response = resources.getJerseyTest() + .target("v1/devicecheck/assert") + .queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId)) + .queryParam("request", Base64.getUrlEncoder().encodeToString(request.getBytes(StandardCharsets.UTF_8))) + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .post(Entity.entity(assertion, MediaType.APPLICATION_OCTET_STREAM)); + assertThat(response.getStatus()).isEqualTo(204); + verify(appleDeviceCheckManager, times(1)).validateAssert( + any(), + eq(keyId), + eq(AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION), + eq(challenge), + eq(request.getBytes(StandardCharsets.UTF_8)), + eq(assertion)); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java index 13e0f009c..6bb6efb66 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java @@ -10,6 +10,7 @@ import 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 enum Tables implements DynamoDbExtension.TableSchema { .attributeName(VerificationSessions.KEY_KEY) .attributeType(ScalarAttributeType.S) .build()), + List.of(), List.of()), + + APPLE_DEVICE_CHECKS("apple_device_check", + AppleDeviceChecks.KEY_ACCOUNT_UUID, + AppleDeviceChecks.KEY_PUBLIC_KEY_ID, + List.of(AttributeDefinition.builder() + .attributeName(AppleDeviceChecks.KEY_ACCOUNT_UUID) + .attributeType(ScalarAttributeType.B) + .build(), + AttributeDefinition.builder() + .attributeName(AppleDeviceChecks.KEY_PUBLIC_KEY_ID) + .attributeType(ScalarAttributeType.B) + .build()), + List.of(), List.of()), + + APPLE_DEVICE_CHECKS_KEY_CONSTRAINT("apple_device_check_key_constraint", + AppleDeviceChecks.KEY_PUBLIC_KEY, + null, + List.of(AttributeDefinition.builder() + .attributeName(AppleDeviceChecks.KEY_PUBLIC_KEY) + .attributeType(ScalarAttributeType.B) + .build()), List.of(), List.of()); private final String tableName; diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManagerTest.java new file mode 100644 index 000000000..284ff5f45 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManagerTest.java @@ -0,0 +1,237 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import com.webauthn4j.appattest.DeviceCheckManager; +import com.webauthn4j.appattest.authenticator.DCAppleDevice; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.DynamoDbExtension; +import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema; +import org.whispersystems.textsecuregcm.util.TestClock; +import org.whispersystems.textsecuregcm.util.TestRandomUtil; +import org.whispersystems.textsecuregcm.util.Util; + +class AppleDeviceCheckManagerTest { + + private static final UUID ACI = UUID.randomUUID(); + + @RegisterExtension + static final RedisClusterExtension CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); + + @RegisterExtension + static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension( + DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS, + DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT); + + private final TestClock clock = TestClock.pinned(Instant.now()); + private AppleDeviceChecks appleDeviceChecks; + private Account account; + private AppleDeviceCheckManager appleDeviceCheckManager; + + @BeforeEach + void setupDeviceChecks() { + clock.pin(Instant.now()); + account = mock(Account.class); + when(account.getUuid()).thenReturn(ACI); + + final DeviceCheckManager deviceCheckManager = DeviceCheckTestUtil.appleDeviceCheckManager(); + appleDeviceChecks = new AppleDeviceChecks(DYNAMO_DB_EXTENSION.getDynamoDbClient(), + DeviceCheckManager.createObjectConverter(), + DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS.tableName(), + DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT.tableName()); + appleDeviceCheckManager = new AppleDeviceCheckManager(appleDeviceChecks, CLUSTER_EXTENSION.getRedisCluster(), + deviceCheckManager, DeviceCheckTestUtil.SAMPLE_TEAM_ID, DeviceCheckTestUtil.SAMPLE_BUNDLE_ID); + } + + @Test + public void missingChallengeAttest() { + assertThatExceptionOfType(ChallengeNotFoundException.class).isThrownBy(() -> + appleDeviceCheckManager.registerAttestation(account, + DeviceCheckTestUtil.SAMPLE_KEY_ID, + DeviceCheckTestUtil.SAMPLE_ATTESTATION)); + } + + @Test + public void missingChallengeAssert() { + assertThatExceptionOfType(ChallengeNotFoundException.class).isThrownBy(() -> + appleDeviceCheckManager.validateAssert(account, + DeviceCheckTestUtil.SAMPLE_KEY_ID, + AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE, + DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8), + DeviceCheckTestUtil.SAMPLE_ASSERTION)); + } + + @Test + public void tooManyKeys() throws DuplicatePublicKeyException { + final DCAppleDevice dcAppleDevice = DeviceCheckTestUtil.sampleDevice(); + + // Fill the table with a bunch of keyIds + final List keyIds = IntStream + .range(0, AppleDeviceCheckManager.MAX_DEVICE_KEYS - 1) + .mapToObj(i -> TestRandomUtil.nextBytes(16)).toList(); + for (byte[] keyId : keyIds) { + appleDeviceChecks.storeAttestation(account, keyId, dcAppleDevice); + } + + // We're allowed 1 more key for this account + assertThatNoException().isThrownBy(() -> registerAttestation(account)); + + // a new key should be rejected + assertThatExceptionOfType(TooManyKeysException.class).isThrownBy(() -> + appleDeviceCheckManager.registerAttestation(account, + TestRandomUtil.nextBytes(16), + DeviceCheckTestUtil.SAMPLE_ATTESTATION)); + + // we can however accept an existing key + assertThatNoException().isThrownBy(() -> registerAttestation(account, false)); + } + + @Test + public void duplicateKeys() { + assertThatNoException().isThrownBy(() -> registerAttestation(account)); + final Account duplicator = mock(Account.class); + when(duplicator.getUuid()).thenReturn(UUID.randomUUID()); + + // Both accounts use the attestation keyId, the second registration should fail + assertThatExceptionOfType(DuplicatePublicKeyException.class) + .isThrownBy(() -> registerAttestation(duplicator)); + } + + @Test + public void fetchingChallengeRefreshesTtl() throws RateLimitExceededException { + final String challenge = + appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account); + final String redisKey = AppleDeviceCheckManager.challengeKey(AppleDeviceCheckManager.ChallengeType.ATTEST, + account.getUuid()); + + final String storedChallenge = CLUSTER_EXTENSION.getRedisCluster() + .withCluster(cluster -> cluster.sync().get(redisKey)); + assertThat(storedChallenge).isEqualTo(challenge); + + final Supplier ttl = () -> CLUSTER_EXTENSION.getRedisCluster() + .withCluster(cluster -> cluster.sync().ttl(redisKey)); + + // Wait until the TTL visibly changes (~1sec) + while (ttl.get() >= AppleDeviceCheckManager.CHALLENGE_TTL.toSeconds()) { + Util.sleep(100); + } + + // Our TTL fetch needs to happen before the TTL ticks down to make sure the TTL was actually refreshed. So it must + // happen within 1 second. This should be plenty of time, but allow a few retries in case we get very unlucky. + final boolean ttlRefreshed = IntStream.range(0, 5) + .mapToObj(i -> { + assertThatNoException() + .isThrownBy( + () -> appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account)); + return ttl.get() == AppleDeviceCheckManager.CHALLENGE_TTL.toSeconds(); + }) + .anyMatch(detectedRefresh -> detectedRefresh); + assertThat(ttlRefreshed).isTrue(); + + assertThat(appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account)) + .isEqualTo(challenge); + } + + @Test + public void validateAssertion() { + assertThatNoException().isThrownBy(() -> registerAttestation(account)); + + // The sign counter should be 0 since we've made no attests + assertThat(appleDeviceChecks.lookup(account, DeviceCheckTestUtil.SAMPLE_KEY_ID).get().getCounter()) + .isEqualTo(0L); + + // Rig redis to return our sample challenge for the assert + final String assertChallengeKey = AppleDeviceCheckManager.challengeKey( + AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, + account.getUuid()); + CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster -> + cluster.sync().set(assertChallengeKey, DeviceCheckTestUtil.SAMPLE_CHALLENGE)); + + assertThatNoException().isThrownBy(() -> + appleDeviceCheckManager.validateAssert( + account, + DeviceCheckTestUtil.SAMPLE_KEY_ID, + AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE, + DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8), + DeviceCheckTestUtil.SAMPLE_ASSERTION)); + + CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster -> + assertThat(cluster.sync().get(assertChallengeKey)).isNull()); + + // the sign counter should now be 1 (read from our sample assert) + assertThat(appleDeviceChecks.lookup(account, DeviceCheckTestUtil.SAMPLE_KEY_ID).get().getCounter()) + .isEqualTo(1L); + } + + @Test + public void assertionCounterMovesBackwards() { + assertThatNoException().isThrownBy(() -> registerAttestation(account)); + + // force set the sign counter for our keyId to be larger than the sign counter in our sample assert (1) + appleDeviceChecks.updateCounter(account, DeviceCheckTestUtil.SAMPLE_KEY_ID, 2); + + // Rig redis to return our sample challenge for the assert + CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster -> cluster.sync().set( + AppleDeviceCheckManager.challengeKey( + AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, + account.getUuid()), + DeviceCheckTestUtil.SAMPLE_CHALLENGE)); + + assertThatExceptionOfType(RequestReuseException.class).isThrownBy(() -> + appleDeviceCheckManager.validateAssert( + account, + DeviceCheckTestUtil.SAMPLE_KEY_ID, + AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE, + DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8), + DeviceCheckTestUtil.SAMPLE_ASSERTION)); + } + + private void registerAttestation(final Account account) + throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException { + registerAttestation(account, true); + } + + private void registerAttestation(final Account account, boolean assertChallengeRemoved) + throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException { + final String attestChallengeKey = AppleDeviceCheckManager.challengeKey( + AppleDeviceCheckManager.ChallengeType.ATTEST, + account.getUuid()); + CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster -> + cluster.sync().set(attestChallengeKey, DeviceCheckTestUtil.SAMPLE_CHALLENGE)); + try (MockedStatic mocked = mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) { + mocked.when(Instant::now).thenReturn(DeviceCheckTestUtil.SAMPLE_TIME); + appleDeviceCheckManager.registerAttestation(account, + DeviceCheckTestUtil.SAMPLE_KEY_ID, + DeviceCheckTestUtil.SAMPLE_ATTESTATION); + } + if (assertChallengeRemoved) { + CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster -> { + // should be deleted once the attestation is registered + assertThat(cluster.sync().get(attestChallengeKey)).isNull(); + }); + } + } + +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecksTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecksTest.java new file mode 100644 index 000000000..59d012f0a --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecksTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.webauthn4j.appattest.DeviceCheckManager; +import com.webauthn4j.appattest.authenticator.DCAppleDevice; +import java.util.List; +import java.util.UUID; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.DynamoDbExtension; +import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema; +import org.whispersystems.textsecuregcm.util.TestRandomUtil; + + +class AppleDeviceChecksTest { + + private static final UUID ACI = UUID.randomUUID(); + + @RegisterExtension + static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension( + DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS, + DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT); + + private AppleDeviceChecks deviceChecks; + private Account account; + + @BeforeEach + void setupDeviceChecks() { + account = mock(Account.class); + when(account.getUuid()).thenReturn(ACI); + deviceChecks = new AppleDeviceChecks(DYNAMO_DB_EXTENSION.getDynamoDbClient(), + DeviceCheckManager.createObjectConverter(), + DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS.tableName(), + DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT.tableName()); + } + + @Test + public void testSerde() throws DuplicatePublicKeyException { + final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice(); + final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId(); + assertThat(deviceChecks.storeAttestation(account, keyId, appleDevice)).isTrue(); + + assertThat(deviceChecks.keyIds(account)).containsExactly(keyId); + + final DCAppleDevice deserialized = deviceChecks.lookup(account, keyId).orElseThrow(); + assertThat(deserialized.getClass()).isEqualTo(appleDevice.getClass()); + assertThat(deserialized.getAttestationStatement().getFormat()) + .isEqualTo(appleDevice.getAttestationStatement().getFormat()); + assertThat(deserialized.getAttestationStatement().getClass()) + .isEqualTo(appleDevice.getAttestationStatement().getClass()); + assertThat(deserialized.getAttestedCredentialData().getCredentialId()) + .isEqualTo(appleDevice.getAttestedCredentialData().getCredentialId()); + assertThat(deserialized.getAttestedCredentialData().getCOSEKey()) + .isEqualTo(appleDevice.getAttestedCredentialData().getCOSEKey()); + assertThat(deserialized.getAttestedCredentialData().getAaguid()) + .isEqualTo(appleDevice.getAttestedCredentialData().getAaguid()); + assertThat(deserialized.getAuthenticatorExtensions().getExtensions()) + .containsExactlyEntriesOf(appleDevice.getAuthenticatorExtensions().getExtensions()); + assertThat(deserialized.getCounter()) + .isEqualTo(appleDevice.getCounter()); + } + + @Test + public void duplicateKeys() throws DuplicatePublicKeyException { + final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice(); + final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId(); + + final Account dupliateAccount = mock(Account.class); + when(dupliateAccount.getUuid()).thenReturn(UUID.randomUUID()); + + deviceChecks.storeAttestation(account, keyId, appleDevice); + + // Storing same key with a different account fails + assertThatExceptionOfType(DuplicatePublicKeyException.class) + .isThrownBy(() -> deviceChecks.storeAttestation(dupliateAccount, keyId, appleDevice)); + + // Storing the same key with the same account is fine + assertThatNoException().isThrownBy(() -> deviceChecks.storeAttestation(account, keyId, appleDevice)); + } + + @Test + public void multipleKeys() throws DuplicatePublicKeyException { + final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice(); + + final List keyIds = IntStream.range(0, 10).mapToObj(i -> TestRandomUtil.nextBytes(16)).toList(); + + for (byte[] keyId : keyIds) { + // The keyId should typically match the device attestation, but we don't check that at this layer + assertThat(deviceChecks.storeAttestation(account, keyId, appleDevice)).isTrue(); + assertThat(deviceChecks.lookup(account, keyId)).isNotEmpty(); + } + final List actual = deviceChecks.keyIds(account); + + assertThat(actual).containsExactlyInAnyOrderElementsOf(keyIds); + } + + @Test + public void updateCounter() throws DuplicatePublicKeyException { + final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice(); + final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId(); + + assertThat(appleDevice.getCounter()).isEqualTo(0L); + deviceChecks.storeAttestation(account, keyId, appleDevice); + assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(0L); + assertThat(deviceChecks.updateCounter(account, keyId, 2)).isTrue(); + assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(2L); + + // Should not update since the counter is stale + assertThat(deviceChecks.updateCounter(account, keyId, 1)).isFalse(); + assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(2L); + + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckTestUtil.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckTestUtil.java new file mode 100644 index 000000000..e482d407e --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckTestUtil.java @@ -0,0 +1,134 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.storage.devicecheck; + +import static org.mockito.Mockito.mockStatic; + +import com.webauthn4j.appattest.DeviceCheckManager; +import com.webauthn4j.appattest.authenticator.DCAppleDevice; +import com.webauthn4j.appattest.authenticator.DCAppleDeviceImpl; +import com.webauthn4j.appattest.data.DCAttestationData; +import com.webauthn4j.appattest.data.DCAttestationParameters; +import com.webauthn4j.appattest.data.DCAttestationRequest; +import com.webauthn4j.appattest.server.DCServerProperty; +import com.webauthn4j.data.attestation.AttestationObject; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Base64; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +public class DeviceCheckTestUtil { + + // https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem + private static final String APPLE_APP_ATTEST_ROOT = """ + -----BEGIN CERTIFICATE----- + MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw + JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK + QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa + Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv + biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y + bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh + NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au + Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ + MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw + CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn + 53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV + oyFraWVIyd/dganmrduC1bmTBGwD + -----END CERTIFICATE----- + """; + + // Sample attestation from apple docs: + // https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide#Example-setup + final static String APPLE_SAMPLE_TEAM_ID = "0352187391"; + final static String APPLE_SAMPLE_BUNDLE_ID = "com.apple.example_app_attest"; + final static String APPLE_SAMPLE_CHALLENGE = "test_server_challenge"; + final static byte[] APPLE_SAMPLE_KEY_ID = Base64.getDecoder().decode("bSrEhF8TIzIvWSPwvZ0i2+UOBre4ASH84rK15m6emNY="); + final static byte[] APPLE_SAMPLE_ATTESTATION = loadBinaryResource("apple-sample-attestation"); + // Leaf certificate in apple sample attestation expires 2024-04-20 + final static Instant APPLE_SAMPLE_TIME = Instant.parse("2024-04-19T00:00:00.00Z"); + + // Sample attestation from webauthn4j: + // https://github.com/webauthn4j/webauthn4j/blob/6b7a8f8edce4ab589c49ecde8740873ab96c4218/webauthn4j-appattest/src/test/java/com/webauthn4j/appattest/DeviceCheckManagerTest.java#L126 + final static String SAMPLE_TEAM_ID = "8YE23NZS57"; + final static String SAMPLE_BUNDLE_ID = "com.kayak.travel"; + final static byte[] SAMPLE_KEY_ID = Base64.getDecoder().decode("VnfqjSp0rWyyqNhrfh+9/IhLIvXuYTPAmJEVQwl4dko="); + final static String SAMPLE_CHALLENGE = "1234567890abcdefgh"; // same challenge used for the attest and assert + final static byte[] SAMPLE_ASSERTION = loadBinaryResource("webauthn4j-sample-assertion"); + final static byte[] SAMPLE_ATTESTATION = loadBinaryResource("webauthn4j-sample-attestation"); + // Leaf certificate in sample attestation expires 2020-09-30 + final static Instant SAMPLE_TIME = Instant.parse("2020-09-28T00:00:00Z"); + + + public static DeviceCheckManager appleDeviceCheckManager() { + return new DeviceCheckManager(new AppleDeviceCheckTrustAnchor()); + } + + public static DCAppleDevice sampleDevice() { + final byte[] clientDataHash = sha256(SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8)); + return validate(SAMPLE_CHALLENGE, clientDataHash, SAMPLE_KEY_ID, SAMPLE_ATTESTATION, SAMPLE_TEAM_ID, + SAMPLE_BUNDLE_ID, SAMPLE_TIME); + } + + public static DCAppleDevice appleSampleDevice() { + // Note: the apple example provides the clientDataHash (typically the SHA256 of the challenge), NOT the challenge, + // despite them referring to the value as a challenge + final byte[] clientDataHash = APPLE_SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8); + + return validate(APPLE_SAMPLE_CHALLENGE, clientDataHash, APPLE_SAMPLE_KEY_ID, APPLE_SAMPLE_ATTESTATION, + APPLE_SAMPLE_TEAM_ID, APPLE_SAMPLE_BUNDLE_ID, APPLE_SAMPLE_TIME); + } + + private static DCAppleDevice validate(final String challengePlainText, final byte[] clientDataHash, + final byte[] keyId, final byte[] attestation, final String teamId, final String bundleId, final Instant now) { + + final DCAttestationRequest dcAttestationRequest = new DCAttestationRequest(keyId, attestation, clientDataHash); + + final DCAttestationData dcAttestationData; + try (final MockedStatic instantMock = mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) { + instantMock.when(Instant::now).thenReturn(now); + + dcAttestationData = appleDeviceCheckManager().validate(dcAttestationRequest, new DCAttestationParameters( + new DCServerProperty( + teamId, bundleId, + new DefaultChallenge(challengePlainText.getBytes(StandardCharsets.UTF_8))))); + } + + final AttestationObject attestationObject = dcAttestationData.getAttestationObject(); + return new DCAppleDeviceImpl( + attestationObject.getAuthenticatorData().getAttestedCredentialData(), + attestationObject.getAttestationStatement(), + attestationObject.getAuthenticatorData().getSignCount(), + attestationObject.getAuthenticatorData().getExtensions()); + } + + private static byte[] sha256(byte[] bytes) { + final MessageDigest sha256; + try { + sha256 = MessageDigest.getInstance("SHA-256"); + } catch (final NoSuchAlgorithmException e) { + // All Java implementations are required to support SHA-256 + throw new AssertionError(e); + } + return sha256.digest(bytes); + } + + private static byte[] loadBinaryResource(final String resourceName) { + try (InputStream stream = DeviceCheckTestUtil.class.getResourceAsStream(resourceName)) { + if (stream == null) { + throw new IllegalArgumentException("Resource not found: " + resourceName); + } + return stream.readAllBytes(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index 242ca490b..a12f1b150 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -89,6 +89,15 @@ appleAppStore: # An apple root cert https://www.apple.com/certificateauthority/ - MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9wtj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IWq6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKMaLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAEggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBcNplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQPy3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4FgxhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oPIQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AXUKqK1drk/NAJBzewdXUh +appleDeviceCheck: + production: false + teamId: 0123456789 + bundleId: bundle.name + +deviceCheck: + backupRedemptionDuration: P30D + backupRedemptionLevel: 201 + dynamoDbClient: type: local @@ -99,6 +108,10 @@ dynamoDbTables: phoneNumberIdentifierTableName: pni_assignment_test usernamesTableName: usernames_test usedLinkDeviceTokensTableName: used_link_device_tokens_test + appleDeviceChecks: + tableName: apple_device_checks_test + appleDeviceCheckPublicKeys: + tableName: apple_device_check_public_keys_test backups: tableName: backups_test clientReleases: diff --git a/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple-sample-attestation b/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple-sample-attestation new file mode 100644 index 0000000000000000000000000000000000000000..88e2ad5a8a60bed116f0fa07f8e4d3a7624ad7f8 GIT binary patch literal 5637 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 literal 0 HcmV?d00001 diff --git a/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/webauthn4j-sample-assertion b/service/src/test/resources/org/whispersystems/textsecuregcm/storage/devicecheck/webauthn4j-sample-assertion new file mode 100644 index 0000000000000000000000000000000000000000..00f0e647b2d3e60d3056c2abe483f1bdbe23a26b GIT binary patch literal 140 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 U`s2v31pN0-)BF