Use protopiler for protobufs

This commit is contained in:
Fedor Indutny 2026-03-02 13:34:27 -08:00 committed by GitHub
parent 0d4b2e05ce
commit 1d64799ecb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 863 additions and 402 deletions

2
.nvmrc
View File

@ -1 +1 @@
20.18.2
24.11.1

View File

@ -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"
}
}

View File

@ -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)),
);

View File

@ -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<Buffer | undefined>;
getGroup: (publicParams: Uint8Array) => Promise<ServerGroup | undefined>;
createGroup: (group: Proto.IGroup) => Promise<ServerGroup>;
createGroup: (group: Proto.Group.Params) => Promise<ServerGroup>;
modifyGroup: (options: ModifyGroupOptions) => Promise<ModifyGroupResult>;
waitForGroupUpdate: (group: GroupData) => Promise<void>;
getStorageManifest: () => Promise<Proto.IStorageManifest | undefined>;
getStorageManifest: () => Promise<Proto.StorageManifest.Params | undefined>;
getStorageItem: (key: Buffer) => Promise<Buffer | undefined>;
getAllStorageKeys: () => Promise<Array<Buffer>>;
waitForStorageManifest: (afterVersion?: number) => Promise<void>;
applyStorageWrite: (
operation: Proto.IWriteOperation,
operation: Proto.WriteOperation.Params,
shouldNotify?: boolean,
) => Promise<StorageWriteResult>;
}>;
@ -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<number, SignedPreKeyRecord>();
@ -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<DecryptionErrorQueueEntry>();
@ -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<Buffer> {
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<Buffer> {
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<void> {
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<void> {
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<void> {
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<void> {
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<Buffer> {
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<void> {
debug(
@ -1701,7 +1790,7 @@ export class PrimaryDevice {
private async handleSync(
source: Device,
sync: Proto.ISyncMessage,
sync: Proto.SyncMessage,
): Promise<void> {
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<StorageState> {
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,
};
}),
);

View File

@ -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<string, PrimaryDevice>();
private readonly knownNumbers = new Set<string>();
private emptyAttachment: Proto.IAttachmentPointer | undefined;
private emptyAttachment: Proto.AttachmentPointer.Params | undefined;
private provisionQueue: PromiseQueue<PendingProvision>;
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<Buffer>,
): Promise<Array<Proto.IStorageItem> | undefined> {
): Promise<Array<Proto.StorageItem.Params> | 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<void> {
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(

View File

@ -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<Proto.StorageRecord.Params['record']>;
export type StorageStateRecord<Value extends RecordValue = RecordValue> =
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<Proto.IStorageRecord>;
removed: ReadonlyArray<Proto.IStorageRecord>;
added: ReadonlyArray<RecordValue>;
removed: ReadonlyArray<RecordValue>;
}>;
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<Value extends RecordValue> = (
record: StorageStateRecord,
) => record is StorageStateRecord<Value>;
type StorageRecordMapper<Value extends RecordValue> = (record: Value) => Value;
type StorageItemPredicate<Value extends RecordValue> = (
item: StorageStateItem,
index: number,
) => item is StorageStateItem<Value>;
class StorageStateItem {
class StorageStateItem<Value extends RecordValue = RecordValue> {
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<Value>) {
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<RecordValue, { kind: 'account' }>
> {
return (
this.type === IdentifierType.ACCOUNT && this.record.kind === 'account'
);
}
public isGroup(group: Group): boolean {
public isGroup(
group: Group,
): this is StorageStateItem<Extract<RecordValue, { kind: 'groupV2' }>> {
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<Extract<RecordValue, { kind: 'contact' }>> {
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<Value> {
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<StorageStateItem>;
@ -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<Proto.AccountRecord.Params>,
): 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<Proto.AccountRecord.Params>,
): 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<Proto.GroupV2Record.Params> = {},
): 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<Proto.GroupV2Record.Params>,
): 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<Proto.ContactRecord.Params> = {},
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<Proto.ContactRecord.Params>,
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<Proto.ContactRecord.Params>,
): 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<Value extends RecordValue>(
find: StorageRecordPredicate<Value>,
): StorageStateRecord<Value> | undefined {
const item = this.items.find((item): item is StorageStateItem<Value> => {
return find(item.toRecord());
});
return item?.toRecord();
}
public filterRecords(
filter: StorageRecordPredicate,
): ReadonlyArray<StorageStateRecord> {
return this.items.filter((item) => filter(item.toRecord()));
public filterRecords<Value extends RecordValue>(
filter: StorageRecordPredicate<Value>,
): ReadonlyArray<StorageStateRecord<Value>> {
return this.items.filter((item): item is StorageStateItem<Value> =>
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<RecordValue>) !== undefined
);
}
public updateRecord(
find: StorageRecordPredicate,
map: StorageRecordMapper,
public updateRecord<Value extends RecordValue>(
find: StorageRecordPredicate<Value>,
map: StorageRecordMapper<Value>,
): StorageState {
return this.updateItem((item) => find(item.toRecord()), map);
return this.updateItem(
(item): item is StorageStateItem<Value> => find(item.toRecord()),
map,
);
}
public updateManyRecords(
filter: StorageRecordPredicate,
map: StorageRecordMapper,
public updateManyRecords<Value extends RecordValue>(
filter: StorageRecordPredicate<Value>,
map: StorageRecordMapper<Value>,
): StorageState {
return this.updateManyItems((item) => filter(item.toRecord()), map);
return this.updateManyItems(
(item): item is StorageStateItem<Value> => 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<StorageStateRecord> {
public getAllGroupRecords(): ReadonlyArray<
StorageStateRecord<Extract<RecordValue, { kind: 'groupV2' }>>
> {
return this.items
.filter((item) => item.type === IdentifierType.GROUPV2)
.filter(
(
item,
): item is StorageStateItem<
Extract<RecordValue, { kind: 'groupV2' }>
> => 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<Proto.IStorageItem>();
const insertItem = new Array<Proto.StorageItem.Params>();
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<string, Proto.IStorageRecord>();
const removedIds = new Map<string, Proto.IStorageRecord>();
const addedIds = new Map<string, RecordValue>();
const removedIds = new Map<string, RecordValue>();
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<Value extends RecordValue>(
find: StorageItemPredicate<Value>,
map: StorageRecordMapper<Value>,
): StorageState {
const itemIndex = this.findItemIndex(find);
const item = this.items[itemIndex];
const item = this.items[itemIndex] as StorageStateItem<Value> | undefined;
assert(item, 'consistency check');
return this.replaceItem(itemIndex, {
@ -528,9 +670,9 @@ export class StorageState {
});
}
public updateManyItems(
filter: StorageItemPredicate,
map: StorageRecordMapper,
public updateManyItems<Value extends RecordValue>(
filter: StorageItemPredicate<Value>,
map: StorageRecordMapper<Value>,
): 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,
},

View File

@ -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,
);

View File

@ -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,
};
}

View File

@ -18,7 +18,11 @@ export function serializeContacts(contacts: ReadonlyArray<Contact>): Buffer {
aciBinary,
number,
name,
}).finish(),
avatar: null,
expireTimer: null,
expireTimerVersion: null,
inboxPosition: null,
}),
);
})
.map((chunk) => {

View File

@ -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<Proto.IGroupChanges> {
public get changes(): Readonly<Proto.GroupChanges.Params> {
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<Proto.IGroup> {
public get state(): Readonly<Proto.Group.Params> {
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<Proto.IGroupChanges> {
public getChangesSince(since: number): Readonly<Proto.GroupChanges.Params> {
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 (

View File

@ -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';

View File

@ -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<Device, StorageAuthEntry>();
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<ServerGroup> {
public async createGroup(group: Proto.Group.Params): Promise<ServerGroup> {
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<Proto.IStorageManifest | undefined> {
): Promise<Proto.StorageManifest.Params | undefined> {
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<StorageWriteResult> {
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<Buffer>,
): Promise<Array<Proto.IStorageItem> | undefined> {
const result = new Array<Proto.IStorageItem>();
): Promise<Array<Proto.StorageItem.Params> | undefined> {
const result = new Array<Proto.StorageItem.Params>();
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<void>;
//

View File

@ -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,
};
}
}

View File

@ -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());
}),
),

View File

@ -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);
}

View File

@ -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)),
};

View File

@ -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,
});
}
}

View File

@ -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<T, K extends keyof T> = Omit<T, K> &
RequireExactlyOne<Pick<T, K>>;
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,
};
}

View File

@ -42,7 +42,25 @@ async function createPrimaryDevice(name: string): Promise<PrimaryDevice> {
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, {

View File

@ -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', () => {