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