Introduce Service Id Types

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
Fedor Indutny 2023-08-10 18:43:33 +02:00 committed by Jamie Kyle
parent 414c0a58d3
commit 366b875fd2
269 changed files with 5832 additions and 5550 deletions

View file

@ -3,29 +3,33 @@
import { strictAssert } from '../util/assert';
import type { UUIDStringType } from './UUID';
import { UUID } from './UUID';
import type { ServiceIdString } from './ServiceId';
import { isServiceIdString } from './ServiceId';
export type AddressStringType = `${UUIDStringType}.${number}`;
export type AddressStringType = `${ServiceIdString}.${number}`;
const ADDRESS_REGEXP = /^([0-9a-f-]+).(\d+)$/i;
const ADDRESS_REGEXP = /^([:0-9a-f-]+).(\d+)$/i;
export class Address {
constructor(public readonly uuid: UUID, public readonly deviceId: number) {}
constructor(
public readonly serviceId: ServiceIdString,
public readonly deviceId: number
) {}
public toString(): AddressStringType {
return `${this.uuid.toString()}.${this.deviceId}`;
return `${this.serviceId}.${this.deviceId}`;
}
public static parse(value: string): Address {
const match = value.match(ADDRESS_REGEXP);
strictAssert(match != null, `Invalid Address: ${value}`);
const [whole, uuid, deviceId] = match;
const [whole, serviceId, deviceId] = match;
strictAssert(whole === value, 'Integrity check');
return Address.create(uuid, parseInt(deviceId, 10));
strictAssert(isServiceIdString(serviceId), 'Their service id is incorrect');
return Address.create(serviceId, parseInt(deviceId, 10));
}
public static create(uuid: string, deviceId: number): Address {
return new Address(new UUID(uuid), deviceId);
public static create(serviceId: ServiceIdString, deviceId: number): Address {
return new Address(serviceId, deviceId);
}
}

View file

@ -8,6 +8,7 @@ import { escapeRegExp, isNumber, omit } from 'lodash';
import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log';
import { missingCaseError } from '../util/missingCaseError';
import { isNotNil } from '../util/isNotNil';
import type { ConversationType } from '../state/ducks/conversations';
import {
SNIPPET_LEFT_PLACEHOLDER,
@ -15,6 +16,8 @@ import {
SNIPPET_TRUNCATION_PLACEHOLDER,
} from '../util/search';
import { assertDev } from '../util/assert';
import type { AciString } from './ServiceId';
import { normalizeAci } from './ServiceId';
// Cold storage of body ranges
@ -37,7 +40,7 @@ export namespace BodyRange {
export const { Style } = Proto.DataMessage.BodyRange;
export type Mention = {
mentionUuid: string;
mentionUuid: AciString;
};
export type Link = {
url: string;
@ -144,7 +147,10 @@ const MENTION_NAME = 'mention';
// We drop unknown bodyRanges and remove extra stuff so they serialize properly
export function filterAndClean(
ranges: ReadonlyArray<Proto.DataMessage.IBodyRange> | undefined | null
ranges:
| ReadonlyArray<Proto.DataMessage.IBodyRange | RawBodyRange>
| undefined
| null
): ReadonlyArray<RawBodyRange> | undefined {
if (!ranges) {
return undefined;
@ -164,35 +170,54 @@ export function filterAndClean(
};
return ranges
.filter((range: Proto.DataMessage.IBodyRange): range is RawBodyRange => {
if (!isNumber(range.start)) {
.map(range => {
const { start, length, ...restOfRange } = range;
if (!isNumber(start)) {
log.warn('filterAndClean: Dropping bodyRange with non-number start');
return false;
return undefined;
}
if (!isNumber(range.length)) {
if (!isNumber(length)) {
log.warn('filterAndClean: Dropping bodyRange with non-number length');
return false;
return undefined;
}
if (range.mentionUuid) {
let mentionUuid: AciString | undefined;
if ('mentionAci' in range && range.mentionAci) {
mentionUuid = normalizeAci(range.mentionAci, 'BodyRange.mentionAci');
} else if ('mentionUuid' in range && range.mentionUuid) {
mentionUuid = normalizeAci(range.mentionUuid, 'BodyRange.mentionUuid');
}
if (mentionUuid) {
countByTypeRecord[MENTION_NAME] += 1;
if (countByTypeRecord[MENTION_NAME] > MAX_PER_TYPE) {
return false;
return undefined;
}
return true;
return {
...restOfRange,
start,
length,
mentionUuid,
};
}
if (range.style) {
if ('style' in range && range.style) {
countByTypeRecord[range.style] += 1;
if (countByTypeRecord[range.style] > MAX_PER_TYPE) {
return false;
return undefined;
}
return true;
return {
...restOfRange,
start,
length,
style: range.style,
};
}
log.warn('filterAndClean: Dropping unknown bodyRange');
return false;
return undefined;
})
.map(range => ({ ...range }));
.filter(isNotNil);
}
export function hydrateRanges(

View file

@ -4,6 +4,7 @@
import { z } from 'zod';
import Long from 'long';
import { CallMode } from './Calling';
import type { ServiceIdString } from './ServiceId';
import { bytesToUuid } from '../util/uuidToBytes';
import { SignalService as Proto } from '../protobuf';
import * as Bytes from '../Bytes';
@ -66,7 +67,7 @@ export type CallStatus = DirectCallStatus | GroupCallStatus;
export type CallDetails = Readonly<{
callId: string;
peerId: string;
peerId: ServiceIdString | string;
ringerId: string | null;
mode: CallMode;
type: CallType;

View file

@ -19,7 +19,7 @@ import type {
} from './Attachment';
import { toLogFormat } from './errors';
import type { LoggerType } from './Logging';
import type { UUIDStringType } from './UUID';
import type { ServiceIdString } from './ServiceId';
type GenericEmbeddedContactType<AvatarType> = {
name?: Name;
@ -31,7 +31,7 @@ type GenericEmbeddedContactType<AvatarType> = {
// Populated by selector
firstNumber?: string;
uuid?: UUIDStringType;
uuid?: ServiceIdString;
};
export type EmbeddedContactType = GenericEmbeddedContactType<Avatar>;
@ -149,7 +149,7 @@ export function embeddedContactSelector(
options: {
regionCode?: string;
firstNumber?: string;
uuid?: UUIDStringType;
uuid?: ServiceIdString;
getAbsoluteAttachmentPath: (path: string) => string;
}
): EmbeddedContactType {

View file

@ -3,7 +3,7 @@
import type { EmbeddedContactType } from './EmbeddedContact';
import type { MessageAttributesType } from '../model-types.d';
import type { UUIDStringType } from './UUID';
import type { ServiceIdString } from './ServiceId';
export enum PanelType {
AllMedia = 'AllMedia',
@ -28,7 +28,7 @@ export type PanelRequestType =
contact: EmbeddedContactType;
signalAccount?: {
phoneNumber: string;
uuid: UUIDStringType;
uuid: ServiceIdString;
};
};
}
@ -50,7 +50,7 @@ export type PanelRenderType =
contact: EmbeddedContactType;
signalAccount?: {
phoneNumber: string;
uuid: UUIDStringType;
uuid: ServiceIdString;
};
};
}

View file

@ -3,30 +3,30 @@
import { strictAssert } from '../util/assert';
import type { UUIDStringType } from './UUID';
import { UUID } from './UUID';
import type { ServiceIdString } from './ServiceId';
import { isServiceIdString } from './ServiceId';
import type { AddressStringType } from './Address';
import { Address } from './Address';
const QUALIFIED_ADDRESS_REGEXP = /^([0-9a-f-]+):([0-9a-f-]+).(\d+)$/i;
const QUALIFIED_ADDRESS_REGEXP = /^([:0-9a-f-]+):([:0-9a-f-]+).(\d+)$/i;
export type QualifiedAddressCreateOptionsType = Readonly<{
ourUuid: string;
uuid: string;
ourServiceId: ServiceIdString;
serviceId: ServiceIdString;
deviceId: number;
}>;
export type QualifiedAddressStringType =
`${UUIDStringType}:${AddressStringType}`;
`${ServiceIdString}:${AddressStringType}`;
export class QualifiedAddress {
constructor(
public readonly ourUuid: UUID,
public readonly ourServiceId: ServiceIdString,
public readonly address: Address
) {}
public get uuid(): UUID {
return this.address.uuid;
public get serviceId(): ServiceIdString {
return this.address.serviceId;
}
public get deviceId(): number {
@ -34,18 +34,23 @@ export class QualifiedAddress {
}
public toString(): QualifiedAddressStringType {
return `${this.ourUuid.toString()}:${this.address.toString()}`;
return `${this.ourServiceId}:${this.address.toString()}`;
}
public static parse(value: string): QualifiedAddress {
const match = value.match(QUALIFIED_ADDRESS_REGEXP);
strictAssert(match != null, `Invalid QualifiedAddress: ${value}`);
const [whole, ourUuid, uuid, deviceId] = match;
const [whole, ourServiceId, serviceId, deviceId] = match;
strictAssert(whole === value, 'Integrity check');
strictAssert(
isServiceIdString(ourServiceId),
'Our service id is incorrect'
);
strictAssert(isServiceIdString(serviceId), 'Their service id is incorrect');
return new QualifiedAddress(
new UUID(ourUuid),
Address.create(uuid, parseInt(deviceId, 10))
ourServiceId,
Address.create(serviceId, parseInt(deviceId, 10))
);
}
}

124
ts/types/ServiceId.ts Normal file
View file

@ -0,0 +1,124 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v4 as generateUuid } from 'uuid';
import { isValidUuid } from '../util/isValidUuid';
import * as log from '../logging/log';
import type { LoggerType } from './Logging';
export enum ServiceIdKind {
ACI = 'ACI',
PNI = 'PNI',
Unknown = 'Unknown',
}
export type PniString = string & { __pni: never };
export type UntaggedPniString = string & { __pni: never };
export type AciString = string & { __aci: never };
export type ServiceIdString = PniString | AciString;
export function isServiceIdString(value?: string): value is ServiceIdString {
return isAciString(value) || isPniString(value);
}
export function isAciString(value?: string): value is AciString {
return isValidUuid(value);
}
export function isPniString(value?: string): value is PniString {
if (value === undefined) {
return false;
}
if (value.startsWith('PNI:')) {
return true;
}
// Legacy IDs
return isValidUuid(value);
}
export function isUntaggedPniString(
value?: string
): value is UntaggedPniString {
return isValidUuid(value);
}
export function toTaggedPni(untagged: UntaggedPniString): PniString {
return `PNI:${untagged}` as PniString;
}
export function normalizeServiceId(
rawServiceId: string,
context: string,
logger: Pick<LoggerType, 'warn'> = log
): ServiceIdString {
const result = rawServiceId.toLowerCase().replace(/^pni:/, 'PNI:');
if (!isAciString(result) && !isPniString(result)) {
logger.warn(
`Normalizing invalid serviceId: ${rawServiceId} to ${result} in context "${context}"`
);
// Cast anyway we don't want to throw here
return result as ServiceIdString;
}
return result;
}
export function normalizeAci(
rawAci: string,
context: string,
logger: Pick<LoggerType, 'warn'> = log
): AciString {
const result = rawAci.toLowerCase();
if (!isAciString(result)) {
logger.warn(
`Normalizing invalid serviceId: ${rawAci} to ${result} in context "${context}"`
);
// Cast anyway we don't want to throw here
return result as AciString;
}
return result;
}
export function normalizePni(
rawPni: string,
context: string,
logger: Pick<LoggerType, 'warn'> = log
): PniString {
const result = rawPni.toLowerCase().replace(/^pni:/, 'PNI:');
if (!isPniString(result)) {
logger.warn(
`Normalizing invalid serviceId: ${rawPni} to ${result} in context "${context}"`
);
// Cast anyway we don't want to throw here
return result as PniString;
}
return result;
}
// For tests
export function generateAci(): AciString {
return generateUuid() as AciString;
}
export function generatePni(): PniString {
return `PNI:${generateUuid()}` as PniString;
}
export function getAciFromPrefix(prefix: string): AciString {
let padded = prefix;
while (padded.length < 8) {
padded += '0';
}
return `${padded}-0000-4000-8000-${'0'.repeat(12)}` as AciString;
}

View file

@ -1,7 +1,7 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { UUID } from './UUID';
import type { AciString } from './ServiceId';
export const SIGNAL_ACI = UUID.cast('11111111-1111-4111-8111-111111111111');
export const SIGNAL_ACI = '11111111-1111-4111-8111-111111111111' as AciString;
export const SIGNAL_AVATAR_PATH = 'images/profile-avatar.svg';

View file

@ -20,6 +20,7 @@ import type {
StorageServiceCredentials,
} from '../textsecure/Types.d';
import type { ThemeSettingType } from './StorageUIKeys';
import type { ServiceIdString } from './ServiceId';
import type { RegisteredChallengeType } from '../challenge';
@ -35,7 +36,7 @@ export type SentMediaQualitySettingType = 'standard' | 'high';
export type NotificationSettingType = 'message' | 'name' | 'count' | 'off';
export type IdentityKeyMap = Record<
string,
ServiceIdString,
{
privKey: Uint8Array;
pubKey: Uint8Array;
@ -50,7 +51,7 @@ export type StorageAccessType = {
'auto-download-update': boolean;
'badge-count-muted-conversations': boolean;
'blocked-groups': ReadonlyArray<string>;
'blocked-uuids': ReadonlyArray<string>;
'blocked-uuids': ReadonlyArray<ServiceIdString>;
'call-ringtone-notification': boolean;
'call-system-notification': boolean;
'hide-menu-bar': boolean;
@ -93,7 +94,7 @@ export type StorageAccessType = {
password: string;
profileKey: Uint8Array;
regionCode: string;
registrationIdMap: Record<string, number>;
registrationIdMap: Record<ServiceIdString, number>;
remoteBuildExpiration: number;
sendEditWarningShown: boolean;
sessionResets: SessionResetsType;

View file

@ -9,7 +9,8 @@ import type { ConversationType } from '../state/ducks/conversations';
import type { ReadStatus } from '../messages/MessageReadStatus';
import type { SendStatus } from '../messages/MessageSendState';
import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists';
import type { UUIDStringType } from './UUID';
import type { ServiceIdString } from './ServiceId';
import type { StoryDistributionIdString } from './StoryDistributionId';
export type ReplyType = {
author: Pick<
@ -102,14 +103,15 @@ export type StoryViewType = {
};
export type MyStoryType = {
id: string;
// Either a distribution list id or a conversation (group) id
id: StoryDistributionIdString | string;
name: string;
reducedSendStatus: ResolvedSendStatus;
stories: Array<StoryViewType>;
};
export const MY_STORY_ID: UUIDStringType =
'00000000-0000-0000-0000-000000000000';
export const MY_STORY_ID: StoryDistributionIdString =
'00000000-0000-0000-0000-000000000000' as StoryDistributionIdString;
export enum StoryViewDirectionType {
Next = 'Next',
@ -138,14 +140,15 @@ export enum StoryViewModeType {
export type StoryDistributionListWithMembersDataType = Omit<
StoryDistributionListDataType,
'memberUuids'
'memberServiceIds'
> & {
members: Array<ConversationType>;
};
export function getStoryDistributionListName(
i18n: LocalizerType,
id: string,
// Distribution id or conversation (group) id
id: StoryDistributionIdString | string | undefined,
name: string
): string {
return id === MY_STORY_ID ? i18n('icu:Stories__mine') : name;
@ -170,8 +173,7 @@ export enum ResolvedSendStatus {
}
export type StoryMessageRecipientsType = Array<{
destinationAci?: string;
destinationPni?: string;
distributionListIds: Array<string>;
destinationServiceId?: ServiceIdString;
distributionListIds: Array<StoryDistributionIdString>;
isAllowedToReply: boolean;
}>;

View file

@ -0,0 +1,42 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v4 as generateUuid } from 'uuid';
import { isValidUuid } from '../util/isValidUuid';
import * as log from '../logging/log';
import type { LoggerType } from './Logging';
export type StoryDistributionIdString = string & {
__story_distribution_id: never;
};
export function isStoryDistributionId(
value?: string
): value is StoryDistributionIdString {
return isValidUuid(value);
}
export function generateStoryDistributionId(): StoryDistributionIdString {
return generateUuid() as StoryDistributionIdString;
}
export function normalizeStoryDistributionId(
distributionId: string,
context: string,
logger: Pick<LoggerType, 'warn'> = log
): StoryDistributionIdString {
const result = distributionId.toLowerCase();
if (!isStoryDistributionId(result)) {
logger.warn(
'Normalizing invalid story distribution id: ' +
`${distributionId} to ${result} in context "${context}"`
);
// Cast anyway we don't want to throw here
return result as unknown as StoryDistributionIdString;
}
return result;
}

View file

@ -1,100 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v4 as generateUUID } from 'uuid';
import { strictAssert } from '../util/assert';
export type UUIDStringType =
`${string}-${string}-${string}-${string}-${string}`;
export type TaggedUUIDStringType = Readonly<
| {
aci: UUIDStringType;
pni?: undefined;
}
| {
aci?: undefined;
pni: UUIDStringType;
}
>;
export enum UUIDKind {
ACI = 'ACI',
PNI = 'PNI',
Unknown = 'Unknown',
}
export const UUID_BYTE_SIZE = 16;
const UUID_REGEXP =
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i;
export const isValidUuid = (value: unknown): value is UUIDStringType => {
if (typeof value !== 'string') {
return false;
}
// Zero UUID is a valid uuid.
if (value === '00000000-0000-0000-0000-000000000000') {
return true;
}
return UUID_REGEXP.test(value);
};
export class UUID {
constructor(protected readonly value: string) {
strictAssert(isValidUuid(value), `Invalid UUID: ${value}`);
}
public toString(): UUIDStringType {
return this.value as UUIDStringType;
}
public isEqual(other: UUID): boolean {
return this.value === other.value;
}
public static parse(value: string): UUID {
return new UUID(value);
}
public static lookup(identifier: string): UUID | undefined {
const conversation = window.ConversationController.get(identifier);
const uuid = conversation?.get('uuid');
if (uuid === undefined) {
return undefined;
}
return new UUID(uuid);
}
public static checkedLookup(identifier: string): UUID {
const uuid = UUID.lookup(identifier);
strictAssert(
uuid !== undefined,
`Conversation ${identifier} not found or has no uuid`
);
return uuid;
}
public static generate(): UUID {
return new UUID(generateUUID());
}
public static cast(value: UUIDStringType): never;
public static cast(value: string): UUIDStringType;
public static cast(value: string): UUIDStringType {
return new UUID(value.toLowerCase()).toString();
}
// For testing
public static fromPrefix(value: string): UUID {
let padded = value;
while (padded.length < 8) {
padded += '0';
}
return new UUID(`${padded}-0000-4000-8000-${'0'.repeat(12)}`);
}
}

View file

@ -2,13 +2,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { IntlShape } from 'react-intl';
import type { UUIDStringType } from './UUID';
import type { AciString } from './ServiceId';
import type { LocaleDirection } from '../../app/locale';
import type { HourCyclePreference, LocaleMessagesType } from './I18N';
export type StoryContextType = {
authorUuid?: UUIDStringType;
authorAci?: AciString;
timestamp: number;
};