From 4efba9466287ca0b4f6deb6f971ce47da286ab8b Mon Sep 17 00:00:00 2001 From: Jon Chambers <63609320+jon-signal@users.noreply.github.com> Date: Thu, 16 May 2024 16:53:16 -0500 Subject: [PATCH] Add an API endpoint for storing public keys --- .../textsecuregcm/WhisperServerService.java | 2 +- .../controllers/DeviceController.java | 28 +++++++++++++++++++ .../entities/SetPublicKeyRequest.java | 17 +++++++++++ .../storage/ClientPublicKeys.java | 24 ++++++++++++++++ .../storage/ClientPublicKeysManager.java | 15 ++++++++++ .../controllers/DeviceControllerTest.java | 23 +++++++++++++++ .../storage/ClientPublicKeysTest.java | 13 +++++++++ 7 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/SetPublicKeyRequest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index cc52d1a0d..59ab2f365 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -963,7 +963,7 @@ public class WhisperServerService extends Application maxDeviceConfiguration; @@ -89,11 +93,13 @@ public class DeviceController { public DeviceController(byte[] linkDeviceSecret, AccountsManager accounts, + ClientPublicKeysManager clientPublicKeysManager, RateLimiters rateLimiters, FaultTolerantRedisCluster usedTokenCluster, Map maxDeviceConfiguration, final Clock clock) { this.verificationTokenKey = new SecretKeySpec(linkDeviceSecret, VERIFICATION_TOKEN_ALGORITHM); this.accounts = accounts; + this.clientPublicKeysManager = clientPublicKeysManager; this.rateLimiters = rateLimiters; this.usedTokenCluster = usedTokenCluster; this.maxDeviceConfiguration = maxDeviceConfiguration; @@ -278,6 +284,28 @@ public class DeviceController { accounts.updateDevice(auth.getAccount(), deviceId, d -> d.setCapabilities(capabilities)); } + @PUT + @Produces(MediaType.APPLICATION_JSON) + @Path("/public_key") + @Operation( + summary = "Sets a public key for authentication", + description = """ + Sets the authentication public key for the authenticated device. The public key will be used for + authentication in the nascent gRPC-over-Noise API. Existing devices must upload a public key before they can + use the gRPC-over-Noise API, and this endpoint exists to facilitate migration to the new API. + """ + ) + @ApiResponse(responseCode = "200", description = "Public key stored successfully") + @ApiResponse(responseCode = "401", description = "Account authentication check failed") + @ApiResponse(responseCode = "422", description = "Invalid request format") + public CompletableFuture setPublicKey(@Auth final AuthenticatedAccount auth, + final SetPublicKeyRequest setPublicKeyRequest) { + + return clientPublicKeysManager.setPublicKey(auth.getAccount().getIdentifier(IdentityType.ACI), + auth.getAuthenticatedDevice().getId(), + setPublicKeyRequest.publicKey()); + } + private Mac getInitializedMac() { try { final Mac mac = Mac.getInstance(VERIFICATION_TOKEN_ALGORITHM); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SetPublicKeyRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/SetPublicKeyRequest.java new file mode 100644 index 000000000..240c4e942 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/SetPublicKeyRequest.java @@ -0,0 +1,17 @@ +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import org.signal.libsignal.protocol.ecc.ECPublicKey; +import org.whispersystems.textsecuregcm.util.ECPublicKeyAdapter; + +public record SetPublicKeyRequest( + @JsonSerialize(using = ECPublicKeyAdapter.Serializer.class) + @JsonDeserialize(using = ECPublicKeyAdapter.Deserializer.class) + @Schema(type="string", description=""" + The public key, serialized in libsignal's elliptic-curve public key format and then encoded as a standard (i.e. + not URL-safe), padded, base64-encoded string. + """) + ECPublicKey publicKey) { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeys.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeys.java index c7546ad95..857795792 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeys.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeys.java @@ -9,11 +9,13 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.util.AttributeValues; +import org.whispersystems.textsecuregcm.util.Util; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.Delete; import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; import software.amazon.awssdk.services.dynamodb.model.Put; +import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem; /** @@ -35,6 +37,28 @@ public class ClientPublicKeys { this.tableName = tableName; } + /** + * Stores the given public key for the given account/device, overwriting any previously-stored public key. This method + * is intended for use for adding public keys to existing accounts/devices as a migration step. Callers should use + * {@link #buildTransactWriteItemForInsertion(UUID, byte, ECPublicKey)} instead when creating new accounts/devices. + * + * @param accountIdentifier the identifier for the target account + * @param deviceId the identifier for the target device + * @param publicKey the public key to store for the target account/device + + * @return a future that completes when the given key has been stored + */ + CompletableFuture setPublicKey(final UUID accountIdentifier, final byte deviceId, final ECPublicKey publicKey) { + return dynamoDbAsyncClient.putItem(PutItemRequest.builder() + .tableName(tableName) + .item(Map.of( + KEY_ACCOUNT_UUID, getPartitionKey(accountIdentifier), + KEY_DEVICE_ID, getSortKey(deviceId), + ATTR_PUBLIC_KEY, AttributeValues.fromByteArray(publicKey.serialize()))) + .build()) + .thenRun(Util.NOOP); + } + /** * Builds a {@link TransactWriteItem} that will store a public key for the given account/device. Intended for use when * adding devices to accounts or creating new accounts. diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeysManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeysManager.java index 48a15825b..02c0a99a1 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeysManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeysManager.java @@ -18,6 +18,21 @@ public class ClientPublicKeysManager { this.clientPublicKeys = clientPublicKeys; } + /** + * Stores the given public key for the given account/device, overwriting any previously-stored public key. This method + * is intended for use for adding public keys to existing accounts/devices as a migration step. Callers should use + * {@link #buildTransactWriteItemForInsertion(UUID, byte, ECPublicKey)} instead when creating new accounts/devices. + * + * @param accountIdentifier the identifier for the target account + * @param deviceId the identifier for the target device + * @param publicKey the public key to store for the target account/device + + * @return a future that completes when the given key has been stored + */ + public CompletableFuture setPublicKey(final UUID accountIdentifier, final byte deviceId, final ECPublicKey publicKey) { + return clientPublicKeys.setPublicKey(accountIdentifier, deviceId, publicKey); + } + /** * Builds a {@link TransactWriteItem} that will store a public key for the given account/device. Intended for use when * adding devices to accounts or creating new accounts. diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java index 537ee7a63..46ce4b4f4 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java @@ -61,6 +61,7 @@ import org.whispersystems.textsecuregcm.entities.ECSignedPreKey; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey; import org.whispersystems.textsecuregcm.entities.LinkDeviceRequest; +import org.whispersystems.textsecuregcm.entities.SetPublicKeyRequest; import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; @@ -68,6 +69,7 @@ import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapp import org.whispersystems.textsecuregcm.push.ClientPresenceManager; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager; import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; import org.whispersystems.textsecuregcm.storage.DeviceSpec; @@ -85,6 +87,7 @@ import org.whispersystems.textsecuregcm.util.VerificationCode; class DeviceControllerTest { private static AccountsManager accountsManager = mock(AccountsManager.class); + private static ClientPublicKeysManager clientPublicKeysManager = mock(ClientPublicKeysManager.class); private static RateLimiters rateLimiters = mock(RateLimiters.class); private static RateLimiter rateLimiter = mock(RateLimiter.class); private static RedisAdvancedClusterCommands commands = mock(RedisAdvancedClusterCommands.class); @@ -101,6 +104,7 @@ class DeviceControllerTest { private static DeviceController deviceController = new DeviceController( generateLinkDeviceSecret(), accountsManager, + clientPublicKeysManager, rateLimiters, RedisClusterHelper.builder() .stringCommands(commands) @@ -147,6 +151,9 @@ class DeviceControllerTest { when(accountsManager.getByE164(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of(account)); when(accountsManager.getByE164(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.of(maxedAccount)); + when(clientPublicKeysManager.setPublicKey(any(), anyByte(), any())) + .thenReturn(CompletableFuture.completedFuture(null)); + AccountsHelper.setupMockUpdate(accountsManager); } @@ -807,4 +814,20 @@ class DeviceControllerTest { Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.1691096565171:This is not valid base64", tokenTimestamp) ); } + + @Test + void setPublicKey() { + final SetPublicKeyRequest request = new SetPublicKeyRequest(Curve.generateKeyPair().getPublicKey()); + + try (final Response response = resources.getJerseyTest() + .target("/v1/devices/public_key") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) { + + assertEquals(204, response.getStatus()); + } + + verify(clientPublicKeysManager).setPublicKey(AuthHelper.VALID_UUID, AuthHelper.VALID_DEVICE.getId(), request.publicKey()); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeysTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeysTest.java index 856bdadb7..0113af277 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeysTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ClientPublicKeysTest.java @@ -45,4 +45,17 @@ class ClientPublicKeysTest { assertEquals(Optional.empty(), clientPublicKeys.findPublicKey(accountIdentifier, deviceId).join()); } + + @Test + void setPublicKey() { + final UUID accountIdentifier = UUID.randomUUID(); + final byte deviceId = Device.PRIMARY_ID; + final ECPublicKey publicKey = Curve.generateKeyPair().getPublicKey(); + + assertEquals(Optional.empty(), clientPublicKeys.findPublicKey(accountIdentifier, deviceId).join()); + + clientPublicKeys.setPublicKey(accountIdentifier, deviceId, publicKey).join(); + + assertEquals(Optional.of(publicKey), clientPublicKeys.findPublicKey(accountIdentifier, deviceId).join()); + } }