Import/export avatar colors

This commit is contained in:
Fedor Indutny
2025-03-05 10:56:23 -08:00
committed by GitHub
parent 16d36053ea
commit aff9a3213e
16 changed files with 340 additions and 28 deletions

View File

@@ -240,7 +240,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: 'signalapp/Signal-Message-Backup-Tests'
ref: '2e22808478e08c72e11a7483900c9af6c9b1acf4'
ref: 'a743fbf8e3adc2f1a700577dd8a470beff60db3f'
path: 'backup-integration-tests'
- run: xvfb-run --auto-servernum pnpm run test-electron

View File

@@ -118,7 +118,7 @@
"@react-aria/utils": "3.25.3",
"@react-spring/web": "9.7.5",
"@signalapp/better-sqlite3": "9.0.13",
"@signalapp/libsignal-client": "0.67.0",
"@signalapp/libsignal-client": "0.67.3",
"@signalapp/quill-cjs": "2.1.2",
"@signalapp/ringrtc": "2.50.1",
"@tanstack/react-virtual": "3.11.2",

10
pnpm-lock.yaml generated
View File

@@ -125,8 +125,8 @@ importers:
specifier: 9.0.13
version: 9.0.13
'@signalapp/libsignal-client':
specifier: 0.67.0
version: 0.67.0
specifier: 0.67.3
version: 0.67.3
'@signalapp/quill-cjs':
specifier: 2.1.2
version: 2.1.2
@@ -2532,8 +2532,8 @@ packages:
'@signalapp/libsignal-client@0.60.2':
resolution: {integrity: sha512-tU4kNP/yCwkFntb2ahXOSQJtzdy+YifAB2yv5hw0qyKSidRHLn6bYiz4Zo2tjxLDRoBLAUxCRsQramStiqNZdA==}
'@signalapp/libsignal-client@0.67.0':
resolution: {integrity: sha512-K0MdgOQMqQkeXM9bBRa4FRw+q/IE6a+IICgw7wUwaO7y11IWLCRfaam/hNtHS1uzwPx6D1Ebo0pwKO9kHeShPw==}
'@signalapp/libsignal-client@0.67.3':
resolution: {integrity: sha512-GIiXJMqiIByPZbomytoYQcQLJ3pNgHBCjt5BvlE/3rkrmNwyXW1UVqszX6/WfZi91aqUapO4+7Op+8JCbDGRWA==}
'@signalapp/mock-server@11.0.0':
resolution: {integrity: sha512-JHEqdjXvWcXyLJ90OtaTIQVtizH/w+rmnPplNmHuUiDZIQIEvE3lRyZFyIvgVTNg29lQWs/Gw/o+hICerdOChQ==}
@@ -12260,7 +12260,7 @@ snapshots:
type-fest: 4.26.1
uuid: 8.3.2
'@signalapp/libsignal-client@0.67.0':
'@signalapp/libsignal-client@0.67.3':
dependencies:
node-gyp-build: 4.8.4
type-fest: 4.26.1

View File

@@ -118,6 +118,7 @@ message AccountData {
reserved /*backupsSubscriberData*/ 8; // A deprecated format
AccountSettings accountSettings = 9;
IAPSubscriberData backupsSubscriberData = 10;
string svrPin = 11;
}
message Recipient {
@@ -133,6 +134,30 @@ message Recipient {
}
}
// If unset - computed as the value of the first byte of SHA-256(msg=CONTACT_ID)
// modulo the count of colors. Once set the avatar color for a recipient is
// never recomputed or changed.
//
// `CONTACT_ID` is the first available identifier from the list:
// - ServiceIdToBinary(ACI)
// - E164
// - ServiceIdToBinary(PNI)
// - Group Id
enum AvatarColor {
A100 = 0;
A110 = 1;
A120 = 2;
A130 = 3;
A140 = 4;
A150 = 5;
A160 = 6;
A170 = 7;
A180 = 8;
A190 = 9;
A200 = 10;
A210 = 11;
}
message Contact {
enum IdentityState {
DEFAULT = 0; // A valid value -- indicates unset by the user
@@ -181,6 +206,7 @@ message Contact {
string systemGivenName = 18;
string systemFamilyName = 19;
string systemNickname = 20;
optional AvatarColor avatarColor = 21;
}
message Group {
@@ -196,6 +222,7 @@ message Group {
StorySendMode storySendMode = 4;
GroupSnapshot snapshot = 5;
bool blocked = 6;
optional AvatarColor avatarColor = 7;
// These are simply plaintext copies of the groups proto from Groups.proto.
// They should be kept completely in-sync with Groups.proto.
@@ -275,7 +302,9 @@ message Group {
}
}
message Self {}
message Self {
optional AvatarColor avatarColor = 1;
}
message ReleaseNotes {}

View File

@@ -73,6 +73,30 @@ message StorageRecord {
}
}
// If unset - computed as the value of the first byte of SHA-256(msg=CONTACT_ID)
// modulo the count of colors. Once set the avatar color for a recipient is
// never recomputed or changed.
//
// `CONTACT_ID` is the first available identifier from the list:
// - ServiceIdToBinary(ACI)
// - E164
// - ServiceIdToBinary(PNI)
// - Group Id
enum AvatarColor {
A100 = 0;
A110 = 1;
A120 = 2;
A130 = 3;
A140 = 4;
A150 = 5;
A160 = 6;
A170 = 7;
A180 = 8;
A190 = 9;
A200 = 10;
A210 = 11;
}
message ContactRecord {
enum IdentityState {
DEFAULT = 0;
@@ -108,6 +132,7 @@ message ContactRecord {
optional bool pniSignatureVerified = 21;
optional Name nickname = 22;
optional string note = 23;
optional AvatarColor avatarColor = 24;
}
message GroupV1Record {
@@ -136,6 +161,7 @@ message GroupV2Record {
optional bool hideStory = 8;
reserved /* storySendEnabled */ 9; // removed
optional StorySendMode storySendMode = 10;
optional AvatarColor avatarColor = 11;
}
message AccountRecord {
@@ -229,6 +255,7 @@ message AccountRecord {
// See zkgroup for integer particular values
optional uint64 backupTier = 40;
optional IAPSubscriberData backupSubscriberData = 41;
optional AvatarColor avatarColor = 42;
}
message StoryDistributionListRecord {

View File

@@ -3,20 +3,22 @@
import { Buffer } from 'buffer';
import Long from 'long';
import { Aci, HKDF } from '@signalapp/libsignal-client';
import { sample } from 'lodash';
import { Aci, Pni, HKDF } from '@signalapp/libsignal-client';
import { AccountEntropyPool } from '@signalapp/libsignal-client/dist/AccountKeys';
import * as Bytes from './Bytes';
import { Crypto } from './context/Crypto';
import { calculateAgreement, generateKeyPair } from './Curve';
import { HashType, CipherType } from './types/Crypto';
import { AVATAR_COLOR_COUNT, AvatarColors } from './types/Colors';
import { ProfileDecryptError } from './types/errors';
import { getBytesSubarray } from './util/uuidToBytes';
import { logPadSize } from './util/logPadding';
import { Environment, getEnvironment } from './environment';
import { toWebSafeBase64 } from './util/webSafeBase64';
import type { AciString } from './types/ServiceId';
import type { AciString, PniString } from './types/ServiceId';
export { HashType, CipherType };
@@ -667,3 +669,31 @@ export function constantTimeEqual(
): boolean {
return crypto.constantTimeEqual(left, right);
}
export function generateAvatarColor({
aci,
e164,
pni,
groupId,
}: {
aci: AciString | undefined;
e164: string | undefined;
pni: PniString | undefined;
groupId: string | undefined;
}): string {
let identifier: Uint8Array;
if (aci != null) {
identifier = Aci.parseFromServiceIdString(aci).getServiceIdBinary();
} else if (e164 != null) {
identifier = Bytes.fromString(e164);
} else if (pni != null) {
identifier = Pni.parseFromServiceIdString(pni).getServiceIdBinary();
} else if (groupId != null) {
identifier = Bytes.fromBase64(groupId);
} else {
return sample(AvatarColors) || AvatarColors[0];
}
const digest = hash(HashType.size256, identifier);
return AvatarColors[digest[0] % AVATAR_COLOR_COUNT];
}

3
ts/model-types.d.ts vendored
View File

@@ -345,6 +345,9 @@ export type ConversationAttributesType = {
>;
capabilities?: CapabilitiesType;
color?: string;
// If present - the numeric value of `color` (possibly not yet supported) that
// we got the from primary during either backup or storage service import.
colorFromPrimary?: number;
conversationColor?: ConversationColorType;
customColor?: CustomColorType;
customColorId?: string;

View File

@@ -5273,7 +5273,12 @@ export class ConversationModel extends window.Backbone
}
getColor(): AvatarColorType {
return migrateColor(this.getServiceId(), this.get('color'));
return migrateColor(this.get('color'), {
aci: this.getAci(),
e164: this.get('e164'),
pni: this.getPni(),
groupId: this.get('groupId'),
});
}
getConversationColor(): ConversationColorType | undefined {

View File

@@ -708,6 +708,7 @@ export class BackupExportStream extends Readable {
),
}
: null,
svrPin: storage.get('svrPin'),
accountSettings: {
readReceipts: storage.get('read-receipt-setting'),
sealedSenderIndicators: storage.get('sealedSenderIndicators'),
@@ -824,7 +825,9 @@ export class BackupExportStream extends Readable {
};
if (isMe(convo)) {
res.self = {};
res.self = {
avatarColor: toAvatarColor(convo.color),
};
} else if (isDirectConversation(convo)) {
let visibility: Backups.Contact.Visibility;
if (convo.removalStage == null) {
@@ -880,6 +883,7 @@ export class BackupExportStream extends Readable {
systemNickname: convo.systemNickname,
hideStory: convo.hideStory === true,
identityKey: identityKey?.publicKey || null,
avatarColor: toAvatarColor(convo.color),
// Integer values match so we can use it as is
identityState: identityKey?.verified ?? 0,
@@ -916,6 +920,7 @@ export class BackupExportStream extends Readable {
blocked: convo.groupId
? window.storage.blocked.isGroupBlocked(convo.groupId)
: false,
avatarColor: toAvatarColor(convo.color),
snapshot: {
title: {
title: convo.name?.trim() ?? '',
@@ -3056,3 +3061,36 @@ function toCallLinkRestrictionsProto(
return values.UNKNOWN;
}
function toAvatarColor(
color: string | undefined
): Backups.AvatarColor | undefined {
switch (color) {
case 'A100':
return Backups.AvatarColor.A100;
case 'A110':
return Backups.AvatarColor.A110;
case 'A120':
return Backups.AvatarColor.A120;
case 'A130':
return Backups.AvatarColor.A130;
case 'A140':
return Backups.AvatarColor.A140;
case 'A150':
return Backups.AvatarColor.A150;
case 'A160':
return Backups.AvatarColor.A160;
case 'A170':
return Backups.AvatarColor.A170;
case 'A180':
return Backups.AvatarColor.A180;
case 'A190':
return Backups.AvatarColor.A190;
case 'A200':
return Backups.AvatarColor.A200;
case 'A210':
return Backups.AvatarColor.A210;
default:
return undefined;
}
}

View File

@@ -465,8 +465,7 @@ export class BackupImportStream extends Writable {
// Not yet supported
return;
} else if (recipient.self) {
strictAssert(this.#ourConversation != null, 'Missing account data');
convo = this.#ourConversation;
convo = this.#fromSelf(recipient.self);
} else if (recipient.group) {
convo = await this.#fromGroup(recipient.group);
} else if (recipient.distributionList) {
@@ -684,6 +683,7 @@ export class BackupImportStream extends Writable {
backupsSubscriberData,
donationSubscriberData,
accountSettings,
svrPin,
}: Backups.IAccountData): Promise<void> {
strictAssert(this.#ourConversation === undefined, 'Duplicate AccountData');
const me =
@@ -805,6 +805,9 @@ export class BackupImportStream extends Writable {
'preferredReactionEmoji',
accountSettings?.preferredReactionEmoji || []
);
if (svrPin) {
await storage.put('svrPin', svrPin);
}
const { PhoneNumberSharingMode: BackupMode } = Backups.AccountData;
switch (accountSettings?.phoneNumberSharingMode) {
@@ -879,6 +882,18 @@ export class BackupImportStream extends Writable {
await this.#updateConversation(me);
}
#fromSelf(self: Backups.ISelf): ConversationAttributesType {
strictAssert(this.#ourConversation != null, 'Missing account data');
const convo = this.#ourConversation;
if (self.avatarColor != null) {
convo.color = fromAvatarColor(self.avatarColor);
convo.colorFromPrimary = dropNull(self.avatarColor);
}
return convo;
}
async #fromContact(
contact: Backups.IContact
): Promise<ConversationAttributesType> {
@@ -934,6 +949,8 @@ export class BackupImportStream extends Writable {
nicknameGivenName: dropNull(contact.nickname?.given),
nicknameFamilyName: dropNull(contact.nickname?.family),
note: dropNull(contact.note),
color: fromAvatarColor(contact.avatarColor),
colorFromPrimary: dropNull(contact.avatarColor),
};
if (serviceId != null && Bytes.isNotEmpty(contact.identityKey)) {
@@ -1035,6 +1052,8 @@ export class BackupImportStream extends Writable {
url: avatarUrl,
}
: undefined,
color: fromAvatarColor(group.avatarColor),
colorFromPrimary: dropNull(group.avatarColor),
// Snapshot
name: dropNull(title?.title)?.trim(),
@@ -3744,3 +3763,39 @@ function fromCallLinkRestrictionsProto(
return CallLinkRestrictions.Unknown;
}
function fromAvatarColor(
color: Backups.AvatarColor | null | undefined
): string | undefined {
switch (color) {
case Backups.AvatarColor.A100:
return 'A100';
case Backups.AvatarColor.A110:
return 'A110';
case Backups.AvatarColor.A120:
return 'A120';
case Backups.AvatarColor.A130:
return 'A130';
case Backups.AvatarColor.A140:
return 'A140';
case Backups.AvatarColor.A150:
return 'A150';
case Backups.AvatarColor.A160:
return 'A160';
case Backups.AvatarColor.A170:
return 'A170';
case Backups.AvatarColor.A180:
return 'A180';
case Backups.AvatarColor.A190:
return 'A190';
case Backups.AvatarColor.A200:
return 'A200';
case Backups.AvatarColor.A210:
return 'A210';
case null:
case undefined:
return undefined;
default:
throw missingCaseError(color);
}
}

View File

@@ -139,6 +139,52 @@ function fromRecordVerified(
}
}
function fromAvatarColor(
color: Proto.AvatarColor | null | undefined
): string | undefined {
switch (color) {
case Proto.AvatarColor.A100:
return 'A100';
case Proto.AvatarColor.A110:
return 'A110';
case Proto.AvatarColor.A120:
return 'A120';
case Proto.AvatarColor.A130:
return 'A130';
case Proto.AvatarColor.A140:
return 'A140';
case Proto.AvatarColor.A150:
return 'A150';
case Proto.AvatarColor.A160:
return 'A160';
case Proto.AvatarColor.A170:
return 'A170';
case Proto.AvatarColor.A180:
return 'A180';
case Proto.AvatarColor.A190:
return 'A190';
case Proto.AvatarColor.A200:
return 'A200';
case Proto.AvatarColor.A210:
return 'A210';
case undefined:
case null:
return undefined;
default:
throw missingCaseError(color);
}
}
function applyAvatarColor(
conversation: ConversationModel,
protoColor: Proto.AvatarColor | null | undefined
): void {
conversation.set({
colorFromPrimary: dropNull(protoColor),
color: fromAvatarColor(protoColor) ?? conversation.get('color'),
});
}
function addUnknownFields(
record: RecordClass,
conversation: ConversationModel,
@@ -260,6 +306,10 @@ export async function toContactRecord(
contactRecord.unregisteredAtTimestamp = getSafeLongFromTimestamp(
conversation.get('firstUnregisteredAt')
);
const avatarColor = conversation.get('colorFromPrimary');
if (avatarColor != null) {
contactRecord.avatarColor = avatarColor;
}
applyUnknownFields(contactRecord, conversation);
@@ -485,6 +535,11 @@ export function toAccountRecord(
}
}
const avatarColor = conversation.get('colorFromPrimary');
if (avatarColor != null) {
accountRecord.avatarColor = avatarColor;
}
applyUnknownFields(accountRecord, conversation);
return accountRecord;
@@ -544,6 +599,11 @@ export function toGroupV2Record(
}
}
const avatarColor = conversation.get('colorFromPrimary');
if (avatarColor != null) {
groupV2Record.avatarColor = avatarColor;
}
applyUnknownFields(groupV2Record, conversation);
return groupV2Record;
@@ -1034,6 +1094,8 @@ export async function mergeGroupV2Record(
applyMessageRequestState(groupV2Record, conversation);
applyAvatarColor(conversation, groupV2Record.avatarColor);
let details = new Array<string>();
addUnknownFields(groupV2Record, conversation, details);
@@ -1292,6 +1354,8 @@ export async function mergeContactRecord(
});
}
applyAvatarColor(conversation, contactRecord.avatarColor);
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
await toContactRecord(conversation),
contactRecord,
@@ -1683,6 +1747,8 @@ export async function mergeAccountRecord(
await window.storage.put('avatarUrl', avatarUrl);
}
applyAvatarColor(conversation, accountRecord.avatarColor);
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
toAccountRecord(conversation),
accountRecord,

View File

@@ -19,6 +19,7 @@ import {
decryptProfile,
getRandomBytes,
constantTimeEqual,
generateAvatarColor,
generateRegistrationId,
deriveSecrets,
encryptDeviceName,
@@ -49,6 +50,7 @@ import {
generateAttachmentKeys,
type DecryptedAttachmentV2,
} from '../AttachmentCrypto';
import type { AciString, PniString } from '../types/ServiceId';
import { createTempDir, deleteTempDir } from '../updater/common';
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
@@ -1163,4 +1165,59 @@ describe('Crypto', () => {
}
});
});
describe('generateAvatarColor', () => {
const aci = 'a025bf78-653e-44e0-beb9-deb14ba32487' as AciString;
const pni = 'PNI:11a175e3-fe31-4eda-87da-e0bf2a2e250b' as PniString;
const e164 = '+12135550124';
const groupId = 'BwJRIdomqOSOckHjnJsknNCibCZKJFt+RxLIpa9CWJ4=';
it('generates color based on ACI', () => {
assert.strictEqual(
generateAvatarColor({
aci,
e164,
pni,
groupId,
}),
'A140'
);
});
it('generates color based on E164', () => {
assert.strictEqual(
generateAvatarColor({
aci: undefined,
e164,
pni,
groupId,
}),
'A150'
);
});
it('generates color based on PNI', () => {
assert.strictEqual(
generateAvatarColor({
aci: undefined,
e164: undefined,
pni,
groupId,
}),
'A200'
);
});
it('generates color based on group id', () => {
assert.strictEqual(
generateAvatarColor({
aci: undefined,
e164: undefined,
pni: undefined,
groupId,
}),
'A130'
);
});
});
});

View File

@@ -39,7 +39,7 @@ import type {
ChatServerMessageAck,
ChatServiceListener,
ConnectionEventsListener,
} from '@signalapp/libsignal-client/dist/net';
} from '@signalapp/libsignal-client/dist/net/Chat';
import type { EventHandler } from './EventTarget';
import EventTarget from './EventTarget';

View File

@@ -225,6 +225,9 @@ export type StorageAccessType = {
// The `firstAppVersion` present on an BackupInfo from an imported backup.
restoredBackupFirstAppVersion: string;
// Stored solely for pesistance during import/export sequence
svrPin: string;
postRegistrationSyncsStatus: 'incomplete' | 'complete';
// Deprecated

View File

@@ -36,6 +36,7 @@ import {
canHaveUsername,
} from './getTitle';
import { hasDraft } from './hasDraft';
import { isAciString } from './isAciString';
import { isBlocked } from './isBlocked';
import { isConversationAccepted } from './isConversationAccepted';
import {
@@ -89,7 +90,12 @@ export function getConversation(model: ConversationModel): ConversationType {
const ourAci = window.textsecure.storage.user.getAci();
const ourPni = window.textsecure.storage.user.getPni();
const color = migrateColor(attributes.serviceId, attributes.color);
const color = migrateColor(attributes.color, {
aci: isAciString(attributes.serviceId) ? attributes.serviceId : undefined,
e164: attributes.e164,
pni: attributes.pni,
groupId: attributes.groupId,
});
const { draftTimestamp, draftEditMessage, timestamp } = attributes;
const draftPreview = getDraftPreview(attributes);

View File

@@ -1,29 +1,22 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { sample } from 'lodash';
import { AVATAR_COLOR_COUNT, AvatarColors } from '../types/Colors';
import { AvatarColors } from '../types/Colors';
import type { ConversationAttributesType } from '../model-types';
import type { AvatarColorType, CustomColorType } from '../types/Colors';
import type { ServiceIdString } from '../types/ServiceId';
import { generateAvatarColor } from '../Crypto';
const NEW_COLOR_NAMES = new Set(AvatarColors);
export function migrateColor(
serviceId?: ServiceIdString,
color?: string
color: string | undefined,
options: Parameters<typeof generateAvatarColor>[0]
): AvatarColorType {
if (color && NEW_COLOR_NAMES.has(color)) {
return color;
}
if (!serviceId) {
return sample(AvatarColors) || AvatarColors[0];
}
const index = (parseInt(serviceId.slice(-4), 16) || 0) % AVATAR_COLOR_COUNT;
return AvatarColors[index];
return generateAvatarColor(options);
}
export function getCustomColorData(conversation: ConversationAttributesType): {