Use protopiler for protobufs
This commit is contained in:
parent
0d4b2e05ce
commit
1d64799ecb
11
package.json
11
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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>;
|
||||
|
||||
//
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}),
|
||||
),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
96
src/util.ts
96
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<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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user