signal-desktop/ts/SignalProtocolStore.ts

2162 lines
60 KiB
TypeScript
Raw Normal View History

// Copyright 2016-2022 Signal Messenger, LLC
2021-02-26 23:42:45 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import PQueue from 'p-queue';
2022-07-28 16:35:29 +00:00
import { isNumber, omit } from 'lodash';
2021-05-25 22:40:04 +00:00
import { z } from 'zod';
import {
Direction,
2022-08-02 01:31:24 +00:00
IdentityKeyPair,
PreKeyRecord,
PrivateKey,
PublicKey,
SenderKeyRecord,
SessionRecord,
SignedPreKeyRecord,
} from '@signalapp/libsignal-client';
2021-09-24 00:49:05 +00:00
import * as Bytes from './Bytes';
import { constantTimeEqual, sha256 } from './Crypto';
import { assertDev, strictAssert } from './util/assert';
2021-02-26 23:42:45 +00:00
import { isNotNil } from './util/isNotNil';
2021-05-19 21:25:56 +00:00
import { Zone } from './util/Zone';
import { isMoreRecentThan } from './util/timestamp';
import {
sessionRecordToProtobuf,
2021-09-24 00:49:05 +00:00
sessionStructureToBytes,
} from './util/sessionTranslation';
import type {
2021-05-25 22:40:04 +00:00
DeviceType,
IdentityKeyType,
IdentityKeyIdType,
KeyPairType,
OuterSignedPrekeyType,
2022-08-02 01:31:24 +00:00
PniKeyMaterialType,
2022-08-15 21:53:33 +00:00
PniSignatureMessageType,
PreKeyIdType,
PreKeyType,
SenderKeyIdType,
SenderKeyType,
SessionIdType,
SessionResetsType,
SessionType,
SignedPreKeyIdType,
SignedPreKeyType,
UnprocessedType,
UnprocessedUpdateType,
} from './textsecure/Types.d';
import type { RemoveAllConfiguration } from './types/RemoveAllConfiguration';
import type { UUIDStringType } from './types/UUID';
2022-04-20 19:35:53 +00:00
import { UUID, UUIDKind } from './types/UUID';
import type { Address } from './types/Address';
import type { QualifiedAddressStringType } from './types/QualifiedAddress';
import { QualifiedAddress } from './types/QualifiedAddress';
import * as log from './logging/log';
import { singleProtoJobQueue } from './jobs/singleProtoJobQueue';
import * as Errors from './types/errors';
import MessageSender from './textsecure/SendMessage';
import { MINUTE } from './util/durations';
2021-02-26 23:42:45 +00:00
const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds
const VerifiedStatus = {
DEFAULT: 0,
VERIFIED: 1,
UNVERIFIED: 2,
};
function validateVerifiedStatus(status: number): boolean {
if (
status === VerifiedStatus.DEFAULT ||
status === VerifiedStatus.VERIFIED ||
status === VerifiedStatus.UNVERIFIED
) {
return true;
}
return false;
}
const identityKeySchema = z.object({
id: z.string(),
2021-09-24 00:49:05 +00:00
publicKey: z.instanceof(Uint8Array),
firstUse: z.boolean(),
timestamp: z.number().refine((value: number) => value % 1 === 0 && value > 0),
verified: z.number().refine(validateVerifiedStatus),
nonblockingApproval: z.boolean(),
2021-02-26 23:42:45 +00:00
});
function validateIdentityKey(attrs: unknown): attrs is IdentityKeyType {
// We'll throw if this doesn't match
identityKeySchema.parse(attrs);
return true;
}
type HasIdType<T> = {
id: T;
2021-02-26 23:42:45 +00:00
};
type CacheEntryType<DBType, HydratedType> =
| {
hydrated: false;
fromDB: DBType;
}
| { hydrated: true; fromDB: DBType; item: HydratedType };
2021-02-26 23:42:45 +00:00
type MapFields =
| 'identityKeys'
| 'preKeys'
| 'senderKeys'
| 'sessions'
| 'signedPreKeys';
2022-08-15 21:53:33 +00:00
export type SessionTransactionOptions = Readonly<{
zone?: Zone;
}>;
export type VerifyAlternateIdentityOptionsType = Readonly<{
aci: UUID;
pni: UUID;
signature: Uint8Array;
}>;
2021-05-19 21:25:56 +00:00
export const GLOBAL_ZONE = new Zone('GLOBAL_ZONE');
async function _fillCaches<ID, T extends HasIdType<ID>, HydratedType>(
2021-02-26 23:42:45 +00:00
object: SignalProtocolStore,
field: MapFields,
2021-02-26 23:42:45 +00:00
itemsPromise: Promise<Array<T>>
): Promise<void> {
const items = await itemsPromise;
const cache = new Map<ID, CacheEntryType<T, HydratedType>>();
2021-02-26 23:42:45 +00:00
for (let i = 0, max = items.length; i < max; i += 1) {
const fromDB = items[i];
const { id } = fromDB;
2021-02-26 23:42:45 +00:00
cache.set(id, {
fromDB,
hydrated: false,
});
2021-02-26 23:42:45 +00:00
}
log.info(`SignalProtocolStore: Finished caching ${field} data`);
2021-02-26 23:42:45 +00:00
// eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any
object[field] = cache as any;
}
export function hydrateSession(session: SessionType): SessionRecord {
return SessionRecord.deserialize(Buffer.from(session.record, 'base64'));
}
export function hydratePublicKey(identityKey: IdentityKeyType): PublicKey {
return PublicKey.deserialize(Buffer.from(identityKey.publicKey));
}
export function hydratePreKey(preKey: PreKeyType): PreKeyRecord {
const publicKey = PublicKey.deserialize(Buffer.from(preKey.publicKey));
const privateKey = PrivateKey.deserialize(Buffer.from(preKey.privateKey));
return PreKeyRecord.new(preKey.keyId, publicKey, privateKey);
}
export function hydrateSignedPreKey(
signedPreKey: SignedPreKeyType
): SignedPreKeyRecord {
const createdAt = signedPreKey.created_at;
const pubKey = PublicKey.deserialize(Buffer.from(signedPreKey.publicKey));
const privKey = PrivateKey.deserialize(Buffer.from(signedPreKey.privateKey));
const signature = Buffer.from([]);
return SignedPreKeyRecord.new(
signedPreKey.keyId,
createdAt,
pubKey,
privKey,
signature
);
}
2021-02-26 23:42:45 +00:00
export function freezeSession(session: SessionRecord): string {
return session.serialize().toString('base64');
}
2021-09-24 00:49:05 +00:00
export function freezePublicKey(publicKey: PublicKey): Uint8Array {
return publicKey.serialize();
}
export function freezePreKey(preKey: PreKeyRecord): KeyPairType {
const keyPair = {
2021-09-24 00:49:05 +00:00
pubKey: preKey.publicKey().serialize(),
privKey: preKey.privateKey().serialize(),
};
return keyPair;
}
export function freezeSignedPreKey(
signedPreKey: SignedPreKeyRecord
): KeyPairType {
const keyPair = {
2021-09-24 00:49:05 +00:00
pubKey: signedPreKey.publicKey().serialize(),
privKey: signedPreKey.privateKey().serialize(),
};
return keyPair;
}
2021-02-26 23:42:45 +00:00
// We add a this parameter to avoid an 'implicit any' error on the next line
2021-11-11 22:43:05 +00:00
const EventsMixin = function EventsMixin(this: unknown) {
2022-05-31 21:42:18 +00:00
Object.assign(this, window.Backbone.Events);
2021-02-26 23:42:45 +00:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2021-11-11 22:43:05 +00:00
} as any as typeof window.Backbone.EventsMixin;
2021-02-26 23:42:45 +00:00
type SessionCacheEntry = CacheEntryType<SessionType, SessionRecord>;
type SenderKeyCacheEntry = CacheEntryType<SenderKeyType, SenderKeyRecord>;
2021-08-24 21:07:40 +00:00
type ZoneQueueEntryType = Readonly<{
zone: Zone;
callback(): void;
}>;
2021-02-26 23:42:45 +00:00
export class SignalProtocolStore extends EventsMixin {
// Enums used across the app
VerifiedStatus = VerifiedStatus;
// Cached values
private ourIdentityKeys = new Map<UUIDStringType, KeyPairType>();
2021-02-26 23:42:45 +00:00
private ourRegistrationIds = new Map<UUIDStringType, number>();
2021-02-26 23:42:45 +00:00
2022-08-15 21:53:33 +00:00
private cachedPniSignatureMessage: PniSignatureMessageType | undefined;
identityKeys?: Map<
IdentityKeyIdType,
CacheEntryType<IdentityKeyType, PublicKey>
>;
senderKeys?: Map<SenderKeyIdType, SenderKeyCacheEntry>;
sessions?: Map<SessionIdType, SessionCacheEntry>;
preKeys?: Map<PreKeyIdType, CacheEntryType<PreKeyType, PreKeyRecord>>;
2021-02-26 23:42:45 +00:00
signedPreKeys?: Map<
SignedPreKeyIdType,
CacheEntryType<SignedPreKeyType, SignedPreKeyRecord>
>;
2021-02-26 23:42:45 +00:00
senderKeyQueues = new Map<QualifiedAddressStringType, PQueue>();
sessionQueues = new Map<SessionIdType, PQueue>();
2021-02-26 23:42:45 +00:00
2021-05-19 21:25:56 +00:00
private currentZone?: Zone;
private currentZoneDepth = 0;
2021-08-24 21:07:40 +00:00
private readonly zoneQueue: Array<ZoneQueueEntryType> = [];
2021-05-19 21:25:56 +00:00
private pendingSessions = new Map<SessionIdType, SessionCacheEntry>();
2021-05-19 21:25:56 +00:00
private pendingSenderKeys = new Map<SenderKeyIdType, SenderKeyCacheEntry>();
2021-05-19 21:25:56 +00:00
private pendingUnprocessed = new Map<string, UnprocessedType>();
2021-02-26 23:42:45 +00:00
async hydrateCaches(): Promise<void> {
await Promise.all([
(async () => {
this.ourIdentityKeys.clear();
const map = await window.Signal.Data.getItemById('identityKeyMap');
if (!map) {
return;
}
for (const key of Object.keys(map.value)) {
const { privKey, pubKey } = map.value[key];
this.ourIdentityKeys.set(new UUID(key).toString(), {
2022-07-28 16:35:29 +00:00
privKey,
pubKey,
});
}
2021-02-26 23:42:45 +00:00
})(),
(async () => {
this.ourRegistrationIds.clear();
const map = await window.Signal.Data.getItemById('registrationIdMap');
if (!map) {
return;
}
for (const key of Object.keys(map.value)) {
this.ourRegistrationIds.set(new UUID(key).toString(), map.value[key]);
}
2021-02-26 23:42:45 +00:00
})(),
_fillCaches<string, IdentityKeyType, PublicKey>(
2021-02-26 23:42:45 +00:00
this,
'identityKeys',
window.Signal.Data.getAllIdentityKeys()
),
_fillCaches<string, SessionType, SessionRecord>(
2021-02-26 23:42:45 +00:00
this,
'sessions',
window.Signal.Data.getAllSessions()
),
_fillCaches<string, PreKeyType, PreKeyRecord>(
2021-02-26 23:42:45 +00:00
this,
'preKeys',
window.Signal.Data.getAllPreKeys()
),
_fillCaches<string, SenderKeyType, SenderKeyRecord>(
this,
'senderKeys',
window.Signal.Data.getAllSenderKeys()
),
_fillCaches<string, SignedPreKeyType, SignedPreKeyRecord>(
2021-02-26 23:42:45 +00:00
this,
'signedPreKeys',
window.Signal.Data.getAllSignedPreKeys()
),
]);
}
2022-08-15 21:53:33 +00:00
getIdentityKeyPair(ourUuid: UUID): KeyPairType | undefined {
return this.ourIdentityKeys.get(ourUuid.toString());
2021-02-26 23:42:45 +00:00
}
async getLocalRegistrationId(ourUuid: UUID): Promise<number | undefined> {
return this.ourRegistrationIds.get(ourUuid.toString());
2021-02-26 23:42:45 +00:00
}
// PreKeys
async loadPreKey(
ourUuid: UUID,
keyId: number
): Promise<PreKeyRecord | undefined> {
2021-02-26 23:42:45 +00:00
if (!this.preKeys) {
throw new Error('loadPreKey: this.preKeys not yet cached!');
}
const id: PreKeyIdType = `${ourUuid.toString()}:${keyId}`;
const entry = this.preKeys.get(id);
if (!entry) {
log.error('Failed to fetch prekey:', id);
return undefined;
2021-02-26 23:42:45 +00:00
}
if (entry.hydrated) {
log.info('Successfully fetched prekey (cache hit):', id);
return entry.item;
}
const item = hydratePreKey(entry.fromDB);
this.preKeys.set(id, {
hydrated: true,
fromDB: entry.fromDB,
item,
});
log.info('Successfully fetched prekey (cache miss):', id);
return item;
2021-02-26 23:42:45 +00:00
}
async storePreKey(
ourUuid: UUID,
keyId: number,
keyPair: KeyPairType
): Promise<void> {
2021-02-26 23:42:45 +00:00
if (!this.preKeys) {
throw new Error('storePreKey: this.preKeys not yet cached!');
}
const id: PreKeyIdType = `${ourUuid.toString()}:${keyId}`;
if (this.preKeys.has(id)) {
throw new Error(`storePreKey: prekey ${id} already exists!`);
}
2021-02-26 23:42:45 +00:00
const fromDB = {
id,
keyId,
ourUuid: ourUuid.toString(),
2021-02-26 23:42:45 +00:00
publicKey: keyPair.pubKey,
privateKey: keyPair.privKey,
};
await window.Signal.Data.createOrUpdatePreKey(fromDB);
this.preKeys.set(id, {
hydrated: false,
fromDB,
});
2021-02-26 23:42:45 +00:00
}
async removePreKey(ourUuid: UUID, keyId: number): Promise<void> {
2021-02-26 23:42:45 +00:00
if (!this.preKeys) {
throw new Error('removePreKey: this.preKeys not yet cached!');
}
const id: PreKeyIdType = `${ourUuid.toString()}:${keyId}`;
2021-02-26 23:42:45 +00:00
try {
2021-11-30 19:33:51 +00:00
this.trigger('removePreKey', ourUuid);
2021-02-26 23:42:45 +00:00
} catch (error) {
log.error(
2021-02-26 23:42:45 +00:00
'removePreKey error triggering removePreKey:',
error && error.stack ? error.stack : error
);
}
this.preKeys.delete(id);
await window.Signal.Data.removePreKeyById(id);
2021-02-26 23:42:45 +00:00
}
async clearPreKeyStore(): Promise<void> {
if (this.preKeys) {
this.preKeys.clear();
}
2021-02-26 23:42:45 +00:00
await window.Signal.Data.removeAllPreKeys();
}
// Signed PreKeys
async loadSignedPreKey(
ourUuid: UUID,
2021-02-26 23:42:45 +00:00
keyId: number
): Promise<SignedPreKeyRecord | undefined> {
2021-02-26 23:42:45 +00:00
if (!this.signedPreKeys) {
throw new Error('loadSignedPreKey: this.signedPreKeys not yet cached!');
}
const id: SignedPreKeyIdType = `${ourUuid.toString()}:${keyId}`;
const entry = this.signedPreKeys.get(id);
if (!entry) {
log.error('Failed to fetch signed prekey:', id);
return undefined;
2021-02-26 23:42:45 +00:00
}
if (entry.hydrated) {
log.info('Successfully fetched signed prekey (cache hit):', id);
return entry.item;
}
const item = hydrateSignedPreKey(entry.fromDB);
this.signedPreKeys.set(id, {
hydrated: true,
item,
fromDB: entry.fromDB,
});
log.info('Successfully fetched signed prekey (cache miss):', id);
return item;
2021-02-26 23:42:45 +00:00
}
async loadSignedPreKeys(
ourUuid: UUID
): Promise<Array<OuterSignedPrekeyType>> {
2021-02-26 23:42:45 +00:00
if (!this.signedPreKeys) {
throw new Error('loadSignedPreKeys: this.signedPreKeys not yet cached!');
}
if (arguments.length > 1) {
throw new Error('loadSignedPreKeys takes one argument');
2021-02-26 23:42:45 +00:00
}
const entries = Array.from(this.signedPreKeys.values());
return entries
.filter(({ fromDB }) => fromDB.ourUuid === ourUuid.toString())
.map(entry => {
const preKey = entry.fromDB;
return {
pubKey: preKey.publicKey,
privKey: preKey.privateKey,
created_at: preKey.created_at,
keyId: preKey.keyId,
confirmed: preKey.confirmed,
};
});
2021-02-26 23:42:45 +00:00
}
// Note that this is also called in update scenarios, for confirming that signed prekeys
// have indeed been accepted by the server.
2021-02-26 23:42:45 +00:00
async storeSignedPreKey(
ourUuid: UUID,
2021-02-26 23:42:45 +00:00
keyId: number,
keyPair: KeyPairType,
2022-07-28 16:35:29 +00:00
confirmed?: boolean,
createdAt = Date.now()
2021-02-26 23:42:45 +00:00
): Promise<void> {
if (!this.signedPreKeys) {
throw new Error('storeSignedPreKey: this.signedPreKeys not yet cached!');
}
const id: SignedPreKeyIdType = `${ourUuid.toString()}:${keyId}`;
const fromDB = {
id,
ourUuid: ourUuid.toString(),
keyId,
2021-02-26 23:42:45 +00:00
publicKey: keyPair.pubKey,
privateKey: keyPair.privKey,
2022-07-28 16:35:29 +00:00
created_at: createdAt,
2021-02-26 23:42:45 +00:00
confirmed: Boolean(confirmed),
};
await window.Signal.Data.createOrUpdateSignedPreKey(fromDB);
this.signedPreKeys.set(id, {
hydrated: false,
fromDB,
});
2021-02-26 23:42:45 +00:00
}
async removeSignedPreKey(ourUuid: UUID, keyId: number): Promise<void> {
2021-02-26 23:42:45 +00:00
if (!this.signedPreKeys) {
throw new Error('removeSignedPreKey: this.signedPreKeys not yet cached!');
}
const id: SignedPreKeyIdType = `${ourUuid.toString()}:${keyId}`;
this.signedPreKeys.delete(id);
await window.Signal.Data.removeSignedPreKeyById(id);
2021-02-26 23:42:45 +00:00
}
async clearSignedPreKeysStore(): Promise<void> {
if (this.signedPreKeys) {
this.signedPreKeys.clear();
}
2021-02-26 23:42:45 +00:00
await window.Signal.Data.removeAllSignedPreKeys();
}
// Sender Key
// Re-entrant sender key transaction routine. Only one sender key transaction could
// be running at the same time.
//
// While in transaction:
//
// - `saveSenderKey()` adds the updated session to the `pendingSenderKeys`
// - `getSenderKey()` looks up the session first in `pendingSenderKeys` and only
// then in the main `senderKeys` store
//
// When transaction ends:
//
// - successfully: pending sender key stores are batched into the database
// - with an error: pending sender key stores are reverted
async enqueueSenderKeyJob<T>(
qualifiedAddress: QualifiedAddress,
task: () => Promise<T>,
zone = GLOBAL_ZONE
): Promise<T> {
return this.withZone(zone, 'enqueueSenderKeyJob', async () => {
const queue = this._getSenderKeyQueue(qualifiedAddress);
return queue.add<T>(task);
});
}
private _createSenderKeyQueue(): PQueue {
2021-11-23 22:01:03 +00:00
return new PQueue({
concurrency: 1,
timeout: MINUTE * 30,
2021-11-23 22:01:03 +00:00
throwOnTimeout: true,
});
}
private _getSenderKeyQueue(senderId: QualifiedAddress): PQueue {
const cachedQueue = this.senderKeyQueues.get(senderId.toString());
if (cachedQueue) {
return cachedQueue;
}
const freshQueue = this._createSenderKeyQueue();
this.senderKeyQueues.set(senderId.toString(), freshQueue);
return freshQueue;
}
private getSenderKeyId(
senderKeyId: QualifiedAddress,
distributionId: string
): SenderKeyIdType {
return `${senderKeyId.toString()}--${distributionId}`;
}
async saveSenderKey(
qualifiedAddress: QualifiedAddress,
distributionId: string,
record: SenderKeyRecord,
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
): Promise<void> {
await this.withZone(zone, 'saveSenderKey', async () => {
if (!this.senderKeys) {
throw new Error('saveSenderKey: this.senderKeys not yet cached!');
}
const senderId = qualifiedAddress.toString();
try {
const id = this.getSenderKeyId(qualifiedAddress, distributionId);
const fromDB: SenderKeyType = {
id,
senderId,
distributionId,
data: record.serialize(),
lastUpdatedDate: Date.now(),
};
this.pendingSenderKeys.set(id, {
hydrated: true,
fromDB,
item: record,
});
// Current zone doesn't support pending sessions - commit immediately
if (!zone.supportsPendingSenderKeys()) {
await this.commitZoneChanges('saveSenderKey');
}
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
log.error(
`saveSenderKey: failed to save senderKey ${senderId}/${distributionId}: ${errorString}`
);
}
});
}
async getSenderKey(
qualifiedAddress: QualifiedAddress,
distributionId: string,
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
): Promise<SenderKeyRecord | undefined> {
return this.withZone(zone, 'getSenderKey', async () => {
if (!this.senderKeys) {
throw new Error('getSenderKey: this.senderKeys not yet cached!');
}
const senderId = qualifiedAddress.toString();
try {
const id = this.getSenderKeyId(qualifiedAddress, distributionId);
const map = this.pendingSenderKeys.has(id)
? this.pendingSenderKeys
: this.senderKeys;
const entry = map.get(id);
if (!entry) {
log.error('Failed to fetch sender key:', id);
return undefined;
}
if (entry.hydrated) {
log.info('Successfully fetched sender key (cache hit):', id);
return entry.item;
}
const item = SenderKeyRecord.deserialize(
Buffer.from(entry.fromDB.data)
);
this.senderKeys.set(id, {
hydrated: true,
item,
fromDB: entry.fromDB,
});
log.info('Successfully fetched sender key(cache miss):', id);
return item;
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
log.error(
`getSenderKey: failed to load sender key ${senderId}/${distributionId}: ${errorString}`
);
return undefined;
}
});
}
2021-05-25 22:40:04 +00:00
async removeSenderKey(
qualifiedAddress: QualifiedAddress,
2021-05-25 22:40:04 +00:00
distributionId: string
): Promise<void> {
if (!this.senderKeys) {
throw new Error('getSenderKey: this.senderKeys not yet cached!');
}
const senderId = qualifiedAddress.toString();
2021-05-25 22:40:04 +00:00
try {
const id = this.getSenderKeyId(qualifiedAddress, distributionId);
2021-05-25 22:40:04 +00:00
await window.Signal.Data.removeSenderKeyById(id);
this.senderKeys.delete(id);
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
log.error(
`removeSenderKey: failed to remove senderKey ${senderId}/${distributionId}: ${errorString}`
2021-05-25 22:40:04 +00:00
);
}
}
async removeAllSenderKeys(): Promise<void> {
return this.withZone(GLOBAL_ZONE, 'removeAllSenderKeys', async () => {
if (this.senderKeys) {
this.senderKeys.clear();
}
if (this.pendingSenderKeys) {
this.pendingSenderKeys.clear();
}
await window.Signal.Data.removeAllSenderKeys();
});
}
// Session Queue
async enqueueSessionJob<T>(
qualifiedAddress: QualifiedAddress,
task: () => Promise<T>,
zone: Zone = GLOBAL_ZONE
): Promise<T> {
return this.withZone(zone, 'enqueueSessionJob', async () => {
const queue = this._getSessionQueue(qualifiedAddress);
return queue.add<T>(task);
});
}
private _createSessionQueue(): PQueue {
2021-11-23 22:01:03 +00:00
return new PQueue({
concurrency: 1,
timeout: MINUTE * 30,
2021-11-23 22:01:03 +00:00
throwOnTimeout: true,
});
}
private _getSessionQueue(id: QualifiedAddress): PQueue {
const cachedQueue = this.sessionQueues.get(id.toString());
if (cachedQueue) {
return cachedQueue;
}
const freshQueue = this._createSessionQueue();
this.sessionQueues.set(id.toString(), freshQueue);
return freshQueue;
}
2021-02-26 23:42:45 +00:00
// Sessions
// Re-entrant session transaction routine. Only one session transaction could
// be running at the same time.
//
// While in transaction:
//
// - `storeSession()` adds the updated session to the `pendingSessions`
// - `loadSession()` looks up the session first in `pendingSessions` and only
// then in the main `sessions` store
//
// When transaction ends:
//
// - successfully: pending session stores are batched into the database
// - with an error: pending session stores are reverted
2021-05-19 21:25:56 +00:00
public async withZone<T>(
zone: Zone,
name: string,
2021-05-19 21:25:56 +00:00
body: () => Promise<T>
): Promise<T> {
2021-05-19 21:25:56 +00:00
const debugName = `withZone(${zone.name}:${name})`;
// Allow re-entering from LibSignalStores
2021-05-19 21:25:56 +00:00
if (this.currentZone && this.currentZone !== zone) {
const start = Date.now();
log.info(`${debugName}: locked by ${this.currentZone.name}, waiting`);
return new Promise<T>((resolve, reject) => {
2021-08-24 21:07:40 +00:00
const callback = async () => {
const duration = Date.now() - start;
log.info(`${debugName}: unlocked after ${duration}ms`);
// Call `.withZone` synchronously from `this.zoneQueue` to avoid
// extra in-between ticks while we are on microtasks queue.
try {
resolve(await this.withZone(zone, name, body));
} catch (error) {
reject(error);
}
2021-08-24 21:07:40 +00:00
};
this.zoneQueue.push({ zone, callback });
});
2021-02-26 23:42:45 +00:00
}
2021-05-19 21:25:56 +00:00
this.enterZone(zone, name);
2021-02-26 23:42:45 +00:00
let result: T;
2021-02-26 23:42:45 +00:00
try {
result = await body();
} catch (error) {
2021-05-19 21:25:56 +00:00
if (this.isInTopLevelZone()) {
await this.revertZoneChanges(name, error);
}
2021-05-19 21:25:56 +00:00
this.leaveZone(zone);
throw error;
}
2021-02-26 23:42:45 +00:00
2021-05-19 21:25:56 +00:00
if (this.isInTopLevelZone()) {
await this.commitZoneChanges(name);
}
2021-05-19 21:25:56 +00:00
this.leaveZone(zone);
return result;
}
2021-05-19 21:25:56 +00:00
private async commitZoneChanges(name: string): Promise<void> {
const { pendingSenderKeys, pendingSessions, pendingUnprocessed } = this;
if (
pendingSenderKeys.size === 0 &&
pendingSessions.size === 0 &&
pendingUnprocessed.size === 0
) {
return;
2021-02-26 23:42:45 +00:00
}
log.info(
`commitZoneChanges(${name}): ` +
`pending sender keys ${pendingSenderKeys.size}, ` +
`pending sessions ${pendingSessions.size}, ` +
`pending unprocessed ${pendingUnprocessed.size}`
);
this.pendingSenderKeys = new Map();
this.pendingSessions = new Map();
this.pendingUnprocessed = new Map();
// Commit both sender keys, sessions and unprocessed in the same database transaction
// to unroll both on error.
await window.Signal.Data.commitDecryptResult({
senderKeys: Array.from(pendingSenderKeys.values()).map(
({ fromDB }) => fromDB
),
sessions: Array.from(pendingSessions.values()).map(
({ fromDB }) => fromDB
),
unprocessed: Array.from(pendingUnprocessed.values()),
});
// Apply changes to in-memory storage after successful DB write.
const { sessions } = this;
assertDev(
sessions !== undefined,
"Can't commit unhydrated session storage"
);
pendingSessions.forEach((value, key) => {
sessions.set(key, value);
});
const { senderKeys } = this;
assertDev(
senderKeys !== undefined,
"Can't commit unhydrated sender key storage"
);
pendingSenderKeys.forEach((value, key) => {
senderKeys.set(key, value);
});
}
2021-05-19 21:25:56 +00:00
private async revertZoneChanges(name: string, error: Error): Promise<void> {
log.info(
2021-05-19 21:25:56 +00:00
`revertZoneChanges(${name}): ` +
`pending sender keys size ${this.pendingSenderKeys.size}, ` +
`pending sessions size ${this.pendingSessions.size}, ` +
2021-05-19 21:25:56 +00:00
`pending unprocessed size ${this.pendingUnprocessed.size}`,
error && error.stack
);
this.pendingSenderKeys.clear();
this.pendingSessions.clear();
this.pendingUnprocessed.clear();
}
2021-05-19 21:25:56 +00:00
private isInTopLevelZone(): boolean {
return this.currentZoneDepth === 1;
}
private enterZone(zone: Zone, name: string): void {
this.currentZoneDepth += 1;
if (this.currentZoneDepth === 1) {
assertDev(this.currentZone === undefined, 'Should not be in the zone');
2021-05-19 21:25:56 +00:00
this.currentZone = zone;
if (zone !== GLOBAL_ZONE) {
log.info(`SignalProtocolStore.enterZone(${zone.name}:${name})`);
2021-05-19 21:25:56 +00:00
}
}
}
private leaveZone(zone: Zone): void {
assertDev(this.currentZone === zone, 'Should be in the correct zone');
2021-05-19 21:25:56 +00:00
this.currentZoneDepth -= 1;
assertDev(
this.currentZoneDepth >= 0,
'Unmatched number of leaveZone calls'
);
2021-05-19 21:25:56 +00:00
// Since we allow re-entering zones we might actually be in two overlapping
// async calls. Leave the zone and yield to another one only if there are
// no active zone users anymore.
if (this.currentZoneDepth !== 0) {
return;
}
if (zone !== GLOBAL_ZONE) {
log.info(`SignalProtocolStore.leaveZone(${zone.name})`);
2021-05-19 21:25:56 +00:00
}
this.currentZone = undefined;
2021-08-24 21:07:40 +00:00
2021-05-19 21:25:56 +00:00
const next = this.zoneQueue.shift();
2021-08-24 21:07:40 +00:00
if (!next) {
return;
}
const toEnter = [next];
while (this.zoneQueue[0]?.zone === next.zone) {
const elem = this.zoneQueue.shift();
assertDev(elem, 'Zone element should be present');
2021-08-24 21:07:40 +00:00
toEnter.push(elem);
}
log.info(
2021-08-24 21:07:40 +00:00
`SignalProtocolStore: running blocked ${toEnter.length} jobs in ` +
`zone ${next.zone.name}`
);
for (const { callback } of toEnter) {
callback();
}
}
async loadSession(
qualifiedAddress: QualifiedAddress,
2021-05-19 21:25:56 +00:00
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
): Promise<SessionRecord | undefined> {
2021-05-19 21:25:56 +00:00
return this.withZone(zone, 'loadSession', async () => {
if (!this.sessions) {
throw new Error('loadSession: this.sessions not yet cached!');
}
if (qualifiedAddress == null) {
throw new Error('loadSession: qualifiedAddress was undefined/null');
2021-05-19 21:25:56 +00:00
}
const id = qualifiedAddress.toString();
2021-05-19 21:25:56 +00:00
try {
const map = this.pendingSessions.has(id)
? this.pendingSessions
: this.sessions;
const entry = map.get(id);
2021-05-19 21:25:56 +00:00
if (!entry) {
return undefined;
}
2021-05-19 21:25:56 +00:00
if (entry.hydrated) {
return entry.item;
}
// We'll either just hydrate the item or we'll fully migrate the session
// and save it to the database.
return await this._maybeMigrateSession(entry.fromDB, { zone });
2021-05-19 21:25:56 +00:00
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
log.error(`loadSession: failed to load session ${id}: ${errorString}`);
2021-05-19 21:25:56 +00:00
return undefined;
}
});
}
2021-02-26 23:42:45 +00:00
2021-05-25 22:40:04 +00:00
async loadSessions(
qualifiedAddresses: Array<QualifiedAddress>,
2021-05-25 22:40:04 +00:00
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
): Promise<Array<SessionRecord>> {
2021-05-28 23:09:17 +00:00
return this.withZone(zone, 'loadSessions', async () => {
2021-05-25 22:40:04 +00:00
const sessions = await Promise.all(
qualifiedAddresses.map(async address =>
2021-05-25 22:40:04 +00:00
this.loadSession(address, { zone })
)
);
return sessions.filter(isNotNil);
});
}
private async _maybeMigrateSession(
session: SessionType,
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
): Promise<SessionRecord> {
if (!this.sessions) {
throw new Error('_maybeMigrateSession: this.sessions not yet cached!');
}
// Already migrated, hydrate and update cache
if (session.version === 2) {
const item = hydrateSession(session);
const map = this.pendingSessions.has(session.id)
? this.pendingSessions
: this.sessions;
map.set(session.id, {
hydrated: true,
item,
fromDB: session,
});
return item;
}
// Not yet converted, need to translate to new format and save
if (session.version !== undefined) {
throw new Error('_maybeMigrateSession: Unknown session version type!');
}
const ourUuid = new UUID(session.ourUuid);
2022-08-15 21:53:33 +00:00
const keyPair = this.getIdentityKeyPair(ourUuid);
if (!keyPair) {
throw new Error('_maybeMigrateSession: No identity key for ourself!');
}
const localRegistrationId = await this.getLocalRegistrationId(ourUuid);
if (!isNumber(localRegistrationId)) {
throw new Error('_maybeMigrateSession: No registration id for ourself!');
}
const localUserData = {
identityKeyPublic: keyPair.pubKey,
registrationId: localRegistrationId,
};
log.info(`_maybeMigrateSession: Migrating session with id ${session.id}`);
const sessionProto = sessionRecordToProtobuf(
JSON.parse(session.record),
localUserData
);
const record = SessionRecord.deserialize(
2021-09-24 00:49:05 +00:00
Buffer.from(sessionStructureToBytes(sessionProto))
);
await this.storeSession(QualifiedAddress.parse(session.id), record, {
zone,
});
return record;
2021-02-26 23:42:45 +00:00
}
async storeSession(
qualifiedAddress: QualifiedAddress,
record: SessionRecord,
2021-05-19 21:25:56 +00:00
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
): Promise<void> {
2021-05-19 21:25:56 +00:00
await this.withZone(zone, 'storeSession', async () => {
if (!this.sessions) {
throw new Error('storeSession: this.sessions not yet cached!');
}
2021-02-26 23:42:45 +00:00
if (qualifiedAddress == null) {
throw new Error('storeSession: qualifiedAddress was undefined/null');
2021-05-19 21:25:56 +00:00
}
const { uuid, deviceId } = qualifiedAddress;
const conversation = window.ConversationController.lookupOrCreate({
2021-09-10 17:17:32 +00:00
uuid: uuid.toString(),
});
strictAssert(
conversation !== undefined,
2021-09-10 17:17:32 +00:00
'storeSession: Ensure contact ids failed'
);
const id = qualifiedAddress.toString();
2021-02-26 23:42:45 +00:00
2021-05-19 21:25:56 +00:00
try {
const fromDB = {
id,
version: 2,
ourUuid: qualifiedAddress.ourUuid.toString(),
conversationId: conversation.id,
uuid: uuid.toString(),
2021-05-19 21:25:56 +00:00
deviceId,
record: record.serialize().toString('base64'),
};
const newSession = {
hydrated: true,
fromDB,
item: record,
};
assertDev(this.currentZone, 'Must run in the zone');
2021-05-19 21:25:56 +00:00
this.pendingSessions.set(id, newSession);
// Current zone doesn't support pending sessions - commit immediately
if (!zone.supportsPendingSessions()) {
await this.commitZoneChanges('storeSession');
}
2021-05-19 21:25:56 +00:00
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
log.error(`storeSession: Save failed for ${id}: ${errorString}`);
2021-05-19 21:25:56 +00:00
throw error;
}
});
2021-02-26 23:42:45 +00:00
}
2021-05-25 22:40:04 +00:00
async getOpenDevices(
ourUuid: UUID,
identifiers: Array<string>,
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
2021-05-25 22:40:04 +00:00
): Promise<{
devices: Array<DeviceType>;
emptyIdentifiers: Array<string>;
}> {
return this.withZone(zone, 'getOpenDevices', async () => {
if (!this.sessions) {
2021-05-25 22:40:04 +00:00
throw new Error('getOpenDevices: this.sessions not yet cached!');
}
2021-05-25 22:40:04 +00:00
if (identifiers.length === 0) {
2022-02-25 02:40:56 +00:00
return { devices: [], emptyIdentifiers: [] };
}
2021-02-26 23:42:45 +00:00
try {
const uuidsOrIdentifiers = new Set(
identifiers.map(
identifier => UUID.lookup(identifier)?.toString() || identifier
)
);
const allSessions = this._getAllSessions();
const entries = allSessions.filter(
({ fromDB }) =>
fromDB.ourUuid === ourUuid.toString() &&
uuidsOrIdentifiers.has(fromDB.uuid)
);
2021-05-25 22:40:04 +00:00
const openEntries: Array<
| undefined
| {
entry: SessionCacheEntry;
record: SessionRecord;
}
2021-05-25 22:40:04 +00:00
> = await Promise.all(
entries.map(async entry => {
if (entry.hydrated) {
const record = entry.item;
if (record.hasCurrentState()) {
return { record, entry };
}
return undefined;
}
const record = await this._maybeMigrateSession(entry.fromDB, {
zone,
});
if (record.hasCurrentState()) {
return { record, entry };
}
return undefined;
})
);
2021-02-26 23:42:45 +00:00
2021-05-25 22:40:04 +00:00
const devices = openEntries
.map(item => {
if (!item) {
2021-05-25 22:40:04 +00:00
return undefined;
}
const { entry, record } = item;
2021-05-25 22:40:04 +00:00
const { uuid } = entry.fromDB;
uuidsOrIdentifiers.delete(uuid);
2021-05-25 22:40:04 +00:00
const id = entry.fromDB.deviceId;
const registrationId = record.remoteRegistrationId();
2021-05-25 22:40:04 +00:00
return {
identifier: uuid,
2021-05-25 22:40:04 +00:00
id,
registrationId,
2021-05-25 22:40:04 +00:00
};
})
.filter(isNotNil);
const emptyIdentifiers = Array.from(uuidsOrIdentifiers.values());
2021-05-25 22:40:04 +00:00
return {
devices,
emptyIdentifiers,
};
} catch (error) {
log.error(
2021-05-25 22:40:04 +00:00
'getOpenDevices: Failed to get devices',
error && error.stack ? error.stack : error
);
2021-05-25 22:40:04 +00:00
throw error;
}
});
2021-02-26 23:42:45 +00:00
}
async getDeviceIds({
ourUuid,
identifier,
}: Readonly<{
ourUuid: UUID;
identifier: string;
}>): Promise<Array<number>> {
const { devices } = await this.getOpenDevices(ourUuid, [identifier]);
2021-05-25 22:40:04 +00:00
return devices.map((device: DeviceType) => device.id);
}
async removeSession(qualifiedAddress: QualifiedAddress): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(GLOBAL_ZONE, 'removeSession', async () => {
if (!this.sessions) {
throw new Error('removeSession: this.sessions not yet cached!');
}
2021-02-26 23:42:45 +00:00
const id = qualifiedAddress.toString();
log.info('removeSession: deleting session for', id);
try {
await window.Signal.Data.removeSessionById(id);
this.sessions.delete(id);
this.pendingSessions.delete(id);
} catch (e) {
log.error(`removeSession: Failed to delete session for ${id}`);
}
});
2021-02-26 23:42:45 +00:00
}
async removeAllSessions(identifier: string): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(GLOBAL_ZONE, 'removeAllSessions', async () => {
if (!this.sessions) {
throw new Error('removeAllSessions: this.sessions not yet cached!');
}
2021-02-26 23:42:45 +00:00
if (identifier == null) {
throw new Error('removeAllSessions: identifier was undefined/null');
}
2021-02-26 23:42:45 +00:00
log.info('removeAllSessions: deleting sessions for', identifier);
2021-02-26 23:42:45 +00:00
const id = window.ConversationController.getConversationId(identifier);
2021-09-10 17:17:32 +00:00
strictAssert(
id,
`removeAllSessions: Conversation not found: ${identifier}`
);
2021-02-26 23:42:45 +00:00
const entries = Array.from(this.sessions.values());
2021-02-26 23:42:45 +00:00
for (let i = 0, max = entries.length; i < max; i += 1) {
const entry = entries[i];
if (entry.fromDB.conversationId === id) {
this.sessions.delete(entry.fromDB.id);
this.pendingSessions.delete(entry.fromDB.id);
}
2021-02-26 23:42:45 +00:00
}
await window.Signal.Data.removeSessionsByConversation(id);
});
2021-02-26 23:42:45 +00:00
}
private async _archiveSession(entry?: SessionCacheEntry, zone?: Zone) {
if (!entry) {
return;
}
const addr = QualifiedAddress.parse(entry.fromDB.id);
await this.enqueueSessionJob(
addr,
async () => {
const item = entry.hydrated
? entry.item
: await this._maybeMigrateSession(entry.fromDB, { zone });
if (!item.hasCurrentState()) {
return;
}
item.archiveCurrentState();
await this.storeSession(addr, item, { zone });
},
zone
);
}
async archiveSession(qualifiedAddress: QualifiedAddress): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(GLOBAL_ZONE, 'archiveSession', async () => {
if (!this.sessions) {
throw new Error('archiveSession: this.sessions not yet cached!');
}
const id = qualifiedAddress.toString();
log.info(`archiveSession: session for ${id}`);
const entry = this.pendingSessions.get(id) || this.sessions.get(id);
await this._archiveSession(entry);
});
}
async archiveSiblingSessions(
encodedAddress: Address,
2021-05-19 21:25:56 +00:00
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(zone, 'archiveSiblingSessions', async () => {
if (!this.sessions) {
throw new Error(
'archiveSiblingSessions: this.sessions not yet cached!'
);
2021-05-19 21:25:56 +00:00
}
2021-02-26 23:42:45 +00:00
log.info(
2021-05-19 21:25:56 +00:00
'archiveSiblingSessions: archiving sibling sessions for',
encodedAddress.toString()
2021-05-19 21:25:56 +00:00
);
const { uuid, deviceId } = encodedAddress;
2021-02-26 23:42:45 +00:00
2021-05-19 21:25:56 +00:00
const allEntries = this._getAllSessions();
const entries = allEntries.filter(
entry =>
entry.fromDB.uuid === uuid.toString() &&
entry.fromDB.deviceId !== deviceId
2021-05-19 21:25:56 +00:00
);
await Promise.all(
entries.map(async entry => {
await this._archiveSession(entry, zone);
2021-05-19 21:25:56 +00:00
})
);
});
2021-02-26 23:42:45 +00:00
}
async archiveAllSessions(uuid: UUID): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(GLOBAL_ZONE, 'archiveAllSessions', async () => {
if (!this.sessions) {
throw new Error('archiveAllSessions: this.sessions not yet cached!');
}
2021-02-26 23:42:45 +00:00
log.info(
'archiveAllSessions: archiving all sessions for',
uuid.toString()
);
2021-02-26 23:42:45 +00:00
const allEntries = this._getAllSessions();
const entries = allEntries.filter(
entry => entry.fromDB.uuid === uuid.toString()
);
await Promise.all(
entries.map(async entry => {
await this._archiveSession(entry);
})
);
});
2021-02-26 23:42:45 +00:00
}
async clearSessionStore(): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(GLOBAL_ZONE, 'clearSessionStore', async () => {
if (this.sessions) {
this.sessions.clear();
}
this.pendingSessions.clear();
await window.Signal.Data.removeAllSessions();
});
2021-02-26 23:42:45 +00:00
}
async lightSessionReset(qualifiedAddress: QualifiedAddress): Promise<void> {
const id = qualifiedAddress.toString();
const sessionResets = window.storage.get(
'sessionResets',
<SessionResetsType>{}
);
const lastReset = sessionResets[id];
const ONE_HOUR = 60 * 60 * 1000;
if (lastReset && isMoreRecentThan(lastReset, ONE_HOUR)) {
log.warn(
`lightSessionReset/${id}: Skipping session reset, last reset at ${lastReset}`
);
return;
}
sessionResets[id] = Date.now();
window.storage.put('sessionResets', sessionResets);
try {
const { uuid } = qualifiedAddress;
// First, fetch this conversation
const conversation = window.ConversationController.lookupOrCreate({
uuid: uuid.toString(),
});
assertDev(conversation, `lightSessionReset/${id}: missing conversation`);
log.warn(`lightSessionReset/${id}: Resetting session`);
// Archive open session with this device
await this.archiveSession(qualifiedAddress);
// Enqueue a null message with newly-created session
await singleProtoJobQueue.add(
MessageSender.getNullMessage({
uuid: uuid.toString(),
})
);
} catch (error) {
// If we failed to do the session reset, then we'll allow another attempt sooner
// than one hour from now.
delete sessionResets[id];
window.storage.put('sessionResets', sessionResets);
log.error(
`lightSessionReset/${id}: Encountered error`,
Errors.toLogFormat(error)
);
}
}
2021-02-26 23:42:45 +00:00
// Identity Keys
getIdentityRecord(uuid: UUID): IdentityKeyType | undefined {
2021-02-26 23:42:45 +00:00
if (!this.identityKeys) {
throw new Error('getIdentityRecord: this.identityKeys not yet cached!');
}
const id = uuid.toString();
2021-02-26 23:42:45 +00:00
try {
const entry = this.identityKeys.get(id);
if (!entry) {
return undefined;
2021-02-26 23:42:45 +00:00
}
return entry.fromDB;
2021-02-26 23:42:45 +00:00
} catch (e) {
log.error(
`getIdentityRecord: Failed to get identity record for identifier ${id}`
2021-02-26 23:42:45 +00:00
);
return undefined;
2021-02-26 23:42:45 +00:00
}
}
async getOrMigrateIdentityRecord(
uuid: UUID
): Promise<IdentityKeyType | undefined> {
if (!this.identityKeys) {
throw new Error(
'getOrMigrateIdentityRecord: this.identityKeys not yet cached!'
);
}
const result = this.getIdentityRecord(uuid);
if (result) {
return result;
}
const newId = uuid.toString();
const conversation = window.ConversationController.get(newId);
if (!conversation) {
return undefined;
}
2021-10-26 22:59:08 +00:00
const conversationId = conversation.id;
const record = this.identityKeys.get(`conversation:${conversationId}`);
if (!record) {
return undefined;
}
const newRecord = {
...record.fromDB,
id: newId,
};
log.info(
`SignalProtocolStore: migrating identity key from ${record.fromDB.id} ` +
`to ${newRecord.id}`
);
await this._saveIdentityKey(newRecord);
this.identityKeys.delete(record.fromDB.id);
await window.Signal.Data.removeIdentityKeyById(record.fromDB.id);
return newRecord;
}
2021-02-26 23:42:45 +00:00
async isTrustedIdentity(
encodedAddress: Address,
2021-09-24 00:49:05 +00:00
publicKey: Uint8Array,
2021-02-26 23:42:45 +00:00
direction: number
): Promise<boolean> {
if (!this.identityKeys) {
throw new Error('isTrustedIdentity: this.identityKeys not yet cached!');
2021-02-26 23:42:45 +00:00
}
if (encodedAddress == null) {
throw new Error('isTrustedIdentity: encodedAddress was undefined/null');
2021-02-26 23:42:45 +00:00
}
const ourUuid = window.textsecure.storage.user.getCheckedUuid();
const isOurIdentifier = encodedAddress.uuid.isEqual(ourUuid);
2021-02-26 23:42:45 +00:00
const identityRecord = await this.getOrMigrateIdentityRecord(
encodedAddress.uuid
);
2021-02-26 23:42:45 +00:00
if (isOurIdentifier) {
if (identityRecord && identityRecord.publicKey) {
return constantTimeEqual(identityRecord.publicKey, publicKey);
}
log.warn(
2021-02-26 23:42:45 +00:00
'isTrustedIdentity: No local record for our own identifier. Returning true.'
);
return true;
}
switch (direction) {
case Direction.Sending:
2021-02-26 23:42:45 +00:00
return this.isTrustedForSending(publicKey, identityRecord);
case Direction.Receiving:
2021-02-26 23:42:45 +00:00
return true;
default:
throw new Error(`isTrustedIdentity: Unknown direction: ${direction}`);
2021-02-26 23:42:45 +00:00
}
}
isTrustedForSending(
2021-09-24 00:49:05 +00:00
publicKey: Uint8Array,
2021-02-26 23:42:45 +00:00
identityRecord?: IdentityKeyType
): boolean {
if (!identityRecord) {
log.info('isTrustedForSending: No previous record, returning true...');
2021-02-26 23:42:45 +00:00
return true;
}
const existing = identityRecord.publicKey;
if (!existing) {
log.info('isTrustedForSending: Nothing here, returning true...');
2021-02-26 23:42:45 +00:00
return true;
}
if (!constantTimeEqual(existing, publicKey)) {
log.info("isTrustedForSending: Identity keys don't match...");
2021-02-26 23:42:45 +00:00
return false;
}
if (identityRecord.verified === VerifiedStatus.UNVERIFIED) {
log.error('isTrustedIdentity: Needs unverified approval!');
2021-02-26 23:42:45 +00:00
return false;
}
if (this.isNonBlockingApprovalRequired(identityRecord)) {
log.error('isTrustedForSending: Needs non-blocking approval!');
2021-02-26 23:42:45 +00:00
return false;
}
return true;
}
2021-09-24 00:49:05 +00:00
async loadIdentityKey(uuid: UUID): Promise<Uint8Array | undefined> {
if (uuid == null) {
throw new Error('loadIdentityKey: uuid was undefined/null');
2021-02-26 23:42:45 +00:00
}
const identityRecord = await this.getOrMigrateIdentityRecord(uuid);
2021-02-26 23:42:45 +00:00
if (identityRecord) {
return identityRecord.publicKey;
}
return undefined;
}
async getFingerprint(uuid: UUID): Promise<string | undefined> {
if (uuid == null) {
throw new Error('loadIdentityKey: uuid was undefined/null');
}
const pubKey = await this.loadIdentityKey(uuid);
if (!pubKey) {
return;
}
const hash = sha256(pubKey);
const fingerprint = hash.slice(0, 4);
return Bytes.toBase64(fingerprint);
}
2021-02-26 23:42:45 +00:00
private async _saveIdentityKey(data: IdentityKeyType): Promise<void> {
if (!this.identityKeys) {
throw new Error('_saveIdentityKey: this.identityKeys not yet cached!');
}
const { id } = data;
await window.Signal.Data.createOrUpdateIdentityKey(data);
this.identityKeys.set(id, {
hydrated: false,
fromDB: data,
});
2021-02-26 23:42:45 +00:00
}
async saveIdentity(
encodedAddress: Address,
2021-09-24 00:49:05 +00:00
publicKey: Uint8Array,
nonblockingApproval = false,
2021-05-19 21:25:56 +00:00
{ zone }: SessionTransactionOptions = {}
2021-02-26 23:42:45 +00:00
): Promise<boolean> {
if (!this.identityKeys) {
throw new Error('saveIdentity: this.identityKeys not yet cached!');
}
if (encodedAddress == null) {
throw new Error('saveIdentity: encodedAddress was undefined/null');
2021-02-26 23:42:45 +00:00
}
2021-09-24 00:49:05 +00:00
if (!(publicKey instanceof Uint8Array)) {
2021-02-26 23:42:45 +00:00
// eslint-disable-next-line no-param-reassign
2021-09-24 00:49:05 +00:00
publicKey = Bytes.fromBinary(publicKey);
2021-02-26 23:42:45 +00:00
}
if (typeof nonblockingApproval !== 'boolean') {
// eslint-disable-next-line no-param-reassign
nonblockingApproval = false;
}
const identityRecord = await this.getOrMigrateIdentityRecord(
encodedAddress.uuid
);
const id = encodedAddress.uuid.toString();
2021-02-26 23:42:45 +00:00
if (!identityRecord || !identityRecord.publicKey) {
// Lookup failed, or the current key was removed, so save this one.
log.info('saveIdentity: Saving new identity...');
2021-02-26 23:42:45 +00:00
await this._saveIdentityKey({
id,
publicKey,
firstUse: true,
timestamp: Date.now(),
verified: VerifiedStatus.DEFAULT,
nonblockingApproval,
});
return false;
}
const oldpublicKey = identityRecord.publicKey;
if (!constantTimeEqual(oldpublicKey, publicKey)) {
log.info('saveIdentity: Replacing existing identity...');
2021-02-26 23:42:45 +00:00
const previousStatus = identityRecord.verified;
let verifiedStatus;
if (
previousStatus === VerifiedStatus.VERIFIED ||
previousStatus === VerifiedStatus.UNVERIFIED
) {
verifiedStatus = VerifiedStatus.UNVERIFIED;
} else {
verifiedStatus = VerifiedStatus.DEFAULT;
}
await this._saveIdentityKey({
id,
publicKey,
firstUse: false,
timestamp: Date.now(),
verified: verifiedStatus,
nonblockingApproval,
});
try {
this.trigger('keychange', encodedAddress.uuid);
2021-02-26 23:42:45 +00:00
} catch (error) {
log.error(
'saveIdentity: error triggering keychange:',
2021-02-26 23:42:45 +00:00
error && error.stack ? error.stack : error
);
}
2021-05-19 21:25:56 +00:00
// Pass the zone to facilitate transactional session use in
// MessageReceiver.ts
await this.archiveSiblingSessions(encodedAddress, {
zone,
});
2021-02-26 23:42:45 +00:00
return true;
}
if (this.isNonBlockingApprovalRequired(identityRecord)) {
log.info('saveIdentity: Setting approval status...');
2021-02-26 23:42:45 +00:00
identityRecord.nonblockingApproval = nonblockingApproval;
await this._saveIdentityKey(identityRecord);
return false;
}
return false;
}
isNonBlockingApprovalRequired(identityRecord: IdentityKeyType): boolean {
return (
!identityRecord.firstUse &&
isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) &&
2021-02-26 23:42:45 +00:00
!identityRecord.nonblockingApproval
);
}
async saveIdentityWithAttributes(
uuid: UUID,
attributes: Partial<IdentityKeyType>
2021-02-26 23:42:45 +00:00
): Promise<void> {
if (uuid == null) {
throw new Error('saveIdentityWithAttributes: uuid was undefined/null');
2021-02-26 23:42:45 +00:00
}
const identityRecord = await this.getOrMigrateIdentityRecord(uuid);
const id = uuid.toString();
2022-04-20 19:35:53 +00:00
// When saving a PNI identity - don't create a separate conversation
const uuidKind = window.textsecure.storage.user.getOurUuidKind(uuid);
if (uuidKind !== UUIDKind.PNI) {
window.ConversationController.getOrCreate(id, 'private');
}
2021-02-26 23:42:45 +00:00
const updates: Partial<IdentityKeyType> = {
2021-02-26 23:42:45 +00:00
...identityRecord,
...attributes,
id,
};
if (validateIdentityKey(updates)) {
2021-02-26 23:42:45 +00:00
await this._saveIdentityKey(updates);
}
}
async setApproval(uuid: UUID, nonblockingApproval: boolean): Promise<void> {
if (uuid == null) {
throw new Error('setApproval: uuid was undefined/null');
2021-02-26 23:42:45 +00:00
}
if (typeof nonblockingApproval !== 'boolean') {
throw new Error('setApproval: Invalid approval status');
2021-02-26 23:42:45 +00:00
}
const identityRecord = await this.getOrMigrateIdentityRecord(uuid);
2021-02-26 23:42:45 +00:00
if (!identityRecord) {
throw new Error(`setApproval: No identity record for ${uuid}`);
2021-02-26 23:42:45 +00:00
}
identityRecord.nonblockingApproval = nonblockingApproval;
await this._saveIdentityKey(identityRecord);
}
async setVerified(
uuid: UUID,
2021-02-26 23:42:45 +00:00
verifiedStatus: number,
2021-09-24 00:49:05 +00:00
publicKey?: Uint8Array
2021-02-26 23:42:45 +00:00
): Promise<void> {
if (uuid == null) {
throw new Error('setVerified: uuid was undefined/null');
2021-02-26 23:42:45 +00:00
}
if (!validateVerifiedStatus(verifiedStatus)) {
throw new Error('setVerified: Invalid verified status');
2021-02-26 23:42:45 +00:00
}
const identityRecord = await this.getOrMigrateIdentityRecord(uuid);
2021-02-26 23:42:45 +00:00
if (!identityRecord) {
throw new Error(`setVerified: No identity record for ${uuid.toString()}`);
2021-02-26 23:42:45 +00:00
}
if (!publicKey || constantTimeEqual(identityRecord.publicKey, publicKey)) {
identityRecord.verified = verifiedStatus;
if (validateIdentityKey(identityRecord)) {
2021-02-26 23:42:45 +00:00
await this._saveIdentityKey(identityRecord);
}
} else {
log.info('setVerified: No identity record for specified publicKey');
2021-02-26 23:42:45 +00:00
}
}
async getVerified(uuid: UUID): Promise<number> {
if (uuid == null) {
throw new Error('getVerified: uuid was undefined/null');
2021-02-26 23:42:45 +00:00
}
const identityRecord = await this.getOrMigrateIdentityRecord(uuid);
2021-02-26 23:42:45 +00:00
if (!identityRecord) {
throw new Error(`getVerified: No identity record for ${uuid}`);
2021-02-26 23:42:45 +00:00
}
const verifiedStatus = identityRecord.verified;
if (validateVerifiedStatus(verifiedStatus)) {
return verifiedStatus;
}
return VerifiedStatus.DEFAULT;
}
// See https://github.com/signalapp/Signal-iOS-Private/blob/e32c2dff0d03f67467b4df621d84b11412d50cdb/SignalServiceKit/src/Messages/OWSIdentityManager.m#L317
// for reference.
2021-02-26 23:42:45 +00:00
async processVerifiedMessage(
uuid: UUID,
2021-02-26 23:42:45 +00:00
verifiedStatus: number,
2021-09-24 00:49:05 +00:00
publicKey?: Uint8Array
2021-02-26 23:42:45 +00:00
): Promise<boolean> {
if (uuid == null) {
throw new Error('processVerifiedMessage: uuid was undefined/null');
2021-02-26 23:42:45 +00:00
}
if (!validateVerifiedStatus(verifiedStatus)) {
throw new Error('processVerifiedMessage: Invalid verified status');
2021-02-26 23:42:45 +00:00
}
2021-09-24 00:49:05 +00:00
if (publicKey !== undefined && !(publicKey instanceof Uint8Array)) {
throw new Error('processVerifiedMessage: Invalid public key');
2021-02-26 23:42:45 +00:00
}
const identityRecord = await this.getOrMigrateIdentityRecord(uuid);
2021-02-26 23:42:45 +00:00
let isEqual = false;
if (identityRecord && publicKey) {
isEqual = constantTimeEqual(publicKey, identityRecord.publicKey);
}
// Just update verified status if the key is the same or not present
if (isEqual || !publicKey) {
await this.setVerified(uuid, verifiedStatus, publicKey);
2021-02-26 23:42:45 +00:00
return false;
}
await this.saveIdentityWithAttributes(uuid, {
publicKey,
verified: verifiedStatus,
firstUse: false,
timestamp: Date.now(),
nonblockingApproval: verifiedStatus === VerifiedStatus.VERIFIED,
});
2021-02-26 23:42:45 +00:00
if (identityRecord) {
try {
this.trigger('keychange', uuid);
} catch (error) {
log.error(
'processVerifiedMessage error triggering keychange:',
Errors.toLogFormat(error)
);
2021-02-26 23:42:45 +00:00
}
// true signifies that we overwrote a previous key with a new one
return true;
2021-02-26 23:42:45 +00:00
}
return false;
}
isUntrusted(uuid: UUID, timestampThreshold = TIMESTAMP_THRESHOLD): boolean {
if (uuid == null) {
throw new Error('isUntrusted: uuid was undefined/null');
2021-02-26 23:42:45 +00:00
}
const identityRecord = this.getIdentityRecord(uuid);
2021-02-26 23:42:45 +00:00
if (!identityRecord) {
throw new Error(`isUntrusted: No identity record for ${uuid.toString()}`);
2021-02-26 23:42:45 +00:00
}
if (
isMoreRecentThan(identityRecord.timestamp, timestampThreshold) &&
2021-02-26 23:42:45 +00:00
!identityRecord.nonblockingApproval &&
!identityRecord.firstUse
) {
return true;
}
return false;
}
async removeIdentityKey(uuid: UUID): Promise<void> {
2021-02-26 23:42:45 +00:00
if (!this.identityKeys) {
throw new Error('removeIdentityKey: this.identityKeys not yet cached!');
}
const id = uuid.toString();
this.identityKeys.delete(id);
await window.Signal.Data.removeIdentityKeyById(id);
await this.removeAllSessions(id);
2021-02-26 23:42:45 +00:00
}
// Not yet processed messages - for resiliency
getUnprocessedCount(): Promise<number> {
2021-05-19 21:25:56 +00:00
return this.withZone(GLOBAL_ZONE, 'getUnprocessedCount', async () => {
return window.Signal.Data.getUnprocessedCount();
});
2021-02-26 23:42:45 +00:00
}
getAllUnprocessedAndIncrementAttempts(): Promise<Array<UnprocessedType>> {
2021-05-19 21:25:56 +00:00
return this.withZone(GLOBAL_ZONE, 'getAllUnprocessed', async () => {
return window.Signal.Data.getAllUnprocessedAndIncrementAttempts();
});
2021-02-26 23:42:45 +00:00
}
getUnprocessedById(id: string): Promise<UnprocessedType | undefined> {
2021-05-19 21:25:56 +00:00
return this.withZone(GLOBAL_ZONE, 'getUnprocessedById', async () => {
return window.Signal.Data.getUnprocessedById(id);
});
2021-02-26 23:42:45 +00:00
}
addUnprocessed(
data: UnprocessedType,
2021-05-19 21:25:56 +00:00
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(zone, 'addUnprocessed', async () => {
this.pendingUnprocessed.set(data.id, data);
// Current zone doesn't support pending unprocessed - commit immediately
if (!zone.supportsPendingUnprocessed()) {
await this.commitZoneChanges('addUnprocessed');
}
});
2021-02-26 23:42:45 +00:00
}
addMultipleUnprocessed(
array: Array<UnprocessedType>,
2021-05-19 21:25:56 +00:00
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(zone, 'addMultipleUnprocessed', async () => {
for (const elem of array) {
this.pendingUnprocessed.set(elem.id, elem);
}
// Current zone doesn't support pending unprocessed - commit immediately
if (!zone.supportsPendingUnprocessed()) {
await this.commitZoneChanges('addMultipleUnprocessed');
}
});
2021-02-26 23:42:45 +00:00
}
updateUnprocessedWithData(
id: string,
data: UnprocessedUpdateType
): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(GLOBAL_ZONE, 'updateUnprocessedWithData', async () => {
await window.Signal.Data.updateUnprocessedWithData(id, data);
});
2021-02-26 23:42:45 +00:00
}
updateUnprocessedsWithData(
items: Array<{ id: string; data: UnprocessedUpdateType }>
): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(
GLOBAL_ZONE,
'updateUnprocessedsWithData',
async () => {
await window.Signal.Data.updateUnprocessedsWithData(items);
}
);
2021-02-26 23:42:45 +00:00
}
removeUnprocessed(idOrArray: string | Array<string>): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(GLOBAL_ZONE, 'removeUnprocessed', async () => {
await window.Signal.Data.removeUnprocessed(idOrArray);
});
2021-02-26 23:42:45 +00:00
}
removeAllUnprocessed(): Promise<void> {
2021-05-19 21:25:56 +00:00
return this.withZone(GLOBAL_ZONE, 'removeAllUnprocessed', async () => {
await window.Signal.Data.removeAllUnprocessed();
});
2021-02-26 23:42:45 +00:00
}
2022-07-28 16:35:29 +00:00
async removeOurOldPni(oldPni: UUID): Promise<void> {
const { storage } = window;
log.info(`SignalProtocolStore.removeOurOldPni(${oldPni})`);
// Update caches
this.ourIdentityKeys.delete(oldPni.toString());
this.ourRegistrationIds.delete(oldPni.toString());
const preKeyPrefix = `${oldPni.toString()}:`;
if (this.preKeys) {
for (const key of this.preKeys.keys()) {
if (key.startsWith(preKeyPrefix)) {
this.preKeys.delete(key);
}
}
}
if (this.signedPreKeys) {
for (const key of this.signedPreKeys.keys()) {
if (key.startsWith(preKeyPrefix)) {
this.signedPreKeys.delete(key);
}
}
}
// Update database
await Promise.all([
storage.put(
'identityKeyMap',
omit(storage.get('identityKeyMap') || {}, oldPni.toString())
),
storage.put(
'registrationIdMap',
omit(storage.get('registrationIdMap') || {}, oldPni.toString())
),
window.Signal.Data.removePreKeysByUuid(oldPni.toString()),
window.Signal.Data.removeSignedPreKeysByUuid(oldPni.toString()),
]);
}
2022-08-02 01:31:24 +00:00
async updateOurPniKeyMaterial(
pni: UUID,
{
identityKeyPair: identityBytes,
signedPreKey: signedPreKeyBytes,
registrationId,
}: PniKeyMaterialType
): Promise<void> {
2022-07-29 16:17:59 +00:00
log.info(`SignalProtocolStore.updateOurPniKeyMaterial(${pni})`);
2022-08-02 01:31:24 +00:00
2022-07-28 16:35:29 +00:00
const identityKeyPair = IdentityKeyPair.deserialize(
Buffer.from(identityBytes)
);
const signedPreKey = SignedPreKeyRecord.deserialize(
Buffer.from(signedPreKeyBytes)
);
2022-08-02 01:31:24 +00:00
2022-07-28 16:35:29 +00:00
const { storage } = window;
2022-08-02 01:31:24 +00:00
2022-07-28 16:35:29 +00:00
const pniPublicKey = identityKeyPair.publicKey.serialize();
const pniPrivateKey = identityKeyPair.privateKey.serialize();
2022-08-02 01:31:24 +00:00
2022-07-28 16:35:29 +00:00
// Update caches
this.ourIdentityKeys.set(pni.toString(), {
pubKey: pniPublicKey,
privKey: pniPrivateKey,
});
this.ourRegistrationIds.set(pni.toString(), registrationId);
2022-08-02 01:31:24 +00:00
2022-07-28 16:35:29 +00:00
// Update database
await Promise.all([
storage.put('identityKeyMap', {
...(storage.get('identityKeyMap') || {}),
[pni.toString()]: {
pubKey: pniPublicKey,
privKey: pniPrivateKey,
},
}),
storage.put('registrationIdMap', {
...(storage.get('registrationIdMap') || {}),
[pni.toString()]: registrationId,
}),
this.storeSignedPreKey(
pni,
signedPreKey.id(),
{
privKey: signedPreKey.privateKey().serialize(),
pubKey: signedPreKey.publicKey().serialize(),
},
true,
signedPreKey.timestamp()
),
]);
}
2021-02-26 23:42:45 +00:00
async removeAllData(): Promise<void> {
await window.Signal.Data.removeAll();
await this.hydrateCaches();
window.storage.reset();
await window.storage.fetch();
window.ConversationController.reset();
await window.ConversationController.load();
}
async removeAllConfiguration(mode: RemoveAllConfiguration): Promise<void> {
await window.Signal.Data.removeAllConfiguration(mode);
2021-02-26 23:42:45 +00:00
await this.hydrateCaches();
window.storage.reset();
await window.storage.fetch();
}
2022-08-15 21:53:33 +00:00
signAlternateIdentity(): PniSignatureMessageType | undefined {
const ourACI = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI);
const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI);
if (!ourPNI) {
log.error('signAlternateIdentity: No local pni');
return undefined;
}
if (this.cachedPniSignatureMessage?.pni === ourPNI.toString()) {
return this.cachedPniSignatureMessage;
}
const aciKeyPair = this.getIdentityKeyPair(ourACI);
const pniKeyPair = this.getIdentityKeyPair(ourPNI);
if (!aciKeyPair) {
log.error('signAlternateIdentity: No local ACI key pair');
return undefined;
}
if (!pniKeyPair) {
log.error('signAlternateIdentity: No local PNI key pair');
return undefined;
}
const pniIdentity = new IdentityKeyPair(
PublicKey.deserialize(Buffer.from(pniKeyPair.pubKey)),
PrivateKey.deserialize(Buffer.from(pniKeyPair.privKey))
);
const aciPubKey = PublicKey.deserialize(Buffer.from(aciKeyPair.pubKey));
this.cachedPniSignatureMessage = {
pni: ourPNI.toString(),
signature: pniIdentity.signAlternateIdentity(aciPubKey),
};
return this.cachedPniSignatureMessage;
}
async verifyAlternateIdentity({
aci,
pni,
signature,
}: VerifyAlternateIdentityOptionsType): Promise<boolean> {
const logId = `SignalProtocolStore.verifyAlternateIdentity(${aci}, ${pni})`;
const aciPublicKeyBytes = await this.loadIdentityKey(aci);
if (!aciPublicKeyBytes) {
log.warn(`${logId}: no ACI public key`);
return false;
}
const pniPublicKeyBytes = await this.loadIdentityKey(pni);
if (!pniPublicKeyBytes) {
log.warn(`${logId}: no PNI public key`);
return false;
}
const aciPublicKey = PublicKey.deserialize(Buffer.from(aciPublicKeyBytes));
const pniPublicKey = PublicKey.deserialize(Buffer.from(pniPublicKeyBytes));
return pniPublicKey.verifyAlternateIdentity(
aciPublicKey,
Buffer.from(signature)
);
}
private _getAllSessions(): Array<SessionCacheEntry> {
const union = new Map<string, SessionCacheEntry>();
this.sessions?.forEach((value, key) => {
union.set(key, value);
});
this.pendingSessions.forEach((value, key) => {
union.set(key, value);
});
return Array.from(union.values());
}
2021-02-26 23:42:45 +00:00
}
window.SignalProtocolStore = SignalProtocolStore;