diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index a3e3387ae..345a83285 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -27,6 +27,8 @@ export type ConfigKeyType = | 'desktop.messageCleanup' | 'desktop.messageRequests' | 'desktop.pnp' + | 'desktop.safetyNumberUUID' + | 'desktop.safetyNumberUUID.timestamp' | 'desktop.retryReceiptLifespan' | 'desktop.retryRespondMaxAge' | 'desktop.senderKey.retry' diff --git a/ts/state/ducks/safetyNumber.ts b/ts/state/ducks/safetyNumber.ts index 1884c231c..9eb236b4a 100644 --- a/ts/state/ducks/safetyNumber.ts +++ b/ts/state/ducks/safetyNumber.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ReadonlyDeep } from 'type-fest'; +import type { ThunkAction } from 'redux-thunk'; + import { generateSecurityNumberBlock } from '../../util/safetyNumber'; import type { ConversationType } from './conversations'; import { @@ -10,6 +12,8 @@ import { } from '../../shims/contactVerification'; import * as log from '../../logging/log'; import * as Errors from '../../types/errors'; +import type { StateType as RootStateType } from '../reducer'; +import { getSecurityNumberIdentifierType } from '../selectors/items'; export type SafetyNumberContactType = ReadonlyDeep<{ safetyNumber: string; @@ -23,83 +27,108 @@ export type SafetyNumberStateType = ReadonlyDeep<{ }; }>; -const GENERATE = 'safetyNumber/GENERATE'; const GENERATE_FULFILLED = 'safetyNumber/GENERATE_FULFILLED'; -const TOGGLE_VERIFIED = 'safetyNumber/TOGGLE_VERIFIED'; const TOGGLE_VERIFIED_FULFILLED = 'safetyNumber/TOGGLE_VERIFIED_FULFILLED'; const TOGGLE_VERIFIED_PENDING = 'safetyNumber/TOGGLE_VERIFIED_PENDING'; -type GenerateAsyncActionType = ReadonlyDeep<{ - contact: ConversationType; - safetyNumber: string; -}>; - -type GenerateActionType = ReadonlyDeep<{ - type: 'safetyNumber/GENERATE'; - payload: Promise; -}>; - type GenerateFulfilledActionType = ReadonlyDeep<{ type: 'safetyNumber/GENERATE_FULFILLED'; - payload: GenerateAsyncActionType; -}>; - -type ToggleVerifiedAsyncActionType = ReadonlyDeep<{ - contact: ConversationType; - safetyNumber?: string; - safetyNumberChanged?: boolean; -}>; - -type ToggleVerifiedActionType = ReadonlyDeep<{ - type: 'safetyNumber/TOGGLE_VERIFIED'; payload: { - data: { contact: ConversationType }; - promise: Promise; + contact: ConversationType; + safetyNumber: string; }; }>; type ToggleVerifiedPendingActionType = ReadonlyDeep<{ type: 'safetyNumber/TOGGLE_VERIFIED_PENDING'; - payload: ToggleVerifiedAsyncActionType; + payload: { + contact: ConversationType; + }; }>; type ToggleVerifiedFulfilledActionType = ReadonlyDeep<{ type: 'safetyNumber/TOGGLE_VERIFIED_FULFILLED'; - payload: ToggleVerifiedAsyncActionType; + payload: { + contact: ConversationType; + safetyNumber?: string; + safetyNumberChanged?: boolean; + }; }>; export type SafetyNumberActionType = ReadonlyDeep< - | GenerateActionType | GenerateFulfilledActionType - | ToggleVerifiedActionType | ToggleVerifiedPendingActionType | ToggleVerifiedFulfilledActionType >; -function generate(contact: ConversationType): GenerateActionType { - return { - type: GENERATE, - payload: doGenerate(contact), - }; -} - -async function doGenerate( +function generate( contact: ConversationType -): Promise { - const securityNumberBlock = await generateSecurityNumberBlock(contact); - return { - contact, - safetyNumber: securityNumberBlock.join(' '), +): ThunkAction { + return async (dispatch, getState) => { + try { + const securityNumberBlock = await generateSecurityNumberBlock( + contact, + getSecurityNumberIdentifierType(getState(), { now: Date.now() }) + ); + dispatch({ + type: GENERATE_FULFILLED, + payload: { + contact, + safetyNumber: securityNumberBlock.join(' '), + }, + }); + } catch (error) { + log.error( + 'failed to generate security number:', + Errors.toLogFormat(error) + ); + } }; } -function toggleVerified(contact: ConversationType): ToggleVerifiedActionType { - return { - type: TOGGLE_VERIFIED, - payload: { - data: { contact }, - promise: doToggleVerified(contact), - }, +function toggleVerified( + contact: ConversationType +): ThunkAction< + void, + RootStateType, + unknown, + ToggleVerifiedPendingActionType | ToggleVerifiedFulfilledActionType +> { + return async (dispatch, getState) => { + dispatch({ + type: TOGGLE_VERIFIED_PENDING, + payload: { + contact, + }, + }); + + try { + await alterVerification(contact); + + dispatch({ + type: TOGGLE_VERIFIED_FULFILLED, + payload: { + contact, + }, + }); + } catch (err) { + if (err.name === 'OutgoingIdentityKeyError') { + await reloadProfiles(contact.id); + const securityNumberBlock = await generateSecurityNumberBlock( + contact, + getSecurityNumberIdentifierType(getState(), { now: Date.now() }) + ); + + dispatch({ + type: TOGGLE_VERIFIED_FULFILLED, + payload: { + contact, + safetyNumber: securityNumberBlock.join(' '), + safetyNumberChanged: true, + }, + }); + } + } }; } @@ -128,27 +157,6 @@ async function alterVerification(contact: ConversationType): Promise { } } -async function doToggleVerified( - contact: ConversationType -): Promise { - try { - await alterVerification(contact); - } catch (err) { - if (err.name === 'OutgoingIdentityKeyError') { - await reloadProfiles(contact.id); - const securityNumberBlock = await generateSecurityNumberBlock(contact); - - return { - contact, - safetyNumber: securityNumberBlock.join(' '), - safetyNumberChanged: true, - }; - } - } - - return { contact }; -} - export const actions = { generateSafetyNumber: generate, toggleVerified, diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index ba65c2e1f..7f9592391 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -5,6 +5,7 @@ import { createSelector } from 'reselect'; import { isInteger } from 'lodash'; import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer'; +import { SecurityNumberIdentifierType } from '../../util/safetyNumber'; import { innerIsBucketValueEnabled } from '../../RemoteConfig'; import type { ConfigKeyType, ConfigMapType } from '../../RemoteConfig'; import type { StateType } from '../reducer'; @@ -145,6 +146,25 @@ export const getContactManagementEnabled = createSelector( } ); +export const getSecurityNumberIdentifierType = createSelector( + getRemoteConfig, + (_state: StateType, { now }: { now: number }) => now, + (remoteConfig: ConfigMapType, now: number): SecurityNumberIdentifierType => { + if (isRemoteConfigFlagEnabled(remoteConfig, 'desktop.safetyNumberUUID')) { + return SecurityNumberIdentifierType.UUIDIdentifier; + } + + const timestamp = remoteConfig['desktop.safetyNumberUUID.timestamp']?.value; + if (typeof timestamp !== 'number') { + return SecurityNumberIdentifierType.E164Identifier; + } + + return now >= timestamp + ? SecurityNumberIdentifierType.UUIDIdentifier + : SecurityNumberIdentifierType.E164Identifier; + } +); + export const getDefaultConversationColor = createSelector( getItems, ( diff --git a/ts/util/index.ts b/ts/util/index.ts index 2343ec19a..fd324ac5f 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -9,7 +9,6 @@ import { createBatcher } from './batcher'; import { createWaitBatcher } from './waitBatcher'; import { deleteForEveryone } from './deleteForEveryone'; import { downloadAttachment } from './downloadAttachment'; -import { generateSecurityNumber } from './safetyNumber'; import { getStringForProfileChange } from './getStringForProfileChange'; import { getTextWithMentions } from './getTextWithMentions'; import { getUuidsForE164s } from './getUuidsForE164s'; @@ -53,7 +52,6 @@ export { downloadAttachment, flushMessageCounter, fromWebSafeBase64, - generateSecurityNumber, getStringForProfileChange, getTextWithMentions, getUserAgent, diff --git a/ts/util/safetyNumber.ts b/ts/util/safetyNumber.ts index 39b02016f..6291f86e3 100644 --- a/ts/util/safetyNumber.ts +++ b/ts/util/safetyNumber.ts @@ -6,17 +6,18 @@ import type { ConversationType } from '../state/ducks/conversations'; import { UUID } from '../types/UUID'; import { assertDev } from './assert'; +import { missingCaseError } from './missingCaseError'; import * as log from '../logging/log'; -export async function generateSecurityNumber( - ourNumber: string, +function generateSecurityNumber( + ourId: string, ourKey: Uint8Array, - theirNumber: string, + theirId: string, theirKey: Uint8Array -): Promise { - const ourNumberBuf = Buffer.from(ourNumber); +): string { + const ourNumberBuf = Buffer.from(ourId); const ourKeyObj = PublicKey.deserialize(Buffer.from(ourKey)); - const theirNumberBuf = Buffer.from(theirNumber); + const theirNumberBuf = Buffer.from(theirId); const theirKeyObj = PublicKey.deserialize(Buffer.from(theirKey)); const fingerprint = Fingerprint.new( @@ -28,13 +29,21 @@ export async function generateSecurityNumber( theirKeyObj ); - const fingerprintString = fingerprint.displayableFingerprint().toString(); - return Promise.resolve(fingerprintString); + return fingerprint.displayableFingerprint().toString(); +} + +export enum SecurityNumberIdentifierType { + UUIDIdentifier = 'UUIDIdentifier', + E164Identifier = 'E164Identifier', } export async function generateSecurityNumberBlock( - contact: ConversationType + contact: ConversationType, + identifierType: SecurityNumberIdentifierType ): Promise> { + const logId = `generateSecurityNumberBlock(${contact.id}, ${identifierType})`; + log.info(`${logId}: starting`); + const { storage } = window.textsecure; const ourNumber = storage.user.getNumber(); const ourUuid = storage.user.getCheckedUuid(); @@ -56,20 +65,33 @@ export async function generateSecurityNumberBlock( throw new Error('Could not load their key'); } - if (!contact.e164) { - log.error( - 'generateSecurityNumberBlock: Attempted to generate security number for contact with no e164' - ); - return []; - } + let securityNumber: string; + if (identifierType === SecurityNumberIdentifierType.E164Identifier) { + if (!contact.e164) { + log.error( + `${logId}: Attempted to generate security number for contact with no e164` + ); + return []; + } - assertDev(ourNumber, 'Should have our number'); - const securityNumber = await generateSecurityNumber( - ourNumber, - ourKey, - contact.e164, - theirKey - ); + assertDev(ourNumber, 'Should have our number'); + securityNumber = generateSecurityNumber( + ourNumber, + ourKey, + contact.e164, + theirKey + ); + } else if (identifierType === SecurityNumberIdentifierType.UUIDIdentifier) { + assertDev(theirUuid, 'Should have their uuid'); + securityNumber = generateSecurityNumber( + ourUuid.toString(), + ourKey, + theirUuid.toString(), + theirKey + ); + } else { + throw missingCaseError(identifierType); + } const chunks = []; for (let i = 0; i < securityNumber.length; i += 5) {