diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java index 483ec5cd4..c8b9d2ae1 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java @@ -57,6 +57,7 @@ import org.whispersystems.textsecuregcm.entities.DeviceInfoList; import org.whispersystems.textsecuregcm.entities.DeviceResponse; import org.whispersystems.textsecuregcm.entities.LinkDeviceRequest; import org.whispersystems.textsecuregcm.entities.PreKeySignatureValidator; +import org.whispersystems.textsecuregcm.entities.ProvisioningMessage; import org.whispersystems.textsecuregcm.entities.SetPublicKeyRequest; import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.limits.RateLimiters; @@ -146,9 +147,34 @@ public class DeviceController { accounts.removeDevice(auth.getAccount(), deviceId).join(); } + /** + * Generates a signed device-linking token. Generally, primary devices will include the signed device-linking token in + * a provisioning message to a new device, and then the new device will include the token in its request to + * {@link #linkDevice(BasicAuthorizationHeader, String, LinkDeviceRequest, ContainerRequest)}. + * + * @param auth the authenticated account/device + * + * @return a signed device-linking token + * + * @throws RateLimitExceededException if the caller has made too many calls to this method in a set amount of time + * @throws DeviceLimitExceededException if the authenticated account has already reached the maximum number of linked + * devices + * + * @see ProvisioningController#sendProvisioningMessage(AuthenticatedDevice, String, ProvisioningMessage, String) + */ @GET @Path("/provisioning/code") @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Generate a signed device-linking token", + description = """ + Generate a signed device-linking token for transmission to a pending linked device via a provisioning message. + """) + @ApiResponse(responseCode="200", description="Token was generated successfully", useReturnTypeSchema=true) + @ApiResponse(responseCode = "411", description = "The authenticated account already has the maximum allowed number of linked devices") + @ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header( + name = "Retry-After", + description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed")) public VerificationCode createDeviceToken(@ReadOnly @Auth AuthenticatedDevice auth) throws RateLimitExceededException, DeviceLimitExceededException { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProvisioningController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProvisioningController.java index d9c530369..a7eee615a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProvisioningController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProvisioningController.java @@ -13,6 +13,9 @@ import io.dropwizard.auth.Auth; import io.dropwizard.util.DataSize; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tags; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Base64; import javax.validation.Valid; @@ -34,6 +37,16 @@ import org.whispersystems.textsecuregcm.push.ProvisioningManager; import org.whispersystems.textsecuregcm.websocket.ProvisioningAddress; import org.whispersystems.websocket.auth.ReadOnly; +/** + * The provisioning controller facilitates transmission of provisioning messages from the primary device associated with + * an existing Signal account to a new device. To send a provisioning message, a primary device generally scans a QR + * code displayed by the new device that contains the device's "provisioning address" and a public key. The primary + * device then encrypts a use-case-specific provisioning message and posts it to + * {@link #sendProvisioningMessage(AuthenticatedDevice, String, ProvisioningMessage, String)}, at which point the server + * delivers the message to the new device via an open provisioning WebSocket. + * + * @see org.whispersystems.textsecuregcm.websocket.ProvisioningConnectListener + */ @Path("/v1/provisioning") @Tag(name = "Provisioning") public class ProvisioningController { @@ -56,21 +69,34 @@ public class ProvisioningController { @PUT @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - public void sendProvisioningMessage(@ReadOnly @Auth AuthenticatedDevice auth, - @PathParam("destination") String destinationName, - @NotNull @Valid ProvisioningMessage message, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent) + @Operation( + summary = "Send a provisioning message to a new device", + description = """ + Send a provisioning message from an authenticated device to a device that (presumably) is not yet associated + with a Signal account. + """) + @ApiResponse(responseCode="204", description="The provisioning message was delivered to the given provisioning address") + @ApiResponse(responseCode="400", description="The provisioning message was too large") + @ApiResponse(responseCode="404", description="No device with the given provisioning address was connected at the time of the request") + public void sendProvisioningMessage(@ReadOnly @Auth final AuthenticatedDevice auth, + + @Parameter(description = "The temporary provisioning address to which to send a provisioning message") + @PathParam("destination") final String provisioningAddress, + + @Parameter(description = "The provisioning message to send to the given provisioning address") + @NotNull @Valid final ProvisioningMessage message, + + @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException { if (message.body().length() > MAX_MESSAGE_SIZE) { - Metrics.counter(REJECT_OVERSIZE_MESSAGE_COUNTER, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))) - .increment(); + Metrics.counter(REJECT_OVERSIZE_MESSAGE_COUNTER, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))).increment(); throw new WebApplicationException(Response.Status.BAD_REQUEST); } rateLimiters.getMessagesLimiter().validate(auth.getAccount().getUuid()); - if (!provisioningManager.sendProvisioningMessage(ProvisioningAddress.create(destinationName), + if (!provisioningManager.sendProvisioningMessage(ProvisioningAddress.create(provisioningAddress), Base64.getMimeDecoder().decode(message.body()))) { throw new WebApplicationException(Response.Status.NOT_FOUND); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProvisioningMessage.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProvisioningMessage.java index b6c6dff8b..959f9d80f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProvisioningMessage.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProvisioningMessage.java @@ -5,7 +5,11 @@ package org.whispersystems.textsecuregcm.entities; +import io.swagger.v3.oas.annotations.media.Schema; import javax.validation.constraints.NotEmpty; -public record ProvisioningMessage(@NotEmpty String body) { +public record ProvisioningMessage( + @Schema(description = "The MIME base64-encoded body of the provisioning message to send to the destination device") + @NotEmpty + String body) { } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnectListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnectListener.java index 3817155be..bdf30aa44 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnectListener.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnectListener.java @@ -5,7 +5,9 @@ package org.whispersystems.textsecuregcm.websocket; +import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; import org.whispersystems.textsecuregcm.entities.MessageProtos; +import org.whispersystems.textsecuregcm.entities.ProvisioningMessage; import org.whispersystems.textsecuregcm.push.ProvisioningManager; import org.whispersystems.textsecuregcm.storage.PubSubProtos; import org.whispersystems.textsecuregcm.util.HeaderUtils; @@ -14,6 +16,20 @@ import org.whispersystems.websocket.setup.WebSocketConnectListener; import java.util.List; import java.util.Optional; +/** + * A "provisioning WebSocket" provides a mechanism for sending a caller-defined provisioning message from the primary + * device associated with a Signal account to a new device that is not yet associated with a Signal account. Generally, + * the message contains key material and credentials the new device needs to associate itself with the primary device's + * Signal account. + *

+ * New devices initiate the provisioning process by opening a provisioning WebSocket. The server assigns the new device + * a random, temporary "provisioning address," which it transmits via the newly-opened WebSocket. From there, the new + * device generally displays the provisioning address (and a public key) as a QR code. After that, the primary device + * will scan the QR code and send an encrypted provisioning message to the new device via + * {@link org.whispersystems.textsecuregcm.controllers.ProvisioningController#sendProvisioningMessage(AuthenticatedDevice, String, ProvisioningMessage, String)}. + * Once the server receives the message from the primary device, it sends the message to the new device via the open + * WebSocket, then closes the WebSocket connection. + */ public class ProvisioningConnectListener implements WebSocketConnectListener { private final ProvisioningManager provisioningManager;