diff --git a/.nvmrc b/.nvmrc index 0254b1e..9e2934a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.18.2 +24.11.1 diff --git a/package.json b/package.json index ae3d396..f107959 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,8 @@ "scripts": { "watch": "npm run build:tsc -- -w", "build:tsc": "tsc", - "build:protobuf": "pbjs --target static-module --force-long --wrap commonjs --out protos/compiled.js protos/*.proto", - "build:protobuf-ts": "pbts --out protos/compiled.d.ts protos/compiled.js", - "build": "npm run build:protobuf && npm run build:protobuf-ts && npm run build:tsc", + "build:protobuf": "protopiler --module cjs --output protos/compiled.js --typedefs protos/compiled.d.ts protos/*.proto", + "build": "npm run build:protobuf && npm run build:tsc", "format": "pprettier --write '**/*.ts'", "mocha": "mocha test/**/*-test.js", "lint:eslint": "eslint .", @@ -45,16 +44,15 @@ "homepage": "https://github.com/signalapp/Mock-Signal-Server#readme", "dependencies": { "@indutny/parallel-prettier": "^3.0.0", + "@indutny/protopiler": "1.0.0-rc.19", "@signalapp/libsignal-client": "^0.76.7", "@tus/file-store": "^1.4.0", "@tus/server": "^1.7.0", "debug": "^4.3.2", "is-plain-obj": "3.0.0", - "long": "5.2.3", "micro": "^9.3.4", "microrouter": "^3.1.3", "prettier": "^3.3.3", - "protobufjs": "^7.2.4", "type-fest": "^4.26.1", "url-pattern": "^1.0.3", "uuid": "^8.3.2", @@ -73,8 +71,7 @@ "@types/ws": "^8.2.2", "eslint": "^9.36.0", "mocha": "^9.2.0", - "protobufjs-cli": "^1.1.1", - "typescript": "^5.1.0", + "typescript": "^5.9.3", "typescript-eslint": "^8.44.1" } } diff --git a/src/api/group.ts b/src/api/group.ts index 7820789..b662043 100644 --- a/src/api/group.ts +++ b/src/api/group.ts @@ -19,7 +19,7 @@ const AccessRequired = Proto.AccessControl.AccessRequired; export type GroupOptions = Readonly<{ secretParams: GroupSecretParams; - groupState: Proto.IGroup; + groupState: Proto.Group.Params; }>; export type GroupMember = Readonly<{ @@ -37,16 +37,16 @@ export type GroupFromConfigOptions = Readonly<{ function encryptBlob( cipher: ClientZkGroupCipher, - proto: Proto.IGroupAttributeBlob, + proto: Proto.GroupAttributeBlob.Params, ): Buffer { - const plaintext = Proto.GroupAttributeBlob.encode(proto).finish(); + const plaintext = Proto.GroupAttributeBlob.encode(proto); return Buffer.from(cipher.encryptBlob(plaintext)); } function decryptBlob( cipher: ClientZkGroupCipher, ciphertext: Uint8Array, -): Proto.IGroupAttributeBlob { +): Proto.GroupAttributeBlob { const plaintext = cipher.decryptBlob(Buffer.from(ciphertext)); return Proto.GroupAttributeBlob.decode(plaintext); } @@ -63,7 +63,9 @@ export class Group extends GroupData { this.secretParams = secretParams; const cipher = new ClientZkGroupCipher(secretParams); - this.title = decryptBlob(cipher, groupState.title).title ?? ''; + const decrypted = decryptBlob(cipher, groupState.title); + assert(decrypted.content?.kind === 'title', 'expected title'); + this.title = decrypted.content.value; this.privPublicParams = this.secretParams.getPublicParams(); @@ -73,8 +75,10 @@ export class Group extends GroupData { groupChanges: [ { groupState, + groupChange: null, }, ], + groupSendEndorsementResponse: null, }; } @@ -85,10 +89,10 @@ export class Group extends GroupData { }: GroupFromConfigOptions): Group { const cipher = new ClientZkGroupCipher(secretParams); - const groupState = { + const groupState: Proto.Group.Params = { publicKey: secretParams.getPublicParams().serialize(), version: 0, - title: encryptBlob(cipher, { title }), + title: encryptBlob(cipher, { content: { kind: 'title', value: title } }), // TODO(indutny): make it configurable accessControl: { @@ -101,8 +105,20 @@ export class Group extends GroupData { return { role: Proto.Member.Role.ADMINISTRATOR, presentation: presentation.serialize(), + userId: null, + profileKey: null, + joinedAtVersion: null, }; }), + + avatar: null, + disappearingMessagesTimer: null, + membersPendingProfileKey: null, + membersPendingAdminApproval: null, + inviteLinkPassword: null, + descriptionBytes: null, + announcementsOnly: null, + membersBanned: null, }; return new Group({ @@ -115,11 +131,12 @@ export class Group extends GroupData { return Buffer.from(this.secretParams.getMasterKey().serialize()); } - public toContext(): Proto.IGroupContextV2 { + public toContext(): Proto.GroupContextV2.Params { const masterKey = this.masterKey; return { masterKey, revision: this.revision, + groupChange: null, }; } @@ -142,13 +159,13 @@ export class Group extends GroupData { public getMemberByServiceId( serviceId: ServiceIdString, - ): Proto.IMember | undefined { + ): Proto.Member.Params | undefined { return this.getMember(new UuidCiphertext(this.encryptServiceId(serviceId))); } public getPendingMemberByServiceId( serviceId: ServiceIdString, - ): Proto.IMemberPendingProfileKey | undefined { + ): Proto.MemberPendingProfileKey.Params | undefined { return this.getPendingMember( new UuidCiphertext(this.encryptServiceId(serviceId)), ); diff --git a/src/api/primary-device.ts b/src/api/primary-device.ts index efd5a08..449a7ed 100644 --- a/src/api/primary-device.ts +++ b/src/api/primary-device.ts @@ -3,7 +3,6 @@ import assert from 'assert'; import crypto from 'crypto'; -import Long from 'long'; import { Aci, CiphertextMessageType, @@ -81,7 +80,13 @@ import { DeviceKeys, SingleUseKey, } from '../data/device'; -import { PromiseQueue, addressToString, generateRegistrationId } from '../util'; +import { + PromiseQueue, + addressToString, + generateRegistrationId, + buildContentParams, + buildSyncMessageParams, +} from '../util'; import { Group } from './group'; import { StorageState } from './storage-state'; @@ -89,7 +94,7 @@ const debug = createDebug('mock:primary-device'); export type Config = Readonly<{ profileName: string; - contacts: Proto.IAttachmentPointer; + contacts: Proto.AttachmentPointer.Params; trustRoot: PublicKey; serverPublicParams: ServerPublicParams; @@ -112,16 +117,16 @@ export type Config = Readonly<{ ) => Promise; getGroup: (publicParams: Uint8Array) => Promise; - createGroup: (group: Proto.IGroup) => Promise; + createGroup: (group: Proto.Group.Params) => Promise; modifyGroup: (options: ModifyGroupOptions) => Promise; waitForGroupUpdate: (group: GroupData) => Promise; - getStorageManifest: () => Promise; + getStorageManifest: () => Promise; getStorageItem: (key: Buffer) => Promise; getAllStorageKeys: () => Promise>; waitForStorageManifest: (afterVersion?: number) => Promise; applyStorageWrite: ( - operation: Proto.IWriteOperation, + operation: Proto.WriteOperation.Params, shouldNotify?: boolean, ) => Promise; }>; @@ -213,7 +218,7 @@ export type ContentQueueEntry = Readonly<{ source: Device; serviceIdKind: ServiceIdKind; envelopeType: EnvelopeType; - content: Proto.IContent; + content: Proto.Content; }>; export type DecryptionErrorQueueEntry = ContentQueueEntry & @@ -226,27 +231,27 @@ export type DecryptionErrorQueueEntry = ContentQueueEntry & export type MessageQueueEntry = ContentQueueEntry & Readonly<{ body: string; - dataMessage: Proto.IDataMessage; + dataMessage: Proto.DataMessage; }>; export type ReceiptQueueEntry = ContentQueueEntry & Readonly<{ - receiptMessage: Proto.IReceiptMessage; + receiptMessage: Proto.ReceiptMessage; }>; export type StoryQueueEntry = ContentQueueEntry & Readonly<{ - storyMessage: Proto.IStoryMessage; + storyMessage: Proto.StoryMessage; }>; export type EditMessageQueueEntry = ContentQueueEntry & Readonly<{ - editMessage: Proto.IEditMessage; + editMessage: Proto.EditMessage; }>; export type SyncMessageQueueEntry = Readonly<{ source: Device; - syncMessage: Proto.ISyncMessage; + syncMessage: Proto.SyncMessage; }>; export type PrepareChangeNumberEntry = Readonly<{ @@ -275,10 +280,65 @@ type SyncEntry = { type DecryptResult = Readonly<{ unsealedSource: Device; - content: Proto.IContent; + content: Proto.Content; envelopeType: EnvelopeType; }>; +const EMPTY_GROUP_ACTIONS: Proto.GroupChange.Actions.Params = { + sourceUserId: null, + version: null, + groupId: null, + addMembers: null, + deleteMembers: null, + modifyMemberRoles: null, + modifyMemberProfileKeys: null, + addPendingMembers: null, + deletePendingMembers: null, + promotePendingMembers: null, + modifyTitle: null, + modifyAvatar: null, + modifyDisappearingMessagesTimer: null, + modifyAttributesAccess: null, + modifyMemberAccess: null, + modifyAddFromInviteLinkAccess: null, + addMemberPendingAdminApprovals: null, + deleteMemberPendingAdminApprovals: null, + promoteMemberPendingAdminApprovals: null, + modifyInviteLinkPassword: null, + modifyDescription: null, + modifyAnnouncementsOnly: null, + addMembersBanned: null, + deleteMembersBanned: null, + promoteMembersPendingPniAciProfileKey: null, +}; + +export const EMPTY_DATA_MESSAGE: Proto.DataMessage.Params = { + body: null, + attachments: null, + groupV2: null, + flags: null, + expireTimer: null, + expireTimerVersion: null, + profileKey: null, + timestamp: null, + quote: null, + contact: null, + preview: null, + sticker: null, + requiredProtocolVersion: null, + isViewOnce: null, + reaction: null, + delete: null, + bodyRanges: null, + groupCallUpdate: null, + payment: null, + storyContext: null, + giftBadge: null, + pollCreate: null, + pollTerminate: null, + pollVote: null, +}; + class SignedPreKeyStore extends SignedPreKeyStoreBase { private lastId = 0; private readonly records = new Map(); @@ -485,7 +545,7 @@ export class PrimaryDevice { private readonly storageKey: Buffer; private readonly privateKey = PrivateKey.generate(); private pniPrivateKey = PrivateKey.generate(); - private readonly contactsBlob: Proto.IAttachmentPointer; + private readonly contactsBlob: Proto.AttachmentPointer.Params; private privSenderCertificate: SenderCertificate | undefined; private readonly decryptionErrorQueue = new PromiseQueue(); @@ -803,9 +863,7 @@ export class PrimaryDevice { return Promise.all( records.map(async ({ record }) => { - const { groupV2 } = record; - assert.ok(groupV2, 'Not a group v2 record!'); - + const { value: groupV2 } = record; const { masterKey } = groupV2; assert.ok(masterKey, 'Group v2 record without master key'); @@ -843,7 +901,7 @@ export class PrimaryDevice { aci: member.device.aci, profileKey: member.profileKey, presentation, - joinedAtVersion: Long.fromNumber(0), + joinedAtVersion: 0n, }; }), ); @@ -893,11 +951,20 @@ export class PrimaryDevice { const modifyResult = await this.config.modifyGroup({ group: serverGroup, actions: { + ...EMPTY_GROUP_ACTIONS, version: group.revision + 1, addPendingMembers: [ { added: { - member: { userId, role: Proto.Member.Role.DEFAULT }, + member: { + userId, + role: Proto.Member.Role.DEFAULT, + profileKey: null, + presentation: null, + joinedAtVersion: null, + }, + addedByUserId: null, + timestamp: null, }, }, ], @@ -916,9 +983,7 @@ export class PrimaryDevice { if (sendUpdateTo.length) { const groupV2 = { ...updatedGroup.toContext(), - groupChange: Proto.GroupChange.encode( - modifyResult.signedChange, - ).finish(), + groupChange: Proto.GroupChange.encode(modifyResult.signedChange), }; await Promise.all( @@ -931,12 +996,14 @@ export class PrimaryDevice { const envelope = await this.encryptContent( device, - { + buildContentParams({ dataMessage: { + ...EMPTY_DATA_MESSAGE, groupV2, - timestamp: Long.fromNumber(encryptOptions.timestamp), + timestamp: BigInt(encryptOptions.timestamp), }, - }, + pniSignatureMessage: null, + }), encryptOptions, ); await this.config.send(device, envelope); @@ -966,10 +1033,14 @@ export class PrimaryDevice { const modifyResult = await this.config.modifyGroup({ group: serverGroup, actions: { + ...EMPTY_GROUP_ACTIONS, version: group.revision + 1, promoteMembersPendingPniAciProfileKey: [ { presentation: presentation.serialize(), + userId: null, + pni: null, + profileKey: null, }, ], }, @@ -986,7 +1057,7 @@ export class PrimaryDevice { const groupV2 = { ...updatedGroup.toContext(), - groupChange: Proto.GroupChange.encode(modifyResult.signedChange).finish(), + groupChange: Proto.GroupChange.encode(modifyResult.signedChange), }; await Promise.all( @@ -996,12 +1067,14 @@ export class PrimaryDevice { timestamp, ...options, }; - const content: Proto.IContent = { + const content: Proto.Content.Params = buildContentParams({ dataMessage: { + ...EMPTY_DATA_MESSAGE, groupV2, - timestamp: Long.fromNumber(encryptOptions.timestamp), + timestamp: BigInt(encryptOptions.timestamp), }, - }; + pniSignatureMessage: null, + }); const envelope = await this.encryptContent( device, @@ -1131,10 +1204,7 @@ export class PrimaryDevice { }); let handled = true; - if ( - content.decryptionErrorMessage && - content.decryptionErrorMessage.length > 0 - ) { + if (content.decryptionErrorMessage.length > 0) { assert.strictEqual( serviceIdKind, ServiceIdKind.ACI, @@ -1186,10 +1256,7 @@ export class PrimaryDevice { } const { senderKeyDistributionMessage } = content; - if ( - senderKeyDistributionMessage && - senderKeyDistributionMessage.length > 0 - ) { + if (senderKeyDistributionMessage.length > 0) { handled = true; await this.processSenderKeyDistribution( unsealedSource, @@ -1212,7 +1279,7 @@ export class PrimaryDevice { ...options, }; - let pniSignatureMessage: Proto.IPniSignatureMessage | undefined; + let pniSignatureMessage: Proto.PniSignatureMessage.Params | null = null; if (options.withPniSignature) { const pniPrivate = this.getPrivateKey(ServiceIdKind.PNI); const pniPublic = this.getPublicKey(ServiceIdKind.PNI); @@ -1228,17 +1295,16 @@ export class PrimaryDevice { }; } - const content: Proto.IContent = { + const content: Proto.Content.Params = buildContentParams({ dataMessage: { - groupV2: options.group?.toContext(), + ...EMPTY_DATA_MESSAGE, + groupV2: options.group?.toContext() ?? null, body: text, - profileKey: options.withProfileKey - ? this.profileKey.serialize() - : undefined, - timestamp: Long.fromNumber(encryptOptions.timestamp), + profileKey: options.withProfileKey ? this.profileKey.serialize() : null, + timestamp: BigInt(encryptOptions.timestamp), }, pniSignatureMessage, - }; + }); return this.encryptContent(target, content, encryptOptions); } @@ -1247,22 +1313,31 @@ export class PrimaryDevice { text: string, options: SyncSentOptions, ): Promise { - const dataMessage = { + const dataMessage: Proto.DataMessage.Params = { + ...EMPTY_DATA_MESSAGE, body: text, - timestamp: Long.fromNumber(options.timestamp), + timestamp: BigInt(options.timestamp), }; - const content: Proto.IContent = { - syncMessage: { + const content: Proto.Content.Params = buildContentParams({ + syncMessage: buildSyncMessageParams({ sent: { destinationServiceIdBinary: ServiceId.parseFromServiceIdString( options.destinationServiceId, ).getServiceIdBinary(), - timestamp: Long.fromNumber(options.timestamp), + timestamp: BigInt(options.timestamp), message: dataMessage, + destinationE164: null, + unidentifiedStatus: null, + isRecipientUpdate: null, + expirationStartTimestamp: null, + storyMessage: null, + storyMessageRecipients: null, + editMessage: null, }, - }, - }; + }), + pniSignatureMessage: null, + }); return this.encryptContent(target, content, options); } @@ -1270,28 +1345,30 @@ export class PrimaryDevice { target: Device, options: SyncReadOptions, ): Promise { - const content: Proto.IContent = { - syncMessage: { + const content: Proto.Content.Params = buildContentParams({ + syncMessage: buildSyncMessageParams({ read: options.messages.map(({ senderAci, timestamp }) => { return { senderAciBinary: Aci.parseFromServiceIdString(senderAci).getRawUuidBytes(), - timestamp: Long.fromNumber(timestamp), + timestamp: BigInt(timestamp), }; }), - }, - }; + }), + pniSignatureMessage: null, + }); return this.encryptContent(target, content, options); } public async sendFetchStorage(options: FetchStorageOptions): Promise { - const content: Proto.IContent = { - syncMessage: { + const content: Proto.Content.Params = buildContentParams({ + syncMessage: buildSyncMessageParams({ fetchLatest: { type: Proto.SyncMessage.FetchLatest.Type.STORAGE_MANIFEST, }, - }, - }; + }), + pniSignatureMessage: null, + }); return this.broadcast('fetch storage', content, options); } @@ -1301,8 +1378,8 @@ export class PrimaryDevice { ): Promise { const Type = Proto.SyncMessage.StickerPackOperation.Type; - const content: Proto.IContent = { - syncMessage: { + const content: Proto.Content.Params = buildContentParams({ + syncMessage: buildSyncMessageParams({ stickerPackOperation: [ { packId: options.packId, @@ -1310,8 +1387,9 @@ export class PrimaryDevice { type: options.type === 'install' ? Type.INSTALL : Type.REMOVE, }, ], - }, - }; + }), + pniSignatureMessage: null, + }); return this.broadcast('sticker pack sync', content, options); } @@ -1328,14 +1406,15 @@ export class PrimaryDevice { type = Proto.ReceiptMessage.Type.READ; } - const content: Proto.IContent = { + const content: Proto.Content.Params = buildContentParams({ receiptMessage: { type, timestamp: options.messageTimestamps.map((timestamp) => - Long.fromNumber(timestamp), + BigInt(timestamp), ), }, - }; + pniSignatureMessage: null, + }); return this.encryptContent(target, content, options); } @@ -1351,17 +1430,25 @@ export class PrimaryDevice { target: Device, { messageTimestamp, timestamp = Date.now() }: UnencryptedReceiptOptions, ): Promise { - const envelope: Proto.IEnvelope = { + const envelope: Proto.Envelope.Params = { type: Proto.Envelope.Type.SERVER_DELIVERY_RECEIPT, - clientTimestamp: Long.fromNumber(messageTimestamp), - serverTimestamp: Long.fromNumber(timestamp), + clientTimestamp: BigInt(messageTimestamp), + serverTimestamp: BigInt(timestamp), sourceServiceIdBinary: this.device.aciBinary, sourceDeviceId: this.device.deviceId, destinationServiceIdBinary: target.aciBinary, + serverGuid: null, + ephemeral: null, + urgent: null, + story: null, + reportSpamToken: null, + serverGuidBinary: null, + content: null, + updatedPniBinary: null, }; return this.config.send( target, - Buffer.from(Proto.Envelope.encode(envelope).finish()), + Buffer.from(Proto.Envelope.encode(envelope)), ); } @@ -1421,8 +1508,8 @@ export class PrimaryDevice { // Send sync message const { signedPreKeyRecord, lastResortKeyRecord } = keys; - const content: Proto.IContent = { - syncMessage: { + const content: Proto.Content.Params = buildContentParams({ + syncMessage: buildSyncMessageParams({ pniChangeNumber: { identityKeyPair: newPniIdentity.serialize(), lastResortKyberPreKey: lastResortKeyRecord.serialize(), @@ -1430,8 +1517,9 @@ export class PrimaryDevice { registrationId: newPniRegistrationId, newE164: newNumber, }, - }, - }; + }), + pniSignatureMessage: null, + }); const envelope = await this.encryptContent(device, content, { ...options, @@ -1476,7 +1564,7 @@ export class PrimaryDevice { public async sendRaw( target: Device, - content: Proto.IContent, + content: Proto.Content.Params, options?: EncryptOptions, ): Promise { await this.config.send( @@ -1503,9 +1591,10 @@ export class PrimaryDevice { if (!options?.skipSkdmSend) { void this.sendRaw( target, - { + buildContentParams({ senderKeyDistributionMessage: skdm.serialize(), - }, + pniSignatureMessage: null, + }), options, ); } @@ -1644,10 +1733,10 @@ export class PrimaryDevice { private async encryptContent( target: Device, - content: Proto.IContent, + content: Proto.Content.Params, options?: EncryptOptions, ): Promise { - const encoded = Buffer.from(Proto.Content.encode(content).finish()); + const encoded = Buffer.from(Proto.Content.encode(content)); return this.lock(async () => { return this.encrypt(target, encoded, options); @@ -1656,7 +1745,7 @@ export class PrimaryDevice { private async broadcast( type: string, - content: Proto.IContent, + content: Proto.Content.Params, options?: EncryptOptions, ): Promise { debug( @@ -1701,7 +1790,7 @@ export class PrimaryDevice { private async handleSync( source: Device, - sync: Proto.ISyncMessage, + sync: Proto.SyncMessage, ): Promise { const { request } = sync; if (!request) { @@ -1714,42 +1803,47 @@ export class PrimaryDevice { } let stateChange: SyncState; - let response: Proto.ISyncMessage; + let response: Proto.SyncMessage.Params; if (request.type === Proto.SyncMessage.Request.Type.CONTACTS) { debug('got sync contacts request'); - response = { + response = buildSyncMessageParams({ contacts: { blob: this.contactsBlob, complete: true, }, - }; + }); stateChange = SyncState.Contacts; } else if (request.type === Proto.SyncMessage.Request.Type.BLOCKED) { debug('got sync blocked request'); - response = { - blocked: {}, - }; + response = buildSyncMessageParams({ + blocked: { + numbers: null, + groupIds: null, + acisBinary: null, + }, + }); stateChange = SyncState.Blocked; } else if (request.type === Proto.SyncMessage.Request.Type.CONFIGURATION) { debug('got sync configuration request'); - response = { + response = buildSyncMessageParams({ configuration: { readReceipts: true, unidentifiedDeliveryIndicators: false, typingIndicators: false, linkPreviews: false, + provisioningVersion: null, }, - }; + }); stateChange = SyncState.Configuration; } else if (request.type === Proto.SyncMessage.Request.Type.KEYS) { debug('got sync keys request'); - response = { + response = buildSyncMessageParams({ keys: { master: this.masterKey, mediaRootBackupKey: this.mediaRootBackupKey, accountEntropyPool: this.accountEntropyPool, }, - }; + }); stateChange = SyncState.Keys; } else { debug('Unsupported sync request', request); @@ -1758,6 +1852,16 @@ export class PrimaryDevice { const encrypted = await this.encryptContent(source, { syncMessage: response, + dataMessage: null, + callMessage: null, + nullMessage: null, + receiptMessage: null, + typingMessage: null, + senderKeyDistributionMessage: null, + decryptionErrorMessage: null, + storyMessage: null, + pniSignatureMessage: null, + editMessage: null, }); // Intentionally not awaiting since the device might be offline or @@ -1777,10 +1881,13 @@ export class PrimaryDevice { source: Device, serviceIdKind: ServiceIdKind, envelopeType: EnvelopeType, - content: Proto.IContent, + content: Proto.Content, ): void { const { decryptionErrorMessage } = content; - assert.ok(decryptionErrorMessage, 'decryptionErrorMessage must be present'); + assert.ok( + decryptionErrorMessage.length, + 'decryptionErrorMessage must be present', + ); const request = DecryptionErrorMessage.deserialize( Buffer.from(decryptionErrorMessage), @@ -1801,7 +1908,7 @@ export class PrimaryDevice { source: Device, serviceIdKind: ServiceIdKind, envelopeType: EnvelopeType, - content: Proto.IContent, + content: Proto.Content, ): void { const { dataMessage } = content; assert.ok(dataMessage, 'dataMessage must be present'); @@ -1810,7 +1917,7 @@ export class PrimaryDevice { this.messageQueue.push({ source, serviceIdKind, - body: body ?? '', + body, envelopeType, dataMessage, content, @@ -1821,7 +1928,7 @@ export class PrimaryDevice { source: Device, serviceIdKind: ServiceIdKind, envelopeType: EnvelopeType, - content: Proto.IContent, + content: Proto.Content, ): void { const { receiptMessage } = content; assert.ok(receiptMessage, 'receiptMessage must be present'); @@ -1839,7 +1946,7 @@ export class PrimaryDevice { source: Device, serviceIdKind: ServiceIdKind, envelopeType: EnvelopeType, - content: Proto.IContent, + content: Proto.Content, ): void { const { storyMessage } = content; assert.ok(storyMessage, 'storyMessage must be present'); @@ -1857,7 +1964,7 @@ export class PrimaryDevice { source: Device, serviceIdKind: ServiceIdKind, envelopeType: EnvelopeType, - content: Proto.IContent, + content: Proto.Content, ): void { const { editMessage } = content; assert.ok(editMessage, 'editMessage must be present'); @@ -1968,18 +2075,24 @@ export class PrimaryDevice { const envelope = Buffer.from( Proto.Envelope.encode({ type: envelopeType, - sourceServiceIdBinary: sealed ? undefined : this.device.aciBinary, - sourceDeviceId: sealed ? undefined : this.device.deviceId, + sourceServiceIdBinary: sealed ? null : this.device.aciBinary, + sourceDeviceId: sealed ? null : this.device.deviceId, destinationServiceIdBinary: target.getServiceIdBinaryByKind(serviceIdKind), updatedPniBinary: updatedPni === undefined ? null : Pni.parseFromServiceIdString(updatedPni).getRawUuidBytes(), - serverTimestamp: Long.fromNumber(timestamp), - clientTimestamp: Long.fromNumber(timestamp), + serverTimestamp: BigInt(timestamp), + clientTimestamp: BigInt(timestamp), content, - }).finish(), + serverGuid: null, + ephemeral: null, + urgent: null, + story: null, + reportSpamToken: null, + serverGuidBinary: null, + }), ); debug('encrypting envelope finish'); @@ -2159,37 +2272,38 @@ export class PrimaryDevice { } private async convertManifestToStorageState( - manifest: Proto.IStorageManifest, + manifest: Proto.StorageManifest.Params, ): Promise { const decryptedManifest = decryptStorageManifest(this.storageKey, manifest); assert(decryptedManifest.version, 'Consistency check'); - const version = decryptedManifest.version.toNumber(); + const version = Number(decryptedManifest.version); const items = await Promise.all( - (decryptedManifest.identifiers ?? []).map(async ({ type, raw: key }) => { - assert( - type !== null && type !== undefined, - 'Missing manifestRecord.keys.type', - ); - assert(key, 'Missing manifestRecord.keys.raw'); - + decryptedManifest.identifiers.map(async ({ type, raw: key }) => { const keyBuffer = Buffer.from(key); const item = await this.config.getStorageItem(keyBuffer); if (!item) { throw new Error(`Missing item ${keyBuffer.toString('base64')}`); } + const decrypted = decryptStorageItem({ + storageKey: this.storageKey, + recordIkm: this.storageRecordIkm, + item: { + key, + value: item, + }, + }); + if (!decrypted.record) { + throw new Error( + `Missing item record ${keyBuffer.toString('base64')}`, + ); + } + return { type, key: keyBuffer, - record: decryptStorageItem({ - storageKey: this.storageKey, - recordIkm: this.storageRecordIkm, - item: { - key, - value: item, - }, - }), + record: decrypted.record, }; }), ); diff --git a/src/api/server.ts b/src/api/server.ts index f5c804b..80eefbc 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -5,7 +5,6 @@ import assert from 'assert'; import fs from 'fs'; import fsPromises from 'fs/promises'; import { type Readable } from 'stream'; -import Long from 'long'; import path from 'path'; import https, { ServerOptions } from 'https'; import { parse as parseURL } from 'url'; @@ -144,7 +143,7 @@ export class Server extends BaseServer { private readonly trustRoot: PrivateKey; private readonly primaryDevices = new Map(); private readonly knownNumbers = new Set(); - private emptyAttachment: Proto.IAttachmentPointer | undefined; + private emptyAttachment: Proto.AttachmentPointer.Params | undefined; private provisionQueue: PromiseQueue; private provisionResultQueueByCode = new Map< @@ -379,7 +378,7 @@ export class Server extends BaseServer { ); const contactsCDNKey = await this.storeAttachment(contactsAttachment.blob); debug('contacts cdn key', contactsCDNKey); - debug('groups cdn key', this.emptyAttachment.cdnKey); + debug('groups cdn key', this.emptyAttachment.attachmentIdentifier?.value); const primary = new PrimaryDevice(device, { profileName: profileName, @@ -604,10 +603,10 @@ export class Server extends BaseServer { readReceipts: true, provisioningVersion: Proto.ProvisioningVersion.CURRENT, masterKey: primaryDevice.masterKey, - ephemeralBackupKey: primaryDevice.ephemeralBackupKey, + ephemeralBackupKey: primaryDevice.ephemeralBackupKey ?? null, mediaRootBackupKey: primaryDevice.mediaRootBackupKey, accountEntropyPool: primaryDevice.accountEntropyPool, - }).finish(); + }); const { body, ephemeralKey } = encryptProvisionMessage( Buffer.from(envelopeData), @@ -617,7 +616,7 @@ export class Server extends BaseServer { const envelope = Proto.ProvisionEnvelope.encode({ publicKey: ephemeralKey, body, - }).finish(); + }); return { envelope: Buffer.from(envelope) }; } @@ -661,14 +660,21 @@ export class Server extends BaseServer { Buffer.from( Proto.Envelope.encode({ type, - sourceServiceIdBinary: source?.aciBinary, - sourceDeviceId: source?.deviceId, + sourceServiceIdBinary: source?.aciBinary ?? null, + sourceDeviceId: source?.deviceId ?? null, destinationServiceIdBinary: target.getServiceIdBinaryByKind(serviceIdKind), - serverTimestamp: Long.fromNumber(timestamp), - clientTimestamp: Long.fromNumber(timestamp), + serverTimestamp: BigInt(timestamp), + clientTimestamp: BigInt(timestamp), content: encrypted, - }).finish(), + urgent: null, + serverGuid: null, + ephemeral: null, + story: null, + reportSpamToken: null, + serverGuidBinary: null, + updatedPniBinary: null, + }), ), ); } @@ -792,7 +798,7 @@ export class Server extends BaseServer { public override async getStorageItems( device: Device, keys: ReadonlyArray, - ): Promise | undefined> { + ): Promise | undefined> { if ( this.config.maxStorageReadKeys !== undefined && keys.length > this.config.maxStorageReadKeys @@ -826,7 +832,7 @@ export class Server extends BaseServer { protected override async onStorageManifestUpdate( device: Device, - version: Long, + version: bigint, ): Promise { debug('onStorageManifestUpdate', device.debugId); @@ -836,7 +842,7 @@ export class Server extends BaseServer { this.manifestQueueByAci.set(device.aci, queue); } - queue.push(version.toNumber()); + queue.push(Number(version)); } protected override async backupTransitAttachments( diff --git a/src/api/storage-state.ts b/src/api/storage-state.ts index db19258..d5cdc8f 100644 --- a/src/api/storage-state.ts +++ b/src/api/storage-state.ts @@ -3,7 +3,6 @@ import assert from 'assert'; import crypto from 'crypto'; -import Long from 'long'; import { Buffer } from 'node:buffer'; import { signalservice as Proto } from '../../protos/compiled'; @@ -13,21 +12,24 @@ import { ServiceIdKind } from '../types'; import { Group } from './group'; import { PrimaryDevice } from './primary-device'; -export type StorageStateRecord = Readonly<{ - type: Proto.ManifestRecord.Identifier.Type; - key: Buffer; - record: Proto.IStorageRecord; -}>; +type RecordValue = NonNullable; + +export type StorageStateRecord = + Readonly<{ + type: Proto.ManifestRecord.Identifier.Type; + key: Buffer; + record: Value; + }>; export type StorageStateNewRecord = Readonly<{ type: Proto.ManifestRecord.Identifier.Type; key?: Buffer; - record: Proto.IStorageRecord; + record: RecordValue; }>; export type DiffResult = Readonly<{ - added: ReadonlyArray; - removed: ReadonlyArray; + added: ReadonlyArray; + removed: ReadonlyArray; }>; const KEY_SIZE = 16; @@ -46,18 +48,21 @@ export type CreateWriteOperationOptions = Readonly<{ previous?: StorageState; }>; -type StorageRecordPredicate = (record: StorageStateRecord) => boolean; -type StorageRecordMapper = ( - record: Proto.IStorageRecord, -) => Proto.IStorageRecord; -type StorageItemPredicate = (item: StorageStateItem, index: number) => boolean; +type StorageRecordPredicate = ( + record: StorageStateRecord, +) => record is StorageStateRecord; +type StorageRecordMapper = (record: Value) => Value; +type StorageItemPredicate = ( + item: StorageStateItem, + index: number, +) => item is StorageStateItem; -class StorageStateItem { +class StorageStateItem { public readonly type: IdentifierType; public readonly key: Buffer; - public readonly record: Proto.IStorageRecord; + public readonly record: Value; - constructor({ type, key, record }: StorageStateRecord) { + constructor({ type, key, record }: StorageStateRecord) { this.type = type; this.key = key; this.record = record; @@ -70,32 +75,39 @@ class StorageStateItem { public toStorageItem({ storageKey, recordIkm, - }: ToStorageItemOptions): Proto.IStorageItem { + }: ToStorageItemOptions): Proto.StorageItem.Params { return encryptStorageItem({ storageKey, recordIkm, key: this.key, - record: this.record, + record: { record: this.record }, }); } - public toIdentifier(): Proto.ManifestRecord.IIdentifier { + public toIdentifier(): Proto.ManifestRecord.Identifier.Params { return { type: this.type, raw: this.key, }; } - public isAccount(): boolean { - return this.type === IdentifierType.ACCOUNT && Boolean(this.record.account); + public isAccount(): this is StorageStateItem< + Extract + > { + return ( + this.type === IdentifierType.ACCOUNT && this.record.kind === 'account' + ); } - public isGroup(group: Group): boolean { + public isGroup( + group: Group, + ): this is StorageStateItem> { if (this.type !== IdentifierType.GROUPV2) { return false; } + assert(this.record.kind === 'groupV2', 'consistency check'); - const masterKey = this.record.groupV2?.masterKey; + const masterKey = this.record.value.masterKey; if (!masterKey) { return false; } @@ -103,13 +115,17 @@ class StorageStateItem { return group.masterKey.equals(masterKey); } - public isContact(device: Device, serviceIdKind: ServiceIdKind): boolean { + public isContact( + device: Device, + serviceIdKind: ServiceIdKind, + ): this is StorageStateItem> { if (this.type !== IdentifierType.CONTACT) { return false; } + assert(this.record.kind === 'contact', 'consistency check'); if (serviceIdKind === ServiceIdKind.ACI) { - const existingAci = this.record.contact?.aciBinary; + const existingAci = this.record.value.aciBinary; if (!existingAci?.length) { return false; } @@ -117,7 +133,7 @@ class StorageStateItem { return Buffer.compare(existingAci, device.aciRawUuid) === 0; } - const existingPni = this.record.contact?.pniBinary; + const existingPni = this.record.value.pniBinary; if (!existingPni?.length) { return false; } @@ -135,7 +151,7 @@ class StorageStateItem { .join('\n'); } - public toRecord(): StorageStateRecord { + public toRecord(): StorageStateRecord { return { type: this.type, key: this.key, @@ -144,6 +160,46 @@ class StorageStateItem { } } +const EMPTY_CONTACT: Proto.ContactRecord.Params = { + e164: null, + profileKey: null, + identityKey: null, + identityState: null, + givenName: null, + familyName: null, + username: null, + blocked: null, + whitelisted: null, + archived: null, + markedUnread: null, + mutedUntilTimestamp: null, + hideStory: null, + unregisteredAtTimestamp: null, + systemGivenName: null, + systemFamilyName: null, + systemNickname: null, + hidden: null, + pniSignatureVerified: null, + nickname: null, + note: null, + avatarColor: null, + aciBinary: null, + pniBinary: null, +}; + +const EMPTY_GROUP: Proto.GroupV2Record.Params = { + masterKey: null, + blocked: null, + whitelisted: null, + archived: null, + markedUnread: null, + mutedUntilTimestamp: null, + dontNotifyForMentionsIfMuted: null, + hideStory: null, + storySendMode: null, + avatarColor: null, +}; + export class StorageState { private readonly items: ReadonlyArray; @@ -160,7 +216,45 @@ export class StorageState { key: StorageState.createStorageID(), type: IdentifierType.ACCOUNT, record: { - account: {}, + kind: 'account', + value: { + profileKey: null, + givenName: null, + familyName: null, + avatarUrlPath: null, + noteToSelfArchived: null, + readReceipts: null, + sealedSenderIndicators: null, + typingIndicators: null, + noteToSelfMarkedUnread: null, + linkPreviews: null, + phoneNumberSharingMode: null, + unlistedPhoneNumber: null, + pinnedConversations: null, + preferContactAvatars: null, + payments: null, + universalExpireTimer: null, + preferredReactionEmoji: null, + donorSubscriberId: null, + donorSubscriberCurrencyCode: null, + displayBadgesOnProfile: null, + donorSubscriptionManuallyCancelled: null, + keepMutedChatsArchived: null, + hasSetMyStoriesPrivacy: null, + hasViewedOnboardingStory: null, + storiesDisabled: null, + storyViewReceiptsEnabled: null, + hasSeenGroupStoryEducationSheet: null, + username: null, + hasCompletedUsernameOnboarding: null, + usernameLink: null, + hasBackup: null, + backupTier: null, + backupSubscriberData: null, + avatarColor: null, + notificationProfileManualOverride: null, + notificationProfileSyncDisabled: null, + }, }, }), ]); @@ -170,39 +264,46 @@ export class StorageState { // Account // - public getAccountRecord(): Proto.IAccountRecord | undefined { + public getAccountRecord(): Proto.AccountRecord.Params | undefined { const item = this.items.find((item) => item.isAccount()); if (!item) { return undefined; } - const { account } = item.record; - assert(account, 'consistency check'); - - return account; + return item.record.value; } - public updateAccount(diff: Proto.IAccountRecord): StorageState { + public updateAccount( + diff: Partial, + ): StorageState { return this.updateItem( (item) => item.isAccount(), - ({ account }) => ({ - account: { - ...account, - ...diff, - }, - }), + (record) => { + return { + kind: 'account', + value: { + ...record.value, + ...diff, + }, + }; + }, ); } - public updateManyAccounts(diff: Proto.IAccountRecord): StorageState { + public updateManyAccounts( + diff: Partial, + ): StorageState { return this.updateManyItems( (item) => item.isAccount(), - ({ account }) => ({ - account: { - ...account, - ...diff, - }, - }), + (record) => { + return { + kind: 'account', + value: { + ...record.value, + ...diff, + }, + }; + }, ); } @@ -210,23 +311,25 @@ export class StorageState { // Group // - public getGroup(group: Group): Proto.IGroupV2Record | undefined { + public getGroup(group: Group): Proto.GroupV2Record.Params | undefined { const item = this.items.find((item) => item.isGroup(group)); if (!item) { return undefined; } - const { groupV2 } = item.record; - assert(groupV2, 'consistency check'); - - return groupV2; + return item.record.value; } - public addGroup(group: Group, diff: Proto.IGroupV2Record = {}): StorageState { + public addGroup( + group: Group, + diff: Partial = {}, + ): StorageState { return this.addItem({ type: IdentifierType.GROUPV2, record: { - groupV2: { + kind: 'groupV2', + value: { + ...EMPTY_GROUP, ...diff, masterKey: group.masterKey, }, @@ -234,15 +337,21 @@ export class StorageState { }); } - public updateGroup(group: Group, diff: Proto.IGroupV2Record): StorageState { + public updateGroup( + group: Group, + diff: Partial, + ): StorageState { return this.updateItem( (item) => item.isGroup(group), - ({ groupV2 }) => ({ - groupV2: { - ...groupV2, - ...diff, - }, - }), + (record) => { + return { + kind: 'groupV2', + value: { + ...record.value, + ...diff, + }, + }; + }, ); } public pinGroup(group: Group): StorageState { @@ -258,10 +367,10 @@ export class StorageState { assert(account, 'No account record found'); return (account.pinnedConversations ?? []).some((convo) => { - if (!convo.groupMasterKey) { + if (convo.identifier?.kind !== 'groupMasterKey') { return false; } - return group.masterKey.equals(convo.groupMasterKey); + return group.masterKey.equals(convo.identifier.value); }); } @@ -271,17 +380,19 @@ export class StorageState { public addContact( { device }: PrimaryDevice, - diff: Proto.IContactRecord = {}, + diff: Partial = {}, serviceIdKind = ServiceIdKind.ACI, ): StorageState { return this.addItem({ type: IdentifierType.CONTACT, record: { - contact: { + kind: 'contact', + value: { + ...EMPTY_CONTACT, aciBinary: - serviceIdKind === ServiceIdKind.ACI ? device.aciRawUuid : undefined, + serviceIdKind === ServiceIdKind.ACI ? device.aciRawUuid : null, pniBinary: - serviceIdKind === ServiceIdKind.PNI ? device.pniRawUuid : undefined, + serviceIdKind === ServiceIdKind.PNI ? device.pniRawUuid : null, e164: device.number, ...diff, }, @@ -291,24 +402,27 @@ export class StorageState { public updateContact( { device }: PrimaryDevice, - diff: Proto.IContactRecord, + diff: Partial, serviceIdKind = ServiceIdKind.ACI, ): StorageState { return this.updateItem( (item) => item.isContact(device, serviceIdKind), - ({ contact }) => ({ - contact: { - ...contact, - ...diff, - }, - }), + (record) => { + return { + kind: 'contact', + value: { + ...record.value, + ...diff, + }, + }; + }, ); } public getContact( { device }: PrimaryDevice, serviceIdKind = ServiceIdKind.ACI, - ): Proto.IContactRecord | undefined { + ): Proto.ContactRecord.Params | undefined { const item = this.items.find((item) => item.isContact(device, serviceIdKind), ); @@ -316,10 +430,7 @@ export class StorageState { return undefined; } - const { contact } = item.record; - assert(contact, 'consistency check'); - - return contact; + return item.record.value; } public removeContact( @@ -331,7 +442,7 @@ export class StorageState { public mergeContact( primary: PrimaryDevice, - diff: Proto.IContactRecord, + diff: Partial, ): StorageState { const { device } = primary; return this.removeItem((item) => item.isContact(device, ServiceIdKind.ACI)) @@ -362,7 +473,10 @@ export class StorageState { assert(account, 'No account record found'); return (account.pinnedConversations ?? []).some((convo) => { - const existing = convo.contact?.serviceIdBinary; + if (convo.identifier?.kind !== 'contact') { + return false; + } + const existing = convo.identifier.value.serviceIdBinary; return existing && Buffer.compare(existing, device.aciRawUuid) === 0; }); } @@ -375,54 +489,78 @@ export class StorageState { return this.addItem(newRecord); } - public findRecord( - find: StorageRecordPredicate, - ): StorageStateRecord | undefined { - const item = this.items.find((item) => find(item.toRecord())); + public findRecord( + find: StorageRecordPredicate, + ): StorageStateRecord | undefined { + const item = this.items.find((item): item is StorageStateItem => { + return find(item.toRecord()); + }); return item?.toRecord(); } - public filterRecords( - filter: StorageRecordPredicate, - ): ReadonlyArray { - return this.items.filter((item) => filter(item.toRecord())); + public filterRecords( + filter: StorageRecordPredicate, + ): ReadonlyArray> { + return this.items.filter((item): item is StorageStateItem => + filter(item.toRecord()), + ); } - public hasRecord(find: StorageRecordPredicate): boolean { - return this.findRecord(find) !== undefined; + public hasRecord(find: (record: StorageStateRecord) => boolean): boolean { + return ( + this.findRecord(find as StorageRecordPredicate) !== undefined + ); } - public updateRecord( - find: StorageRecordPredicate, - map: StorageRecordMapper, + public updateRecord( + find: StorageRecordPredicate, + map: StorageRecordMapper, ): StorageState { - return this.updateItem((item) => find(item.toRecord()), map); + return this.updateItem( + (item): item is StorageStateItem => find(item.toRecord()), + map, + ); } - public updateManyRecords( - filter: StorageRecordPredicate, - map: StorageRecordMapper, + public updateManyRecords( + filter: StorageRecordPredicate, + map: StorageRecordMapper, ): StorageState { - return this.updateManyItems((item) => filter(item.toRecord()), map); + return this.updateManyItems( + (item): item is StorageStateItem => filter(item.toRecord()), + map, + ); } - public removeRecord(find: StorageRecordPredicate): StorageState { + public removeRecord( + find: (record: StorageStateRecord) => boolean, + ): StorageState { return this.removeItem((item) => find(item.toRecord())); } - public removeManyRecords(filter: StorageRecordPredicate): StorageState { + public removeManyRecords( + filter: (record: StorageStateRecord) => boolean, + ): StorageState { return this.removeManyItems((item) => filter(item.toRecord())); } - public getAllGroupRecords(): ReadonlyArray { + public getAllGroupRecords(): ReadonlyArray< + StorageStateRecord> + > { return this.items - .filter((item) => item.type === IdentifierType.GROUPV2) + .filter( + ( + item, + ): item is StorageStateItem< + Extract + > => item.type === IdentifierType.GROUPV2, + ) .map((item) => item.toRecord()); } public hasKey(storageKey: Buffer): boolean { - return this.hasRecord(({ key }) => key.equals(storageKey)); + return this.hasRecord((item) => item.key.equals(storageKey)); } // @@ -433,8 +571,8 @@ export class StorageState { storageKey, recordIkm, previous, - }: CreateWriteOperationOptions): Proto.IWriteOperation { - const newVersion = Long.fromNumber( + }: CreateWriteOperationOptions): Proto.WriteOperation.Params { + const newVersion = BigInt( previous ? previous.version + 1 : this.version + 1, ); @@ -443,7 +581,7 @@ export class StorageState { return item.getKeyString(); }), ); - const insertItem = new Array(); + const insertItem = new Array(); for (const item of this.items) { if (!keysToDelete.delete(item.getKeyString())) { @@ -454,7 +592,8 @@ export class StorageState { const manifest = encryptStorageManifest(storageKey, { version: newVersion, identifiers: this.items.map((item) => item.toIdentifier()), - recordIkm, + recordIkm: recordIkm ?? null, + sourceDevice: null, }); return { @@ -463,6 +602,7 @@ export class StorageState { deleteKey: Array.from(keysToDelete).map((key) => { return Buffer.from(key, 'base64'); }), + clearAll: null, }; } @@ -474,8 +614,8 @@ export class StorageState { } public diff(oldState: StorageState): DiffResult { - const addedIds = new Map(); - const removedIds = new Map(); + const addedIds = new Map(); + const removedIds = new Map(); for (const item of this.items) { addedIds.set(item.key.toString('base64'), item.record); @@ -502,7 +642,9 @@ export class StorageState { return this.replaceItem(this.items.length, newRecord); } - private findItemIndex(find: StorageItemPredicate): number { + private findItemIndex( + find: (record: StorageStateItem, index: number) => boolean, + ): number { const itemIndex = this.items.findIndex(find); if (itemIndex === -1) { throw new Error('Item not found'); @@ -514,12 +656,12 @@ export class StorageState { return itemIndex; } - private updateItem( - find: StorageItemPredicate, - map: StorageRecordMapper, + private updateItem( + find: StorageItemPredicate, + map: StorageRecordMapper, ): StorageState { const itemIndex = this.findItemIndex(find); - const item = this.items[itemIndex]; + const item = this.items[itemIndex] as StorageStateItem | undefined; assert(item, 'consistency check'); return this.replaceItem(itemIndex, { @@ -528,9 +670,9 @@ export class StorageState { }); } - public updateManyItems( - filter: StorageItemPredicate, - map: StorageRecordMapper, + public updateManyItems( + filter: StorageItemPredicate, + map: StorageRecordMapper, ): StorageState { let updated = 0; const newItems = this.items.map((item, index) => { @@ -568,7 +710,9 @@ export class StorageState { return new StorageState(this.version, newItems); } - private removeItem(find: StorageItemPredicate): StorageState { + private removeItem( + find: (item: StorageStateItem, index: number) => boolean, + ): StorageState { const itemIndex = this.findItemIndex(find); const newItems = [ @@ -579,7 +723,9 @@ export class StorageState { return new StorageState(this.version, newItems); } - private removeManyItems(filter: StorageItemPredicate): StorageState { + private removeManyItems( + filter: (item: StorageStateItem, index: number) => boolean, + ): StorageState { const newItems = this.items.filter((item, index) => { return !filter(item, index); }); @@ -599,15 +745,19 @@ export class StorageState { return this.updateItem( (item) => item.isAccount(), - ({ account }) => { - assert(account, 'consistency check'); + (record) => { + const { value: account } = record; const { pinnedConversations } = account; const newPinnedConversations = pinnedConversations?.slice() ?? []; const existingIndex = newPinnedConversations.findIndex((convo) => { - const existing = convo.contact?.serviceIdBinary; + const { identifier } = convo; + if (identifier?.kind !== 'contact') { + return false; + } + const existing = identifier.value.serviceIdBinary; return ( existing && Buffer.compare(existing, deviceServiceIdBinary) === 0 ); @@ -615,14 +765,21 @@ export class StorageState { if (isPinned && existingIndex === -1) { newPinnedConversations.push({ - contact: { serviceIdBinary: deviceServiceIdBinary }, + identifier: { + kind: 'contact', + value: { + e164: null, + serviceIdBinary: deviceServiceIdBinary, + }, + }, }); } else if (!isPinned && existingIndex !== -1) { newPinnedConversations.splice(existingIndex, 1); } return { - account: { + kind: 'account', + value: { ...account, pinnedConversations: newPinnedConversations, }, @@ -634,30 +791,34 @@ export class StorageState { private changeGroupPin(group: Group, isPinned: boolean): StorageState { return this.updateItem( (item) => item.isAccount(), - ({ account }) => { - assert(account, 'consistency check'); - + (record) => { + const { value: account } = record; const { pinnedConversations } = account; const newPinnedConversations = pinnedConversations?.slice() ?? []; const existingIndex = newPinnedConversations.findIndex((convo) => { - if (!convo.groupMasterKey) { + const { identifier } = convo; + if (identifier?.kind !== 'groupMasterKey') { return false; } - return group.masterKey.equals(convo.groupMasterKey); + return group.masterKey.equals(identifier.value); }); if (isPinned && existingIndex === -1) { newPinnedConversations.push({ - groupMasterKey: group.masterKey, + identifier: { + kind: 'groupMasterKey', + value: group.masterKey, + }, }); } else if (!isPinned && existingIndex !== -1) { newPinnedConversations.splice(existingIndex, 1); } return { - account: { + kind: 'account', + value: { ...account, pinnedConversations: newPinnedConversations, }, diff --git a/src/crypto.ts b/src/crypto.ts index 32b8c8c..48db1a6 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -3,7 +3,6 @@ import crypto from 'crypto'; import { Buffer } from 'buffer'; -import Long from 'long'; import { KEMPublicKey, PrivateKey, @@ -39,7 +38,7 @@ export type EncryptedProvisionMessage = { export type ServerCertificate = { privateKey: PrivateKey; - certificate: Proto.IServerCertificate; + certificate: Proto.ServerCertificate.Params; }; export type Sender = { @@ -134,7 +133,7 @@ export function generateServerCertificate( Proto.ServerCertificate.Certificate.encode({ id: SERVER_CERTIFICATE_ID, key: privateKey.getPublicKey().serialize(), - }).finish(), + }), ); const signature = rootKey.sign(data); @@ -156,13 +155,13 @@ export function generateSenderCertificate( ): SenderCertificate { const data = Buffer.from( Proto.SenderCertificate.Certificate.encode({ - senderE164: sender.number, + senderE164: sender.number ?? null, senderUuid: sender.aci, senderDevice: sender.deviceId, - expires: Long.fromNumber(sender.expires ?? NEVER_EXPIRES), + expires: BigInt(sender.expires ?? NEVER_EXPIRES), identityKey: sender.identityKey.serialize(), signer: serverCert.certificate, - }).finish(), + }), ); const signature = serverCert.privateKey.sign(data); @@ -171,7 +170,7 @@ export function generateSenderCertificate( Proto.SenderCertificate.encode({ certificate: data, signature, - }).finish(), + }), ); return SenderCertificate.deserialize(certificate); @@ -204,7 +203,7 @@ export function deriveStorageKey(masterKey: Buffer): Buffer { return hash.digest(); } -function deriveStorageManifestKey(storageKey: Buffer, version: Long): Buffer { +function deriveStorageManifestKey(storageKey: Buffer, version: bigint): Buffer { const hash = crypto.createHmac('sha256', storageKey); hash.update(`Manifest_${version.toString()}`); return hash.digest(); @@ -266,26 +265,22 @@ function encryptAESGCM(plaintext: Uint8Array, key: Uint8Array): Buffer { export function decryptStorageManifest( storageKey: Buffer, - manifest: Proto.IStorageManifest, -): Proto.IManifestRecord { - if (!manifest.version) { - throw new Error('Missing manifest.version'); - } - if (!manifest.value) { + manifest: Proto.StorageManifest.Params, +): Proto.ManifestRecord { + if (!manifest.value?.length) { throw new Error('Missing manifest.value'); } - const manifestKey = deriveStorageManifestKey(storageKey, manifest.version); + const manifestKey = deriveStorageManifestKey( + storageKey, + manifest.version ?? 0n, + ); const decoded = Proto.ManifestRecord.decode( decryptAESGCM(Buffer.from(manifest.value), manifestKey), ); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions - if (!decoded.version) { - throw new Error('Missing manifestRecord.version'); - } - if (!decoded.version.eq(manifest.version)) { + if (decoded.version !== manifest.version) { throw new Error('manifestRecord.version != manifest.version'); } @@ -294,8 +289,8 @@ export function decryptStorageManifest( export function encryptStorageManifest( storageKey: Buffer, - manifestRecord: Proto.IManifestRecord, -): Proto.IStorageManifest { + manifestRecord: Proto.ManifestRecord.Params, +): Proto.StorageManifest.Params { if (!manifestRecord.version) { throw new Error('Missing manifest.version'); } @@ -306,7 +301,7 @@ export function encryptStorageManifest( ); const encrypted = encryptAESGCM( - Buffer.from(Proto.ManifestRecord.encode(manifestRecord).finish()), + Buffer.from(Proto.ManifestRecord.encode(manifestRecord)), manifestKey, ); @@ -319,14 +314,14 @@ export function encryptStorageManifest( export type DecryptStorageItemOptions = Readonly<{ storageKey: Buffer; recordIkm: Buffer | undefined; - item: Proto.IStorageItem; + item: Proto.StorageItem.Params; }>; export function decryptStorageItem({ storageKey, recordIkm, item, -}: DecryptStorageItemOptions): Proto.IStorageRecord { +}: DecryptStorageItemOptions): Proto.StorageRecord { if (!item.key) { throw new Error('Missing item.key'); } @@ -349,7 +344,7 @@ export type EncryptStorageItemOptions = Readonly<{ storageKey: Buffer; key: Buffer; recordIkm: Buffer | undefined; - record: Proto.IStorageRecord; + record: Proto.StorageRecord.Params; }>; export function encryptStorageItem({ @@ -357,7 +352,7 @@ export function encryptStorageItem({ key, recordIkm, record, -}: EncryptStorageItemOptions): Proto.IStorageItem { +}: EncryptStorageItemOptions): Proto.StorageItem.Params { const itemKey = deriveStorageItemKey({ storageKey, recordIkm, @@ -365,7 +360,7 @@ export function encryptStorageItem({ }); const encrypted = encryptAESGCM( - Buffer.from(Proto.StorageRecord.encode(record).finish()), + Buffer.from(Proto.StorageRecord.encode(record)), itemKey, ); diff --git a/src/data/attachment.ts b/src/data/attachment.ts index 920685c..a70a777 100644 --- a/src/data/attachment.ts +++ b/src/data/attachment.ts @@ -13,12 +13,28 @@ export type Attachment = { export function attachmentToPointer( cdnKey: string, attachment: Attachment, -): Proto.IAttachmentPointer { +): Proto.AttachmentPointer.Params { return { contentType: 'application/octet-stream', - cdnKey, + attachmentIdentifier: { + kind: 'cdnKey', + value: cdnKey, + }, key: attachment.key, size: attachment.size, digest: attachment.digest, + + clientUuid: null, + thumbnail: null, + incrementalMac: null, + chunkSize: null, + fileName: null, + flags: null, + width: null, + height: null, + caption: null, + blurHash: null, + uploadTimestamp: null, + cdnNumber: null, }; } diff --git a/src/data/contacts.ts b/src/data/contacts.ts index 6c446f0..bb4205c 100644 --- a/src/data/contacts.ts +++ b/src/data/contacts.ts @@ -18,7 +18,11 @@ export function serializeContacts(contacts: ReadonlyArray): Buffer { aciBinary, number, name, - }).finish(), + avatar: null, + expireTimer: null, + expireTimerVersion: null, + inboxPosition: null, + }), ); }) .map((chunk) => { diff --git a/src/data/group.ts b/src/data/group.ts index 4a146b9..64f6247 100644 --- a/src/data/group.ts +++ b/src/data/group.ts @@ -10,10 +10,10 @@ import { import { signalservice as Proto } from '../../protos/compiled'; export abstract class Group { - protected privChanges?: Proto.IGroupChanges; + protected privChanges?: Proto.GroupChanges.Params; protected privPublicParams?: GroupPublicParams; - public get changes(): Readonly { + public get changes(): Readonly { assert(this.privChanges !== undefined, 'Group not initialized'); return this.privChanges; } @@ -23,7 +23,7 @@ export abstract class Group { return this.privPublicParams; } - public get state(): Readonly { + public get state(): Readonly { const { groupChanges } = this.changes; assert(groupChanges, 'Missing group changes in the group state'); const state = groupChanges.at(-1)?.groupState; @@ -41,13 +41,16 @@ export abstract class Group { return this.state.version ?? 0; } - public getChangesSince(since: number): Readonly { + public getChangesSince(since: number): Readonly { return { - groupChanges: this.changes.groupChanges?.slice(since), + groupChanges: this.changes.groupChanges?.slice(since) ?? null, + groupSendEndorsementResponse: null, }; } - public getMember(uuidCiphertext: UuidCiphertext): Proto.IMember | undefined { + public getMember( + uuidCiphertext: UuidCiphertext, + ): Proto.Member.Params | undefined { const state = this.state; const userId = Buffer.from(uuidCiphertext.serialize()); return ( @@ -63,7 +66,7 @@ export abstract class Group { public getPendingMember( uuidCiphertext: UuidCiphertext, - ): Proto.IMemberPendingProfileKey | undefined { + ): Proto.MemberPendingProfileKey.Params | undefined { const state = this.state; const userId = Buffer.from(uuidCiphertext.serialize()); return ( diff --git a/src/index.ts b/src/index.ts index 7d96727..11dbbf8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,9 +12,11 @@ export { SyncReadMessage, SyncReadOptions, SyncSentOptions, + EMPTY_DATA_MESSAGE, } from './api/primary-device'; export { Device, SingleUseKey } from './data/device'; export { EnvelopeType } from './server/base'; export { signalservice as Proto } from '../protos/compiled'; export { load as loadCertificates, Certificates } from './data/certificates'; export { ServiceIdKind } from './types'; +export { buildContentParams, buildSyncMessageParams } from './util'; diff --git a/src/server/base.ts b/src/server/base.ts index c2c0a15..919eaec 100644 --- a/src/server/base.ts +++ b/src/server/base.ts @@ -29,7 +29,6 @@ import assert from 'assert'; import https from 'https'; import crypto from 'crypto'; import createDebug from 'debug'; -import Long from 'long'; import { v4 as uuidv4 } from 'uuid'; import { AddressInfo } from 'net'; @@ -170,7 +169,7 @@ export type SetUsernameLinkResult = Readonly<{ export type StorageWriteResult = Readonly< | { updated: false; - manifest: Proto.IStorageManifest; + manifest: Proto.StorageManifest.Params; error?: void; } | { @@ -186,7 +185,7 @@ export type StorageWriteResult = Readonly< export type ModifyGroupOptions = Readonly<{ group: ServerGroup; - actions: Proto.GroupChange.IActions; + actions: Proto.GroupChange.Actions.Params; aciCiphertext: Uint8Array; pniCiphertext: Uint8Array; }>; @@ -314,7 +313,7 @@ export abstract class Server { private readonly storageAuthByDevice = new Map(); private readonly storageManifestByAci = new Map< AciString, - Proto.IStorageManifest + Proto.StorageManifest.Params >(); private readonly storageItemsByAci = new Map< AciString, @@ -733,7 +732,7 @@ export abstract class Server { targets.push([target, message]); } - if (source && source.aci === targetServiceId) { + if (source?.aci === targetServiceId) { deviceById.delete(source.deviceId); } @@ -877,7 +876,7 @@ export abstract class Server { // Groups // - public async createGroup(group: Proto.IGroup): Promise { + public async createGroup(group: Proto.Group.Params): Promise { const result = new ServerGroup({ zkSecret: this.zkSecret, profileOps: new ServerZkProfileOperations(this.zkSecret), @@ -959,36 +958,31 @@ export abstract class Server { public async getStorageManifest( device: Device, - ): Promise { + ): Promise { return this.storageManifestByAci.get(device.aci); } public async applyStorageWrite( device: Device, - { manifest, clearAll, insertItem, deleteKey }: Proto.IWriteOperation, + { manifest, clearAll, insertItem, deleteKey }: Proto.WriteOperation.Params, shouldNotify = true, ): Promise { if (!manifest) { return { error: 'missing `writeOperation.manifest`' }; } - if (!manifest.version) { - return { - error: - 'not updating storage manifest, ' + - 'missing `writeOperation.manifest.version`', - }; + return { error: 'missing `writeOperation.manifest.version`' }; } const existing = await this.getStorageManifest(device); if (existing) { // Atomicity assert(existing.version, 'consistency check'); - if (!manifest.version.eq(existing.version.add(1))) { + if (manifest.version !== existing.version + 1n) { debug( 'not updating storage manifest, current version=%j new version=%j', - existing.version.toNumber(), - manifest.version.toNumber(), + existing.version, + manifest.version, ); return { updated: false, manifest: existing }; } @@ -1020,7 +1014,7 @@ export abstract class Server { debug( 'updating storage manifest to version=%j for=%j', - manifest.version.toNumber(), + manifest.version, device.debugId, ); this.storageManifestByAci.set(device.aci, manifest); @@ -1074,8 +1068,8 @@ export abstract class Server { public async getStorageItems( device: Device, keys: ReadonlyArray, - ): Promise | undefined> { - const result = new Array(); + ): Promise | undefined> { + const result = new Array(); await Promise.all( keys.map(async (key) => { @@ -1100,7 +1094,7 @@ export abstract class Server { protected abstract onStorageManifestUpdate( device: Device, - version: Long, + version: bigint, ): Promise; // diff --git a/src/server/group.ts b/src/server/group.ts index 725fd89..5008eab 100644 --- a/src/server/group.ts +++ b/src/server/group.ts @@ -11,7 +11,6 @@ import { UuidCiphertext, } from '@signalapp/libsignal-client/zkgroup'; import assert from 'assert'; -import Long from 'long'; import { signalservice as Proto } from '../../protos/compiled'; import { Group } from '../data/group'; @@ -21,13 +20,13 @@ import { daysToSeconds, fromBase64, getTodayInSeconds } from '../util'; export type ServerGroupOptions = Readonly<{ profileOps: ServerZkProfileOperations; zkSecret: ServerSecretParams; - state: Proto.IGroup; + state: Proto.Group.Params; }>; export type ModifyGroupResult = Readonly< | { conflict: false; - signedChange: Proto.IGroupChange; + signedChange: Proto.GroupChange.Params; } | { conflict: true; @@ -72,8 +71,10 @@ export class ServerGroup extends Group { groupChanges: [ { groupState: unrolledState, + groupChange: null, }, ], + groupSendEndorsementResponse: null, }; } @@ -102,17 +103,39 @@ export class ServerGroup extends Group { public modify( sourceAci: UuidCiphertext, sourcePni: UuidCiphertext, - actions: Proto.GroupChange.IActions, + actions: Proto.GroupChange.Actions.Params, ): ModifyGroupResult { - const appliedActions: Proto.GroupChange.IActions = { + const appliedActions: Proto.GroupChange.Actions.Params = { version: actions.version, sourceUserId: sourceAci.serialize(), groupId: fromBase64(this.id), + addMembers: null, + deleteMembers: null, + modifyMemberRoles: null, + modifyMemberProfileKeys: null, + addPendingMembers: null, + deletePendingMembers: null, + promotePendingMembers: null, + modifyTitle: null, + modifyAvatar: null, + modifyDisappearingMessagesTimer: null, + modifyAttributesAccess: null, + modifyMemberAccess: null, + modifyAddFromInviteLinkAccess: null, + addMemberPendingAdminApprovals: null, + deleteMemberPendingAdminApprovals: null, + promoteMemberPendingAdminApprovals: null, + modifyInviteLinkPassword: null, + modifyDescription: null, + modifyAnnouncementsOnly: null, + addMembersBanned: null, + deleteMembersBanned: null, + promoteMembersPendingPniAciProfileKey: null, }; assert.ok(actions.version, 'Actions should have a new version'); - const timestamp = Long.fromNumber(Date.now()); + const timestamp = BigInt(Date.now()); const newState = { ...this.state, @@ -179,7 +202,13 @@ export class ServerGroup extends Group { ); const newPendingMember = { - member: { userId, role }, + member: { + userId, + role, + profileKey: null, + presentation: null, + joinedAtVersion: null, + }, addedByUserId: sourceAci.serialize(), timestamp, }; @@ -260,12 +289,14 @@ export class ServerGroup extends Group { role: Role.DEFAULT, userId, profileKey, + presentation: null, + joinedAtVersion: null, }, ]; appliedActions.promotePendingMembers = [ ...(appliedActions.promotePendingMembers ?? []), - { userId, profileKey }, + { userId, profileKey, presentation: null }, ]; } @@ -307,6 +338,8 @@ export class ServerGroup extends Group { role: Role.DEFAULT, userId: aci.serialize(), profileKey: profileKey.serialize(), + presentation: null, + joinedAtVersion: null, }, ]; @@ -318,6 +351,7 @@ export class ServerGroup extends Group { userId: aci.serialize(), pni: pni.serialize(), profileKey: profileKey.serialize(), + presentation: null, }, ]; } @@ -331,15 +365,15 @@ export class ServerGroup extends Group { return { conflict: true, signedChange: undefined }; } - const encodedActions = - Proto.GroupChange.Actions.encode(appliedActions).finish(); + const encodedActions = Proto.GroupChange.Actions.encode(appliedActions); const serverSignature = this.zkSecret .sign(Buffer.from(encodedActions)) .serialize(); - const groupChange: Proto.IGroupChange = { + const groupChange: Proto.GroupChange.Params = { actions: encodedActions, changeEpoch, + serverSignature: null, }; assert.ok(this.privChanges?.groupChanges, 'Must be initialized'); @@ -363,7 +397,7 @@ export class ServerGroup extends Group { private verifyAccess( attribute: string, - member: Proto.IMember | undefined, + member: Proto.Member.Params | undefined, access: Proto.AccessControl.AccessRequired, affectedUserId?: Uint8Array, ): void { @@ -400,7 +434,10 @@ export class ServerGroup extends Group { } } - private unrollMember({ role, presentation }: Proto.IMember): Proto.IMember { + private unrollMember({ + role, + presentation, + }: Proto.Member.Params): Proto.Member.Params { assert.strictEqual(typeof role, 'number', 'Group member role is undefined'); assert.ok(presentation, 'Group member presentation is undefined'); @@ -416,6 +453,8 @@ export class ServerGroup extends Group { role, userId: presentationFFI.getUuidCiphertext().serialize(), profileKey: presentationFFI.getProfileKeyCiphertext().serialize(), + presentation: null, + joinedAtVersion: null, }; } } diff --git a/src/server/http.ts b/src/server/http.ts index 6e14d57..80096bf 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -5,7 +5,6 @@ import { UuidCiphertext } from '@signalapp/libsignal-client/zkgroup'; import assert from 'assert'; import { Buffer } from 'buffer'; import createDebug from 'debug'; -import Long from 'long'; import { RequestHandler, buffer, json, send } from 'micro'; import { AugmentedRequestHandler as RouteHandler, @@ -437,7 +436,7 @@ export const createHandler = ( Proto.GroupResponse.encode({ group: group.state, groupSendEndorsementResponse, - }).finish(), + }), ); }); @@ -461,8 +460,12 @@ export const createHandler = ( res, 200, Proto.Member.encode({ + userId: null, + role: null, + profileKey: null, + presentation: null, joinedAtVersion: member.joinedAtVersion, - }).finish(), + }), ); }, ); @@ -474,7 +477,7 @@ export const createHandler = ( res: ServerResponse, ): Promise<{ auth: GroupAuthAndFetchResult; - groupChanges: Proto.IGroupChanges; + groupChanges: Proto.GroupChanges.Params; } | void> { const auth = await groupAuthAndFetch(req, res); if (!auth) { @@ -552,7 +555,7 @@ export const createHandler = ( Proto.GroupChanges.encode({ groupChanges, groupSendEndorsementResponse, - }).finish(), + }), ); }); @@ -597,7 +600,7 @@ export const createHandler = ( groupSendEndorsementResponse: group.getGroupSendEndorsementResponse( auth.aciCiphertext, ), - }).finish(), + }), ); }); @@ -606,7 +609,7 @@ export const createHandler = ( res: ServerResponse, ): Promise<{ auth: GroupAuthAndFetchResult; - signedChange: Proto.IGroupChange; + signedChange: Proto.GroupChange.Params; } | void> { const auth = await groupAuthAndFetch(req, res); if (!auth) { @@ -664,7 +667,7 @@ export const createHandler = ( groupChange: signedChange, groupSendEndorsementResponse: group.getGroupSendEndorsementResponse(aciCiphertext), - }).finish(), + }), ); }); @@ -683,7 +686,7 @@ export const createHandler = ( return send(res, 404, { error: 'Manifest not found' }); } - return send(res, 200, Proto.StorageManifest.encode(manifest).finish()); + return send(res, 200, Proto.StorageManifest.encode(manifest)); }); const getStorageManifestByVersion = get( @@ -695,13 +698,16 @@ export const createHandler = ( } assert(req.params.after != null, 'Missing after param'); - const after = Long.fromString(req.params.after); + const after = BigInt(req.params.after); const manifest = await server.getStorageManifest(device); - if (!manifest?.version?.gt(after)) { + if (manifest === undefined) { + return send(res, 404); + } + if (!manifest.version || manifest.version <= after) { return send(res, 204); } - return send(res, 200, Proto.StorageManifest.encode(manifest).finish()); + return send(res, 200, Proto.StorageManifest.encode(manifest)); }, ); @@ -721,11 +727,7 @@ export const createHandler = ( } if (!result.updated) { - return send( - res, - 409, - Proto.StorageManifest.encode(result.manifest).finish(), - ); + return send(res, 409, Proto.StorageManifest.encode(result.manifest)); } return send(res, 200); @@ -753,7 +755,7 @@ export const createHandler = ( 200, Proto.StorageItems.encode({ items, - }).finish(), + }), ); }); @@ -776,7 +778,7 @@ export const createHandler = ( withNamespace('/storageService')( // All storage service routes have the X-Signal-Timestamp header ...ALL_METHODS.map((method) => - method('/*', (req, res) => { + method('/*', (_req, res) => { res.setHeader('X-Signal-Timestamp', Date.now()); }), ), diff --git a/src/server/ws/connection.ts b/src/server/ws/connection.ts index d29073e..18fccce 100644 --- a/src/server/ws/connection.ts +++ b/src/server/ws/connection.ts @@ -1051,7 +1051,7 @@ export class Connection extends Service { const { status } = await this.send('PUT', '/v1/address', { body: Proto.ProvisioningAddress.encode({ address: id, - }).finish(), + }), }); assert.strictEqual(status, 200); } diff --git a/src/server/ws/router.ts b/src/server/ws/router.ts index d00d06f..0a0895e 100644 --- a/src/server/ws/router.ts +++ b/src/server/ws/router.ts @@ -120,7 +120,9 @@ export class Router { if (json instanceof Uint8Array) { return { + id: request.id, status, + message: null, headers: ['Content-Type:application/x-protobuf'].concat(replyHeaders), body: Buffer.from(json), }; @@ -128,7 +130,9 @@ export class Router { assertJsonValue(json); return { + id: request.id, status, + message: null, headers: replyHeaders.concat(['Content-Type:application/json']), body: Buffer.from(JSON.stringify(json)), }; diff --git a/src/server/ws/service.ts b/src/server/ws/service.ts index 5db27ca..365f1cb 100644 --- a/src/server/ws/service.ts +++ b/src/server/ws/service.ts @@ -2,14 +2,13 @@ // SPDX-License-Identifier: AGPL-3.0-only import assert from 'assert'; -import Long from 'long'; import WebSocket from 'ws'; import createDebug from 'debug'; import { signalservice as SignalService } from '../../../protos/compiled'; -export type WSRequest = SignalService.IWebSocketRequestMessage; -export type WSResponse = SignalService.IWebSocketResponseMessage; +export type WSRequest = SignalService.WebSocketRequestMessage.Params; +export type WSResponse = SignalService.WebSocketResponseMessage.Params; const debug = createDebug('mock:ws:service'); @@ -49,12 +48,14 @@ export abstract class Service { const packet = WSMessage.encode({ type: WSMessage.Type.REQUEST, request: { - ...options, + headers: options.headers ?? null, + body: options.body ?? null, verb, path, - id: Long.fromNumber(id), + id: BigInt(id), }, - }).finish(); + response: null, + }); this.ws.send(packet); @@ -74,10 +75,6 @@ export abstract class Service { throw new Error('Expected response in message'); } - if (!response.id) { - throw new Error('Expected response.id'); - } - const id = parseInt(response.id.toString(), 10); if (isNaN(id)) { throw new Error(`Invalid response.id: ${response.id.toString()}`); @@ -95,10 +92,6 @@ export abstract class Service { throw new Error('Expected request in message'); } - if (!request.id) { - throw new Error('Expected request.id'); - } - let response: WSResponse; try { response = await this.handleRequest(request); @@ -106,7 +99,10 @@ export abstract class Service { assert(error instanceof Error); console.error('handleRequest error', error.stack); response = { + id: request.id, status: 500, + message: null, + headers: null, body: Buffer.from( JSON.stringify({ error: error.stack, @@ -118,11 +114,12 @@ export abstract class Service { // Keepalive responses const packet = WSMessage.encode({ type: WSMessage.Type.RESPONSE, + request: null, response: { ...response, id: request.id, }, - }).finish(); + }); this.ws.send(packet); } else { @@ -133,9 +130,11 @@ export abstract class Service { private onClose(): void { for (const [id, resolve] of this.requests.entries()) { resolve({ - id: Long.fromNumber(id), + id: BigInt(id), status: 500, message: 'WebSocket is gone', + headers: null, + body: null, }); } } diff --git a/src/util.ts b/src/util.ts index 8349b21..5a417c8 100644 --- a/src/util.ts +++ b/src/util.ts @@ -6,8 +6,9 @@ import assert from 'assert'; import isPlainObject from 'is-plain-obj'; import crypto from 'node:crypto'; import util from 'node:util'; -import type { JsonValue } from 'type-fest'; +import type { JsonValue, RequireExactlyOne } from 'type-fest'; +import { signalservice as Proto } from '../protos/compiled'; import { DAY_IN_SECONDS } from './constants'; import { type RegistrationId, ServiceIdKind } from './types'; import { ParsedUrlQuery } from 'node:querystring'; @@ -325,3 +326,96 @@ export async function getDevicesKeysResult( ), }; } + +type WithOneOf = Omit & + RequireExactlyOne>; + +type ContentParamsBuilderInput = WithOneOf< + Proto.Content.Params, + | 'dataMessage' + | 'syncMessage' + | 'callMessage' + | 'nullMessage' + | 'receiptMessage' + | 'typingMessage' + | 'senderKeyDistributionMessage' + | 'decryptionErrorMessage' + | 'storyMessage' + | 'editMessage' +>; + +export function buildContentParams( + input: ContentParamsBuilderInput, +): Proto.Content.Params { + return { + dataMessage: null, + syncMessage: null, + callMessage: null, + nullMessage: null, + receiptMessage: null, + typingMessage: null, + senderKeyDistributionMessage: null, + decryptionErrorMessage: null, + storyMessage: null, + editMessage: null, + ...input, + }; +} + +type SyncMessageParamsBuilderInput = WithOneOf< + Proto.SyncMessage.Params, + | 'keys' + | 'sent' + | 'contacts' + | 'request' + | 'read' + | 'blocked' + | 'verified' + | 'configuration' + | 'padding' + | 'stickerPackOperation' + | 'viewOnceOpen' + | 'fetchLatest' + | 'messageRequestResponse' + | 'outgoingPayment' + | 'viewed' + | 'pniChangeNumber' + | 'callEvent' + | 'callLinkUpdate' + | 'callLogEvent' + | 'deleteForMe' + | 'deviceNameChange' + | 'attachmentBackfillRequest' + | 'attachmentBackfillResponse' +>; + +export function buildSyncMessageParams( + input: SyncMessageParamsBuilderInput, +): Proto.SyncMessage.Params { + return { + keys: null, + sent: null, + contacts: null, + request: null, + read: null, + blocked: null, + verified: null, + configuration: null, + padding: null, + stickerPackOperation: null, + viewOnceOpen: null, + fetchLatest: null, + messageRequestResponse: null, + outgoingPayment: null, + viewed: null, + pniChangeNumber: null, + callEvent: null, + callLinkUpdate: null, + callLogEvent: null, + deleteForMe: null, + deviceNameChange: null, + attachmentBackfillRequest: null, + attachmentBackfillResponse: null, + ...input, + }; +} diff --git a/test/primary-device-test.ts b/test/primary-device-test.ts index 2958f48..381aea1 100644 --- a/test/primary-device-test.ts +++ b/test/primary-device-test.ts @@ -42,7 +42,25 @@ async function createPrimaryDevice(name: string): Promise { trustRoot: trustRoot.getPublicKey(), serverPublicParams: serverSecretParams.getPublicParams(), profileName: name, - contacts: {}, + contacts: { + attachmentIdentifier: null, + clientUuid: null, + contentType: null, + key: null, + size: null, + thumbnail: null, + digest: null, + incrementalMac: null, + chunkSize: null, + fileName: null, + flags: null, + width: null, + height: null, + caption: null, + blurHash: null, + uploadTimestamp: null, + cdnNumber: null, + }, async getSenderCertificate() { return generateSenderCertificate(serverCert, { diff --git a/test/util-test.ts b/test/util-test.ts index 6528cd8..3bbfe61 100644 --- a/test/util-test.ts +++ b/test/util-test.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import assert from 'assert'; -import Long from 'long'; import { PromiseQueue, assertJsonValue } from '../src/util'; @@ -152,10 +151,7 @@ describe('util', () => { invalid(class Foo {}, /value: \[class Foo\]/); // eslint-disable-next-line @typescript-eslint/no-extraneous-class invalid(new (class Foo {})(), /value: Foo {}/); - invalid( - Long.fromNumber(42), - /value: Long { low: 42, high: 0, unsigned: false }/, - ); + invalid(42n, /value: 42n/); }); it('should report multiple errors', () => {