diff --git a/package.json b/package.json index d9ccaafe9..9ffdfabf1 100644 --- a/package.json +++ b/package.json @@ -191,7 +191,7 @@ "@babel/preset-typescript": "7.17.12", "@electron/fuses": "1.5.0", "@mixer/parallel-prettier": "2.0.1", - "@signalapp/mock-server": "2.4.1", + "@signalapp/mock-server": "2.6.0", "@storybook/addon-a11y": "6.5.6", "@storybook/addon-actions": "6.5.6", "@storybook/addon-controls": "6.5.6", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 9daabd2aa..81adba4cb 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -39,15 +39,16 @@ message Envelope { } message Content { - optional DataMessage dataMessage = 1; - optional SyncMessage syncMessage = 2; - optional CallingMessage callingMessage = 3; - optional NullMessage nullMessage = 4; - optional ReceiptMessage receiptMessage = 5; - optional TypingMessage typingMessage = 6; - optional bytes senderKeyDistributionMessage = 7; - optional bytes decryptionErrorMessage = 8; - optional StoryMessage storyMessage = 9; + optional DataMessage dataMessage = 1; + optional SyncMessage syncMessage = 2; + optional CallingMessage callingMessage = 3; + optional NullMessage nullMessage = 4; + optional ReceiptMessage receiptMessage = 5; + optional TypingMessage typingMessage = 6; + optional bytes senderKeyDistributionMessage = 7; + optional bytes decryptionErrorMessage = 8; + optional StoryMessage storyMessage = 9; + optional PniSignatureMessage pniSignatureMessage = 10; } // Everything in CallingMessage must be kept in sync with RingRTC (ringrtc-node). @@ -627,3 +628,9 @@ message GroupDetails { optional bool blocked = 8; optional uint32 inboxPosition = 10; } + +message PniSignatureMessage { + optional bytes pni = 1; + // Signature *by* the PNI identity key *of* the ACI identity key + optional bytes signature = 2; +} diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 88d6edaef..e7a15b883 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -1097,6 +1097,28 @@ export class ConversationController { } } + // For testing + async _forgetE164(e164: string): Promise { + const { server } = window.textsecure; + strictAssert(server, 'Server must be initialized'); + const { [e164]: pni } = await server.getUuidsForE164s([e164]); + + log.info(`ConversationController: forgetting e164=${e164} pni=${pni}`); + + const convos = [this.get(e164), this.get(pni)]; + + for (const convo of convos) { + if (!convo) { + continue; + } + + // eslint-disable-next-line no-await-in-loop + await removeConversation(convo.id); + this._conversations.remove(convo); + this._conversations.resetLookups(); + } + } + private async doLoad(): Promise { log.info('ConversationController: starting initial fetch'); diff --git a/ts/LibSignalStores.ts b/ts/LibSignalStores.ts index dfb71ba6a..06297dc42 100644 --- a/ts/LibSignalStores.ts +++ b/ts/LibSignalStores.ts @@ -111,7 +111,7 @@ export class IdentityKeys extends IdentityKeyStore { } async getIdentityKey(): Promise { - const keyPair = await window.textsecure.storage.protocol.getIdentityKeyPair( + const keyPair = window.textsecure.storage.protocol.getIdentityKeyPair( this.ourUuid ); if (!keyPair) { diff --git a/ts/SignalProtocolStore.ts b/ts/SignalProtocolStore.ts index 8a4db04b7..1055cd6d3 100644 --- a/ts/SignalProtocolStore.ts +++ b/ts/SignalProtocolStore.ts @@ -33,6 +33,7 @@ import type { KeyPairType, OuterSignedPrekeyType, PniKeyMaterialType, + PniSignatureMessageType, PreKeyIdType, PreKeyType, SenderKeyIdType, @@ -108,9 +109,15 @@ type MapFields = | 'sessions' | 'signedPreKeys'; -export type SessionTransactionOptions = { - readonly zone?: Zone; -}; +export type SessionTransactionOptions = Readonly<{ + zone?: Zone; +}>; + +export type VerifyAlternateIdentityOptionsType = Readonly<{ + aci: UUID; + pni: UUID; + signature: Uint8Array; +}>; export const GLOBAL_ZONE = new Zone('GLOBAL_ZONE'); @@ -213,6 +220,8 @@ export class SignalProtocolStore extends EventsMixin { private ourRegistrationIds = new Map(); + private cachedPniSignatureMessage: PniSignatureMessageType | undefined; + identityKeys?: Map< IdentityKeyIdType, CacheEntryType @@ -301,7 +310,7 @@ export class SignalProtocolStore extends EventsMixin { ]); } - async getIdentityKeyPair(ourUuid: UUID): Promise { + getIdentityKeyPair(ourUuid: UUID): KeyPairType | undefined { return this.ourIdentityKeys.get(ourUuid.toString()); } @@ -999,7 +1008,7 @@ export class SignalProtocolStore extends EventsMixin { const ourUuid = new UUID(session.ourUuid); - const keyPair = await this.getIdentityKeyPair(ourUuid); + const keyPair = this.getIdentityKeyPair(ourUuid); if (!keyPair) { throw new Error('_maybeMigrateSession: No identity key for ourself!'); } @@ -2049,6 +2058,69 @@ export class SignalProtocolStore extends EventsMixin { await window.storage.fetch(); } + signAlternateIdentity(): PniSignatureMessageType | undefined { + const ourACI = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI); + const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI); + if (!ourPNI) { + log.error('signAlternateIdentity: No local pni'); + return undefined; + } + + if (this.cachedPniSignatureMessage?.pni === ourPNI.toString()) { + return this.cachedPniSignatureMessage; + } + + const aciKeyPair = this.getIdentityKeyPair(ourACI); + const pniKeyPair = this.getIdentityKeyPair(ourPNI); + if (!aciKeyPair) { + log.error('signAlternateIdentity: No local ACI key pair'); + return undefined; + } + if (!pniKeyPair) { + log.error('signAlternateIdentity: No local PNI key pair'); + return undefined; + } + + const pniIdentity = new IdentityKeyPair( + PublicKey.deserialize(Buffer.from(pniKeyPair.pubKey)), + PrivateKey.deserialize(Buffer.from(pniKeyPair.privKey)) + ); + const aciPubKey = PublicKey.deserialize(Buffer.from(aciKeyPair.pubKey)); + this.cachedPniSignatureMessage = { + pni: ourPNI.toString(), + signature: pniIdentity.signAlternateIdentity(aciPubKey), + }; + + return this.cachedPniSignatureMessage; + } + + async verifyAlternateIdentity({ + aci, + pni, + signature, + }: VerifyAlternateIdentityOptionsType): Promise { + const logId = `SignalProtocolStore.verifyAlternateIdentity(${aci}, ${pni})`; + const aciPublicKeyBytes = await this.loadIdentityKey(aci); + if (!aciPublicKeyBytes) { + log.warn(`${logId}: no ACI public key`); + return false; + } + + const pniPublicKeyBytes = await this.loadIdentityKey(pni); + if (!pniPublicKeyBytes) { + log.warn(`${logId}: no PNI public key`); + return false; + } + + const aciPublicKey = PublicKey.deserialize(Buffer.from(aciPublicKeyBytes)); + const pniPublicKey = PublicKey.deserialize(Buffer.from(pniPublicKeyBytes)); + + return pniPublicKey.verifyAlternateIdentity( + aciPublicKey, + Buffer.from(signature) + ); + } + private _getAllSessions(): Array { const union = new Map(); diff --git a/ts/background.ts b/ts/background.ts index 799114dbf..c2f25669f 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -55,7 +55,7 @@ import { RoutineProfileRefresher } from './routineProfileRefresh'; import { isMoreRecentThan, isOlderThan, toDayMillis } from './util/timestamp'; import { isValidReactionEmoji } from './reactions/isValidReactionEmoji'; import type { ConversationModel } from './models/conversations'; -import { getContact } from './messages/helpers'; +import { getContact, isIncoming } from './messages/helpers'; import { migrateMessageData } from './messages/migrateMessageData'; import { createBatcher } from './util/batcher'; import { updateConversationsWithUuidLookup } from './updateConversationsWithUuidLookup'; @@ -102,7 +102,6 @@ import { BackOff, FIBONACCI_TIMEOUTS } from './util/BackOff'; import { AppViewType } from './state/ducks/app'; import type { BadgesStateType } from './state/ducks/badges'; import { badgeImageFileDownloader } from './badges/badgeImageFileDownloader'; -import { isIncoming } from './state/selectors/message'; import { actionCreators } from './state/actions'; import { Deletes } from './messageModifiers/Deletes'; import { @@ -2948,7 +2947,9 @@ export async function startApp(): Promise { const messageDescriptor = getMessageDescriptor({ confirm, - ...data, + message: data.message, + source: data.source, + sourceUuid: data.sourceUuid, // 'message' event: for 1:1 converations, the conversation is same as sender destination: data.source, destinationUuid: data.sourceUuid, @@ -2967,19 +2968,28 @@ export async function startApp(): Promise { const message = initIncomingMessage(data, messageDescriptor); - if ( - isIncoming(message.attributes) && - !message.get('unidentifiedDeliveryReceived') - ) { + if (isIncoming(message.attributes)) { const sender = getContact(message.attributes); + strictAssert(sender, 'MessageModel has no sender'); - if (!sender) { - throw new Error('MessageModel has no sender.'); + const uuidKind = window.textsecure.storage.user.getOurUuidKind( + new UUID(data.destinationUuid) + ); + + if (uuidKind === UUIDKind.PNI && !sender.get('shareMyPhoneNumber')) { + log.info( + 'onMessageReceived: setting shareMyPhoneNumber ' + + `for ${sender.idForLogging()}` + ); + sender.set({ shareMyPhoneNumber: true }); + window.Signal.Data.updateConversation(sender.attributes); } - profileKeyResponseQueue.add(() => { - respondWithProfileKeyBatcher.add(sender); - }); + if (!message.get('unidentifiedDeliveryReceived')) { + profileKeyResponseQueue.add(() => { + respondWithProfileKeyBatcher.add(sender); + }); + } } if (data.message.reaction) { @@ -3731,8 +3741,14 @@ export async function startApp(): Promise { logTitle: string; type: MessageReceiptType.Read | MessageReceiptType.View; }>): void { - const { envelopeTimestamp, timestamp, source, sourceUuid, sourceDevice } = - event.receipt; + const { + envelopeTimestamp, + timestamp, + source, + sourceUuid, + sourceDevice, + wasSentEncrypted, + } = event.receipt; const sourceConversation = window.ConversationController.maybeMergeContacts( { aci: sourceUuid, @@ -3770,6 +3786,7 @@ export async function startApp(): Promise { sourceUuid, sourceDevice, type, + wasSentEncrypted, }; const receipt = MessageReceipts.getSingleton().add(attributes); @@ -3856,8 +3873,14 @@ export async function startApp(): Promise { function onDeliveryReceipt(ev: DeliveryEvent) { const { deliveryReceipt } = ev; - const { envelopeTimestamp, sourceUuid, source, sourceDevice, timestamp } = - deliveryReceipt; + const { + envelopeTimestamp, + sourceUuid, + source, + sourceDevice, + timestamp, + wasSentEncrypted, + } = deliveryReceipt; ev.confirm(); @@ -3902,6 +3925,7 @@ export async function startApp(): Promise { sourceUuid, sourceDevice, type: MessageReceiptType.Delivery, + wasSentEncrypted, }; const receipt = MessageReceipts.getSingleton().add(attributes); diff --git a/ts/jobs/helpers/sendDeleteForEveryone.ts b/ts/jobs/helpers/sendDeleteForEveryone.ts index 0cd7a108f..c0378ef54 100644 --- a/ts/jobs/helpers/sendDeleteForEveryone.ts +++ b/ts/jobs/helpers/sendDeleteForEveryone.ts @@ -188,6 +188,7 @@ export async function sendDeleteForEveryone( profileKey, options: sendOptions, urgent: true, + includePniSignatureMessage: true, }), sendType, timestamp, diff --git a/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts b/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts index 8aac4ff46..783a165bf 100644 --- a/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts +++ b/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts @@ -82,6 +82,7 @@ export async function sendDirectExpirationTimerUpdate( profileKey, recipients: conversation.getRecipients(), timestamp, + includePniSignatureMessage: true, }); if (!proto.dataMessage) { diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index c963f01e9..84531c168 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -283,6 +283,7 @@ export async function sendNormalMessage( storyContext, timestamp: messageTimestamp, urgent: true, + includePniSignatureMessage: true, }); } diff --git a/ts/jobs/helpers/sendProfileKey.ts b/ts/jobs/helpers/sendProfileKey.ts index d24b208b9..b9de58764 100644 --- a/ts/jobs/helpers/sendProfileKey.ts +++ b/ts/jobs/helpers/sendProfileKey.ts @@ -129,6 +129,7 @@ export async function sendProfileKey( profileKey, recipients: conversation.getRecipients(), timestamp, + includePniSignatureMessage: true, }); sendPromise = messaging.sendIndividualProto({ contentHint, diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts index 85e954af8..e161613d4 100644 --- a/ts/jobs/helpers/sendReaction.ts +++ b/ts/jobs/helpers/sendReaction.ts @@ -240,6 +240,7 @@ export async function sendReaction( } : undefined, urgent: true, + includePniSignatureMessage: true, }); } else { log.info('sending group reaction message'); diff --git a/ts/messageModifiers/MessageReceipts.ts b/ts/messageModifiers/MessageReceipts.ts index e28357292..9030d084b 100644 --- a/ts/messageModifiers/MessageReceipts.ts +++ b/ts/messageModifiers/MessageReceipts.ts @@ -39,6 +39,7 @@ export type MessageReceiptAttributesType = { sourceConversationId: string; sourceDevice: number; type: MessageReceiptType; + wasSentEncrypted: boolean; }; class MessageReceiptModel extends Model {} @@ -53,7 +54,25 @@ const deleteSentProtoBatcher = createWaitBatcher({ log.info( `MessageReceipts: Batching ${items.length} sent proto recipients deletes` ); - await deleteSentProtoRecipient(items); + const { successfulPhoneNumberShares } = await deleteSentProtoRecipient( + items + ); + + for (const uuid of successfulPhoneNumberShares) { + const convo = window.ConversationController.get(uuid); + if (!convo) { + continue; + } + + log.info( + 'MessageReceipts: unsetting shareMyPhoneNumber ' + + `for ${convo.idForLogging()}` + ); + + // `deleteSentProtoRecipient` has already updated the database so there + // is no need in calling `updateConversation` + convo.unset('shareMyPhoneNumber'); + } }, }); @@ -193,7 +212,8 @@ export class MessageReceipts extends Collection { if ( (type === MessageReceiptType.Delivery && - wasDeliveredWithSealedSender(sourceConversationId, message)) || + wasDeliveredWithSealedSender(sourceConversationId, message) && + receipt.get('wasSentEncrypted')) || type === MessageReceiptType.Read ) { const recipient = window.ConversationController.get(sourceConversationId); @@ -201,11 +221,17 @@ export class MessageReceipts extends Collection { const deviceId = receipt.get('sourceDevice'); if (recipientUuid && deviceId) { - await deleteSentProtoBatcher.add({ - timestamp: messageSentAt, - recipientUuid, - deviceId, - }); + await Promise.all([ + deleteSentProtoBatcher.add({ + timestamp: messageSentAt, + recipientUuid, + deviceId, + }), + + // We want the above call to not be delayed when testing with + // CI. + window.CI ? deleteSentProtoBatcher.flushAndWait() : Promise.resolve(), + ]); } else { log.warn( `MessageReceipts.onReceipt: Missing uuid or deviceId for deliveredTo ${sourceConversationId}` @@ -249,6 +275,7 @@ export class MessageReceipts extends Collection { 'No message for receipt', type, sourceConversationId, + sourceUuid, messageSentAt ); return; diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index d2fbda631..6549bf83c 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -335,6 +335,7 @@ export type ConversationAttributesType = { profileLastFetchedAt?: number; pendingUniversalTimer?: string; username?: string; + shareMyPhoneNumber?: boolean; // Group-only groupId?: string; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 62d656357..4b01ce6ad 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -34,7 +34,10 @@ import type { } from '../textsecure/SendMessage'; import createTaskWithTimeout from '../textsecure/TaskWithTimeout'; import MessageSender from '../textsecure/SendMessage'; -import type { CallbackResultType } from '../textsecure/Types.d'; +import type { + CallbackResultType, + PniSignatureMessageType, +} from '../textsecure/Types.d'; import type { ConversationType } from '../state/ducks/conversations'; import type { AvatarColorType, @@ -2023,6 +2026,7 @@ export class ConversationModel extends window.Backbone senderE164: m.source, senderUuid: m.sourceUuid, timestamp: m.sent_at, + isDirectConversation: isDirectConversation(this.attributes), })) ); } @@ -5377,6 +5381,13 @@ export class ConversationModel extends window.Backbone ); } } + + getPniSignatureMessage(): PniSignatureMessageType | undefined { + if (!this.get('shareMyPhoneNumber')) { + return undefined; + } + return window.textsecure.storage.protocol.signAlternateIdentity(); + } } window.Whisper.Conversation = ConversationModel; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 60afa1e50..d1842d496 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -2373,6 +2373,7 @@ export class MessageModel extends window.Backbone.Model { senderE164: source, senderUuid: sourceUuid, timestamp: this.get('sent_at'), + isDirectConversation: isDirectConversation(conversation.attributes), }); }); } diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 9f164c955..2bc7c3f58 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -53,6 +53,7 @@ import type { ConversationType, ConversationMetricsType, DeleteSentProtoRecipientOptionsType, + DeleteSentProtoRecipientResultType, EmojiType, GetUnreadByConversationAndMarkReadResultType, GetConversationRangeCenteredOnMessageResultType, @@ -952,8 +953,8 @@ async function deleteSentProtoRecipient( options: | DeleteSentProtoRecipientOptionsType | ReadonlyArray -): Promise { - await channels.deleteSentProtoRecipient(options); +): Promise { + return channels.deleteSentProtoRecipient(options); } async function getSentProtoByRecipient(options: { diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 3eff8c1b4..95bd69dc2 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -119,6 +119,7 @@ export type SentProtoType = { proto: Uint8Array; timestamp: number; urgent: boolean; + hasPniSignatureMessage: boolean; }; export type SentProtoWithMessageIdsType = SentProtoType & { messageIds: Array; @@ -287,6 +288,10 @@ export type DeleteSentProtoRecipientOptionsType = Readonly<{ deviceId: number; }>; +export type DeleteSentProtoRecipientResultType = Readonly<{ + successfulPhoneNumberShares: ReadonlyArray; +}>; + export type StoryDistributionType = Readonly<{ id: UUIDStringType; name: string; @@ -381,7 +386,7 @@ export type DataInterface = { options: | DeleteSentProtoRecipientOptionsType | ReadonlyArray - ) => Promise; + ) => Promise; getSentProtoByRecipient: (options: { now: number; recipientUuid: string; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 745f34d31..50640135a 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -76,6 +76,7 @@ import type { ConversationMetricsType, ConversationType, DeleteSentProtoRecipientOptionsType, + DeleteSentProtoRecipientResultType, EmojiType, GetConversationRangeCenteredOnMessageResultType, GetUnreadByConversationAndMarkReadResultType, @@ -855,17 +856,20 @@ async function insertSentProto( contentHint, proto, timestamp, - urgent + urgent, + hasPniSignatureMessage ) VALUES ( $contentHint, $proto, $timestamp, - $urgent + $urgent, + $hasPniSignatureMessage ); ` ).run({ ...proto, urgent: proto.urgent ? 1 : 0, + hasPniSignatureMessage: proto.hasPniSignatureMessage ? 1 : 0, }); const id = parseIntOrThrow( info.lastInsertRowid, @@ -999,7 +1003,7 @@ async function deleteSentProtoRecipient( options: | DeleteSentProtoRecipientOptionsType | ReadonlyArray -): Promise { +): Promise { const db = getInstance(); const items = Array.isArray(options) ? options : [options]; @@ -1007,7 +1011,9 @@ async function deleteSentProtoRecipient( // Note: we use `pluck` in this function to fetch only the first column of // returned row. - db.transaction(() => { + return db.transaction(() => { + const successfulPhoneNumberShares = new Array(); + for (const item of items) { const { timestamp, recipientUuid, deviceId } = item; @@ -1015,7 +1021,8 @@ async function deleteSentProtoRecipient( const rows = prepare( db, ` - SELECT sendLogPayloads.id FROM sendLogPayloads + SELECT sendLogPayloads.id, sendLogPayloads.hasPniSignatureMessage + FROM sendLogPayloads INNER JOIN sendLogRecipients ON sendLogRecipients.payloadId = sendLogPayloads.id WHERE @@ -1032,10 +1039,9 @@ async function deleteSentProtoRecipient( 'deleteSentProtoRecipient: More than one payload matches ' + `recipient and timestamp ${timestamp}. Using the first.` ); - continue; } - const { id } = rows[0]; + const { id, hasPniSignatureMessage } = rows[0]; // 2. Delete the recipient/device combination in question. prepare( @@ -1050,32 +1056,61 @@ async function deleteSentProtoRecipient( ).run({ id, recipientUuid, deviceId }); // 3. See how many more recipient devices there were for this payload. - const remaining = prepare( + const remainingDevices = prepare( + db, + ` + SELECT count(*) FROM sendLogRecipients + WHERE payloadId = $id AND recipientUuid = $recipientUuid; + ` + ) + .pluck(true) + .get({ id, recipientUuid }); + + // 4. If there are no remaining devices for this recipient and we included + // the pni signature in the proto - return the recipient to the caller. + if (remainingDevices === 0 && hasPniSignatureMessage) { + logger.info( + 'deleteSentProtoRecipient: ' + + `Successfully shared phone number with ${recipientUuid} ` + + `through message ${timestamp}` + ); + successfulPhoneNumberShares.push(recipientUuid); + } + + strictAssert( + isNumber(remainingDevices), + 'deleteSentProtoRecipient: select count() returned non-number!' + ); + + // 5. See how many more recipients there were for this payload. + const remainingTotal = prepare( db, 'SELECT count(*) FROM sendLogRecipients WHERE payloadId = $id;' ) .pluck(true) .get({ id }); - if (!isNumber(remaining)) { - throw new Error( - 'deleteSentProtoRecipient: select count() returned non-number!' - ); - } + strictAssert( + isNumber(remainingTotal), + 'deleteSentProtoRecipient: select count() returned non-number!' + ); - if (remaining > 0) { + if (remainingTotal > 0) { continue; } - // 4. Delete the entire payload if there are no more recipients left. + // 6. Delete the entire payload if there are no more recipients left. logger.info( 'deleteSentProtoRecipient: ' + `Deleting proto payload for timestamp ${timestamp}` ); + prepare(db, 'DELETE FROM sendLogPayloads WHERE id = $id;').run({ id, }); } + + return { successfulPhoneNumberShares }; })(); } @@ -1122,6 +1157,9 @@ async function getSentProtoByRecipient({ return { ...row, urgent: isNumber(row.urgent) ? Boolean(row.urgent) : true, + hasPniSignatureMessage: isNumber(row.hasPniSignatureMessage) + ? Boolean(row.hasPniSignatureMessage) + : true, messageIds: messageIds ? messageIds.split(',') : [], }; } @@ -1136,6 +1174,9 @@ async function getAllSentProtos(): Promise> { return rows.map(row => ({ ...row, urgent: isNumber(row.urgent) ? Boolean(row.urgent) : true, + hasPniSignatureMessage: isNumber(row.hasPniSignatureMessage) + ? Boolean(row.hasPniSignatureMessage) + : true, })); } async function _getAllSentProtoRecipients(): Promise< diff --git a/ts/sql/migrations/66-add-pni-signature-to-sent-protos.ts b/ts/sql/migrations/66-add-pni-signature-to-sent-protos.ts new file mode 100644 index 000000000..c6d3e1467 --- /dev/null +++ b/ts/sql/migrations/66-add-pni-signature-to-sent-protos.ts @@ -0,0 +1,29 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from 'better-sqlite3'; + +import type { LoggerType } from '../../types/Logging'; + +export default function updateToSchemaVersion66( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 66) { + return; + } + + db.transaction(() => { + db.exec( + ` + ALTER TABLE sendLogPayloads + ADD COLUMN hasPniSignatureMessage INTEGER DEFAULT 0 NOT NULL; + ` + ); + + db.pragma('user_version = 66'); + })(); + + logger.info('updateToSchemaVersion66: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index db8dac214..38546aa13 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -41,6 +41,7 @@ import updateToSchemaVersion62 from './62-add-urgent-to-send-log'; import updateToSchemaVersion63 from './63-add-urgent-to-unprocessed'; import updateToSchemaVersion64 from './64-uuid-column-for-pre-keys'; import updateToSchemaVersion65 from './65-add-storage-id-to-stickers'; +import updateToSchemaVersion66 from './66-add-pni-signature-to-sent-protos'; function updateToSchemaVersion1( currentVersion: number, @@ -1945,6 +1946,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion63, updateToSchemaVersion64, updateToSchemaVersion65, + updateToSchemaVersion66, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 1e863dcc0..b61a62e2c 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -410,6 +410,7 @@ function markStoryRead( senderE164: message.attributes.source, senderUuid: message.attributes.sourceUuid, timestamp: message.attributes.sent_at, + isDirectConversation: false, }; const viewSyncs: Array = [viewedReceipt]; diff --git a/ts/test-electron/SignalProtocolStore_test.ts b/ts/test-electron/SignalProtocolStore_test.ts index d1e3f7d84..1fac3ccf6 100644 --- a/ts/test-electron/SignalProtocolStore_test.ts +++ b/ts/test-electron/SignalProtocolStore_test.ts @@ -166,7 +166,7 @@ describe('SignalProtocolStore', () => { describe('getIdentityKeyPair', () => { it('retrieves my identity key', async () => { await store.hydrateCaches(); - const key = await store.getIdentityKeyPair(ourUuid); + const key = store.getIdentityKeyPair(ourUuid); if (!key) { throw new Error('Missing key!'); } @@ -1810,13 +1810,13 @@ describe('SignalProtocolStore', () => { }); // Old data has to be removed - assert.isUndefined(await store.getIdentityKeyPair(oldPni)); + assert.isUndefined(store.getIdentityKeyPair(oldPni)); assert.isUndefined(await store.getLocalRegistrationId(oldPni)); assert.isUndefined(await store.loadPreKey(oldPni, 2)); assert.isUndefined(await store.loadSignedPreKey(oldPni, 3)); // New data has to be added - const storedIdentity = await store.getIdentityKeyPair(newPni); + const storedIdentity = store.getIdentityKeyPair(newPni); if (!storedIdentity) { throw new Error('New identity not found'); } diff --git a/ts/test-electron/sql/sendLog_test.ts b/ts/test-electron/sql/sendLog_test.ts index 5565f60c1..aa9d05c62 100644 --- a/ts/test-electron/sql/sendLog_test.ts +++ b/ts/test-electron/sql/sendLog_test.ts @@ -40,6 +40,7 @@ describe('sql/sendLog', () => { proto: bytes, timestamp, urgent: false, + hasPniSignatureMessage: false, }; await insertSentProto(proto, { messageIds: [getUuid()], @@ -56,6 +57,10 @@ describe('sql/sendLog', () => { assert.isTrue(constantTimeEqual(actual.proto, proto.proto)); assert.strictEqual(actual.timestamp, proto.timestamp); assert.strictEqual(actual.urgent, proto.urgent); + assert.strictEqual( + actual.hasPniSignatureMessage, + proto.hasPniSignatureMessage + ); await removeAllSentProtos(); @@ -74,6 +79,7 @@ describe('sql/sendLog', () => { proto: bytes, timestamp, urgent: true, + hasPniSignatureMessage: true, }; await insertSentProto(proto, { messageIds: [getUuid(), getUuid()], @@ -91,6 +97,10 @@ describe('sql/sendLog', () => { assert.isTrue(constantTimeEqual(actual.proto, proto.proto)); assert.strictEqual(actual.timestamp, proto.timestamp); assert.strictEqual(actual.urgent, proto.urgent); + assert.strictEqual( + actual.hasPniSignatureMessage, + proto.hasPniSignatureMessage + ); assert.lengthOf(await _getAllSentProtoMessageIds(), 2); assert.lengthOf(await _getAllSentProtoRecipients(), 3); @@ -127,6 +137,7 @@ describe('sql/sendLog', () => { proto: bytes, timestamp, urgent: false, + hasPniSignatureMessage: false, }; await insertSentProto(proto, { messageIds: [id], @@ -159,12 +170,14 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; const proto2 = { contentHint: 9, proto: getRandomBytes(128), timestamp, urgent: false, + hasPniSignatureMessage: true, }; assert.lengthOf(await getAllSentProtos(), 0); @@ -195,6 +208,7 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; assert.lengthOf(await getAllSentProtos(), 0); @@ -234,18 +248,21 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp: timestamp + 10, urgent: true, + hasPniSignatureMessage: false, }; const proto2 = { contentHint: 2, proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; const proto3 = { contentHint: 0, proto: getRandomBytes(128), timestamp: timestamp - 15, urgent: true, + hasPniSignatureMessage: false, }; await insertSentProto(proto1, { messageIds: [getUuid()], @@ -298,18 +315,21 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; const proto2 = { contentHint: 1, proto: getRandomBytes(128), timestamp: timestamp - 10, urgent: true, + hasPniSignatureMessage: false, }; const proto3 = { contentHint: 1, proto: getRandomBytes(128), timestamp: timestamp - 20, urgent: true, + hasPniSignatureMessage: false, }; await insertSentProto(proto1, { messageIds: [messageId, getUuid()], @@ -354,6 +374,7 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; await insertSentProto(proto, { messageIds: [getUuid()], @@ -366,11 +387,12 @@ describe('sql/sendLog', () => { assert.lengthOf(await getAllSentProtos(), 1); assert.lengthOf(await _getAllSentProtoRecipients(), 3); - await deleteSentProtoRecipient({ + const { successfulPhoneNumberShares } = await deleteSentProtoRecipient({ timestamp, recipientUuid: recipientUuid1, deviceId: 1, }); + assert.lengthOf(successfulPhoneNumberShares, 0); assert.lengthOf(await getAllSentProtos(), 1); assert.lengthOf(await _getAllSentProtoRecipients(), 2); @@ -386,6 +408,7 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; await insertSentProto(proto, { messageIds: [getUuid()], @@ -398,30 +421,99 @@ describe('sql/sendLog', () => { assert.lengthOf(await getAllSentProtos(), 1); assert.lengthOf(await _getAllSentProtoRecipients(), 3); - await deleteSentProtoRecipient({ - timestamp, - recipientUuid: recipientUuid1, - deviceId: 1, - }); + { + const { successfulPhoneNumberShares } = await deleteSentProtoRecipient({ + timestamp, + recipientUuid: recipientUuid1, + deviceId: 1, + }); + assert.lengthOf(successfulPhoneNumberShares, 0); + } assert.lengthOf(await getAllSentProtos(), 1); assert.lengthOf(await _getAllSentProtoRecipients(), 2); - await deleteSentProtoRecipient({ - timestamp, - recipientUuid: recipientUuid1, - deviceId: 2, - }); + { + const { successfulPhoneNumberShares } = await deleteSentProtoRecipient({ + timestamp, + recipientUuid: recipientUuid1, + deviceId: 2, + }); + assert.lengthOf(successfulPhoneNumberShares, 0); + } assert.lengthOf(await getAllSentProtos(), 1); assert.lengthOf(await _getAllSentProtoRecipients(), 1); - await deleteSentProtoRecipient({ + { + const { successfulPhoneNumberShares } = await deleteSentProtoRecipient({ + timestamp, + recipientUuid: recipientUuid2, + deviceId: 1, + }); + assert.lengthOf(successfulPhoneNumberShares, 0); + } + + assert.lengthOf(await getAllSentProtos(), 0); + assert.lengthOf(await _getAllSentProtoRecipients(), 0); + }); + + it('returns deleted recipients when pni signature was sent', async () => { + const timestamp = Date.now(); + + const recipientUuid1 = getUuid(); + const recipientUuid2 = getUuid(); + const proto = { + contentHint: 1, + proto: getRandomBytes(128), timestamp, - recipientUuid: recipientUuid2, - deviceId: 1, + urgent: true, + hasPniSignatureMessage: true, + }; + await insertSentProto(proto, { + messageIds: [getUuid()], + recipients: { + [recipientUuid1]: [1, 2], + [recipientUuid2]: [1], + }, }); + assert.lengthOf(await getAllSentProtos(), 1); + assert.lengthOf(await _getAllSentProtoRecipients(), 3); + + { + const { successfulPhoneNumberShares } = await deleteSentProtoRecipient({ + timestamp, + recipientUuid: recipientUuid1, + deviceId: 1, + }); + assert.lengthOf(successfulPhoneNumberShares, 0); + } + + assert.lengthOf(await getAllSentProtos(), 1); + assert.lengthOf(await _getAllSentProtoRecipients(), 2); + + { + const { successfulPhoneNumberShares } = await deleteSentProtoRecipient({ + timestamp, + recipientUuid: recipientUuid1, + deviceId: 2, + }); + assert.deepStrictEqual(successfulPhoneNumberShares, [recipientUuid1]); + } + + assert.lengthOf(await getAllSentProtos(), 1); + assert.lengthOf(await _getAllSentProtoRecipients(), 1); + + { + const { successfulPhoneNumberShares } = await deleteSentProtoRecipient({ + timestamp, + recipientUuid: recipientUuid2, + deviceId: 1, + }); + assert.deepStrictEqual(successfulPhoneNumberShares, [recipientUuid2]); + } + assert.lengthOf(await getAllSentProtos(), 0); assert.lengthOf(await _getAllSentProtoRecipients(), 0); }); @@ -436,6 +528,7 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; await insertSentProto(proto, { messageIds: [getUuid()], @@ -448,7 +541,7 @@ describe('sql/sendLog', () => { assert.lengthOf(await getAllSentProtos(), 1); assert.lengthOf(await _getAllSentProtoRecipients(), 3); - await deleteSentProtoRecipient([ + const { successfulPhoneNumberShares } = await deleteSentProtoRecipient([ { timestamp, recipientUuid: recipientUuid1, @@ -465,6 +558,7 @@ describe('sql/sendLog', () => { deviceId: 1, }, ]); + assert.lengthOf(successfulPhoneNumberShares, 0); assert.lengthOf(await getAllSentProtos(), 0); assert.lengthOf(await _getAllSentProtoRecipients(), 0); @@ -482,6 +576,7 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; await insertSentProto(proto, { messageIds, @@ -518,6 +613,7 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; await insertSentProto(proto, { messageIds: [], @@ -554,6 +650,7 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; await insertSentProto(proto, { messageIds: [getUuid()], @@ -583,6 +680,7 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; await insertSentProto(proto, { messageIds: [getUuid()], @@ -613,6 +711,7 @@ describe('sql/sendLog', () => { proto: getRandomBytes(128), timestamp, urgent: true, + hasPniSignatureMessage: false, }; await insertSentProto(proto, { messageIds: [getUuid()], diff --git a/ts/test-electron/textsecure/AccountManager_test.ts b/ts/test-electron/textsecure/AccountManager_test.ts index fdb204137..6fb9ba11e 100644 --- a/ts/test-electron/textsecure/AccountManager_test.ts +++ b/ts/test-electron/textsecure/AccountManager_test.ts @@ -44,8 +44,7 @@ describe('AccountManager', () => { window.textsecure.storage.user.getUuid = () => ourUuid; - window.textsecure.storage.protocol.getIdentityKeyPair = async () => - identityKey; + window.textsecure.storage.protocol.getIdentityKeyPair = () => identityKey; window.textsecure.storage.protocol.loadSignedPreKeys = async () => signedPreKeys; }); diff --git a/ts/test-mock/gv2/accept_invite_test.ts b/ts/test-mock/pnp/accept_gv2_invite_test.ts similarity index 100% rename from ts/test-mock/gv2/accept_invite_test.ts rename to ts/test-mock/pnp/accept_gv2_invite_test.ts diff --git a/ts/test-mock/change-number/prekey_test.ts b/ts/test-mock/pnp/change_number_test.ts similarity index 97% rename from ts/test-mock/change-number/prekey_test.ts rename to ts/test-mock/pnp/change_number_test.ts index df478082b..94aa615c3 100644 --- a/ts/test-mock/change-number/prekey_test.ts +++ b/ts/test-mock/pnp/change_number_test.ts @@ -10,7 +10,7 @@ import type { App } from '../bootstrap'; export const debug = createDebug('mock:test:change-number'); -describe('change number', function needsName() { +describe('PNP change number', function needsName() { this.timeout(durations.MINUTE); let bootstrap: Bootstrap; diff --git a/ts/test-mock/pnp/pni_signature_test.ts b/ts/test-mock/pnp/pni_signature_test.ts new file mode 100644 index 000000000..ebf43f91c --- /dev/null +++ b/ts/test-mock/pnp/pni_signature_test.ts @@ -0,0 +1,350 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { + UUIDKind, + Proto, + ReceiptType, + StorageState, +} from '@signalapp/mock-server'; +import type { PrimaryDevice } from '@signalapp/mock-server'; +import createDebug from 'debug'; + +import * as durations from '../../util/durations'; +import { uuidToBytes } from '../../util/uuidToBytes'; +import { MY_STORIES_ID } from '../../types/Stories'; +import { Bootstrap } from '../bootstrap'; +import type { App } from '../bootstrap'; + +export const debug = createDebug('mock:test:pni-signature'); + +const IdentifierType = Proto.ManifestRecord.Identifier.Type; + +describe('PNI Signature', function needsName() { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App; + let pniContact: PrimaryDevice; + + beforeEach(async () => { + bootstrap = new Bootstrap(); + await bootstrap.init(); + + const { server, phone } = bootstrap; + + pniContact = await server.createPrimaryDevice({ + profileName: 'ACI Contact', + }); + + let state = StorageState.getEmpty(); + + state = state.updateAccount({ + profileKey: phone.profileKey.serialize(), + e164: phone.device.number, + }); + + state = state.addContact( + pniContact, + { + identityState: Proto.ContactRecord.IdentityState.VERIFIED, + whitelisted: true, + + identityKey: pniContact.getPublicKey(UUIDKind.PNI).serialize(), + + serviceE164: pniContact.device.number, + givenName: 'PNI Contact', + }, + UUIDKind.PNI + ); + + state = state.addContact(pniContact, { + identityState: Proto.ContactRecord.IdentityState.VERIFIED, + whitelisted: true, + + serviceE164: undefined, + identityKey: pniContact.publicKey.serialize(), + profileKey: pniContact.profileKey.serialize(), + }); + + // Just to make PNI Contact visible in the left pane + state = state.pin(pniContact, UUIDKind.PNI); + + // Add my story + state = state.addRecord({ + type: IdentifierType.STORY_DISTRIBUTION_LIST, + record: { + storyDistributionList: { + allowsReplies: true, + identifier: uuidToBytes(MY_STORIES_ID), + isBlockList: true, + name: MY_STORIES_ID, + recipientUuids: [], + }, + }, + }); + + await phone.setStorageState(state); + + app = await bootstrap.link(); + }); + + afterEach(async function after() { + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(); + } + + await app.close(); + await bootstrap.teardown(); + }); + + it('should be sent by Desktop until encrypted delivery receipt', async () => { + const { server, desktop } = bootstrap; + + const ourPNIKey = await desktop.getIdentityKey(UUIDKind.PNI); + const ourACIKey = await desktop.getIdentityKey(UUIDKind.ACI); + + const window = await app.getWindow(); + + const leftPane = window.locator('.left-pane-wrapper'); + const conversationStack = window.locator('.conversation-stack'); + const composeArea = window.locator( + '.composition-area-wrapper, ' + + '.ConversationView__template .react-wrapper' + ); + + debug('creating a stranger'); + const stranger = await server.createPrimaryDevice({ + profileName: 'Mysterious Stranger', + }); + + const ourKey = await desktop.popSingleUseKey(UUIDKind.PNI); + await stranger.addSingleUseKey(desktop, ourKey, UUIDKind.PNI); + + const checkPniSignature = ( + message: Proto.IPniSignatureMessage | null | undefined, + source: string + ) => { + if (!message) { + throw new Error( + `Missing expected pni signature message from ${source}` + ); + } + + assert.deepEqual( + message.pni, + uuidToBytes(desktop.pni), + `Incorrect pni in pni signature message from ${source}` + ); + + const isValid = ourPNIKey.verifyAlternateIdentity( + ourACIKey, + Buffer.from(message.signature ?? []) + ); + assert.isTrue(isValid, `Invalid pni signature from ${source}`); + }; + + debug('sending a message to our PNI'); + await stranger.sendText(desktop, 'A message to PNI', { + uuidKind: UUIDKind.PNI, + withProfileKey: true, + timestamp: bootstrap.getTimestamp(), + }); + + debug('opening conversation with the stranger'); + await leftPane + .locator( + '_react=ConversationListItem' + + `[title = ${JSON.stringify(stranger.profileName)}]` + ) + .click(); + + debug('Accept conversation from a stranger'); + await conversationStack + .locator('.module-message-request-actions button >> "Accept"') + .click(); + + debug('Waiting for a pniSignatureMessage'); + { + const { source, content } = await stranger.waitForMessage(); + + assert.strictEqual(source, desktop, 'initial message has valid source'); + checkPniSignature(content.pniSignatureMessage, 'initial message'); + } + + debug('Enter first message text'); + const compositionInput = composeArea.locator('_react=CompositionInput'); + + await compositionInput.type('first'); + await compositionInput.press('Enter'); + + debug('Waiting for the first message with pni signature'); + { + const { source, content, body, dataMessage } = + await stranger.waitForMessage(); + + assert.strictEqual( + source, + desktop, + 'first message must have valid source' + ); + assert.strictEqual(body, 'first', 'first message must have valid body'); + checkPniSignature(content.pniSignatureMessage, 'first message'); + + const receiptTimestamp = bootstrap.getTimestamp(); + debug('Sending unencrypted receipt', receiptTimestamp); + + await stranger.sendUnencryptedReceipt(desktop, { + messageTimestamp: dataMessage.timestamp?.toNumber() ?? 0, + timestamp: receiptTimestamp, + }); + } + + debug('Enter second message text'); + + await compositionInput.type('second'); + await compositionInput.press('Enter'); + + debug('Waiting for the second message with pni signature'); + { + const { source, content, body, dataMessage } = + await stranger.waitForMessage(); + + assert.strictEqual( + source, + desktop, + 'second message must have valid source' + ); + assert.strictEqual(body, 'second', 'second message must have valid body'); + checkPniSignature(content.pniSignatureMessage, 'second message'); + + const receiptTimestamp = bootstrap.getTimestamp(); + debug('Sending encrypted receipt', receiptTimestamp); + + await stranger.sendReceipt(desktop, { + type: ReceiptType.Delivery, + messageTimestamps: [dataMessage.timestamp?.toNumber() ?? 0], + timestamp: receiptTimestamp, + }); + } + + debug('Enter third message text'); + + await compositionInput.type('third'); + await compositionInput.press('Enter'); + + debug('Waiting for the third message without pni signature'); + { + const { source, content, body } = await stranger.waitForMessage(); + + assert.strictEqual( + source, + desktop, + 'third message must have valid source' + ); + assert.strictEqual(body, 'third', 'third message must have valid body'); + assert( + !content.pniSignatureMessage, + 'third message must not have pni signature message' + ); + } + }); + + it('should be received by Desktop and trigger contact merge', async () => { + const { desktop, phone } = bootstrap; + + const window = await app.getWindow(); + + const leftPane = window.locator('.left-pane-wrapper'); + const composeArea = window.locator( + '.composition-area-wrapper, ' + + '.ConversationView__template .react-wrapper' + ); + + debug('opening conversation with the pni contact'); + await leftPane + .locator('_react=ConversationListItem[title = "PNI Contact"]') + .click(); + + debug('Enter a PNI message text'); + const compositionInput = composeArea.locator('_react=CompositionInput'); + + await compositionInput.type('Hello PNI'); + await compositionInput.press('Enter'); + + debug('Waiting for a PNI message'); + { + const { source, body, uuidKind } = await pniContact.waitForMessage(); + + assert.strictEqual(source, desktop, 'PNI message has valid source'); + assert.strictEqual(body, 'Hello PNI', 'PNI message has valid body'); + assert.strictEqual( + uuidKind, + UUIDKind.PNI, + 'PNI message has valid destination' + ); + } + + debug('Capture storage service state before merging'); + const state = await phone.expectStorageState('state before merge'); + + debug('Enter a draft text without hitting enter'); + await compositionInput.type('Draft text'); + + debug('Send back the response with profile key and pni signature'); + + const ourKey = await desktop.popSingleUseKey(); + await pniContact.addSingleUseKey(desktop, ourKey); + + await pniContact.sendText(desktop, 'Hello Desktop!', { + timestamp: bootstrap.getTimestamp(), + withPniSignature: true, + }); + + debug('Wait for merge to happen'); + await leftPane + .locator('_react=ConversationListItem[title = "ACI Contact"]') + .waitFor(); + + debug('Wait for composition input to clear'); + await composeArea + .locator('_react=CompositionInput[draftText = ""]') + .waitFor(); + + debug('Enter an ACI message text'); + await compositionInput.type('Hello ACI'); + await compositionInput.press('Enter'); + + debug('Waiting for a ACI message'); + { + const { source, body, uuidKind } = await pniContact.waitForMessage(); + + assert.strictEqual(source, desktop, 'ACI message has valid source'); + assert.strictEqual(body, 'Hello ACI', 'ACI message has valid body'); + assert.strictEqual( + uuidKind, + UUIDKind.ACI, + 'ACI message has valid destination' + ); + } + + debug('Verify final state'); + { + const newState = await phone.waitForStorageState({ + after: state, + }); + + assert.isUndefined( + newState.getContact(pniContact, UUIDKind.PNI), + 'PNI Contact must be removed from storage service' + ); + + const aci = newState.getContact(pniContact, UUIDKind.ACI); + assert(aci, 'ACI Contact must be in storage service'); + + assert.strictEqual(aci?.serviceUuid, pniContact.device.uuid); + assert.strictEqual(aci?.pni, pniContact.device.pni); + } + }); +}); diff --git a/ts/test-mock/gv2/create_test.ts b/ts/test-mock/pnp/send_gv2_invite_test.ts similarity index 94% rename from ts/test-mock/gv2/create_test.ts rename to ts/test-mock/pnp/send_gv2_invite_test.ts index 6bd60beaf..afe645814 100644 --- a/ts/test-mock/gv2/create_test.ts +++ b/ts/test-mock/pnp/send_gv2_invite_test.ts @@ -51,16 +51,18 @@ describe('gv2', function needsName() { pniContact = await server.createPrimaryDevice({ profileName: 'My profile is a secret', }); - state = state.addContact(pniContact, { - identityState: Proto.ContactRecord.IdentityState.VERIFIED, - whitelisted: true, + state = state.addContact( + pniContact, + { + identityState: Proto.ContactRecord.IdentityState.VERIFIED, + whitelisted: true, - identityKey: pniContact.getPublicKey(UUIDKind.PNI).serialize(), + identityKey: pniContact.getPublicKey(UUIDKind.PNI).serialize(), - // Give PNI as the uuid! - serviceUuid: pniContact.device.pni, - givenName: 'PNI Contact', - }); + givenName: 'PNI Contact', + }, + UUIDKind.PNI + ); state = state.addRecord({ type: IdentifierType.STORY_DISTRIBUTION_LIST, diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index 24a3b8180..436add51a 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -107,7 +107,7 @@ export default class AccountManager extends EventTarget { async decryptDeviceName(base64: string): Promise { const ourUuid = window.textsecure.storage.user.getCheckedUuid(); const identityKey = - await window.textsecure.storage.protocol.getIdentityKeyPair(ourUuid); + window.textsecure.storage.protocol.getIdentityKeyPair(ourUuid); if (!identityKey) { throw new Error('decryptDeviceName: No identity key pair!'); } @@ -132,7 +132,7 @@ export default class AccountManager extends EventTarget { } const { storage } = window.textsecure; const deviceName = storage.user.getDeviceName(); - const identityKeyPair = await storage.protocol.getIdentityKeyPair( + const identityKeyPair = storage.protocol.getIdentityKeyPair( storage.user.getCheckedUuid() ); strictAssert( @@ -362,7 +362,7 @@ export default class AccountManager extends EventTarget { let identityKey: KeyPairType | undefined; try { - identityKey = await store.getIdentityKeyPair(ourUuid); + identityKey = store.getIdentityKeyPair(ourUuid); } catch (error) { // We swallow any error here, because we don't want to get into // a loop of repeated retries. @@ -788,8 +788,7 @@ export default class AccountManager extends EventTarget { } const store = storage.protocol; - const identityKey = - maybeIdentityKey ?? (await store.getIdentityKeyPair(ourUuid)); + const identityKey = maybeIdentityKey ?? store.getIdentityKeyPair(ourUuid); strictAssert(identityKey, 'generateKeys: No identity key pair!'); const result: Omit = { diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 1c2daad12..bc5cf1de0 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -45,7 +45,7 @@ import { normalizeUuid } from '../util/normalizeUuid'; import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import { Zone } from '../util/Zone'; -import { deriveMasterKeyFromGroupV1 } from '../Crypto'; +import { deriveMasterKeyFromGroupV1, bytesToUuid } from '../Crypto'; import type { DownloadedAttachmentType } from '../types/Attachment'; import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; @@ -122,7 +122,8 @@ const GROUPV2_ID_LENGTH = 32; const RETRY_TIMEOUT = 2 * 60 * 1000; type UnsealedEnvelope = Readonly< - ProcessedEnvelope & { + Omit & { + sourceUuid: UUIDStringType; unidentifiedDeliveryReceived?: boolean; contentHint?: number; groupId?: string; @@ -133,10 +134,16 @@ type UnsealedEnvelope = Readonly< } >; -type DecryptResult = Readonly<{ - envelope: UnsealedEnvelope; - plaintext?: Uint8Array; -}>; +type DecryptResult = Readonly< + | { + envelope: UnsealedEnvelope; + plaintext: Uint8Array; + } + | { + envelope?: UnsealedEnvelope; + plaintext?: undefined; + } +>; type DecryptSealedSenderResult = Readonly<{ plaintext?: Uint8Array; @@ -757,9 +764,9 @@ export default class MessageReceiver // Proto.Envelope fields type: decoded.type, source: item.source, - sourceUuid: decoded.sourceUuid - ? UUID.cast(decoded.sourceUuid) - : item.sourceUuid, + sourceUuid: + item.sourceUuid || + (decoded.sourceUuid ? UUID.cast(decoded.sourceUuid) : undefined), sourceDevice: decoded.sourceDevice || item.sourceDevice, destinationUuid: new UUID( decoded.destinationUuid || item.destinationUuid || ourUuid.toString() @@ -787,10 +794,21 @@ export default class MessageReceiver throw new Error('Cached decrypted value was not a string!'); } + strictAssert( + envelope.sourceUuid, + 'Decrypted envelope must have source uuid' + ); + + // Pacify typescript + const decryptedEnvelope = { + ...envelope, + sourceUuid: envelope.sourceUuid, + }; + // Maintain invariant: encrypted queue => decrypted queue this.addToQueue( async () => { - this.queueDecryptedEnvelope(envelope, payloadPlaintext); + this.queueDecryptedEnvelope(decryptedEnvelope, payloadPlaintext); }, 'queueDecryptedEnvelope', TaskType.Encrypted @@ -1088,7 +1106,7 @@ export default class MessageReceiver `Rejecting envelope ${getEnvelopeId(envelope)}, ` + `unknown uuid: ${destinationUuid}` ); - return { plaintext: undefined, envelope }; + return { plaintext: undefined, envelope: undefined }; } const unsealedEnvelope = await this.unsealEnvelope( @@ -1099,7 +1117,7 @@ export default class MessageReceiver // Dropped early if (!unsealedEnvelope) { - return { plaintext: undefined, envelope }; + return { plaintext: undefined, envelope: undefined }; } logId = getEnvelopeId(unsealedEnvelope); @@ -1185,8 +1203,13 @@ export default class MessageReceiver } if (envelope.type !== Proto.Envelope.Type.UNIDENTIFIED_SENDER) { + strictAssert( + envelope.sourceUuid, + 'Unsealed envelope must have source uuid' + ); return { ...envelope, + sourceUuid: envelope.sourceUuid, cipherTextBytes: envelope.content, cipherTextType: envelopeTypeToCiphertextType(envelope.type), }; @@ -1259,6 +1282,10 @@ export default class MessageReceiver } if (envelope.type === Proto.Envelope.Type.RECEIPT) { + strictAssert( + envelope.sourceUuid, + 'Unsealed delivery receipt must have sourceUuid' + ); await this.onDeliveryReceipt(envelope); return { plaintext: undefined, envelope }; } @@ -1291,6 +1318,7 @@ export default class MessageReceiver // sender key to decrypt the next message in the queue! let isGroupV2 = false; + let inProgressMessageType = ''; try { const content = Proto.Content.decode(plaintext); @@ -1300,6 +1328,7 @@ export default class MessageReceiver content.senderKeyDistributionMessage && Bytes.isNotEmpty(content.senderKeyDistributionMessage) ) { + inProgressMessageType = 'sender key distribution'; await this.handleSenderKeyDistributionMessage( stores, envelope, @@ -1307,22 +1336,35 @@ export default class MessageReceiver ); } + if (content.pniSignatureMessage) { + inProgressMessageType = 'pni signature'; + await this.handlePniSignatureMessage( + envelope, + content.pniSignatureMessage + ); + } + // Some sync messages have to be fully processed in the middle of // decryption queue since subsequent envelopes use their key material. const { syncMessage } = content; if (syncMessage?.pniIdentity) { + inProgressMessageType = 'pni identity'; await this.handlePNIIdentity(envelope, syncMessage.pniIdentity); return { plaintext: undefined, envelope }; } if (syncMessage?.pniChangeNumber) { + inProgressMessageType = 'pni change number'; await this.handlePNIChangeNumber(envelope, syncMessage.pniChangeNumber); return { plaintext: undefined, envelope }; } + + inProgressMessageType = ''; } catch (error) { log.error( - 'MessageReceiver.decryptEnvelope: Failed to process sender ' + - `key distribution message: ${Errors.toLogFormat(error)}` + 'MessageReceiver.decryptEnvelope: ' + + `Failed to process ${inProgressMessageType} ` + + `message: ${Errors.toLogFormat(error)}` ); } @@ -1412,6 +1454,7 @@ export default class MessageReceiver source: envelope.source, sourceUuid: envelope.sourceUuid, sourceDevice: envelope.sourceDevice, + wasSentEncrypted: false, }, this.removeFromCache.bind(this, envelope) ) @@ -1549,7 +1592,7 @@ export default class MessageReceiver private async innerDecrypt( stores: LockedStores, - envelope: ProcessedEnvelope, + envelope: UnsealedEnvelope, ciphertext: Uint8Array, uuidKind: UUIDKind ): Promise { @@ -2014,6 +2057,7 @@ export default class MessageReceiver source: envelope.source, sourceUuid: envelope.sourceUuid, sourceDevice: envelope.sourceDevice, + destinationUuid: envelope.destinationUuid.toString(), timestamp: envelope.timestamp, serverGuid: envelope.serverGuid, serverTimestamp: envelope.serverTimestamp, @@ -2138,6 +2182,7 @@ export default class MessageReceiver source: envelope.source, sourceUuid: envelope.sourceUuid, sourceDevice: envelope.sourceDevice, + destinationUuid: envelope.destinationUuid.toString(), timestamp: envelope.timestamp, serverGuid: envelope.serverGuid, serverTimestamp: envelope.serverTimestamp, @@ -2154,8 +2199,8 @@ export default class MessageReceiver } private async maybeUpdateTimestamp( - envelope: ProcessedEnvelope - ): Promise { + envelope: UnsealedEnvelope + ): Promise { const { retryPlaceholders } = window.Signal.Services; if (!retryPlaceholders) { log.warn('maybeUpdateTimestamp: retry placeholders not available!'); @@ -2209,7 +2254,7 @@ export default class MessageReceiver } private async innerHandleContentMessage( - incomingEnvelope: ProcessedEnvelope, + incomingEnvelope: UnsealedEnvelope, plaintext: Uint8Array ): Promise { const content = Proto.Content.decode(plaintext); @@ -2311,7 +2356,7 @@ export default class MessageReceiver private async handleSenderKeyDistributionMessage( stores: LockedStores, - envelope: ProcessedEnvelope, + envelope: UnsealedEnvelope, distributionMessage: Uint8Array ): Promise { const envelopeId = getEnvelopeId(envelope); @@ -2324,11 +2369,6 @@ export default class MessageReceiver const identifier = envelope.sourceUuid; const { sourceDevice } = envelope; - if (!identifier) { - throw new Error( - `handleSenderKeyDistributionMessage: No identifier for envelope ${envelopeId}` - ); - } if (!isNumber(sourceDevice)) { throw new Error( `handleSenderKeyDistributionMessage: Missing sourceDevice for envelope ${envelopeId}` @@ -2358,8 +2398,44 @@ export default class MessageReceiver ); } + private async handlePniSignatureMessage( + envelope: UnsealedEnvelope, + pniSignatureMessage: Proto.IPniSignatureMessage + ): Promise { + const envelopeId = getEnvelopeId(envelope); + const logId = `handlePniSignatureMessage/${envelopeId}`; + log.info(logId); + + // Note: we don't call removeFromCache here because this message can be combined + // with a dataMessage, for example. That processing will dictate cache removal. + + const aci = envelope.sourceUuid; + + const { pni: pniBytes, signature } = pniSignatureMessage; + strictAssert(Bytes.isNotEmpty(pniBytes), `${logId}: missing PNI bytes`); + const pni = bytesToUuid(pniBytes); + strictAssert(pni, `${logId}: missing PNI`); + strictAssert(Bytes.isNotEmpty(signature), `${logId}: empty signature`); + + const isValid = await this.storage.protocol.verifyAlternateIdentity({ + aci: new UUID(aci), + pni: new UUID(pni), + signature, + }); + + if (isValid) { + log.info(`${logId}: merging pni=${pni} aci=${aci}`); + window.ConversationController.maybeMergeContacts({ + pni, + aci, + e164: window.ConversationController.get(pni)?.get('e164'), + reason: logId, + }); + } + } + private async handleCallingMessage( - envelope: ProcessedEnvelope, + envelope: UnsealedEnvelope, callingMessage: Proto.ICallingMessage ): Promise { logUnexpectedUrgentValue(envelope, 'callingMessage'); @@ -2372,7 +2448,7 @@ export default class MessageReceiver } private async handleReceiptMessage( - envelope: ProcessedEnvelope, + envelope: UnsealedEnvelope, receiptMessage: Proto.IReceiptMessage ): Promise { strictAssert(receiptMessage.timestamp, 'Receipt message without timestamp'); @@ -2409,6 +2485,7 @@ export default class MessageReceiver source: envelope.source, sourceUuid: envelope.sourceUuid, sourceDevice: envelope.sourceDevice, + wasSentEncrypted: true, }, this.removeFromCache.bind(this, envelope) ); @@ -2418,7 +2495,7 @@ export default class MessageReceiver } private async handleTypingMessage( - envelope: ProcessedEnvelope, + envelope: UnsealedEnvelope, typingMessage: Proto.ITypingMessage ): Promise { this.removeFromCache(envelope); @@ -2475,7 +2552,7 @@ export default class MessageReceiver ); } - private handleNullMessage(envelope: ProcessedEnvelope): void { + private handleNullMessage(envelope: UnsealedEnvelope): void { log.info('MessageReceiver.handleNullMessage', getEnvelopeId(envelope)); logUnexpectedUrgentValue(envelope, 'nullMessage'); @@ -2591,7 +2668,7 @@ export default class MessageReceiver } private async handleSyncMessage( - envelope: ProcessedEnvelope, + envelope: UnsealedEnvelope, syncMessage: ProcessedSyncMessage ): Promise { const ourNumber = this.storage.user.getNumber(); diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index 097d8b124..c50ae44d4 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -196,9 +196,13 @@ export default class OutgoingMessage { const contentProto = this.getContentProtoBytes(); const { timestamp, contentHint, recipients, urgent } = this; let dataMessage: Uint8Array | undefined; + let hasPniSignatureMessage = false; - if (proto instanceof Proto.Content && proto.dataMessage) { - dataMessage = Proto.DataMessage.encode(proto.dataMessage).finish(); + if (proto instanceof Proto.Content) { + if (proto.dataMessage) { + dataMessage = Proto.DataMessage.encode(proto.dataMessage).finish(); + } + hasPniSignatureMessage = Boolean(proto.pniSignatureMessage); } else if (proto instanceof Proto.DataMessage) { dataMessage = Proto.DataMessage.encode(proto).finish(); } @@ -215,6 +219,7 @@ export default class OutgoingMessage { contentProto, timestamp, urgent, + hasPniSignatureMessage, }); } } diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index b5db66263..e46dd6f07 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -15,8 +15,9 @@ import { } from '@signalapp/libsignal-client'; import type { QuotedMessageType } from '../model-types.d'; +import type { ConversationModel } from '../models/conversations'; import { GLOBAL_ZONE } from '../SignalProtocolStore'; -import { assert } from '../util/assert'; +import { assert, strictAssert } from '../util/assert'; import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; @@ -65,6 +66,7 @@ import type { import { concat, isEmpty, map } from '../util/iterables'; import type { SendTypesType } from '../util/handleMessageSend'; import { shouldSaveProto, sendTypesEnum } from '../util/handleMessageSend'; +import { uuidToBytes } from '../util/uuidToBytes'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; import type { Avatar, EmbeddedContactType } from '../types/EmbeddedContact'; @@ -574,11 +576,43 @@ class Message { return proto; } - encode() { + encode(): Uint8Array { return Proto.DataMessage.encode(this.toProto()).finish(); } } +type AddPniSignatureMessageToProtoOptionsType = Readonly<{ + conversation?: ConversationModel; + proto: Proto.Content; + reason: string; +}>; + +function addPniSignatureMessageToProto({ + conversation, + proto, + reason, +}: AddPniSignatureMessageToProtoOptionsType): void { + if (!conversation) { + return; + } + + const pniSignatureMessage = conversation?.getPniSignatureMessage(); + if (!pniSignatureMessage) { + return; + } + + log.info( + `addPniSignatureMessageToProto(${reason}): ` + + `adding pni signature for ${conversation.idForLogging()}` + ); + + // eslint-disable-next-line no-param-reassign + proto.pniSignatureMessage = { + pni: uuidToBytes(pniSignatureMessage.pni), + signature: pniSignatureMessage.signature, + }; +} + export default class MessageSender { pendingMessages: { [id: string]: PQueue; @@ -944,7 +978,10 @@ export default class MessageSender { } async getContentMessage( - options: Readonly + options: Readonly & + Readonly<{ + includePniSignatureMessage?: boolean; + }> ): Promise { const message = await this.getHydratedMessage(options); const dataMessage = message.toProto(); @@ -952,6 +989,24 @@ export default class MessageSender { const contentMessage = new Proto.Content(); contentMessage.dataMessage = dataMessage; + const { includePniSignatureMessage } = options; + if (includePniSignatureMessage) { + strictAssert( + message.recipients.length === 1, + 'getContentMessage: includePniSignatureMessage is single recipient only' + ); + + const conversation = window.ConversationController.get( + message.recipients[0] + ); + + addPniSignatureMessageToProto({ + conversation, + proto: contentMessage, + reason: `getContentMessage(${message.timestamp})`, + }); + } + return contentMessage; } @@ -1001,6 +1056,14 @@ export default class MessageSender { const contentMessage = new Proto.Content(); contentMessage.typingMessage = typingMessage; + if (recipientId) { + addPniSignatureMessageToProto({ + conversation: window.ConversationController.get(recipientId), + proto: contentMessage, + reason: `getTypingContentMessage(${finalTimestamp})`, + }); + } + return contentMessage; } @@ -1100,14 +1163,19 @@ export default class MessageSender { groupId, options, urgent, + includePniSignatureMessage, }: Readonly<{ messageOptions: MessageOptionsType; contentHint: number; groupId: string | undefined; options?: SendOptionsType; urgent: boolean; + includePniSignatureMessage?: boolean; }>): Promise { - const message = await this.getHydratedMessage(messageOptions); + const proto = await this.getContentMessage({ + ...messageOptions, + includePniSignatureMessage, + }); return new Promise((resolve, reject) => { this.sendMessageProto({ @@ -1121,9 +1189,9 @@ export default class MessageSender { contentHint, groupId, options, - proto: message.toProto(), - recipients: message.recipients || [], - timestamp: message.timestamp, + proto, + recipients: messageOptions.recipients || [], + timestamp: messageOptions.timestamp, urgent, }); }); @@ -1276,6 +1344,7 @@ export default class MessageSender { storyContext, timestamp, urgent, + includePniSignatureMessage, }: Readonly<{ attachments: ReadonlyArray | undefined; contact?: Array; @@ -1294,6 +1363,7 @@ export default class MessageSender { storyContext?: StoryContextType; timestamp: number; urgent: boolean; + includePniSignatureMessage?: boolean; }>): Promise { return this.sendMessage({ messageOptions: { @@ -1315,6 +1385,7 @@ export default class MessageSender { groupId, options, urgent, + includePniSignatureMessage, }); } @@ -1886,6 +1957,14 @@ export default class MessageSender { const contentMessage = new Proto.Content(); contentMessage.callingMessage = callingMessage; + const conversation = window.ConversationController.get(recipientId); + + addPniSignatureMessageToProto({ + conversation, + proto: contentMessage, + reason: `sendCallingMessage(${finalTimestamp})`, + }); + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; return this.sendMessageProtoAndWait({ @@ -1904,6 +1983,7 @@ export default class MessageSender { senderE164?: string; senderUuid?: string; timestamps: Array; + isDirectConversation: boolean; options?: Readonly; }> ): Promise { @@ -1918,6 +1998,7 @@ export default class MessageSender { senderE164?: string; senderUuid?: string; timestamps: Array; + isDirectConversation: boolean; options?: Readonly; }> ): Promise { @@ -1932,6 +2013,7 @@ export default class MessageSender { senderE164?: string; senderUuid?: string; timestamps: Array; + isDirectConversation: boolean; options?: Readonly; }> ): Promise { @@ -1946,12 +2028,14 @@ export default class MessageSender { senderUuid, timestamps, type, + isDirectConversation, options, }: Readonly<{ senderE164?: string; senderUuid?: string; timestamps: Array; type: Proto.ReceiptMessage.Type; + isDirectConversation: boolean; options?: Readonly; }>): Promise { if (!senderUuid && !senderE164) { @@ -1960,21 +2044,35 @@ export default class MessageSender { ); } + const timestamp = Date.now(); + const receiptMessage = new Proto.ReceiptMessage(); receiptMessage.type = type; - receiptMessage.timestamp = timestamps.map(timestamp => - Long.fromNumber(timestamp) + receiptMessage.timestamp = timestamps.map(receiptTimestamp => + Long.fromNumber(receiptTimestamp) ); const contentMessage = new Proto.Content(); contentMessage.receiptMessage = receiptMessage; + if (isDirectConversation) { + const conversation = window.ConversationController.get( + senderUuid || senderE164 + ); + + addPniSignatureMessageToProto({ + conversation, + proto: contentMessage, + reason: `sendReceiptMessage(${type}, ${timestamp})`, + }); + } + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; return this.sendIndividualProto({ identifier: senderUuid || senderE164, proto: contentMessage, - timestamp: Date.now(), + timestamp, contentHint: ContentHint.RESENDABLE, options, urgent: false, @@ -2052,6 +2150,7 @@ export default class MessageSender { sendType, timestamp, urgent, + hasPniSignatureMessage, }: Readonly<{ contentHint: number; messageId?: string; @@ -2059,6 +2158,7 @@ export default class MessageSender { sendType: SendTypesType; timestamp: number; urgent: boolean; + hasPniSignatureMessage: boolean; }>): SendLogCallbackType { let initialSavePromise: Promise; @@ -2095,6 +2195,7 @@ export default class MessageSender { proto, timestamp, urgent, + hasPniSignatureMessage, }, { recipients: { [recipientUuid]: deviceIds }, @@ -2270,6 +2371,7 @@ export default class MessageSender { sendType: 'senderKeyDistributionMessage', timestamp, urgent, + hasPniSignatureMessage: false, }) : undefined; @@ -2313,6 +2415,7 @@ export default class MessageSender { sendType: 'legacyGroupChange', timestamp, urgent: false, + hasPniSignatureMessage: false, }) : undefined; diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index fb7e0c4de..a973ce07d 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -267,6 +267,7 @@ export interface CallbackResultType { timestamp?: number; recipients?: Record>; urgent?: boolean; + hasPniSignatureMessage?: boolean; } export interface IRequestHandler { @@ -278,3 +279,8 @@ export type PniKeyMaterialType = Readonly<{ signedPreKey: Uint8Array; registrationId: number; }>; + +export type PniSignatureMessageType = Readonly<{ + pni: UUIDStringType; + signature: Uint8Array; +}>; diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index 9a6a65a2d..919c8e7c2 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -132,6 +132,7 @@ export type DeliveryEventData = Readonly<{ source?: string; sourceUuid?: UUIDStringType; sourceDevice?: number; + wasSentEncrypted: boolean; }>; export class DeliveryEvent extends ConfirmableEvent { @@ -220,8 +221,9 @@ export class ProfileKeyUpdateEvent extends ConfirmableEvent { export type MessageEventData = Readonly<{ source?: string; - sourceUuid?: UUIDStringType; + sourceUuid: UUIDStringType; sourceDevice?: number; + destinationUuid: UUIDStringType; timestamp: number; serverGuid?: string; serverTimestamp?: number; @@ -246,6 +248,7 @@ export type ReadOrViewEventData = Readonly<{ source?: string; sourceUuid?: UUIDStringType; sourceDevice?: number; + wasSentEncrypted: true; }>; export class ReadEvent extends ConfirmableEvent { diff --git a/ts/types/Receipt.ts b/ts/types/Receipt.ts index 3bb8cb0f2..efd97a2ac 100644 --- a/ts/types/Receipt.ts +++ b/ts/types/Receipt.ts @@ -8,6 +8,7 @@ export const receiptSchema = z.object({ senderE164: z.string().optional(), senderUuid: z.string().optional(), timestamp: z.number(), + isDirectConversation: z.boolean().optional(), }); export enum ReceiptType { diff --git a/ts/util/getSendOptions.ts b/ts/util/getSendOptions.ts index f6388418f..5f0e1d667 100644 --- a/ts/util/getSendOptions.ts +++ b/ts/util/getSendOptions.ts @@ -10,13 +10,8 @@ import * as Bytes from '../Bytes'; import { getRandomBytes } from '../Crypto'; import { getConversationMembers } from './getConversationMembers'; import { isDirectConversation, isMe } from './whatTypeOfConversation'; -import { isInSystemContacts } from './isInSystemContacts'; -import { missingCaseError } from './missingCaseError'; import { senderCertificateService } from '../services/senderCertificate'; -import { - PhoneNumberSharingMode, - parsePhoneNumberSharingMode, -} from './phoneNumberSharingMode'; +import { shouldSharePhoneNumberWith } from './phoneNumberSharingMode'; import type { SerializedCertificateType } from '../textsecure/OutgoingMessage'; import { SenderCertificateMode } from '../textsecure/OutgoingMessage'; import { isNotNil } from './isNotNil'; @@ -146,25 +141,11 @@ function getSenderCertificateForDirectConversation( ); } - const phoneNumberSharingMode = parsePhoneNumberSharingMode( - window.storage.get('phoneNumberSharingMode') - ); - let certificateMode: SenderCertificateMode; - switch (phoneNumberSharingMode) { - case PhoneNumberSharingMode.Everybody: - certificateMode = SenderCertificateMode.WithE164; - break; - case PhoneNumberSharingMode.ContactsOnly: - certificateMode = isInSystemContacts(conversationAttrs) - ? SenderCertificateMode.WithE164 - : SenderCertificateMode.WithoutE164; - break; - case PhoneNumberSharingMode.Nobody: - certificateMode = SenderCertificateMode.WithoutE164; - break; - default: - throw missingCaseError(phoneNumberSharingMode); + if (shouldSharePhoneNumberWith(conversationAttrs)) { + certificateMode = SenderCertificateMode.WithE164; + } else { + certificateMode = SenderCertificateMode.WithoutE164; } return senderCertificateService.get(certificateMode); diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts index 3ad31d76b..95f75f5e0 100644 --- a/ts/util/handleMessageSend.ts +++ b/ts/util/handleMessageSend.ts @@ -236,7 +236,14 @@ async function maybeSaveToSendLog( sendType: SendTypesType; } ): Promise { - const { contentHint, contentProto, recipients, timestamp, urgent } = result; + const { + contentHint, + contentProto, + recipients, + timestamp, + urgent, + hasPniSignatureMessage, + } = result; if (!shouldSaveProto(sendType)) { return; @@ -268,6 +275,7 @@ async function maybeSaveToSendLog( proto: Buffer.from(contentProto), contentHint, urgent: isBoolean(urgent) ? urgent : true, + hasPniSignatureMessage: Boolean(hasPniSignatureMessage), }, { messageIds, diff --git a/ts/util/markConversationRead.ts b/ts/util/markConversationRead.ts index 86bc964b3..59cca37ff 100644 --- a/ts/util/markConversationRead.ts +++ b/ts/util/markConversationRead.ts @@ -10,7 +10,7 @@ import { readSyncJobQueue } from '../jobs/readSyncJobQueue'; import { notificationService } from '../services/notifications'; import { expiringMessagesDeletionService } from '../services/expiringMessagesDeletion'; import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService'; -import { isGroup } from './whatTypeOfConversation'; +import { isGroup, isDirectConversation } from './whatTypeOfConversation'; import * as log from '../logging/log'; import { getConversationIdForLogging } from './idForLogging'; import { ReadStatus } from '../messages/MessageReadStatus'; @@ -94,6 +94,7 @@ export async function markConversationRead( uuid: messageSyncData.sourceUuid, })?.id, timestamp: messageSyncData.sent_at, + isDirectConversation: isDirectConversation(conversationAttrs), hasErrors: message ? hasErrors(message.attributes) : false, }; }); diff --git a/ts/util/phoneNumberSharingMode.ts b/ts/util/phoneNumberSharingMode.ts index eef62b278..5a6ae20bd 100644 --- a/ts/util/phoneNumberSharingMode.ts +++ b/ts/util/phoneNumberSharingMode.ts @@ -1,7 +1,12 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import type { ConversationAttributesType } from '../model-types.d'; + import { makeEnumParser } from './enum'; +import { isInSystemContacts } from './isInSystemContacts'; +import { missingCaseError } from './missingCaseError'; +import { isDirectConversation, isMe } from './whatTypeOfConversation'; // These strings are saved to disk, so be careful when changing them. export enum PhoneNumberSharingMode { @@ -14,3 +19,26 @@ export const parsePhoneNumberSharingMode = makeEnumParser( PhoneNumberSharingMode, PhoneNumberSharingMode.Everybody ); + +export const shouldSharePhoneNumberWith = ( + conversation: ConversationAttributesType +): boolean => { + if (!isDirectConversation(conversation) || isMe(conversation)) { + return false; + } + + const phoneNumberSharingMode = parsePhoneNumberSharingMode( + window.storage.get('phoneNumberSharingMode') + ); + + switch (phoneNumberSharingMode) { + case PhoneNumberSharingMode.Everybody: + return true; + case PhoneNumberSharingMode.ContactsOnly: + return isInSystemContacts(conversation); + case PhoneNumberSharingMode.Nobody: + return false; + default: + throw missingCaseError(phoneNumberSharingMode); + } +}; diff --git a/ts/util/sendReceipts.ts b/ts/util/sendReceipts.ts index fe53fbec2..a9d76d898 100644 --- a/ts/util/sendReceipts.ts +++ b/ts/util/sendReceipts.ts @@ -122,11 +122,15 @@ export async function sendReceipts({ map(batches, async batch => { const timestamps = batch.map(receipt => receipt.timestamp); const messageIds = batch.map(receipt => receipt.messageId); + const isDirectConversation = batch.some( + receipt => receipt.isDirectConversation + ); await handleMessageSend( messaging[methodName]({ senderE164: sender.get('e164'), senderUuid: sender.get('uuid'), + isDirectConversation, timestamps, options: sendOptions, }), diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts index 57d19704a..8578691e2 100644 --- a/ts/util/sendToGroup.ts +++ b/ts/util/sendToGroup.ts @@ -225,6 +225,7 @@ export async function sendContentMessageToGroup({ sendType, timestamp, urgent, + hasPniSignatureMessage: false, }); const groupId = sendTarget.isGroupV2() ? sendTarget.getGroupId() : undefined; return window.textsecure.messaging.sendGroupProto({ @@ -544,6 +545,7 @@ export async function sendToGroupViaSenderKey(options: { proto: Buffer.from(Proto.Content.encode(contentMessage).finish()), timestamp, urgent, + hasPniSignatureMessage: false, }, { recipients: senderKeyRecipientsWithDevices, diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index 6e9ccd3e4..2904c9c27 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -794,6 +794,7 @@ export class ConversationView extends window.Backbone.View { senderE164, senderUuid, timestamp, + isDirectConversation: isDirectConversation(this.model.attributes), }, }); } diff --git a/yarn.lock b/yarn.lock index d141f1701..6c843d094 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1753,10 +1753,10 @@ node-gyp-build "^4.2.3" uuid "^8.3.0" -"@signalapp/mock-server@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.4.1.tgz#74db72514319acea828803747082ec8403a4ab04" - integrity sha512-TaTIVjHRWtLTJVYuG7GsVdcWeC/OEuRXmlyfp9FGxygvrJncsWG1pCq3YZEHrisAnWJl/Hcogg97lDkUvtjRJA== +"@signalapp/mock-server@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.6.0.tgz#64277abd5ad5a540c0ae7e98d0347b420d69acfd" + integrity sha512-EYI52E0ZwtNO0tt7V7PZJ5vs5Yy/nReHZMWovfHqcdG3iurwxq4/YIbz0fP4HylpoiJLbZ1cVzY7A8A3IAlrLQ== dependencies: "@signalapp/libsignal-client" "^0.19.2" debug "^4.3.2"