signal-desktop/ts/textsecure/ContactsParser.ts

224 lines
6.6 KiB
TypeScript
Raw Normal View History

2020-10-30 20:34:04 +00:00
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { Transform } from 'stream';
2021-07-09 19:36:10 +00:00
import { SignalService as Proto } from '../protobuf';
import protobuf from '../protobuf/wrap';
2023-09-14 17:04:48 +00:00
import { normalizeAci } from '../util/normalizeAci';
import { isAciString } from '../util/isAciString';
2022-11-16 20:18:02 +00:00
import { DurationInSeconds } from '../util/durations';
import * as log from '../logging/log';
import type { ContactAvatarType } from '../types/Avatar';
2024-07-11 19:44:09 +00:00
import type { AttachmentType } from '../types/Attachment';
import { computeHash } from '../Crypto';
import { dropNull } from '../util/dropNull';
2024-07-11 19:44:09 +00:00
import { decryptAttachmentV2ToSink } from '../AttachmentCrypto';
2021-07-09 19:36:10 +00:00
import Avatar = Proto.ContactDetails.IAvatar;
import { stringToMIMEType } from '../types/MIME';
const { Reader } = protobuf;
type OptionalFields = {
avatar?: Avatar | null;
expireTimer?: number | null;
number?: string | null;
};
2022-11-16 20:18:02 +00:00
type MessageWithAvatar<Message extends OptionalFields> = Omit<
2021-07-09 19:36:10 +00:00
Message,
'avatar' | 'toJSON'
2021-07-09 19:36:10 +00:00
> & {
avatar?: ContactAvatarType;
2022-11-16 20:18:02 +00:00
expireTimer?: DurationInSeconds;
expireTimerVersion: number | null;
number?: string | undefined;
};
export type ContactDetailsWithAvatar = MessageWithAvatar<Proto.IContactDetails>;
2024-07-11 19:44:09 +00:00
export async function parseContactsV2(
attachment: AttachmentType
): Promise<ReadonlyArray<ContactDetailsWithAvatar>> {
if (!attachment.path) {
throw new Error('Contact attachment not downloaded');
}
if (attachment.version !== 2) {
throw new Error('Contact attachment is not up-to-date');
}
if (attachment.localKey == null) {
throw new Error('Contact attachment has no keys');
}
2022-09-13 21:48:09 +00:00
const parseContactsTransform = new ParseContactsTransform();
2021-07-09 19:36:10 +00:00
2024-07-11 19:44:09 +00:00
await decryptAttachmentV2ToSink(
{
idForLogging: 'parseContactsV2',
2024-07-11 19:44:09 +00:00
ciphertextPath: window.Signal.Migrations.getAbsoluteAttachmentPath(
attachment.path
),
keysBase64: attachment.localKey,
size: attachment.size,
type: 'local',
2024-07-11 19:44:09 +00:00
},
parseContactsTransform
);
Profiles (#1453) * Add AES-GCM encryption for profiles With tests. * Add profileKey to DataMessage protobuf // FREEBIE * Decrypt and save profile names // FREEBIE * Save incoming profile keys * Move pad/unpad to crypto module // FREEBIE * Support fetching avatars from the cdn // FREEBIE * Translate failed authentication errors When AES-GCM authentication fails, webcrypto returns a very generic error. The same error is thrown for invalid length inputs, but our earlier checks in decryptProfile should rule out those failure modes and leave us safe to assume that we either had bad ciphertext or the wrong key. // FREEBIE * Handle profile avatars (wip) and log decrypt errors // FREEBIE * Display profile avatars Synced contact avatars will still override profile avatars. * Display profile names in convo list Only if we don't have a synced contact name. // FREEBIE * Make cdn url an environment config Use different ones for staging and production // FREEBIE * Display profile name in conversation header * Display profile name in group messages * Update conversation header if profile avatar changes // FREEBIE * Style profile names small with ~ * Save profileKeys from contact sync messages // FREEBIE * Save profile keys from provisioning messages For standalone accounts, generate a random profile key. // FREEBIE * Special case for one-time sync of our profile key Android will use a contact sync message to sync a profile key from Android clients who have just upgraded and generated their profile key. Normally we should receive this data in a provisioning message. // FREEBIE * Infer profile sharing from synced data messages * Populate profile keys on outgoing messages Requires that `profileSharing` be set on the conversation. // FREEBIE * Support for the profile key update flag When receiving a message with this flag, don't init a message record, just process the profile key and move on. // FREEBIE * Display profile names in group member list * Refresh contact's profile on profile key changes // FREEBIE * Catch errors on profile save // FREEBIE * Save our own synced contact info Don't return early if we get a contact sync for our own number // FREEBIE
2017-09-11 16:50:35 +00:00
return parseContactsTransform.contacts;
}
2022-11-16 20:18:02 +00:00
// This transform pulls contacts and their avatars from a stream of bytes. This is tricky,
// because the chunk boundaries might fall in the middle of a contact or their avatar.
// So we are ready for decodeDelimited() to throw, and to keep activeContact around
// while we wait for more chunks to get to the expected avatar size.
// Note: exported only for testing
export class ParseContactsTransform extends Transform {
public contacts: Array<ContactDetailsWithAvatar> = [];
public activeContact: Proto.ContactDetails | undefined;
private unused: Uint8Array | undefined;
override async _transform(
chunk: Buffer | undefined,
_encoding: string,
done: (error?: Error) => void
): Promise<void> {
if (!chunk || chunk.byteLength === 0) {
done();
return;
}
2022-11-16 20:18:02 +00:00
try {
let data = chunk;
if (this.unused) {
data = Buffer.concat([this.unused, data]);
this.unused = undefined;
}
const reader = Reader.create(data);
while (reader.pos < reader.len) {
const startPos = reader.pos;
if (!this.activeContact) {
try {
this.activeContact = Proto.ContactDetails.decodeDelimited(reader);
} catch (err) {
// We get a RangeError if there wasn't enough data to read the next record.
if (err instanceof RangeError) {
// Note: A failed decodeDelimited() does in fact update reader.pos, so we
// must reset to startPos
this.unused = data.subarray(startPos);
done();
return;
}
// Something deeper has gone wrong; the proto is malformed or something
done(err);
return;
}
}
// Something has really gone wrong if the above parsing didn't throw but gave
// us nothing back. Let's end the parse.
if (!this.activeContact) {
done(new Error('ParseContactsTransform: No active contact!'));
return;
}
const attachmentSize = this.activeContact?.avatar?.length ?? 0;
if (attachmentSize === 0) {
// No avatar attachment for this contact
const prepared = prepareContact(this.activeContact);
if (prepared) {
this.contacts.push(prepared);
}
this.activeContact = undefined;
continue;
}
const spaceLeftAfterRead = reader.len - (reader.pos + attachmentSize);
if (spaceLeftAfterRead >= 0) {
// We've read enough data to read the entire attachment
const avatarData = reader.buf.slice(
reader.pos,
reader.pos + attachmentSize
);
const hash = computeHash(avatarData);
2024-07-24 00:31:40 +00:00
const local =
// eslint-disable-next-line no-await-in-loop
await window.Signal.Migrations.writeNewAttachmentData(avatarData);
const contentType = this.activeContact.avatar?.contentType;
const prepared = prepareContact(this.activeContact, {
...this.activeContact.avatar,
2024-07-11 19:44:09 +00:00
...local,
contentType: contentType
? stringToMIMEType(contentType)
: undefined,
hash,
});
if (prepared) {
this.contacts.push(prepared);
} else {
// eslint-disable-next-line no-await-in-loop
2024-07-11 19:44:09 +00:00
await window.Signal.Migrations.deleteAttachmentData(local.path);
}
this.activeContact = undefined;
reader.skip(attachmentSize);
} else {
// We have an attachment, but we haven't read enough data yet. We need to
// wait for another chunk.
this.unused = data.subarray(reader.pos);
done();
return;
}
2022-11-16 20:18:02 +00:00
}
// No need to push; no downstream consumers!
} catch (error) {
done(error);
return;
}
2022-08-25 05:04:42 +00:00
done();
2022-08-25 05:04:42 +00:00
}
}
function prepareContact(
proto: Proto.ContactDetails,
avatar?: ContactAvatarType
): ContactDetailsWithAvatar | undefined {
const expireTimer =
proto.expireTimer != null
? DurationInSeconds.fromSeconds(proto.expireTimer)
: undefined;
// We reject incoming contacts with invalid aci information
if (proto.aci && !isAciString(proto.aci)) {
log.warn('ParseContactsTransform: Dropping contact with invalid aci');
2021-07-09 19:36:10 +00:00
return undefined;
}
const aci = proto.aci
? normalizeAci(proto.aci, 'ContactBuffer.aci')
: proto.aci;
const result = {
...proto,
expireTimer,
expireTimerVersion: proto.expireTimerVersion ?? null,
aci,
avatar,
number: dropNull(proto.number),
};
return result;
}