diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java index b7fe9d9a2..a33b968c9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java @@ -83,6 +83,7 @@ public AccountControllerV2(final AccountsManager accountsManager, final ChangeNu @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Change number", description = "Changes a phone number for an existing account.") @ApiResponse(responseCode = "200", description = "The phone number associated with the authenticated account was changed successfully", useReturnTypeSchema = true) + @ApiResponse(responseCode = "401", description = "Account authentication check failed.") @ApiResponse(responseCode = "403", description = "Verification failed for the provided Registration Recovery Password") @ApiResponse(responseCode = "409", description = "Mismatched number of devices or device ids in 'devices to notify' list", content = @Content(schema = @Schema(implementation = MismatchedDevices.class))) @ApiResponse(responseCode = "410", description = "Mismatched registration ids in 'devices to notify' list", content = @Content(schema = @Schema(implementation = StaleDevices.class))) @@ -159,7 +160,17 @@ public AccountIdentityResponse changeNumber(@Auth final AuthenticatedAccount aut @Path("/phone_number_identity_key_distribution") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "Updates key material for the phone-number identity for all devices and sends a synchronization message to companion devices") + @Operation(summary = "Set phone-number identity keys", + description = "Updates key material for the phone-number identity for all devices and sends a synchronization message to companion devices") + @ApiResponse(responseCode = "200", description = "Indicates the transaction was successful and returns basic information about this account.", useReturnTypeSchema = true) + @ApiResponse(responseCode = "401", description = "Account authentication check failed.") + @ApiResponse(responseCode = "403", description = "This endpoint can only be invoked from the account's primary device.") + @ApiResponse(responseCode = "422", description = "The request body failed validation.") + @ApiResponse(responseCode = "425", description = "Not all of this account's devices support phone-number identities yet.") + @ApiResponse(responseCode = "409", description = "The set of devices specified in the request does not match the set of devices active on the account.", + content = @Content(schema = @Schema(implementation = MismatchedDevices.class))) + @ApiResponse(responseCode = "410", description = "The registration IDs provided for some devices do not match those stored on the server.", + content = @Content(schema = @Schema(implementation = StaleDevices.class))) public AccountIdentityResponse distributePhoneNumberIdentityKeys(@Auth final AuthenticatedAccount authenticatedAccount, @NotNull @Valid final PhoneNumberIdentityKeyDistributionRequest request) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeysController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeysController.java index baa9382e4..7288ba1bf 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeysController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeysController.java @@ -13,8 +13,10 @@ 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.headers.Header; 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 io.swagger.v3.oas.annotations.tags.Tag; import java.time.Duration; import java.time.Instant; @@ -86,7 +88,10 @@ public KeysController(RateLimiters rateLimiters, KeysManager keys, AccountsManag @GET @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "Returns the number of available one-time prekeys for this device") + @Operation(summary = "Get prekey count", + description = "Gets the number of one-time prekeys uploaded for this device and still available") + @ApiResponse(responseCode = "200", description = "Body contains the number of available one-time prekeys for the device.", useReturnTypeSchema = true) + @ApiResponse(responseCode = "401", description = "Account authentication check failed.") public PreKeyCount getStatus(@Auth final AuthenticatedAccount auth, @QueryParam("identity") final Optional identityType) { @@ -101,7 +106,15 @@ public PreKeyCount getStatus(@Auth final AuthenticatedAccount auth, @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @ChangesDeviceEnabledState - @Operation(summary = "Sets the identity key for the account or phone-number identity and/or prekeys for this device") + @Operation(summary = "Upload new prekeys", + description = """ + Upload new prekeys for this device. Can also be used, from the primary device only, to set the account's identity + key, but this is deprecated now that accounts can be created atomically. + """) + @ApiResponse(responseCode = "200", description = "Indicates that new keys were successfully stored.") + @ApiResponse(responseCode = "401", description = "Account authentication check failed.") + @ApiResponse(responseCode = "403", description = "Attempt to change identity key from a non-primary device.") + @ApiResponse(responseCode = "422", description = "Invalid request format.") public void setKeys(@Auth final DisabledPermittedAuthenticatedAccount disabledPermittedAuth, @RequestBody @NotNull @Valid final PreKeyState preKeys, @@ -176,8 +189,15 @@ public void setKeys(@Auth final DisabledPermittedAuthenticatedAccount disabledPe @GET @Path("/{identifier}/{device_id}") @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "Retrieves the public identity key and available device prekeys for a specified account or phone-number identity") - public Response getDeviceKeys(@Auth Optional auth, + @Operation(summary = "Fetch public keys for another user", + description = "Retrieves the public identity key and available device prekeys for a specified account or phone-number identity") + @ApiResponse(responseCode = "200", description = "Indicates at least one prekey was available for at least one requested device.", useReturnTypeSchema = true) + @ApiResponse(responseCode = "401", description = "Account authentication check failed and unidentified-access key was not supplied or invalid.") + @ApiResponse(responseCode = "404", description = "Requested identity or device does not exist, is not active, or has no available prekeys.") + @ApiResponse(responseCode = "429", description = "Rate limit exceeded.", headers = @Header( + name = "Retry-After", + description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed")) + public PreKeyResponse getDeviceKeys(@Auth Optional auth, @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, @Parameter(description="the account or phone-number identifier to retrieve keys for") @@ -241,9 +261,9 @@ public Response getDeviceKeys(@Auth Optional auth, final IdentityKey identityKey = usePhoneNumberIdentity ? target.getPhoneNumberIdentityKey() : target.getIdentityKey(); if (responseItems.isEmpty()) { - return Response.status(404).build(); + throw new WebApplicationException(Response.Status.NOT_FOUND); } - return Response.ok().entity(new PreKeyResponse(identityKey, responseItems)).build(); + return new PreKeyResponse(identityKey, responseItems); } @Timed @@ -251,6 +271,13 @@ public Response getDeviceKeys(@Auth Optional auth, @Path("/signed") @Consumes(MediaType.APPLICATION_JSON) @ChangesDeviceEnabledState + @Operation(summary = "Upload a new signed prekey", + description = """ + Upload a new signed elliptic-curve prekey for this device. Deprecated; use PUT /v2/keys with instead. + """) + @ApiResponse(responseCode = "200", description = "Indicates that new prekey was successfully stored.") + @ApiResponse(responseCode = "401", description = "Account authentication check failed.") + @ApiResponse(responseCode = "422", description = "Invalid request format.") public void setSignedKey(@Auth final AuthenticatedAccount auth, @Valid final ECSignedPreKey signedPreKey, @QueryParam("identity") final Optional identityType) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ChangeNumberRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ChangeNumberRequest.java index 74a394956..7f37aa404 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ChangeNumberRequest.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ChangeNumberRequest.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import java.util.ArrayList; import java.util.List; @@ -27,8 +28,8 @@ public record ChangeNumberRequest( Must not be combined with `recoveryPassword`.""") String sessionId, - @Schema(description=""" - The recovery password for the new phone number, if using a recovery password to authenticate this request. + @Schema(type="string", description=""" + The base64-encoded recovery password for the new phone number, if using a recovery password to authenticate this request. Must not be combined with `sessionId`.""") @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) byte[] recoveryPassword, @@ -43,10 +44,11 @@ public record ChangeNumberRequest( @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class) @NotNull IdentityKey pniIdentityKey, - @Schema(description=""" + @ArraySchema( + arraySchema=@Schema(description=""" A list of synchronization messages to send to companion devices to supply the private keysManager associated with the new identity key and their new prekeys. - Exactly one message must be supplied for each enabled device other than the sending (primary) device.""") + Exactly one message must be supplied for each enabled device other than the sending (primary) device.""")) @NotNull @Valid List<@NotNull @Valid IncomingMessage> deviceMessages, @Schema(description=""" diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ECPreKey.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ECPreKey.java index 7f244945c..e638183d6 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ECPreKey.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ECPreKey.java @@ -7,13 +7,23 @@ 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 ECPreKey(long keyId, - @JsonSerialize(using = ECPublicKeyAdapter.Serializer.class) - @JsonDeserialize(using = ECPublicKeyAdapter.Deserializer.class) - ECPublicKey publicKey) implements PreKey { +public record ECPreKey( + @Schema(description=""" + An arbitrary ID for this key, which will be provided by peers using this key to encrypt messages so the private key can be looked up. + Should not be zero. Should be less than 2^24. + """) + long keyId, + + @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 base64-encoded. + """) + ECPublicKey publicKey) implements PreKey { @Override public byte[] serializedPublicKey() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ECSignedPreKey.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ECSignedPreKey.java index b8dac36bb..b585286bc 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ECSignedPreKey.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ECSignedPreKey.java @@ -7,21 +7,33 @@ 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.ByteArrayAdapter; import org.whispersystems.textsecuregcm.util.ECPublicKeyAdapter; import java.util.Arrays; import java.util.Objects; -public record ECSignedPreKey(long keyId, - - @JsonSerialize(using = ECPublicKeyAdapter.Serializer.class) - @JsonDeserialize(using = ECPublicKeyAdapter.Deserializer.class) - ECPublicKey publicKey, - - @JsonSerialize(using = ByteArrayAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) - byte[] signature) implements SignedPreKey { +public record ECSignedPreKey( + @Schema(description=""" + An arbitrary ID for this key, which will be provided by peers using this key to encrypt messages so the private key can be looked up. + Should not be zero. Should be less than 2^24. + """) + long keyId, + + @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 base64-encoded. + """) + ECPublicKey publicKey, + + @JsonSerialize(using = ByteArrayAdapter.Serializing.class) + @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) + @Schema(type="string", description=""" + The signature of the serialized `publicKey` with the account (or phone-number identity)'s identity key, base64-encoded. + """) + byte[] signature) implements SignedPreKey { @Override public byte[] serializedPublicKey() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/KEMSignedPreKey.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KEMSignedPreKey.java index 88e83c978..5e2ff6eca 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/KEMSignedPreKey.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KEMSignedPreKey.java @@ -7,21 +7,34 @@ 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.kem.KEMPublicKey; import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; import org.whispersystems.textsecuregcm.util.KEMPublicKeyAdapter; import java.util.Arrays; import java.util.Objects; -public record KEMSignedPreKey(long keyId, - - @JsonSerialize(using = KEMPublicKeyAdapter.Serializer.class) - @JsonDeserialize(using = KEMPublicKeyAdapter.Deserializer.class) - KEMPublicKey publicKey, - - @JsonSerialize(using = ByteArrayAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) - byte[] signature) implements SignedPreKey { +public record KEMSignedPreKey( + @Schema(description=""" + An arbitrary ID for this key, which will be provided by peers using this key to encrypt messages so the private key can be looked up. + Should not be zero. Should be less than 2^24. The owner of this key must be able to determine from the key ID whether this represents + a single-use or last-resort key, but another party should *not* be able to tell. + """) + long keyId, + + @JsonSerialize(using = KEMPublicKeyAdapter.Serializer.class) + @JsonDeserialize(using = KEMPublicKeyAdapter.Deserializer.class) + @Schema(type="string", description=""" + The public key, serialized in libsignal's Kyber1024 public key format and then base64-encoded. + """) + KEMPublicKey publicKey, + + @JsonSerialize(using = ByteArrayAdapter.Serializing.class) + @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) + @Schema(type="string", description=""" + The signature of the serialized `publicKey` with the account (or phone-number identity)'s identity key, base64-encoded. + """) + byte[] signature) implements SignedPreKey { @Override public byte[] serializedPublicKey() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneNumberIdentityKeyDistributionRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneNumberIdentityKeyDistributionRequest.java index e564f93b1..3db331a18 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneNumberIdentityKeyDistributionRequest.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneNumberIdentityKeyDistributionRequest.java @@ -6,6 +6,7 @@ package org.whispersystems.textsecuregcm.entities; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import java.util.ArrayList; import java.util.List; @@ -24,10 +25,12 @@ public record PhoneNumberIdentityKeyDistributionRequest( @NotNull @Valid - @Schema(description=""" - A list of synchronization messages to send to companion devices to supply the private keysManager - associated with the new identity key and their new prekeys. - Exactly one message must be supplied for each enabled device other than the sending (primary) device.""") + @ArraySchema( + arraySchema=@Schema(description=""" + A list of synchronization messages to send to companion devices to supply the private keys + associated with the new identity key and their new prekeys. + Exactly one message must be supplied for each enabled device other than the sending (primary) device. + """)) List<@NotNull @Valid IncomingMessage> deviceMessages, @NotNull @@ -47,7 +50,7 @@ Exactly one message must be supplied for each enabled device other than the send @NotNull @Valid - @Schema(description="The new registration ID to use for the phone-number identity of each device") + @Schema(description="The new registration ID to use for the phone-number identity of each device, including this one.") Map pniRegistrationIds) { @AssertTrue