diff --git a/package.json b/package.json index b8633d522..896047e71 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "fs-xattr": "0.3.0" }, "dependencies": { + "@signalapp/signal-client": "0.5.1", "@sindresorhus/is": "0.8.0", "@types/pino": "6.3.6", "@types/pino-multi-stream": "5.1.0", @@ -102,7 +103,6 @@ "intl-tel-input": "12.1.15", "jquery": "3.5.0", "js-yaml": "3.13.1", - "libsignal-client": "https://github.com/signalapp/libsignal-client-node.git#afa8f40eaa218b7fff94278d9b2ab2e13a2ee04b", "linkify-it": "2.2.0", "lodash": "4.17.21", "lru-cache": "6.0.0", @@ -294,7 +294,7 @@ "asarUnpack": [ "**/*.node", "node_modules/zkgroup/libzkgroup.*", - "node_modules/libsignal-client/build/*.node" + "node_modules/@signalapp/signal-client/build/*.node" ], "artifactName": "${name}-mac-${version}.${ext}", "category": "public.app-category.social-networking", @@ -320,7 +320,7 @@ "node_modules/spellchecker/vendor/hunspell_dictionaries", "node_modules/sharp", "node_modules/zkgroup/libzkgroup.*", - "node_modules/libsignal-client/build/*.node" + "node_modules/@signalapp/signal-client/build/*.node" ], "artifactName": "${name}-win-${version}.${ext}", "certificateSubjectName": "Signal (Quiet Riddle Ventures, LLC)", @@ -350,7 +350,7 @@ "node_modules/spellchecker/vendor/hunspell_dictionaries", "node_modules/sharp", "node_modules/zkgroup/libzkgroup.*", - "node_modules/libsignal-client/build/*.node" + "node_modules/@signalapp/signal-client/build/*.node" ], "target": [ "deb" @@ -438,7 +438,7 @@ "!node_modules/better-sqlite3/deps/*", "!node_modules/better-sqlite3/src/*", "node_modules/better-sqlite3/build/Release/better_sqlite3.node", - "node_modules/libsignal-client/build/*${platform}*.node", + "node_modules/@signalapp/signal-client/build/*${platform}*.node", "node_modules/ringrtc/build/${platform}/**", "!**/node_modules/ffi-napi/deps", "!**/node_modules/react-dom/*/*.development.js", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index b19f05032..d7c664cfd 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -12,6 +12,7 @@ message Envelope { PREKEY_BUNDLE = 3; RECEIPT = 5; UNIDENTIFIED_SENDER = 6; + SENDERKEY = 7; } optional Type type = 1; @@ -27,12 +28,13 @@ 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 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; } // Everything in CallingMessage must be kept in sync with RingRTC (ringrtc-node). @@ -362,7 +364,7 @@ message SyncMessage { optional bool readReceipts = 1; optional bool unidentifiedDeliveryIndicators = 2; optional bool typingIndicators = 3; - // 4 is reserved + reserved 4; optional uint32 provisioningVersion = 5; optional bool linkPreviews = 6; } diff --git a/protos/UnidentifiedDelivery.proto b/protos/UnidentifiedDelivery.proto index 8a177c4df..2b9339eae 100644 --- a/protos/UnidentifiedDelivery.proto +++ b/protos/UnidentifiedDelivery.proto @@ -15,12 +15,12 @@ message ServerCertificate { message SenderCertificate { message Certificate { - optional string sender = 1; - optional string senderUuid = 6; - optional uint32 senderDevice = 2; - optional fixed64 expires = 3; - optional bytes identityKey = 4; - optional ServerCertificate signer = 5; + optional string senderE164 = 1; + optional string senderUuid = 6; + optional uint32 senderDevice = 2; + optional fixed64 expires = 3; + optional bytes identityKey = 4; + optional ServerCertificate signer = 5; } optional bytes certificate = 1; @@ -31,16 +31,34 @@ message UnidentifiedSenderMessage { message Message { enum Type { - PREKEY_MESSAGE = 1; - MESSAGE = 2; + PREKEY_MESSAGE = 1; + MESSAGE = 2; + // Further cases should line up with Envelope.Type, even though old cases don't. + + // Our parser does not handle reserved in enums: DESKTOP-1569 + // reserved 3 to 6; + + SENDERKEY_MESSAGE = 7; + } + + enum ContentHint { + // Commented out here, even though it is correct syntax. Our parser cannot handle it. + + // Our parser does not handle reserved in enums: DESKTOP-1569 + // reserved 0; // A content hint of "default" should never be encoded. + + SUPPLEMENTARY = 1; + RETRY = 2; } optional Type type = 1; optional SenderCertificate senderCertificate = 2; optional bytes content = 3; + optional ContentHint contentHint = 4; + optional bytes groupId = 5; } optional bytes ephemeralPublic = 1; optional bytes encryptedStatic = 2; optional bytes encryptedMessage = 3; -} +} \ No newline at end of file diff --git a/scripts/generate-acknowledgments.js b/scripts/generate-acknowledgments.js index 708bc8adf..a34ada979 100644 --- a/scripts/generate-acknowledgments.js +++ b/scripts/generate-acknowledgments.js @@ -16,7 +16,7 @@ const { const SKIPPED_DEPENDENCIES = new Set([ 'ringrtc', 'zkgroup', - 'libsignal-client', + '@signalapp/signal-client', ]); const rootDir = join(__dirname, '..'); diff --git a/sticker-creator/preload.js b/sticker-creator/preload.js index e96486cdd..95e964ca8 100644 --- a/sticker-creator/preload.js +++ b/sticker-creator/preload.js @@ -9,7 +9,7 @@ const { readFile } = require('fs'); const config = require('url').parse(window.location.toString(), true).query; const { noop, uniqBy } = require('lodash'); const pMap = require('p-map'); -const client = require('libsignal-client'); +const client = require('@signalapp/signal-client'); const { deriveStickerPackKey } = require('../ts/Crypto'); const { getEnvironment, diff --git a/test/crypto_test.js b/test/crypto_test.js index a91c3fb26..564b1e17b 100644 --- a/test/crypto_test.js +++ b/test/crypto_test.js @@ -23,7 +23,7 @@ describe('Crypto', () => { const result = window.Signal.Crypto.deriveSecrets(input, salt, info); assert.lengthOf(result, 3); result.forEach(part => { - // This is a smoke test; HKDF is tested as part of libsignal-client. + // This is a smoke test; HKDF is tested as part of @signalapp/signal-client. assert.instanceOf(part, ArrayBuffer); assert.strictEqual(part.byteLength, 32); }); diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 94133a04c..25e166c5c 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -3,7 +3,7 @@ import pProps from 'p-props'; import { chunk } from 'lodash'; -import { HKDF } from 'libsignal-client'; +import { HKDF } from '@signalapp/signal-client'; import { calculateAgreement, generateKeyPair } from './Curve'; import { diff --git a/ts/Curve.ts b/ts/Curve.ts index 020c4e72f..21ee906bf 100644 --- a/ts/Curve.ts +++ b/ts/Curve.ts @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import * as client from 'libsignal-client'; +import * as client from '@signalapp/signal-client'; import { constantTimeEqual, typedArrayToArrayBuffer } from './Crypto'; import { diff --git a/ts/LibSignalStores.ts b/ts/LibSignalStores.ts index 3d04823ad..bf5159fba 100644 --- a/ts/LibSignalStores.ts +++ b/ts/LibSignalStores.ts @@ -8,17 +8,20 @@ import { isNumber } from 'lodash'; import { Direction, - ProtocolAddress, - SessionStore, - SessionRecord, IdentityKeyStore, PreKeyRecord, PreKeyStore, PrivateKey, + ProtocolAddress, PublicKey, - SignedPreKeyStore, + SenderKeyRecord, + SenderKeyStore, + SessionRecord, + SessionStore, SignedPreKeyRecord, -} from 'libsignal-client'; + SignedPreKeyStore, + Uuid, +} from '@signalapp/signal-client'; import { freezePreKey, freezeSignedPreKey } from './SignalProtocolStore'; import { typedArrayToArrayBuffer } from './Crypto'; @@ -131,6 +134,36 @@ export class PreKeys extends PreKeyStore { } } +export class SenderKeys extends SenderKeyStore { + async saveSenderKey( + sender: ProtocolAddress, + distributionId: Uuid, + record: SenderKeyRecord + ): Promise { + const encodedAddress = encodedNameFromAddress(sender); + + await window.textsecure.storage.protocol.saveSenderKey( + encodedAddress, + distributionId, + record + ); + } + + async getSenderKey( + sender: ProtocolAddress, + distributionId: Uuid + ): Promise { + const encodedAddress = encodedNameFromAddress(sender); + + const senderKey = await window.textsecure.storage.protocol.getSenderKey( + encodedAddress, + distributionId + ); + + return senderKey || null; + } +} + export class SignedPreKeys extends SignedPreKeyStore { async saveSignedPreKey( id: number, diff --git a/ts/SignalProtocolStore.ts b/ts/SignalProtocolStore.ts index a884af794..03b0fb328 100644 --- a/ts/SignalProtocolStore.ts +++ b/ts/SignalProtocolStore.ts @@ -8,13 +8,14 @@ import { isNumber } from 'lodash'; import * as z from 'zod'; import { - SessionRecord, + Direction, PreKeyRecord, PrivateKey, PublicKey, + SenderKeyRecord, + SessionRecord, SignedPreKeyRecord, - Direction, -} from 'libsignal-client'; +} from '@signalapp/signal-client'; import { constantTimeEqual, @@ -30,6 +31,7 @@ import { import { KeyPairType, IdentityKeyType, + SenderKeyType, SessionType, SignedPreKeyType, OuterSignedPrekeyType, @@ -90,8 +92,8 @@ async function normalizeEncodedAddress( } } -type HasIdType = { - id: string | number; +type HasIdType = { + id: T; }; type CacheEntryType = | { @@ -100,24 +102,22 @@ type CacheEntryType = } | { hydrated: true; fromDB: DBType; item: HydratedType }; -async function _fillCaches( +async function _fillCaches, HydratedType>( object: SignalProtocolStore, field: keyof SignalProtocolStore, itemsPromise: Promise> ): Promise { const items = await itemsPromise; - const cache: Record> = Object.create( - null - ); + const cache = new Map>(); for (let i = 0, max = items.length; i < max; i += 1) { const fromDB = items[i]; const { id } = fromDB; - cache[id] = { + cache.set(id, { fromDB, hydrated: false, - }; + }); } window.log.info(`SignalProtocolStore: Finished caching ${field} data`); @@ -193,17 +193,21 @@ export class SignalProtocolStore extends EventsMixin { ourRegistrationId?: number; - identityKeys?: Record>; + identityKeys?: Map>; - sessions?: Record>; + senderKeys?: Map>; - preKeys?: Record>; + sessions?: Map>; - signedPreKeys?: Record< - string, + preKeys?: Map>; + + signedPreKeys?: Map< + number, CacheEntryType >; + senderKeyQueues: Map = new Map(); + sessionQueues: Map = new Map(); async hydrateCaches(): Promise { @@ -216,22 +220,27 @@ export class SignalProtocolStore extends EventsMixin { const item = await window.Signal.Data.getItemById('registrationId'); this.ourRegistrationId = item ? item.value : undefined; })(), - _fillCaches( + _fillCaches( this, 'identityKeys', window.Signal.Data.getAllIdentityKeys() ), - _fillCaches( + _fillCaches( this, 'sessions', window.Signal.Data.getAllSessions() ), - _fillCaches( + _fillCaches( this, 'preKeys', window.Signal.Data.getAllPreKeys() ), - _fillCaches( + _fillCaches( + this, + 'senderKeys', + window.Signal.Data.getAllSenderKeys() + ), + _fillCaches( this, 'signedPreKeys', window.Signal.Data.getAllSignedPreKeys() @@ -249,12 +258,12 @@ export class SignalProtocolStore extends EventsMixin { // PreKeys - async loadPreKey(keyId: string | number): Promise { + async loadPreKey(keyId: number): Promise { if (!this.preKeys) { throw new Error('loadPreKey: this.preKeys not yet cached!'); } - const entry = this.preKeys[keyId]; + const entry = this.preKeys.get(keyId); if (!entry) { window.log.error('Failed to fetch prekey:', keyId); return undefined; @@ -266,11 +275,11 @@ export class SignalProtocolStore extends EventsMixin { } const item = hydratePreKey(entry.fromDB); - this.preKeys[keyId] = { + this.preKeys.set(keyId, { hydrated: true, fromDB: entry.fromDB, item, - }; + }); window.log.info('Successfully fetched prekey (cache miss):', keyId); return item; } @@ -279,7 +288,7 @@ export class SignalProtocolStore extends EventsMixin { if (!this.preKeys) { throw new Error('storePreKey: this.preKeys not yet cached!'); } - if (this.preKeys[keyId]) { + if (this.preKeys.has(keyId)) { throw new Error(`storePreKey: prekey ${keyId} already exists!`); } @@ -290,10 +299,10 @@ export class SignalProtocolStore extends EventsMixin { }; await window.Signal.Data.createOrUpdatePreKey(fromDB); - this.preKeys[keyId] = { + this.preKeys.set(keyId, { hydrated: false, fromDB, - }; + }); } async removePreKey(keyId: number): Promise { @@ -310,12 +319,14 @@ export class SignalProtocolStore extends EventsMixin { ); } - delete this.preKeys[keyId]; + this.preKeys.delete(keyId); await window.Signal.Data.removePreKeyById(keyId); } async clearPreKeyStore(): Promise { - this.preKeys = Object.create(null); + if (this.preKeys) { + this.preKeys.clear(); + } await window.Signal.Data.removeAllPreKeys(); } @@ -328,7 +339,7 @@ export class SignalProtocolStore extends EventsMixin { throw new Error('loadSignedPreKey: this.signedPreKeys not yet cached!'); } - const entry = this.signedPreKeys[keyId]; + const entry = this.signedPreKeys.get(keyId); if (!entry) { window.log.error('Failed to fetch signed prekey:', keyId); return undefined; @@ -340,11 +351,11 @@ export class SignalProtocolStore extends EventsMixin { } const item = hydrateSignedPreKey(entry.fromDB); - this.signedPreKeys[keyId] = { + this.signedPreKeys.set(keyId, { hydrated: true, item, fromDB: entry.fromDB, - }; + }); window.log.info('Successfully fetched signed prekey (cache miss):', keyId); return item; } @@ -358,7 +369,7 @@ export class SignalProtocolStore extends EventsMixin { throw new Error('loadSignedPreKeys takes no arguments'); } - const entries = Object.values(this.signedPreKeys); + const entries = Array.from(this.signedPreKeys.values()); return entries.map(entry => { const preKey = entry.fromDB; return { @@ -391,10 +402,10 @@ export class SignalProtocolStore extends EventsMixin { }; await window.Signal.Data.createOrUpdateSignedPreKey(fromDB); - this.signedPreKeys[keyId] = { + this.signedPreKeys.set(keyId, { hydrated: false, fromDB, - }; + }); } async removeSignedPreKey(keyId: number): Promise { @@ -402,15 +413,126 @@ export class SignalProtocolStore extends EventsMixin { throw new Error('removeSignedPreKey: this.signedPreKeys not yet cached!'); } - delete this.signedPreKeys[keyId]; + this.signedPreKeys.delete(keyId); await window.Signal.Data.removeSignedPreKeyById(keyId); } async clearSignedPreKeysStore(): Promise { - this.signedPreKeys = Object.create(null); + if (this.signedPreKeys) { + this.signedPreKeys.clear(); + } await window.Signal.Data.removeAllSignedPreKeys(); } + // Sender Key Queue + + async enqueueSenderKeyJob( + encodedAddress: string, + task: () => Promise + ): Promise { + const senderId = await normalizeEncodedAddress(encodedAddress); + const queue = this._getSenderKeyQueue(senderId); + + return queue.add(task); + } + + private _createSenderKeyQueue(): PQueue { + return new PQueue({ concurrency: 1, timeout: 1000 * 60 * 2 }); + } + + private _getSenderKeyQueue(senderId: string): PQueue { + const cachedQueue = this.senderKeyQueues.get(senderId); + if (cachedQueue) { + return cachedQueue; + } + + const freshQueue = this._createSenderKeyQueue(); + this.senderKeyQueues.set(senderId, freshQueue); + return freshQueue; + } + + // Sender Keys + + private getSenderKeyId(senderKeyId: string, distributionId: string): string { + return `${senderKeyId}--${distributionId}`; + } + + async saveSenderKey( + encodedAddress: string, + distributionId: string, + record: SenderKeyRecord + ): Promise { + if (!this.senderKeys) { + throw new Error('saveSenderKey: this.senderKeys not yet cached!'); + } + + try { + const senderId = await normalizeEncodedAddress(encodedAddress); + const id = this.getSenderKeyId(senderId, distributionId); + + const fromDB: SenderKeyType = { + id, + senderId, + distributionId, + data: record.serialize(), + lastUpdatedDate: Date.now(), + }; + + await window.Signal.Data.createOrUpdateSenderKey(fromDB); + + this.senderKeys.set(id, { + hydrated: true, + fromDB, + item: record, + }); + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `saveSenderKey: failed to save senderKey ${encodedAddress}/${distributionId}: ${errorString}` + ); + } + } + + async getSenderKey( + encodedAddress: string, + distributionId: string + ): Promise { + if (!this.senderKeys) { + throw new Error('getSenderKey: this.senderKeys not yet cached!'); + } + + try { + const senderId = await normalizeEncodedAddress(encodedAddress); + const id = this.getSenderKeyId(senderId, distributionId); + + const entry = this.senderKeys.get(id); + if (!entry) { + window.log.error('Failed to fetch sender key:', id); + return undefined; + } + + if (entry.hydrated) { + window.log.info('Successfully fetched signed prekey (cache hit):', id); + return entry.item; + } + + const item = SenderKeyRecord.deserialize(entry.fromDB.data); + this.senderKeys.set(id, { + hydrated: true, + item, + fromDB: entry.fromDB, + }); + window.log.info('Successfully fetched signed prekey (cache miss):', id); + return item; + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `getSenderKey: failed to load senderKey ${encodedAddress}/${distributionId}: ${errorString}` + ); + return undefined; + } + } + // Session Queue async enqueueSessionJob( @@ -453,7 +575,7 @@ export class SignalProtocolStore extends EventsMixin { try { const id = await normalizeEncodedAddress(encodedAddress); - const entry = this.sessions[id]; + const entry = this.sessions.get(id); if (!entry) { return undefined; @@ -464,11 +586,11 @@ export class SignalProtocolStore extends EventsMixin { } const item = await this._maybeMigrateSession(entry.fromDB); - this.sessions[id] = { + this.sessions.set(id, { hydrated: true, item, fromDB: entry.fromDB, - }; + }); return item; } catch (error) { const errorString = error && error.stack ? error.stack : error; @@ -544,11 +666,11 @@ export class SignalProtocolStore extends EventsMixin { }; await window.Signal.Data.createOrUpdateSession(fromDB); - this.sessions[id] = { + this.sessions.set(id, { hydrated: true, fromDB, item: record, - }; + }); } catch (error) { const errorString = error && error.stack ? error.stack : error; window.log.error( @@ -574,7 +696,7 @@ export class SignalProtocolStore extends EventsMixin { ); } - const allSessions = Object.values(this.sessions); + const allSessions = Array.from(this.sessions.values()); const entries = allSessions.filter( session => session.fromDB.conversationId === id ); @@ -618,7 +740,7 @@ export class SignalProtocolStore extends EventsMixin { try { const id = await normalizeEncodedAddress(encodedAddress); await window.Signal.Data.removeSessionById(id); - delete this.sessions[id]; + this.sessions.delete(id); } catch (e) { window.log.error( `removeSession: Failed to delete session for ${encodedAddress}` @@ -639,12 +761,12 @@ export class SignalProtocolStore extends EventsMixin { const id = window.ConversationController.getConversationId(identifier); - const entries = Object.values(this.sessions); + const entries = Array.from(this.sessions.values()); for (let i = 0, max = entries.length; i < max; i += 1) { const entry = entries[i]; if (entry.fromDB.conversationId === id) { - delete this.sessions[entry.fromDB.id]; + this.sessions.delete(entry.fromDB.id); } } @@ -681,7 +803,7 @@ export class SignalProtocolStore extends EventsMixin { window.log.info(`archiveSession: session for ${encodedAddress}`); const id = await normalizeEncodedAddress(encodedAddress); - const entry = this.sessions[id]; + const entry = this.sessions.get(id); await this._archiveSession(entry); } @@ -700,7 +822,7 @@ export class SignalProtocolStore extends EventsMixin { const [identifier, deviceId] = window.textsecure.utils.unencodeNumber(id); const deviceIdNumber = parseInt(deviceId, 10); - const allEntries = Object.values(this.sessions); + const allEntries = Array.from(this.sessions.values()); const entries = allEntries.filter( entry => entry.fromDB.conversationId === identifier && @@ -725,7 +847,7 @@ export class SignalProtocolStore extends EventsMixin { ); const id = window.ConversationController.getConversationId(identifier); - const allEntries = Object.values(this.sessions); + const allEntries = Array.from(this.sessions.values()); const entries = allEntries.filter( entry => entry.fromDB.conversationId === id ); @@ -738,7 +860,9 @@ export class SignalProtocolStore extends EventsMixin { } async clearSessionStore(): Promise { - this.sessions = Object.create(null); + if (this.sessions) { + this.sessions.clear(); + } window.Signal.Data.removeAllSessions(); } @@ -757,7 +881,7 @@ export class SignalProtocolStore extends EventsMixin { ); } - const entry = this.identityKeys[id]; + const entry = this.identityKeys.get(id); if (!entry) { return undefined; } @@ -869,10 +993,10 @@ export class SignalProtocolStore extends EventsMixin { const { id } = data; await window.Signal.Data.createOrUpdateIdentityKey(data); - this.identityKeys[id] = { + this.identityKeys.set(id, { hydrated: false, fromDB: data, - }; + }); } async saveIdentity( @@ -1271,7 +1395,7 @@ export class SignalProtocolStore extends EventsMixin { const id = window.ConversationController.getConversationId(identifier); if (id) { - delete this.identityKeys[id]; + this.identityKeys.delete(id); await window.Signal.Data.removeIdentityKeyById(id); await this.removeAllSessions(id); } diff --git a/ts/groups.ts b/ts/groups.ts index ac3accc85..b31bdf258 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -23,6 +23,7 @@ import dataInterface from './sql/Client'; import { toWebSafeBase64, fromWebSafeBase64 } from './util/webSafeBase64'; import { assert } from './util/assert'; import { isMoreRecentThan } from './util/timestamp'; +import { isByteBufferEmpty } from './util/isByteBufferEmpty'; import { ConversationAttributesType, GroupV2MemberType, @@ -321,10 +322,10 @@ export function parseGroupLink( throw error; } - if (!hasData(inviteLinkProto.v1Contents.groupMasterKey)) { + if (isByteBufferEmpty(inviteLinkProto.v1Contents.groupMasterKey)) { throw new Error('v1Contents.groupMasterKey had no data!'); } - if (!hasData(inviteLinkProto.v1Contents.inviteLinkPassword)) { + if (isByteBufferEmpty(inviteLinkProto.v1Contents.inviteLinkPassword)) { throw new Error('v1Contents.inviteLinkPassword had no data!'); } @@ -4673,10 +4674,6 @@ function isValidProfileKey(buffer?: ArrayBuffer): boolean { return Boolean(buffer && buffer.byteLength === 32); } -function hasData(data: ProtoBinaryType): boolean { - return data && data.limit > 0; -} - function normalizeTimestamp( timestamp: ProtoBigNumberType ): number | ProtoBigNumberType { @@ -4703,7 +4700,7 @@ function decryptGroupChange( ): GroupChangeClass.Actions { const clientZkGroupCipher = getClientZkGroupCipher(groupSecretParams); - if (hasData(actions.sourceUuid)) { + if (!isByteBufferEmpty(actions.sourceUuid)) { try { actions.sourceUuid = decryptUuid( clientZkGroupCipher, @@ -4752,7 +4749,7 @@ function decryptGroupChange( // deleteMembers?: Array; actions.deleteMembers = compact( (actions.deleteMembers || []).map(deleteMember => { - if (hasData(deleteMember.deletedUserId)) { + if (!isByteBufferEmpty(deleteMember.deletedUserId)) { try { deleteMember.deletedUserId = decryptUuid( clientZkGroupCipher, @@ -4792,7 +4789,7 @@ function decryptGroupChange( // modifyMemberRoles?: Array; actions.modifyMemberRoles = compact( (actions.modifyMemberRoles || []).map(modifyMember => { - if (hasData(modifyMember.userId)) { + if (!isByteBufferEmpty(modifyMember.userId)) { try { modifyMember.userId = decryptUuid( clientZkGroupCipher, @@ -4840,7 +4837,7 @@ function decryptGroupChange( // >; actions.modifyMemberProfileKeys = compact( (actions.modifyMemberProfileKeys || []).map(modifyMemberProfileKey => { - if (hasData(modifyMemberProfileKey.presentation)) { + if (!isByteBufferEmpty(modifyMemberProfileKey.presentation)) { const { profileKey, uuid } = decryptProfileKeyCredentialPresentation( clientZkGroupCipher, modifyMemberProfileKey.presentation.toArrayBuffer() @@ -4910,7 +4907,7 @@ function decryptGroupChange( // >; actions.deletePendingMembers = compact( (actions.deletePendingMembers || []).map(deletePendingMember => { - if (hasData(deletePendingMember.deletedUserId)) { + if (!isByteBufferEmpty(deletePendingMember.deletedUserId)) { try { deletePendingMember.deletedUserId = decryptUuid( clientZkGroupCipher, @@ -4952,7 +4949,7 @@ function decryptGroupChange( // >; actions.promotePendingMembers = compact( (actions.promotePendingMembers || []).map(promotePendingMember => { - if (hasData(promotePendingMember.presentation)) { + if (!isByteBufferEmpty(promotePendingMember.presentation)) { const { profileKey, uuid } = decryptProfileKeyCredentialPresentation( clientZkGroupCipher, promotePendingMember.presentation.toArrayBuffer() @@ -4991,7 +4988,7 @@ function decryptGroupChange( ); // modifyTitle?: GroupChangeClass.Actions.ModifyTitleAction; - if (actions.modifyTitle && hasData(actions.modifyTitle.title)) { + if (actions.modifyTitle && !isByteBufferEmpty(actions.modifyTitle.title)) { try { actions.modifyTitle.title = window.textsecure.protobuf.GroupAttributeBlob.decode( decryptGroupBlob( @@ -5017,7 +5014,7 @@ function decryptGroupChange( // GroupChangeClass.Actions.ModifyDisappearingMessagesTimerAction; if ( actions.modifyDisappearingMessagesTimer && - hasData(actions.modifyDisappearingMessagesTimer.timer) + !isByteBufferEmpty(actions.modifyDisappearingMessagesTimer.timer) ) { try { actions.modifyDisappearingMessagesTimer.timer = window.textsecure.protobuf.GroupAttributeBlob.decode( @@ -5106,7 +5103,7 @@ function decryptGroupChange( actions.deleteMemberPendingAdminApprovals = compact( (actions.deleteMemberPendingAdminApprovals || []).map( deletePendingApproval => { - if (hasData(deletePendingApproval.deletedUserId)) { + if (!isByteBufferEmpty(deletePendingApproval.deletedUserId)) { try { deletePendingApproval.deletedUserId = decryptUuid( clientZkGroupCipher, @@ -5150,7 +5147,7 @@ function decryptGroupChange( actions.promoteMemberPendingAdminApprovals = compact( (actions.promoteMemberPendingAdminApprovals || []).map( promoteAdminApproval => { - if (hasData(promoteAdminApproval.userId)) { + if (!isByteBufferEmpty(promoteAdminApproval.userId)) { try { promoteAdminApproval.userId = decryptUuid( clientZkGroupCipher, @@ -5183,7 +5180,7 @@ function decryptGroupChange( // modifyInviteLinkPassword?: GroupChangeClass.Actions.ModifyInviteLinkPasswordAction; if ( actions.modifyInviteLinkPassword && - hasData(actions.modifyInviteLinkPassword.inviteLinkPassword) + !isByteBufferEmpty(actions.modifyInviteLinkPassword.inviteLinkPassword) ) { actions.modifyInviteLinkPassword.inviteLinkPassword = actions.modifyInviteLinkPassword.inviteLinkPassword.toString( 'base64' @@ -5200,7 +5197,7 @@ export function decryptGroupTitle( secretParams: string ): string | undefined { const clientZkGroupCipher = getClientZkGroupCipher(secretParams); - if (hasData(title)) { + if (!isByteBufferEmpty(title)) { const blob = window.textsecure.protobuf.GroupAttributeBlob.decode( decryptGroupBlob(clientZkGroupCipher, title.toArrayBuffer()) ); @@ -5221,7 +5218,7 @@ function decryptGroupState( const clientZkGroupCipher = getClientZkGroupCipher(groupSecretParams); // title - if (hasData(groupState.title)) { + if (!isByteBufferEmpty(groupState.title)) { try { groupState.title = window.textsecure.protobuf.GroupAttributeBlob.decode( decryptGroupBlob(clientZkGroupCipher, groupState.title.toArrayBuffer()) @@ -5241,7 +5238,7 @@ function decryptGroupState( // Note: decryption happens during application of the change, on download of the avatar // disappearing message timer - if (hasData(groupState.disappearingMessagesTimer)) { + if (!isByteBufferEmpty(groupState.disappearingMessagesTimer)) { try { groupState.disappearingMessagesTimer = window.textsecure.protobuf.GroupAttributeBlob.decode( decryptGroupBlob( @@ -5314,7 +5311,7 @@ function decryptGroupState( } // inviteLinkPassword - if (hasData(groupState.inviteLinkPassword)) { + if (!isByteBufferEmpty(groupState.inviteLinkPassword)) { groupState.inviteLinkPassword = groupState.inviteLinkPassword.toString( 'base64' ); @@ -5331,7 +5328,7 @@ function decryptMember( logId: string ) { // userId - if (hasData(member.userId)) { + if (!isByteBufferEmpty(member.userId)) { try { member.userId = decryptUuid( clientZkGroupCipher, @@ -5359,7 +5356,7 @@ function decryptMember( } // profileKey - if (hasData(member.profileKey)) { + if (!isByteBufferEmpty(member.profileKey)) { member.profileKey = decryptProfileKey( clientZkGroupCipher, member.profileKey.toArrayBuffer(), @@ -5387,7 +5384,7 @@ function decryptMemberPendingProfileKey( logId: string ) { // addedByUserId - if (hasData(member.addedByUserId)) { + if (!isByteBufferEmpty(member.addedByUserId)) { try { member.addedByUserId = decryptUuid( clientZkGroupCipher, @@ -5433,7 +5430,7 @@ function decryptMemberPendingProfileKey( const { userId, profileKey, role } = member.member; // userId - if (hasData(userId)) { + if (!isByteBufferEmpty(userId)) { try { member.member.userId = decryptUuid( clientZkGroupCipher, @@ -5467,7 +5464,7 @@ function decryptMemberPendingProfileKey( } // profileKey - if (hasData(profileKey)) { + if (!isByteBufferEmpty(profileKey)) { try { member.member.profileKey = decryptProfileKey( clientZkGroupCipher, @@ -5512,7 +5509,7 @@ function decryptMemberPendingAdminApproval( const { userId, profileKey } = member; // userId - if (hasData(userId)) { + if (!isByteBufferEmpty(userId)) { try { member.userId = decryptUuid(clientZkGroupCipher, userId.toArrayBuffer()); } catch (error) { @@ -5541,7 +5538,7 @@ function decryptMemberPendingAdminApproval( } // profileKey - if (hasData(profileKey)) { + if (!isByteBufferEmpty(profileKey)) { try { member.profileKey = decryptProfileKey( clientZkGroupCipher, diff --git a/ts/logging/set_up_renderer_logging.ts b/ts/logging/set_up_renderer_logging.ts index 41771751b..a19a0f250 100644 --- a/ts/logging/set_up_renderer_logging.ts +++ b/ts/logging/set_up_renderer_logging.ts @@ -11,7 +11,10 @@ import * as path from 'path'; import pino from 'pino'; import { createStream } from 'rotating-file-stream'; -import { initLogger, LogLevel as SignalClientLogLevel } from 'libsignal-client'; +import { + initLogger, + LogLevel as SignalClientLogLevel, +} from '@signalapp/signal-client'; import { uploadDebugLogs } from './debuglogs'; import { redactAll } from '../../js/modules/privacy'; @@ -204,7 +207,7 @@ initLogger( } else if (file) { fileString = ` ${file}`; } - const logString = `libsignal-client ${message} ${target}${fileString}`; + const logString = `@signalapp/signal-client ${message} ${target}${fileString}`; if (level === SignalClientLogLevel.Trace) { log.trace(logString); diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index ec8d235a3..1cae12793 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -48,6 +48,7 @@ import { MessageTypeUnhydrated, PreKeyType, SearchResultMessageType, + SenderKeyType, ServerInterface, SessionType, SignedPreKeyType, @@ -134,6 +135,11 @@ const dataInterface: ClientInterface = { removeItemById, removeAllItems, + createOrUpdateSenderKey, + getSenderKeyById, + removeAllSenderKeys, + getAllSenderKeys, + createOrUpdateSession, createOrUpdateSessions, getSessionById, @@ -736,6 +742,23 @@ async function removeAllItems() { await channels.removeAllItems(); } +// Sender Keys + +async function createOrUpdateSenderKey(key: SenderKeyType): Promise { + await channels.createOrUpdateSenderKey(key); +} +async function getSenderKeyById( + id: string +): Promise { + return channels.getSenderKeyById(id); +} +async function removeAllSenderKeys(): Promise { + await channels.removeAllSenderKeys(); +} +async function getAllSenderKeys(): Promise> { + return channels.getAllSenderKeys(); +} + // Sessions async function createOrUpdateSession(data: SessionType) { diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 588f261bb..c6e0e461e 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -66,6 +66,16 @@ export type ClientSearchResultMessageType = MessageType & { bodyRanges: []; snippet: string; }; +export type SenderKeyType = { + // Primary key + id: string; + // These two are combined into one string to give us the final id + senderId: string; + distributionId: string; + // Raw data to serialize/deserialize into signal-client SenderKeyRecord + data: Buffer; + lastUpdatedDate: number; +}; export type SessionType = { id: string; conversationId: string; @@ -171,6 +181,11 @@ export type DataInterface = { removeAllItems: () => Promise; getAllItems: () => Promise>; + createOrUpdateSenderKey: (key: SenderKeyType) => Promise; + getSenderKeyById: (id: string) => Promise; + removeAllSenderKeys: () => Promise; + getAllSenderKeys: () => Promise>; + createOrUpdateSession: (data: SessionType) => Promise; createOrUpdateSessions: (array: Array) => Promise; getSessionById: (id: string) => Promise; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 9fa26c6c1..603517185 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -49,6 +49,7 @@ import { MessageMetricsType, PreKeyType, SearchResultMessageType, + SenderKeyType, ServerInterface, SessionType, SignedPreKeyType, @@ -86,7 +87,7 @@ type StickerRow = Readonly<{ type EmptyQuery = []; type ArrayQuery = Array>; -type Query = { [key: string]: null | number | string }; +type Query = { [key: string]: null | number | string | Buffer }; // Because we can't force this module to conform to an interface, we narrow our exports // to this one default export, which does conform to the interface. @@ -125,6 +126,11 @@ const dataInterface: ServerInterface = { removeItemById, removeAllItems, + createOrUpdateSenderKey, + getSenderKeyById, + removeAllSenderKeys, + getAllSenderKeys, + createOrUpdateSession, createOrUpdateSessions, getSessionById, @@ -1625,6 +1631,7 @@ async function updateToSchemaVersion25(currentVersion: number, db: Database) { db.pragma('user_version = 25'); })(); + console.log('updateToSchemaVersion25: success!'); } async function updateToSchemaVersion26(currentVersion: number, db: Database) { @@ -1660,6 +1667,7 @@ async function updateToSchemaVersion26(currentVersion: number, db: Database) { db.pragma('user_version = 26'); })(); + console.log('updateToSchemaVersion26: success!'); } async function updateToSchemaVersion27(currentVersion: number, db: Database) { @@ -1697,6 +1705,7 @@ async function updateToSchemaVersion27(currentVersion: number, db: Database) { db.pragma('user_version = 27'); })(); + console.log('updateToSchemaVersion27: success!'); } function updateToSchemaVersion28(currentVersion: number, db: Database) { @@ -1718,6 +1727,7 @@ function updateToSchemaVersion28(currentVersion: number, db: Database) { db.pragma('user_version = 28'); })(); + console.log('updateToSchemaVersion28: success!'); } function updateToSchemaVersion29(currentVersion: number, db: Database) { @@ -1751,6 +1761,28 @@ function updateToSchemaVersion29(currentVersion: number, db: Database) { db.pragma('user_version = 29'); })(); + console.log('updateToSchemaVersion29: success!'); +} + +function updateToSchemaVersion30(currentVersion: number, db: Database) { + if (currentVersion >= 30) { + return; + } + + db.transaction(() => { + db.exec(` + CREATE TABLE senderKeys( + id TEXT PRIMARY KEY NOT NULL, + senderId TEXT NOT NULL, + distributionId TEXT NOT NULL, + data BLOB NOT NULL, + lastUpdatedDate NUMBER NOT NULL + ); + `); + + db.pragma('user_version = 30'); + })(); + console.log('updateToSchemaVersion30: success!'); } const SCHEMA_VERSIONS = [ @@ -1783,6 +1815,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion27, updateToSchemaVersion28, updateToSchemaVersion29, + updateToSchemaVersion30, ]; function updateSchema(db: Database): void { @@ -2087,6 +2120,49 @@ function removeAllItems(): Promise { return removeAllFromTable(ITEMS_TABLE); } +async function createOrUpdateSenderKey(key: SenderKeyType): Promise { + const db = getInstance(); + + prepare( + db, + ` + INSERT OR REPLACE INTO senderKeys ( + id, + senderId, + distributionId, + data, + lastUpdatedDate + ) values ( + $id, + $senderId, + $distributionId, + $data, + $lastUpdatedDate + ) + ` + ).run(key); +} +async function getSenderKeyById( + id: string +): Promise { + const db = getInstance(); + const row = prepare(db, 'SELECT * FROM senderKeys WHERE id = $id').get({ + id, + }); + + return row; +} +async function removeAllSenderKeys(): Promise { + const db = getInstance(); + prepare(db, 'DELETE FROM senderKeys').run({}); +} +async function getAllSenderKeys(): Promise> { + const db = getInstance(); + const rows = prepare(db, 'SELECT * FROM senderKeys').all({}); + + return rows; +} + const SESSIONS_TABLE = 'sessions'; async function createOrUpdateSession(data: SessionType): Promise { const db = getInstance(); @@ -4635,6 +4711,7 @@ async function removeAll(): Promise { DELETE FROM items; DELETE FROM messages; DELETE FROM preKeys; + DELETE FROM senderKeys; DELETE FROM sessions; DELETE FROM signedPreKeys; DELETE FROM unprocessed; @@ -4657,6 +4734,7 @@ async function removeAllConfiguration(): Promise { DELETE FROM identityKeys; DELETE FROM items; DELETE FROM preKeys; + DELETE FROM senderKeys; DELETE FROM sessions; DELETE FROM signedPreKeys; DELETE FROM unprocessed; diff --git a/ts/sql/cleanDataForIpc.ts b/ts/sql/cleanDataForIpc.ts index c2ef37a3a..e154dcf0c 100644 --- a/ts/sql/cleanDataForIpc.ts +++ b/ts/sql/cleanDataForIpc.ts @@ -37,6 +37,7 @@ type CleanedDataValue = | boolean | null | undefined + | Buffer | CleanedObject | CleanedArray; /* eslint-disable no-restricted-syntax */ @@ -110,6 +111,10 @@ function cleanDataInner( return undefined; } + if (data instanceof Buffer) { + return data; + } + const dataAsRecord = data as Record; if ( diff --git a/ts/test-both/sql/cleanDataForIpc_test.ts b/ts/test-both/sql/cleanDataForIpc_test.ts index 8f8c2a258..9be0bd1a3 100644 --- a/ts/test-both/sql/cleanDataForIpc_test.ts +++ b/ts/test-both/sql/cleanDataForIpc_test.ts @@ -61,6 +61,15 @@ describe('cleanDataForIpc', () => { }); }); + it('keeps Buffers in a field', () => { + const buffer = Buffer.from('AABBCC', 'hex'); + + assert.deepEqual(cleanDataForIpc(buffer), { + cleaned: buffer, + pathsChanged: [], + }); + }); + it('converts valid dates to ISO strings', () => { assert.deepEqual(cleanDataForIpc(new Date(924588548000)), { cleaned: '1999-04-20T06:09:08.000Z', diff --git a/ts/test-electron/SignalProtocolStore_test.ts b/ts/test-electron/SignalProtocolStore_test.ts index 2d1a9f216..1eeb8cd8c 100644 --- a/ts/test-electron/SignalProtocolStore_test.ts +++ b/ts/test-electron/SignalProtocolStore_test.ts @@ -4,7 +4,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { assert } from 'chai'; -import { Direction, SessionRecord } from 'libsignal-client'; +import { + Direction, + SenderKeyRecord, + SessionRecord, +} from '@signalapp/signal-client'; import { signal } from '../protobuf/compiled'; import { sessionStructureToArrayBuffer } from '../util/sessionTranslation'; @@ -14,7 +18,12 @@ import { clampPrivateKey, setPublicKeyTypeByte } from '../Curve'; import { SignalProtocolStore } from '../SignalProtocolStore'; import { IdentityKeyType, KeyPairType } from '../textsecure/Types.d'; -const { RecordStructure, SessionStructure } = signal.proto.storage; +const { + RecordStructure, + SessionStructure, + SenderKeyRecordStructure, + SenderKeyStateStructure, +} = signal.proto.storage; describe('SignalProtocolStore', () => { const number = '+5558675309'; @@ -47,6 +56,41 @@ describe('SignalProtocolStore', () => { ); } + function getSenderKeyRecord(): SenderKeyRecord { + const proto = new SenderKeyRecordStructure(); + + const state = new SenderKeyStateStructure(); + + state.senderKeyId = 4; + + const senderChainKey = new SenderKeyStateStructure.SenderChainKey(); + + senderChainKey.iteration = 10; + senderChainKey.seed = toUint8Array(getPublicKey()); + state.senderChainKey = senderChainKey; + + const senderSigningKey = new SenderKeyStateStructure.SenderSigningKey(); + senderSigningKey.public = toUint8Array(getPublicKey()); + senderSigningKey.private = toUint8Array(getPrivateKey()); + + state.senderSigningKey = senderSigningKey; + + state.senderMessageKeys = []; + const messageKey = new SenderKeyStateStructure.SenderMessageKey(); + messageKey.iteration = 234; + messageKey.seed = toUint8Array(getPublicKey()); + state.senderMessageKeys.push(messageKey); + + proto.senderKeyStates = []; + proto.senderKeyStates.push(state); + + return SenderKeyRecord.deserialize( + Buffer.from( + signal.proto.storage.SenderKeyRecordStructure.encode(proto).finish() + ) + ); + } + function toUint8Array(buffer: ArrayBuffer): Uint8Array { return new Uint8Array(buffer); } @@ -109,6 +153,49 @@ describe('SignalProtocolStore', () => { }); }); + describe('senderKeys', () => { + it('roundtrips in memory', async () => { + const distributionId = window.getGuid(); + const expected = getSenderKeyRecord(); + + const deviceId = 1; + const encodedAddress = `${number}.${deviceId}`; + + await store.saveSenderKey(encodedAddress, distributionId, expected); + + const actual = await store.getSenderKey(encodedAddress, distributionId); + if (!actual) { + throw new Error('getSenderKey returned nothing!'); + } + + assert.isTrue( + constantTimeEqual(expected.serialize(), actual.serialize()) + ); + }); + + it('roundtrips through database', async () => { + const distributionId = window.getGuid(); + const expected = getSenderKeyRecord(); + + const deviceId = 1; + const encodedAddress = `${number}.${deviceId}`; + + await store.saveSenderKey(encodedAddress, distributionId, expected); + + // Re-fetch from the database to ensure we get the latest database value + await store.hydrateCaches(); + + const actual = await store.getSenderKey(encodedAddress, distributionId); + if (!actual) { + throw new Error('getSenderKey returned nothing!'); + } + + assert.isTrue( + constantTimeEqual(expected.serialize(), actual.serialize()) + ); + }); + }); + describe('saveIdentity', () => { const identifier = `${number}.1`; diff --git a/ts/test-electron/util/isByteBufferEmpty_test.ts b/ts/test-electron/util/isByteBufferEmpty_test.ts new file mode 100644 index 000000000..8ab17b2e0 --- /dev/null +++ b/ts/test-electron/util/isByteBufferEmpty_test.ts @@ -0,0 +1,31 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { isByteBufferEmpty } from '../../util/isByteBufferEmpty'; + +describe('isByteBufferEmpty', () => { + it('returns true for undefined', () => { + assert.isTrue(isByteBufferEmpty(undefined)); + }); + + it('returns true for object missing limit', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const brokenByteBuffer: any = {}; + + assert.isTrue(isByteBufferEmpty(brokenByteBuffer)); + }); + + it('returns true for object limit', () => { + const emptyByteBuffer = new window.dcodeIO.ByteBuffer(0); + + assert.isTrue(isByteBufferEmpty(emptyByteBuffer)); + }); + + it('returns false for object limit', () => { + const byteBuffer = window.dcodeIO.ByteBuffer.wrap('AABBCC', 'hex'); + + assert.isFalse(isByteBufferEmpty(byteBuffer)); + }); +}); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index bf5fa4d34..0d0eec65e 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -573,6 +573,7 @@ export declare class ContentClass { nullMessage?: NullMessageClass; receiptMessage?: ReceiptMessageClass; typingMessage?: TypingMessageClass; + senderKeyDistributionMessage?: ByteBufferClass; } export declare class DataMessageClass { @@ -733,6 +734,7 @@ export declare namespace EnvelopeClass { static PREKEY_BUNDLE: number; static RECEIPT: number; static UNIDENTIFIED_SENDER: number; + static SENDERKEY: number; } } @@ -1345,7 +1347,7 @@ export declare namespace SenderCertificateClass { ) => Certificate; toArrayBuffer: () => ArrayBuffer; - sender?: string; + senderE164?: string; senderUuid?: string; senderDevice?: number; expires?: ProtoBigNumberType; @@ -1377,6 +1379,8 @@ export declare namespace UnidentifiedSenderMessageClass { type?: number; senderCertificate?: SenderCertificateClass; content?: ProtoBinaryType; + contentHint?: number; + groupId?: ProtoBinaryType; } } @@ -1384,5 +1388,11 @@ export declare namespace UnidentifiedSenderMessageClass.Message { class Type { static PREKEY_MESSAGE: number; static MESSAGE: number; + static SENDERKEY_MESSAGE: number; + } + + class ContentHint { + static SUPPLEMENTARY: number; + static RETRY: number; } } diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index becc2e54b..f0ae8b811 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -14,20 +14,25 @@ import PQueue from 'p-queue'; import { v4 as getGuid } from 'uuid'; import { + groupDecrypt, PreKeySignalMessage, + processSenderKeyDistributionMessage, ProtocolAddress, PublicKey, SealedSenderDecryptionResult, sealedSenderDecryptMessage, sealedSenderDecryptToUsmc, + SenderKeyDistributionMessage, signalDecrypt, signalDecryptPreKey, SignalMessage, -} from 'libsignal-client'; + UnidentifiedSenderMessageContent, +} from '@signalapp/signal-client'; import { IdentityKeys, PreKeys, + SenderKeys, Sessions, SignedPreKeys, } from '../LibSignalStores'; @@ -43,6 +48,7 @@ import WebSocketResource, { import Crypto from './Crypto'; import { deriveMasterKeyFromGroupV1, typedArrayToArrayBuffer } from '../Crypto'; import { ContactBuffer, GroupBuffer } from './ContactsParser'; +import { isByteBufferEmpty } from '../util/isByteBufferEmpty'; import { AttachmentPointerClass, @@ -56,6 +62,7 @@ import { UnprocessedType, VerifiedClass, } from '../textsecure.d'; +import { ByteBufferClass } from '../window.d'; import { WebSocket } from './WebSocket'; @@ -962,9 +969,12 @@ class MessageReceiverInner extends EventTarget { async decrypt( envelope: EnvelopeClass, - ciphertext: any + ciphertext: ByteBufferClass ): Promise { const { serverTrustRoot } = this; + const envelopeTypeEnum = window.textsecure.protobuf.Envelope.Type; + const unidentifiedSenderTypeEnum = + window.textsecure.protobuf.UnidentifiedSenderMessage.Message.Type; const identifier = envelope.sourceUuid || envelope.source; const { sourceDevice } = envelope; @@ -989,7 +999,32 @@ class MessageReceiverInner extends EventTarget { ArrayBuffer | { isMe: boolean } | { isBlocked: boolean } | undefined >; - if (envelope.type === window.textsecure.protobuf.Envelope.Type.CIPHERTEXT) { + if (envelope.type === envelopeTypeEnum.SENDERKEY) { + window.log.info('sender key message from', this.getEnvelopeId(envelope)); + if (!identifier) { + throw new Error( + 'MessageReceiver.decrypt: No identifier for SENDERKEY message' + ); + } + if (!sourceDevice) { + throw new Error( + 'MessageReceiver.decrypt: No sourceDevice for SENDERKEY message' + ); + } + + const senderKeyStore = new SenderKeys(); + const address = `${identifier}.${sourceDevice}`; + const messageBuffer = Buffer.from(ciphertext.toArrayBuffer()); + promise = window.textsecure.storage.protocol.enqueueSenderKeyJob( + address, + () => + groupDecrypt( + ProtocolAddress.new(identifier, sourceDevice), + senderKeyStore, + messageBuffer + ).then(plaintext => this.unpad(typedArrayToArrayBuffer(plaintext))) + ); + } else if (envelope.type === envelopeTypeEnum.CIPHERTEXT) { window.log.info('message from', this.getEnvelopeId(envelope)); if (!identifier) { throw new Error( @@ -1016,9 +1051,7 @@ class MessageReceiverInner extends EventTarget { identityKeyStore ).then(plaintext => this.unpad(typedArrayToArrayBuffer(plaintext))) ); - } else if ( - envelope.type === window.textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE - ) { + } else if (envelope.type === envelopeTypeEnum.PREKEY_BUNDLE) { window.log.info('prekey message from', this.getEnvelopeId(envelope)); if (!identifier) { throw new Error( @@ -1047,17 +1080,14 @@ class MessageReceiverInner extends EventTarget { signedPreKeyStore ).then(plaintext => this.unpad(typedArrayToArrayBuffer(plaintext))) ); - } else if ( - envelope.type === - window.textsecure.protobuf.Envelope.Type.UNIDENTIFIED_SENDER - ) { + } else if (envelope.type === envelopeTypeEnum.UNIDENTIFIED_SENDER) { window.log.info('received unidentified sender message'); const buffer = Buffer.from(ciphertext.toArrayBuffer()); const decryptSealedSender = async (): Promise< - SealedSenderDecryptionResult | null | { isBlocked: true } + SealedSenderDecryptionResult | Buffer | null | { isBlocked: true } > => { - const messageContent = await sealedSenderDecryptToUsmc( + const messageContent: UnidentifiedSenderMessageContent = await sealedSenderDecryptToUsmc( buffer, identityKeyStore ); @@ -1101,6 +1131,30 @@ class MessageReceiverInner extends EventTarget { ); } + if ( + messageContent.msgType() === + unidentifiedSenderTypeEnum.SENDERKEY_MESSAGE + ) { + const sealedSenderIdentifier = certificate.senderUuid(); + const sealedSenderSourceDevice = certificate.senderDeviceId(); + const senderKeyStore = new SenderKeys(); + + const address = `${sealedSenderIdentifier}.${sealedSenderSourceDevice}`; + + return window.textsecure.storage.protocol.enqueueSenderKeyJob( + address, + () => + groupDecrypt( + ProtocolAddress.new( + sealedSenderIdentifier, + sealedSenderSourceDevice + ), + senderKeyStore, + buffer + ) + ); + } + const sealedSenderIdentifier = envelope.sourceUuid || envelope.source; const address = `${sealedSenderIdentifier}.${envelope.sourceDevice}`; return window.textsecure.storage.protocol.enqueueSessionJob( @@ -1128,6 +1182,9 @@ class MessageReceiverInner extends EventTarget { if ('isBlocked' in result) { return result; } + if (result instanceof Buffer) { + return this.unpad(typedArrayToArrayBuffer(result)); + } const content = typedArrayToArrayBuffer(result.message()); @@ -1390,7 +1447,10 @@ class MessageReceiverInner extends EventTarget { ); } - async handleDataMessage(envelope: EnvelopeClass, msg: DataMessageClass) { + async handleDataMessage( + envelope: EnvelopeClass, + msg: DataMessageClass + ): Promise { window.log.info( 'MessageReceiver.handleDataMessage', this.getEnvelopeId(envelope) @@ -1519,35 +1579,101 @@ class MessageReceiverInner extends EventTarget { async innerHandleContentMessage( envelope: EnvelopeClass, plaintext: ArrayBuffer - ) { + ): Promise { const content = window.textsecure.protobuf.Content.decode(plaintext); + + // Note: a distribution message can be tacked on to any other message, so we + // make sure to process it first. If that fails, we still try to process + // the rest of the message. + try { + if (content.senderKeyDistributionMessage) { + await this.handleSenderKeyDistributionMessage( + envelope, + content.senderKeyDistributionMessage + ); + } + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `innerHandleContentMessage: Failed to process sender key distribution message: ${errorString}` + ); + } + if (content.syncMessage) { - return this.handleSyncMessage(envelope, content.syncMessage); + await this.handleSyncMessage(envelope, content.syncMessage); + return; } if (content.dataMessage) { - return this.handleDataMessage(envelope, content.dataMessage); + await this.handleDataMessage(envelope, content.dataMessage); + return; } if (content.nullMessage) { - this.handleNullMessage(envelope); - return undefined; + await this.handleNullMessage(envelope); + return; } if (content.callingMessage) { - return this.handleCallingMessage(envelope, content.callingMessage); + await this.handleCallingMessage(envelope, content.callingMessage); + return; } if (content.receiptMessage) { - return this.handleReceiptMessage(envelope, content.receiptMessage); + await this.handleReceiptMessage(envelope, content.receiptMessage); + return; } if (content.typingMessage) { - return this.handleTypingMessage(envelope, content.typingMessage); + await this.handleTypingMessage(envelope, content.typingMessage); + return; } + this.removeFromCache(envelope); - throw new Error('Unsupported content message'); + + if (isByteBufferEmpty(content.senderKeyDistributionMessage)) { + throw new Error('Unsupported content message'); + } + } + + async handleSenderKeyDistributionMessage( + envelope: EnvelopeClass, + distributionMessage: ByteBufferClass + ): Promise { + const envelopeId = this.getEnvelopeId(envelope); + window.log.info(`handleSenderKeyDistributionMessage: ${envelopeId}`); + + // 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 identifier = envelope.sourceUuid || envelope.source; + 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}` + ); + } + + const sender = ProtocolAddress.new(identifier, sourceDevice); + const senderKeyDistributionMessage = SenderKeyDistributionMessage.deserialize( + Buffer.from(distributionMessage.toArrayBuffer()) + ); + const senderKeyStore = new SenderKeys(); + const address = `${identifier}.${sourceDevice}`; + + await window.textsecure.storage.protocol.enqueueSenderKeyJob(address, () => + processSenderKeyDistributionMessage( + sender, + senderKeyDistributionMessage, + senderKeyStore + ) + ); } async handleCallingMessage( envelope: EnvelopeClass, callingMessage: CallingMessageClass - ) { + ): Promise { this.removeFromCache(envelope); await window.Signal.Services.calling.handleCallingMessage( envelope, @@ -1558,7 +1684,7 @@ class MessageReceiverInner extends EventTarget { async handleReceiptMessage( envelope: EnvelopeClass, receiptMessage: ReceiptMessageClass - ) { + ): Promise { const results = []; if ( receiptMessage.type === @@ -1593,13 +1719,13 @@ class MessageReceiverInner extends EventTarget { results.push(this.dispatchAndWait(ev)); } } - return Promise.all(results); + await Promise.all(results); } async handleTypingMessage( envelope: EnvelopeClass, typingMessage: TypingMessageClass - ) { + ): Promise { const ev = new Event('typing'); this.removeFromCache(envelope); @@ -1612,7 +1738,7 @@ class MessageReceiverInner extends EventTarget { window.log.warn( `Typing message envelope timestamp (${envelopeTimestamp}) did not match typing timestamp (${typingTimestamp})` ); - return null; + return; } } @@ -1645,10 +1771,10 @@ class MessageReceiverInner extends EventTarget { } } - return this.dispatchEvent(ev); + await this.dispatchEvent(ev); } - handleNullMessage(envelope: EnvelopeClass) { + handleNullMessage(envelope: EnvelopeClass): void { window.log.info( 'MessageReceiver.handleNullMessage', this.getEnvelopeId(envelope) @@ -1783,7 +1909,7 @@ class MessageReceiverInner extends EventTarget { async handleSyncMessage( envelope: EnvelopeClass, syncMessage: SyncMessageClass - ) { + ): Promise { const unidentified = syncMessage.sent ? syncMessage.sent.unidentifiedStatus || [] : []; @@ -2026,7 +2152,7 @@ class MessageReceiverInner extends EventTarget { async handleRead( envelope: EnvelopeClass, read: Array - ) { + ): Promise { window.log.info('MessageReceiver.handleRead', this.getEnvelopeId(envelope)); const results = []; for (let i = 0; i < read.length; i += 1) { @@ -2046,7 +2172,7 @@ class MessageReceiverInner extends EventTarget { ); results.push(this.dispatchAndWait(ev)); } - return Promise.all(results); + await Promise.all(results); } handleContacts(envelope: EnvelopeClass, contacts: SyncMessageClass.Contacts) { diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index 7cc0f4109..2a1ddd051 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -20,7 +20,7 @@ import { sealedSenderEncryptMessage, SenderCertificate, signalEncrypt, -} from 'libsignal-client'; +} from '@signalapp/signal-client'; import { ServerKeysType, WebAPIType } from './WebAPI'; import { ContentClass, DataMessageClass } from '../textsecure.d'; diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index 9676b8edf..baded98f2 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -3,11 +3,12 @@ export { IdentityKeyType, - SignedPreKeyType, PreKeyType, + SenderKeyType, + SessionType, + SignedPreKeyType, UnprocessedType, UnprocessedUpdateType, - SessionType, } from '../sql/Interface'; // How the legacy APIs generate these types diff --git a/ts/updater/curve.ts b/ts/updater/curve.ts index df0e53358..69868f4b4 100644 --- a/ts/updater/curve.ts +++ b/ts/updater/curve.ts @@ -1,7 +1,7 @@ // Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { PrivateKey, PublicKey } from 'libsignal-client'; +import { PrivateKey, PublicKey } from '@signalapp/signal-client'; export function keyPair(): Record { const privKey = PrivateKey.generate(); diff --git a/ts/util/isByteBufferEmpty.ts b/ts/util/isByteBufferEmpty.ts new file mode 100644 index 000000000..7a345bea0 --- /dev/null +++ b/ts/util/isByteBufferEmpty.ts @@ -0,0 +1,10 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isNumber } from 'lodash'; + +import { ByteBufferClass } from '../window.d'; + +export function isByteBufferEmpty(data?: ByteBufferClass): boolean { + return !data || !isNumber(data.limit) || data.limit === 0; +} diff --git a/ts/util/safetyNumber.ts b/ts/util/safetyNumber.ts index 15985d98a..e2e7486ee 100644 --- a/ts/util/safetyNumber.ts +++ b/ts/util/safetyNumber.ts @@ -1,7 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { PublicKey, Fingerprint } from 'libsignal-client'; +import { PublicKey, Fingerprint } from '@signalapp/signal-client'; import { ConversationType } from '../state/ducks/conversations'; export async function generateSecurityNumber( diff --git a/webpack-preload.config.ts b/webpack-preload.config.ts index ee4210c49..07fbccbb2 100644 --- a/webpack-preload.config.ts +++ b/webpack-preload.config.ts @@ -17,7 +17,7 @@ const EXTERNAL_MODULE = new Set([ 'fsevents', 'got', 'jquery', - 'libsignal-client', + '@signalapp/signal-client', 'node-fetch', 'node-sass', 'pino', diff --git a/yarn.lock b/yarn.lock index 5bd42f585..e932ecf81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1467,6 +1467,14 @@ react-lifecycles-compat "^3.0.4" warning "^3.0.0" +"@signalapp/signal-client@0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.5.1.tgz#b893a658db92f7fe3d3657ac9a4f83909ac1d09d" + integrity sha512-d3wM2vS4IcPGmBzcjigD1Y14J3j4rP+dTpE1J5xrPfknLgGPXLR+dX4I6RU9nFVe5toCyrRnTSBjQbBn/SixKA== + dependencies: + node-gyp-build "^4.2.3" + uuid "^8.3.0" + "@sindresorhus/is@0.8.0": version "0.8.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.8.0.tgz#073aee40b0aab2d4ace33c0a2a2672a37da6fa12" @@ -11134,12 +11142,6 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -"libsignal-client@https://github.com/signalapp/libsignal-client-node.git#afa8f40eaa218b7fff94278d9b2ab2e13a2ee04b": - version "0.3.3" - resolved "https://github.com/signalapp/libsignal-client-node.git#afa8f40eaa218b7fff94278d9b2ab2e13a2ee04b" - dependencies: - bindings "^1.5.0" - lie@*: version "3.2.0" resolved "https://registry.yarnpkg.com/lie/-/lie-3.2.0.tgz#4f13f2f8bbb027d383db338c43043545791aa8dc" @@ -12339,7 +12341,7 @@ node-forge@0.10.0, node-forge@^0.10.0: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA== -node-gyp-build@^4.2.1: +node-gyp-build@^4.2.1, node-gyp-build@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.2.3.tgz#ce6277f853835f718829efb47db20f3e4d9c4739" integrity sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg== @@ -18025,6 +18027,11 @@ uuid@^3.4.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^8.3.0: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache@^2.0.3: version "2.1.1" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745"