diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/IdentityTypeUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/IdentityTypeUtil.java index 6b500fcf3..a94e7e25f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/IdentityTypeUtil.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/IdentityTypeUtil.java @@ -20,4 +20,11 @@ public class IdentityTypeUtil { case IDENTITY_TYPE_UNSPECIFIED, UNRECOGNIZED -> throw Status.INVALID_ARGUMENT.asRuntimeException(); }; } + + public static org.signal.chat.common.IdentityType toGrpcIdentityType(final IdentityType identityType) { + return switch (identityType) { + case ACI -> org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI; + case PNI -> org.signal.chat.common.IdentityType.IDENTITY_TYPE_PNI; + }; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcService.java index 864ab89dc..48a4e3e21 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcService.java @@ -5,15 +5,24 @@ package org.whispersystems.textsecuregcm.grpc; +import com.google.protobuf.ByteString; import io.grpc.Status; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import org.signal.chat.keys.CheckIdentityKeyRequest; +import org.signal.chat.keys.CheckIdentityKeyResponse; import org.signal.chat.keys.GetPreKeysAnonymousRequest; import org.signal.chat.keys.GetPreKeysResponse; import org.signal.chat.keys.ReactorKeysAnonymousGrpc; +import org.signal.libsignal.protocol.IdentityKey; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.KeysManager; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.util.function.Tuples; public class KeysAnonymousGrpcService extends ReactorKeysAnonymousGrpc.KeysAnonymousImplBase { @@ -38,4 +47,35 @@ public class KeysAnonymousGrpcService extends ReactorKeysAnonymousGrpc.KeysAnony ? KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier.identityType(), request.getRequest().getDeviceId(), keysManager) : Mono.error(Status.UNAUTHENTICATED.asException())); } + + @Override + public Flux checkIdentityKeys(final Flux requests) { + return requests + .map(request -> Tuples.of(ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier()), + request.getFingerprint().toByteArray())) + .flatMap(serviceIdentifierAndFingerprint -> Mono.fromFuture( + () -> accountsManager.getByServiceIdentifierAsync(serviceIdentifierAndFingerprint.getT1())) + .flatMap(Mono::justOrEmpty) + .filter(account -> !fingerprintMatches(account.getIdentityKey(serviceIdentifierAndFingerprint.getT1() + .identityType()), serviceIdentifierAndFingerprint.getT2())) + .map(account -> CheckIdentityKeyResponse.newBuilder() + .setTargetIdentifier( + ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifierAndFingerprint.getT1())) + .setIdentityKey(ByteString.copyFrom(account.getIdentityKey(serviceIdentifierAndFingerprint.getT1() + .identityType()).serialize())) + .build()) + ); + } + + private static boolean fingerprintMatches(final IdentityKey identityKey, final byte[] fingerprint) { + final byte[] digest; + try { + digest = MessageDigest.getInstance("SHA-256").digest(identityKey.serialize()); + } catch (NoSuchAlgorithmException e) { + // SHA-256 should always be supported as an algorithm + throw new AssertionError("All Java implementations must support the SHA-256 message digest"); + } + + return Arrays.equals(digest, 0, 4, fingerprint, 0, 4); + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ServiceIdentifierUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ServiceIdentifierUtil.java index 4b30c6165..a3e5e9053 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ServiceIdentifierUtil.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ServiceIdentifierUtil.java @@ -5,8 +5,10 @@ package org.whispersystems.textsecuregcm.grpc; +import com.google.protobuf.ByteString; import io.grpc.Status; import java.util.UUID; +import org.signal.chat.common.IdentityType; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; @@ -31,4 +33,11 @@ public class ServiceIdentifierUtil { case PNI -> new PniServiceIdentifier(uuid); }; } + + public static org.signal.chat.common.ServiceIdentifier toGrpcServiceIdentifier(final ServiceIdentifier serviceIdentifier) { + return org.signal.chat.common.ServiceIdentifier.newBuilder() + .setIdentityType(IdentityTypeUtil.toGrpcIdentityType(serviceIdentifier.identityType())) + .setUuid(UUIDUtil.toByteString(serviceIdentifier.uuid())) + .build(); + } } diff --git a/service/src/main/proto/org/signal/chat/keys.proto b/service/src/main/proto/org/signal/chat/keys.proto index 62d09dbeb..317f64765 100644 --- a/service/src/main/proto/org/signal/chat/keys.proto +++ b/service/src/main/proto/org/signal/chat/keys.proto @@ -104,6 +104,14 @@ service KeysAnonymous { * devices. */ rpc GetPreKeys(GetPreKeysAnonymousRequest) returns (GetPreKeysResponse) {} + + /** + * Checks identity key fingerprints of the target accounts. + * + * Returns a stream of elements, each one representing an account that had a mismatched + * identity key fingerprint with the server and the corresponding identity key stored by the server. + */ + rpc CheckIdentityKeys(stream CheckIdentityKeyRequest) returns (stream CheckIdentityKeyResponse) {} } message GetPreKeyCountRequest { @@ -247,3 +255,25 @@ message SetKemLastResortPreKeyRequest { message SetPreKeyResponse { } +message CheckIdentityKeyRequest { + /** + * The service identifier of the account for which we want to check the associated identity key fingerprint. + */ + common.ServiceIdentifier target_identifier = 1; + /** + * The most significant 4 bytes of the SHA-256 hash of the identity key associated with the target account/identity type. + */ + bytes fingerprint = 2; +} + +message CheckIdentityKeyResponse { + /** + * The service identifier of the account for which there is a mismatch between the client and server identity key fingerprints. + */ + common.ServiceIdentifier target_identifier = 1; + /** + * The identity key that is stored by the server for the target account/identity type. + */ + bytes identity_key = 2; +} + diff --git a/service/src/main/proto/org/signal/chat/profile.proto b/service/src/main/proto/org/signal/chat/profile.proto index 063074459..d9260b588 100644 --- a/service/src/main/proto/org/signal/chat/profile.proto +++ b/service/src/main/proto/org/signal/chat/profile.proto @@ -96,14 +96,6 @@ service ProfileAnonymous { * access key, or an `INVALID_ARGUMENT` status if the given credential type is invalid. */ rpc GetExpiringProfileKeyCredential(GetExpiringProfileKeyCredentialAnonymousRequest) returns (GetExpiringProfileKeyCredentialResponse) {} - - /** - * Checks identity key fingerprints of the target accounts. - * - * Returns a stream of elements, each one representing an account that had a mismatched - * identity key fingerprint with the server and the corresponding identity key stored by the server. - */ - rpc CheckIdentityKeys(stream CheckIdentityKeysRequest) returns (stream CheckIdentityKeysResponse) {} } message SetProfileRequest { @@ -278,28 +270,6 @@ message GetExpiringProfileKeyCredentialResponse { bytes profileKeyCredential = 1; } -message CheckIdentityKeysRequest { - /** - * The service identifier of the account for which we want to check the associated identity key fingerprint. - */ - common.ServiceIdentifier target_identifier = 1; - /** - * The most significant 4 bytes of the SHA-256 hash of the identity key associated with the target account/identity type. - */ - bytes fingerprint = 2; -} - -message CheckIdentityKeysResponse { - /** - * The service identifier of the account for which there is a mismatch between the client and server identity key fingerprints. - */ - common.ServiceIdentifier target_identifier = 1; - /** - * The identity key that is stored by the server for the target account/identity type. - */ - bytes identity_key = 2; -} - message ProfileAvatarUploadAttributes { /** * The S3 upload path for the profile's avatar. diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcServiceTest.java index 721081c92..f66dc08ff 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcServiceTest.java @@ -16,8 +16,11 @@ import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusEx import com.google.protobuf.ByteString; import io.grpc.Status; import io.grpc.StatusRuntimeException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Collections; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -29,11 +32,14 @@ import org.signal.chat.common.EcPreKey; import org.signal.chat.common.EcSignedPreKey; import org.signal.chat.common.KemSignedPreKey; import org.signal.chat.common.ServiceIdentifier; +import org.signal.chat.keys.CheckIdentityKeyRequest; import org.signal.chat.keys.GetPreKeysAnonymousRequest; import org.signal.chat.keys.GetPreKeysRequest; import org.signal.chat.keys.GetPreKeysResponse; import org.signal.chat.keys.KeysAnonymousGrpc; +import org.signal.chat.keys.ReactorKeysAnonymousGrpc; import org.signal.libsignal.protocol.IdentityKey; +import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.ecc.Curve; import org.signal.libsignal.protocol.ecc.ECKeyPair; import org.whispersystems.textsecuregcm.entities.ECPreKey; @@ -41,12 +47,15 @@ import org.whispersystems.textsecuregcm.entities.ECSignedPreKey; import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.IdentityType; +import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.KeysManager; import org.whispersystems.textsecuregcm.tests.util.KeysHelper; import org.whispersystems.textsecuregcm.util.UUIDUtil; +import org.whispersystems.textsecuregcm.util.Util; +import reactor.core.publisher.Flux; class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest { @@ -56,7 +65,6 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest requests = Flux.just( + buildCheckIdentityKeyRequest(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI, mismatchedAciFingerprintAccountIdentifier, + new IdentityKey(Curve.generateKeyPair().getPublicKey())), + buildCheckIdentityKeyRequest(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI, matchingAciFingerprintAccountIdentifier, + matchingAciFingerprintAccountIdentityKey), + buildCheckIdentityKeyRequest(org.signal.chat.common.IdentityType.IDENTITY_TYPE_PNI, UUID.randomUUID(), + new IdentityKey(Curve.generateKeyPair().getPublicKey())), + buildCheckIdentityKeyRequest(org.signal.chat.common.IdentityType.IDENTITY_TYPE_PNI, mismatchedPniFingerprintAccountIdentifier, + new IdentityKey(Curve.generateKeyPair().getPublicKey())) + ); + + final Map expectedResponses = Map.of( + mismatchedAciFingerprintAccountIdentifier, mismatchedAciFingerprintAccountIdentityKey, + mismatchedPniFingerprintAccountIdentifier, mismatchedPniFingerpringAccountIdentityKey); + + final Map responses = reactiveKeysAnonymousStub.checkIdentityKeys(requests) + .collectMap(response -> ServiceIdentifierUtil.fromGrpcServiceIdentifier(response.getTargetIdentifier()).uuid(), + response -> { + try { + return new IdentityKey(response.getIdentityKey().toByteArray()); + } catch (InvalidKeyException e) { + throw new RuntimeException(e); + } + }) + .block(); + + assertEquals(expectedResponses, responses); + } + + private static CheckIdentityKeyRequest buildCheckIdentityKeyRequest(final org.signal.chat.common.IdentityType identityType, + final UUID uuid, final IdentityKey identityKey) { + return CheckIdentityKeyRequest.newBuilder() + .setTargetIdentifier(ServiceIdentifier.newBuilder() + .setIdentityType(identityType) + .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(uuid)))) + .setFingerprint(ByteString.copyFrom(getFingerprint(identityKey))) + .build(); + } + + private static byte[] getFingerprint(final IdentityKey publicKey) { + try { + return Util.truncate(MessageDigest.getInstance("SHA-256").digest(publicKey.serialize()), 4); + } catch (final NoSuchAlgorithmException e) { + throw new AssertionError("All Java implementations must support SHA-256 MessageDigest algorithm", e); + } + } }