diff --git a/background.html b/background.html
index 3ced72b69..8e602dee0 100644
--- a/background.html
+++ b/background.html
@@ -329,7 +329,6 @@
-
diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js
deleted file mode 100644
index 75bbc41ae..000000000
--- a/js/signal_protocol_store.js
+++ /dev/null
@@ -1,1050 +0,0 @@
-// Copyright 2016-2020 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-/* global
- dcodeIO, Backbone, _, libsignal, textsecure, ConversationController, stringObject */
-
-/* eslint-disable no-proto */
-
-// eslint-disable-next-line func-names
-(function () {
- const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds
- const Direction = {
- SENDING: 1,
- RECEIVING: 2,
- };
-
- const VerifiedStatus = {
- DEFAULT: 0,
- VERIFIED: 1,
- UNVERIFIED: 2,
- };
-
- function validateVerifiedStatus(status) {
- if (
- status === VerifiedStatus.DEFAULT ||
- status === VerifiedStatus.VERIFIED ||
- status === VerifiedStatus.UNVERIFIED
- ) {
- return true;
- }
- return false;
- }
-
- const StaticByteBufferProto = new dcodeIO.ByteBuffer().__proto__;
- const StaticArrayBufferProto = new ArrayBuffer().__proto__;
- const StaticUint8ArrayProto = new Uint8Array().__proto__;
-
- function isStringable(thing) {
- return (
- thing === Object(thing) &&
- (thing.__proto__ === StaticArrayBufferProto ||
- thing.__proto__ === StaticUint8ArrayProto ||
- thing.__proto__ === StaticByteBufferProto)
- );
- }
- function convertToArrayBuffer(thing) {
- if (thing === undefined) {
- return undefined;
- }
- if (thing === Object(thing)) {
- if (thing.__proto__ === StaticArrayBufferProto) {
- return thing;
- }
- // TODO: Several more cases here...
- }
-
- if (thing instanceof Array) {
- // Assuming Uint16Array from curve25519
- const res = new ArrayBuffer(thing.length * 2);
- const uint = new Uint16Array(res);
- for (let i = 0; i < thing.length; i += 1) {
- uint[i] = thing[i];
- }
- return res;
- }
-
- let str;
- if (isStringable(thing)) {
- str = stringObject(thing);
- } else if (typeof thing === 'string') {
- str = thing;
- } else {
- throw new Error(
- `Tried to convert a non-stringable thing of type ${typeof thing} to an array buffer`
- );
- }
- const res = new ArrayBuffer(str.length);
- const uint = new Uint8Array(res);
- for (let i = 0; i < str.length; i += 1) {
- uint[i] = str.charCodeAt(i);
- }
- return res;
- }
-
- function equalArrayBuffers(ab1, ab2) {
- if (!(ab1 instanceof ArrayBuffer && ab2 instanceof ArrayBuffer)) {
- return false;
- }
- if (ab1.byteLength !== ab2.byteLength) {
- return false;
- }
- let result = 0;
- const ta1 = new Uint8Array(ab1);
- const ta2 = new Uint8Array(ab2);
- for (let i = 0; i < ab1.byteLength; i += 1) {
- // eslint-disable-next-line no-bitwise
- result |= ta1[i] ^ ta2[i];
- }
- return result === 0;
- }
-
- const IdentityRecord = Backbone.Model.extend({
- storeName: 'identityKeys',
- validAttributes: [
- 'id',
- 'publicKey',
- 'firstUse',
- 'timestamp',
- 'verified',
- 'nonblockingApproval',
- ],
- validate(attrs) {
- const attributeNames = _.keys(attrs);
- const { validAttributes } = this;
- const allValid = _.all(attributeNames, attributeName =>
- _.contains(validAttributes, attributeName)
- );
- if (!allValid) {
- return new Error('Invalid identity key attribute names');
- }
- const allPresent = _.all(validAttributes, attributeName =>
- _.contains(attributeNames, attributeName)
- );
- if (!allPresent) {
- return new Error('Missing identity key attributes');
- }
-
- if (typeof attrs.id !== 'string') {
- return new Error('Invalid identity key id');
- }
- if (!(attrs.publicKey instanceof ArrayBuffer)) {
- return new Error('Invalid identity key publicKey');
- }
- if (typeof attrs.firstUse !== 'boolean') {
- return new Error('Invalid identity key firstUse');
- }
- if (typeof attrs.timestamp !== 'number' || !(attrs.timestamp >= 0)) {
- return new Error('Invalid identity key timestamp');
- }
- if (!validateVerifiedStatus(attrs.verified)) {
- return new Error('Invalid identity key verified');
- }
- if (typeof attrs.nonblockingApproval !== 'boolean') {
- return new Error('Invalid identity key nonblockingApproval');
- }
-
- return null;
- },
- });
-
- async function normalizeEncodedAddress(encodedAddress) {
- const [identifier, deviceId] = textsecure.utils.unencodeNumber(
- encodedAddress
- );
- try {
- const conv = ConversationController.getOrCreate(identifier, 'private');
- return `${conv.get('id')}.${deviceId}`;
- } catch (e) {
- window.log.error(
- `could not get conversation for identifier ${identifier}`
- );
- throw e;
- }
- }
-
- function SignalProtocolStore() {}
-
- async function _hydrateCache(object, field, itemsPromise, idField) {
- const items = await itemsPromise;
-
- const cache = Object.create(null);
- for (let i = 0, max = items.length; i < max; i += 1) {
- const item = items[i];
- const id = item[idField];
-
- cache[id] = item;
- }
-
- window.log.info(`SignalProtocolStore: Finished caching ${field} data`);
- // eslint-disable-next-line no-param-reassign
- object[field] = cache;
- }
-
- SignalProtocolStore.prototype = {
- constructor: SignalProtocolStore,
- async hydrateCaches() {
- await Promise.all([
- (async () => {
- const item = await window.Signal.Data.getItemById('identityKey');
- this.ourIdentityKey = item ? item.value : undefined;
- })(),
- (async () => {
- const item = await window.Signal.Data.getItemById('registrationId');
- this.ourRegistrationId = item ? item.value : undefined;
- })(),
- _hydrateCache(
- this,
- 'identityKeys',
- window.Signal.Data.getAllIdentityKeys(),
- 'id'
- ),
- _hydrateCache(
- this,
- 'sessions',
- await window.Signal.Data.getAllSessions(),
- 'id'
- ),
- _hydrateCache(
- this,
- 'preKeys',
- window.Signal.Data.getAllPreKeys(),
- 'id'
- ),
- _hydrateCache(
- this,
- 'signedPreKeys',
- window.Signal.Data.getAllSignedPreKeys(),
- 'id'
- ),
- ]);
- },
-
- async getIdentityKeyPair() {
- return this.ourIdentityKey;
- },
- async getLocalRegistrationId() {
- return this.ourRegistrationId;
- },
-
- // PreKeys
-
- async loadPreKey(keyId) {
- const key = this.preKeys[keyId];
- if (key) {
- window.log.info('Successfully fetched prekey:', keyId);
- return {
- pubKey: key.publicKey,
- privKey: key.privateKey,
- };
- }
-
- window.log.error('Failed to fetch prekey:', keyId);
- return undefined;
- },
- async storePreKey(keyId, keyPair) {
- const data = {
- id: keyId,
- publicKey: keyPair.pubKey,
- privateKey: keyPair.privKey,
- };
-
- this.preKeys[keyId] = data;
- await window.Signal.Data.createOrUpdatePreKey(data);
- },
- async removePreKey(keyId) {
- try {
- this.trigger('removePreKey');
- } catch (error) {
- window.log.error(
- 'removePreKey error triggering removePreKey:',
- error && error.stack ? error.stack : error
- );
- }
-
- delete this.preKeys[keyId];
- await window.Signal.Data.removePreKeyById(keyId);
- },
- async clearPreKeyStore() {
- this.preKeys = Object.create(null);
- await window.Signal.Data.removeAllPreKeys();
- },
-
- // Signed PreKeys
-
- async loadSignedPreKey(keyId) {
- const key = this.signedPreKeys[keyId];
- if (key) {
- window.log.info('Successfully fetched signed prekey:', key.id);
- return {
- pubKey: key.publicKey,
- privKey: key.privateKey,
- created_at: key.created_at,
- keyId: key.id,
- confirmed: key.confirmed,
- };
- }
-
- window.log.error('Failed to fetch signed prekey:', keyId);
- return undefined;
- },
- async loadSignedPreKeys() {
- if (arguments.length > 0) {
- throw new Error('loadSignedPreKeys takes no arguments');
- }
-
- const keys = Object.values(this.signedPreKeys);
- return keys.map(prekey => ({
- pubKey: prekey.publicKey,
- privKey: prekey.privateKey,
- created_at: prekey.created_at,
- keyId: prekey.id,
- confirmed: prekey.confirmed,
- }));
- },
- async storeSignedPreKey(keyId, keyPair, confirmed) {
- const data = {
- id: keyId,
- publicKey: keyPair.pubKey,
- privateKey: keyPair.privKey,
- created_at: Date.now(),
- confirmed: Boolean(confirmed),
- };
-
- this.signedPreKeys[keyId] = data;
- await window.Signal.Data.createOrUpdateSignedPreKey(data);
- },
- async removeSignedPreKey(keyId) {
- delete this.signedPreKeys[keyId];
- await window.Signal.Data.removeSignedPreKeyById(keyId);
- },
- async clearSignedPreKeysStore() {
- this.signedPreKeys = Object.create(null);
- await window.Signal.Data.removeAllSignedPreKeys();
- },
-
- // Sessions
-
- async loadSession(encodedAddress) {
- if (encodedAddress === null || encodedAddress === undefined) {
- throw new Error('Tried to get session for undefined/null number');
- }
-
- try {
- const id = await normalizeEncodedAddress(encodedAddress);
- const session = this.sessions[id];
-
- if (session) {
- return session.record;
- }
- } catch (error) {
- const errorString = error && error.stack ? error.stack : error;
- window.log.error(
- `could not load session ${encodedAddress}: ${errorString}`
- );
- }
-
- return undefined;
- },
- async storeSession(encodedAddress, record) {
- if (encodedAddress === null || encodedAddress === undefined) {
- throw new Error('Tried to put session for undefined/null number');
- }
- const unencoded = textsecure.utils.unencodeNumber(encodedAddress);
- const deviceId = parseInt(unencoded[1], 10);
-
- try {
- const id = await normalizeEncodedAddress(encodedAddress);
- const previousData = this.sessions[id];
-
- const data = {
- id,
- conversationId: textsecure.utils.unencodeNumber(id)[0],
- deviceId,
- record,
- };
-
- // Optimistically update in-memory cache; will revert if save fails.
- this.sessions[id] = data;
-
- try {
- await window.Signal.Data.createOrUpdateSession(data);
- } catch (e) {
- if (previousData) {
- this.sessions[id] = previousData;
- }
- throw e;
- }
- } catch (error) {
- const errorString = error && error.stack ? error.stack : error;
- window.log.error(
- `could not store session for ${encodedAddress}: ${errorString}`
- );
- }
- },
- async getDeviceIds(identifier) {
- if (identifier === null || identifier === undefined) {
- throw new Error('Tried to get device ids for undefined/null number');
- }
-
- try {
- const id = ConversationController.getConversationId(identifier);
- const allSessions = Object.values(this.sessions);
- const sessions = allSessions.filter(
- session => session.conversationId === id
- );
- const openSessions = await Promise.all(
- sessions.map(async session => {
- const sessionCipher = new libsignal.SessionCipher(
- textsecure.storage.protocol,
- session.id
- );
-
- const hasOpenSession = await sessionCipher.hasOpenSession();
- if (hasOpenSession) {
- return session;
- }
-
- return undefined;
- })
- );
-
- return openSessions.filter(Boolean).map(item => item.deviceId);
- } catch (error) {
- window.log.error(
- `could not get device ids for identifier ${identifier}`,
- error && error.stack ? error.stack : error
- );
- }
-
- return [];
- },
- async removeSession(encodedAddress) {
- window.log.info('removeSession: deleting session for', encodedAddress);
- try {
- const id = await normalizeEncodedAddress(encodedAddress);
- delete this.sessions[id];
- await window.Signal.Data.removeSessionById(id);
- } catch (e) {
- window.log.error(`could not delete session for ${encodedAddress}`);
- }
- },
- async removeAllSessions(identifier) {
- if (identifier === null || identifier === undefined) {
- throw new Error('Tried to remove sessions for undefined/null number');
- }
-
- window.log.info('removeAllSessions: deleting sessions for', identifier);
-
- const id = ConversationController.getConversationId(identifier);
-
- const allSessions = Object.values(this.sessions);
-
- for (let i = 0, max = allSessions.length; i < max; i += 1) {
- const session = allSessions[i];
- if (session.conversationId === id) {
- delete this.sessions[session.id];
- }
- }
-
- await window.Signal.Data.removeSessionsByConversation(identifier);
- },
- async archiveSiblingSessions(identifier) {
- window.log.info(
- 'archiveSiblingSessions: archiving sibling sessions for',
- identifier
- );
-
- const address = libsignal.SignalProtocolAddress.fromString(identifier);
-
- const deviceIds = await this.getDeviceIds(address.getName());
- const siblings = _.without(deviceIds, address.getDeviceId());
-
- await Promise.all(
- siblings.map(async deviceId => {
- const sibling = new libsignal.SignalProtocolAddress(
- address.getName(),
- deviceId
- );
- window.log.info(
- 'archiveSiblingSessions: closing session for',
- sibling.toString()
- );
- const sessionCipher = new libsignal.SessionCipher(
- textsecure.storage.protocol,
- sibling
- );
- await sessionCipher.closeOpenSessionForDevice();
- })
- );
- },
- async archiveAllSessions(identifier) {
- window.log.info(
- 'archiveAllSessions: archiving all sessions for',
- identifier
- );
-
- const deviceIds = await this.getDeviceIds(identifier);
-
- await Promise.all(
- deviceIds.map(async deviceId => {
- const address = new libsignal.SignalProtocolAddress(
- identifier,
- deviceId
- );
- window.log.info(
- 'archiveAllSessions: closing session for',
- address.toString()
- );
- const sessionCipher = new libsignal.SessionCipher(
- textsecure.storage.protocol,
- address
- );
- await sessionCipher.closeOpenSessionForDevice();
- })
- );
- },
- async clearSessionStore() {
- this.sessions = Object.create(null);
- window.Signal.Data.removeAllSessions();
- },
-
- // Identity Keys
-
- getIdentityRecord(identifier) {
- try {
- const id = ConversationController.getConversationId(identifier);
- const record = this.identityKeys[id];
-
- if (record) {
- return record;
- }
- } catch (e) {
- window.log.error(
- `could not get identity record for identifier ${identifier}`
- );
- }
-
- return undefined;
- },
-
- async isTrustedIdentity(encodedAddress, publicKey, direction) {
- if (encodedAddress === null || encodedAddress === undefined) {
- throw new Error('Tried to get identity key for undefined/null key');
- }
- const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0];
- const ourNumber = textsecure.storage.user.getNumber();
- const ourUuid = textsecure.storage.user.getUuid();
- const isOurIdentifier =
- (ourNumber && identifier === ourNumber) ||
- (ourUuid && identifier === ourUuid);
-
- const identityRecord = this.getIdentityRecord(identifier);
-
- if (isOurIdentifier) {
- if (identityRecord && identityRecord.publicKey) {
- return equalArrayBuffers(identityRecord.publicKey, publicKey);
- }
- window.log.warn(
- 'isTrustedIdentity: No local record for our own identifier. Returning true.'
- );
- return true;
- }
-
- switch (direction) {
- case Direction.SENDING:
- return this.isTrustedForSending(publicKey, identityRecord);
- case Direction.RECEIVING:
- return true;
- default:
- throw new Error(`Unknown direction: ${direction}`);
- }
- },
- isTrustedForSending(publicKey, identityRecord) {
- if (!identityRecord) {
- window.log.info(
- 'isTrustedForSending: No previous record, returning true...'
- );
- return true;
- }
-
- const existing = identityRecord.publicKey;
-
- if (!existing) {
- window.log.info('isTrustedForSending: Nothing here, returning true...');
- return true;
- }
- if (!equalArrayBuffers(existing, publicKey)) {
- window.log.info("isTrustedForSending: Identity keys don't match...");
- return false;
- }
- if (identityRecord.verified === VerifiedStatus.UNVERIFIED) {
- window.log.error('Needs unverified approval!');
- return false;
- }
- if (this.isNonBlockingApprovalRequired(identityRecord)) {
- window.log.error('isTrustedForSending: Needs non-blocking approval!');
- return false;
- }
-
- return true;
- },
- async loadIdentityKey(identifier) {
- if (identifier === null || identifier === undefined) {
- throw new Error('Tried to get identity key for undefined/null key');
- }
- const id = textsecure.utils.unencodeNumber(identifier)[0];
- const identityRecord = this.getIdentityRecord(id);
-
- if (identityRecord) {
- return identityRecord.publicKey;
- }
-
- return undefined;
- },
- async _saveIdentityKey(data) {
- const { id } = data;
-
- const previousData = this.identityKeys[id];
-
- // Optimistically update in-memory cache; will revert if save fails.
- this.identityKeys[id] = data;
-
- try {
- await window.Signal.Data.createOrUpdateIdentityKey(data);
- } catch (error) {
- if (previousData) {
- this.identityKeys[id] = previousData;
- }
-
- throw error;
- }
- },
- async saveIdentity(encodedAddress, publicKey, nonblockingApproval) {
- if (encodedAddress === null || encodedAddress === undefined) {
- throw new Error('Tried to put identity key for undefined/null key');
- }
- if (!(publicKey instanceof ArrayBuffer)) {
- // eslint-disable-next-line no-param-reassign
- publicKey = convertToArrayBuffer(publicKey);
- }
- if (typeof nonblockingApproval !== 'boolean') {
- // eslint-disable-next-line no-param-reassign
- nonblockingApproval = false;
- }
-
- const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0];
- const identityRecord = this.getIdentityRecord(identifier);
- const id = ConversationController.getOrCreate(identifier, 'private').get(
- 'id'
- );
-
- if (!identityRecord || !identityRecord.publicKey) {
- // Lookup failed, or the current key was removed, so save this one.
- window.log.info('Saving new identity...');
- await this._saveIdentityKey({
- id,
- publicKey,
- firstUse: true,
- timestamp: Date.now(),
- verified: VerifiedStatus.DEFAULT,
- nonblockingApproval,
- });
-
- return false;
- }
-
- const oldpublicKey = identityRecord.publicKey;
- if (!equalArrayBuffers(oldpublicKey, publicKey)) {
- window.log.info('Replacing existing identity...');
- 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', identifier);
- } catch (error) {
- window.log.error(
- 'saveIdentity error triggering keychange:',
- error && error.stack ? error.stack : error
- );
- }
- await this.archiveSiblingSessions(encodedAddress);
-
- return true;
- }
- if (this.isNonBlockingApprovalRequired(identityRecord)) {
- window.log.info('Setting approval status...');
-
- identityRecord.nonblockingApproval = nonblockingApproval;
- await this._saveIdentityKey(identityRecord);
-
- return false;
- }
-
- return false;
- },
- isNonBlockingApprovalRequired(identityRecord) {
- return (
- !identityRecord.firstUse &&
- Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD &&
- !identityRecord.nonblockingApproval
- );
- },
- async saveIdentityWithAttributes(encodedAddress, attributes) {
- if (encodedAddress === null || encodedAddress === undefined) {
- throw new Error('Tried to put identity key for undefined/null key');
- }
-
- const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0];
- const identityRecord = this.getIdentityRecord(identifier);
- const conv = ConversationController.getOrCreate(identifier, 'private');
- const id = conv.get('id');
-
- const updates = {
- id,
- ...identityRecord,
- ...attributes,
- };
-
- const model = new IdentityRecord(updates);
- if (model.isValid()) {
- await this._saveIdentityKey(updates);
- } else {
- throw model.validationError;
- }
- },
- async setApproval(encodedAddress, nonblockingApproval) {
- if (encodedAddress === null || encodedAddress === undefined) {
- throw new Error('Tried to set approval for undefined/null identifier');
- }
- if (typeof nonblockingApproval !== 'boolean') {
- throw new Error('Invalid approval status');
- }
-
- const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0];
- const identityRecord = this.getIdentityRecord(identifier);
-
- if (!identityRecord) {
- throw new Error(`No identity record for ${identifier}`);
- }
-
- identityRecord.nonblockingApproval = nonblockingApproval;
- await this._saveIdentityKey(identityRecord);
- },
- async setVerified(encodedAddress, verifiedStatus, publicKey) {
- if (encodedAddress === null || encodedAddress === undefined) {
- throw new Error('Tried to set verified for undefined/null key');
- }
- if (!validateVerifiedStatus(verifiedStatus)) {
- throw new Error('Invalid verified status');
- }
- if (arguments.length > 2 && !(publicKey instanceof ArrayBuffer)) {
- throw new Error('Invalid public key');
- }
-
- const identityRecord = this.getIdentityRecord(encodedAddress);
-
- if (!identityRecord) {
- throw new Error(`No identity record for ${encodedAddress}`);
- }
-
- if (
- !publicKey ||
- equalArrayBuffers(identityRecord.publicKey, publicKey)
- ) {
- identityRecord.verified = verifiedStatus;
-
- const model = new IdentityRecord(identityRecord);
- if (model.isValid()) {
- await this._saveIdentityKey(identityRecord);
- } else {
- throw identityRecord.validationError;
- }
- } else {
- window.log.info('No identity record for specified publicKey');
- }
- },
- async getVerified(identifier) {
- if (identifier === null || identifier === undefined) {
- throw new Error('Tried to set verified for undefined/null key');
- }
-
- const identityRecord = this.getIdentityRecord(identifier);
- if (!identityRecord) {
- throw new Error(`No identity record for ${identifier}`);
- }
-
- const verifiedStatus = identityRecord.verified;
- if (validateVerifiedStatus(verifiedStatus)) {
- return verifiedStatus;
- }
-
- return VerifiedStatus.DEFAULT;
- },
- // Resolves to true if a new identity key was saved
- processContactSyncVerificationState(identifier, verifiedStatus, publicKey) {
- if (verifiedStatus === VerifiedStatus.UNVERIFIED) {
- return this.processUnverifiedMessage(
- identifier,
- verifiedStatus,
- publicKey
- );
- }
- return this.processVerifiedMessage(identifier, verifiedStatus, publicKey);
- },
- // This function encapsulates the non-Java behavior, since the mobile apps don't
- // currently receive contact syncs and therefore will see a verify sync with
- // UNVERIFIED status
- async processUnverifiedMessage(identifier, verifiedStatus, publicKey) {
- if (identifier === null || identifier === undefined) {
- throw new Error('Tried to set verified for undefined/null key');
- }
- if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) {
- throw new Error('Invalid public key');
- }
-
- const identityRecord = this.getIdentityRecord(identifier);
-
- const isPresent = Boolean(identityRecord);
- let isEqual = false;
-
- if (isPresent && publicKey) {
- isEqual = equalArrayBuffers(publicKey, identityRecord.publicKey);
- }
-
- if (
- isPresent &&
- isEqual &&
- identityRecord.verified !== VerifiedStatus.UNVERIFIED
- ) {
- await textsecure.storage.protocol.setVerified(
- identifier,
- verifiedStatus,
- publicKey
- );
- return false;
- }
-
- if (!isPresent || !isEqual) {
- await textsecure.storage.protocol.saveIdentityWithAttributes(
- identifier,
- {
- publicKey,
- verified: verifiedStatus,
- firstUse: false,
- timestamp: Date.now(),
- nonblockingApproval: true,
- }
- );
-
- if (isPresent && !isEqual) {
- try {
- this.trigger('keychange', identifier);
- } catch (error) {
- window.log.error(
- 'processUnverifiedMessage error triggering keychange:',
- error && error.stack ? error.stack : error
- );
- }
-
- await this.archiveAllSessions(identifier);
-
- return true;
- }
- }
-
- // The situation which could get us here is:
- // 1. had a previous key
- // 2. new key is the same
- // 3. desired new status is same as what we had before
- return false;
- },
- // This matches the Java method as of
- // https://github.com/signalapp/Signal-Android/blob/d0bb68e1378f689e4d10ac6a46014164992ca4e4/src/org/thoughtcrime/securesms/util/IdentityUtil.java#L188
- async processVerifiedMessage(identifier, verifiedStatus, publicKey) {
- if (identifier === null || identifier === undefined) {
- throw new Error('Tried to set verified for undefined/null key');
- }
- if (!validateVerifiedStatus(verifiedStatus)) {
- throw new Error('Invalid verified status');
- }
- if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) {
- throw new Error('Invalid public key');
- }
-
- const identityRecord = this.getIdentityRecord(identifier);
-
- const isPresent = Boolean(identityRecord);
- let isEqual = false;
-
- if (isPresent && publicKey) {
- isEqual = equalArrayBuffers(publicKey, identityRecord.publicKey);
- }
-
- if (!isPresent && verifiedStatus === VerifiedStatus.DEFAULT) {
- window.log.info('No existing record for default status');
- return false;
- }
-
- if (
- isPresent &&
- isEqual &&
- identityRecord.verified !== VerifiedStatus.DEFAULT &&
- verifiedStatus === VerifiedStatus.DEFAULT
- ) {
- await textsecure.storage.protocol.setVerified(
- identifier,
- verifiedStatus,
- publicKey
- );
- return false;
- }
-
- if (
- verifiedStatus === VerifiedStatus.VERIFIED &&
- (!isPresent ||
- (isPresent && !isEqual) ||
- (isPresent && identityRecord.verified !== VerifiedStatus.VERIFIED))
- ) {
- await textsecure.storage.protocol.saveIdentityWithAttributes(
- identifier,
- {
- publicKey,
- verified: verifiedStatus,
- firstUse: false,
- timestamp: Date.now(),
- nonblockingApproval: true,
- }
- );
-
- if (isPresent && !isEqual) {
- try {
- this.trigger('keychange', identifier);
- } catch (error) {
- window.log.error(
- 'processVerifiedMessage error triggering keychange:',
- error && error.stack ? error.stack : error
- );
- }
-
- await this.archiveAllSessions(identifier);
-
- // true signifies that we overwrote a previous key with a new one
- return true;
- }
- }
-
- // We get here if we got a new key and the status is DEFAULT. If the
- // message is out of date, we don't want to lose whatever more-secure
- // state we had before.
- return false;
- },
- isUntrusted(identifier) {
- if (identifier === null || identifier === undefined) {
- throw new Error('Tried to set verified for undefined/null key');
- }
-
- const identityRecord = this.getIdentityRecord(identifier);
- if (!identityRecord) {
- throw new Error(`No identity record for ${identifier}`);
- }
-
- if (
- Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD &&
- !identityRecord.nonblockingApproval &&
- !identityRecord.firstUse
- ) {
- return true;
- }
-
- return false;
- },
- async removeIdentityKey(identifier) {
- const id = ConversationController.getConversationId(identifier);
- if (id) {
- delete this.identityKeys[id];
- await window.Signal.Data.removeIdentityKeyById(id);
- await textsecure.storage.protocol.removeAllSessions(id);
- }
- },
-
- // Not yet processed messages - for resiliency
- getUnprocessedCount() {
- return window.Signal.Data.getUnprocessedCount();
- },
- getAllUnprocessed() {
- return window.Signal.Data.getAllUnprocessed();
- },
- getUnprocessedById(id) {
- return window.Signal.Data.getUnprocessedById(id);
- },
- addUnprocessed(data) {
- // We need to pass forceSave because the data has an id already, which will cause
- // an update instead of an insert.
- return window.Signal.Data.saveUnprocessed(data, {
- forceSave: true,
- });
- },
- addMultipleUnprocessed(array) {
- // We need to pass forceSave because the data has an id already, which will cause
- // an update instead of an insert.
- return window.Signal.Data.saveUnprocesseds(array, {
- forceSave: true,
- });
- },
- updateUnprocessedAttempts(id, attempts) {
- return window.Signal.Data.updateUnprocessedAttempts(id, attempts);
- },
- updateUnprocessedWithData(id, data) {
- return window.Signal.Data.updateUnprocessedWithData(id, data);
- },
- updateUnprocessedsWithData(items) {
- return window.Signal.Data.updateUnprocessedsWithData(items);
- },
- removeUnprocessed(idOrArray) {
- return window.Signal.Data.removeUnprocessed(idOrArray);
- },
- removeAllUnprocessed() {
- return window.Signal.Data.removeAllUnprocessed();
- },
- async removeAllData() {
- await window.Signal.Data.removeAll();
- await this.hydrateCaches();
-
- window.storage.reset();
- await window.storage.fetch();
-
- ConversationController.reset();
- await ConversationController.load();
- },
- async removeAllConfiguration() {
- await window.Signal.Data.removeAllConfiguration();
- await this.hydrateCaches();
-
- window.storage.reset();
- await window.storage.fetch();
- },
- };
- _.extend(SignalProtocolStore.prototype, Backbone.Events);
-
- window.SignalProtocolStore = SignalProtocolStore;
- window.SignalProtocolStore.prototype.Direction = Direction;
- window.SignalProtocolStore.prototype.VerifiedStatus = VerifiedStatus;
-})();
diff --git a/preload.js b/preload.js
index 386fcf9d4..66e8dc143 100644
--- a/preload.js
+++ b/preload.js
@@ -523,6 +523,7 @@ try {
require('./ts/backbone/views/whisper_view');
require('./ts/backbone/views/toast_view');
require('./ts/views/conversation_view');
+ require('./ts/LibSignalStore');
require('./ts/background');
function wrapWithPromise(fn) {
diff --git a/test/index.html b/test/index.html
index 540786f11..f79d1d116 100644
--- a/test/index.html
+++ b/test/index.html
@@ -339,7 +339,6 @@
-
@@ -351,8 +350,6 @@
-
-
diff --git a/ts/LibSignalStore.ts b/ts/LibSignalStore.ts
new file mode 100644
index 000000000..eb34e1be2
--- /dev/null
+++ b/ts/LibSignalStore.ts
@@ -0,0 +1,1233 @@
+// Copyright 2016-2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+/* eslint-disable class-methods-use-this */
+
+import { fromEncodedBinaryToArrayBuffer, constantTimeEqual } from './Crypto';
+import { isNotNil } from './util/isNotNil';
+
+const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds
+const Direction = {
+ SENDING: 1,
+ RECEIVING: 2,
+};
+
+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 IdentityRecord = window.Backbone.Model.extend({
+ storeName: 'identityKeys',
+ validAttributes: [
+ 'id',
+ 'publicKey',
+ 'firstUse',
+ 'timestamp',
+ 'verified',
+ 'nonblockingApproval',
+ ],
+ validate(attrs: IdentityKeyType) {
+ const attributeNames = window._.keys(attrs);
+ const { validAttributes } = this;
+ const allValid = window._.all(attributeNames, attributeName =>
+ window._.contains(validAttributes, attributeName)
+ );
+ if (!allValid) {
+ return new Error('Invalid identity key attribute names');
+ }
+ const allPresent = window._.all(validAttributes, attributeName =>
+ window._.contains(attributeNames, attributeName)
+ );
+ if (!allPresent) {
+ return new Error('Missing identity key attributes');
+ }
+
+ if (typeof attrs.id !== 'string') {
+ return new Error('Invalid identity key id');
+ }
+ if (!(attrs.publicKey instanceof ArrayBuffer)) {
+ return new Error('Invalid identity key publicKey');
+ }
+ if (typeof attrs.firstUse !== 'boolean') {
+ return new Error('Invalid identity key firstUse');
+ }
+ if (typeof attrs.timestamp !== 'number' || !(attrs.timestamp >= 0)) {
+ return new Error('Invalid identity key timestamp');
+ }
+ if (!validateVerifiedStatus(attrs.verified)) {
+ return new Error('Invalid identity key verified');
+ }
+ if (typeof attrs.nonblockingApproval !== 'boolean') {
+ return new Error('Invalid identity key nonblockingApproval');
+ }
+
+ return null;
+ },
+});
+
+async function normalizeEncodedAddress(
+ encodedAddress: string
+): Promise {
+ const [identifier, deviceId] = window.textsecure.utils.unencodeNumber(
+ encodedAddress
+ );
+ try {
+ const conv = window.ConversationController.getOrCreate(
+ identifier,
+ 'private'
+ );
+ return `${conv.get('id')}.${deviceId}`;
+ } catch (e) {
+ window.log.error(`could not get conversation for identifier ${identifier}`);
+ throw e;
+ }
+}
+
+type HasIdType = {
+ id: string | number;
+};
+
+async function _hydrateCache(
+ object: SignalProtocolStore,
+ field: keyof SignalProtocolStore,
+ itemsPromise: Promise>
+): Promise {
+ const items = await itemsPromise;
+
+ const cache: Record = Object.create(null);
+ for (let i = 0, max = items.length; i < max; i += 1) {
+ const item = items[i];
+ const { id } = item;
+
+ cache[id] = item;
+ }
+
+ window.log.info(`SignalProtocolStore: Finished caching ${field} data`);
+ // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any
+ object[field] = cache as any;
+}
+
+type KeyPairType = {
+ privKey: ArrayBuffer;
+ pubKey: ArrayBuffer;
+};
+
+type IdentityKeyType = {
+ firstUse: boolean;
+ id: string;
+ nonblockingApproval: boolean;
+ publicKey: ArrayBuffer;
+ timestamp: number;
+ verified: number;
+};
+
+type SessionType = {
+ conversationId: string;
+ deviceId: number;
+ id: string;
+ record: string;
+};
+
+type SignedPreKeyType = {
+ confirmed: boolean;
+ // eslint-disable-next-line camelcase
+ created_at: number;
+ id: number;
+ privateKey: ArrayBuffer;
+ publicKey: ArrayBuffer;
+};
+type OuterSignedPrekeyType = {
+ confirmed: boolean;
+ // eslint-disable-next-line camelcase
+ created_at: number;
+ keyId: number;
+ privKey: ArrayBuffer;
+ pubKey: ArrayBuffer;
+};
+type PreKeyType = {
+ id: number;
+ privateKey: ArrayBuffer;
+ publicKey: ArrayBuffer;
+};
+
+type UnprocessedType = {
+ id: string;
+ timestamp: number;
+ version: number;
+ attempts: number;
+ envelope: string;
+ decrypted?: string;
+ source?: string;
+ sourceDevice: string;
+ serverTimestamp: number;
+};
+
+// We add a this parameter to avoid an 'implicit any' error on the next line
+const EventsMixin = (function EventsMixin(this: unknown) {
+ window._.assign(this, window.Backbone.Events);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+} as any) as typeof window.Backbone.EventsMixin;
+
+export class SignalProtocolStore extends EventsMixin {
+ // Enums used across the app
+
+ Direction = Direction;
+
+ VerifiedStatus = VerifiedStatus;
+
+ // Cached values
+
+ ourIdentityKey?: KeyPairType;
+
+ ourRegistrationId?: number;
+
+ identityKeys?: Record;
+
+ sessions?: Record;
+
+ signedPreKeys?: Record;
+
+ preKeys?: Record;
+
+ async hydrateCaches(): Promise {
+ await Promise.all([
+ (async () => {
+ const item = await window.Signal.Data.getItemById('identityKey');
+ this.ourIdentityKey = item ? item.value : undefined;
+ })(),
+ (async () => {
+ const item = await window.Signal.Data.getItemById('registrationId');
+ this.ourRegistrationId = item ? item.value : undefined;
+ })(),
+ _hydrateCache(
+ this,
+ 'identityKeys',
+ window.Signal.Data.getAllIdentityKeys()
+ ),
+ _hydrateCache(
+ this,
+ 'sessions',
+ window.Signal.Data.getAllSessions()
+ ),
+ _hydrateCache(
+ this,
+ 'preKeys',
+ window.Signal.Data.getAllPreKeys()
+ ),
+ _hydrateCache(
+ this,
+ 'signedPreKeys',
+ window.Signal.Data.getAllSignedPreKeys()
+ ),
+ ]);
+ }
+
+ async getIdentityKeyPair(): Promise {
+ return this.ourIdentityKey;
+ }
+
+ async getLocalRegistrationId(): Promise {
+ return this.ourRegistrationId;
+ }
+
+ // PreKeys
+
+ async loadPreKey(keyId: string | number): Promise {
+ if (!this.preKeys) {
+ throw new Error('loadPreKey: this.preKeys not yet cached!');
+ }
+
+ const key = this.preKeys[keyId];
+ if (key) {
+ window.log.info('Successfully fetched prekey:', keyId);
+ return {
+ pubKey: key.publicKey,
+ privKey: key.privateKey,
+ };
+ }
+
+ window.log.error('Failed to fetch prekey:', keyId);
+ return undefined;
+ }
+
+ async storePreKey(keyId: number, keyPair: KeyPairType): Promise {
+ if (!this.preKeys) {
+ throw new Error('storePreKey: this.preKeys not yet cached!');
+ }
+
+ const data = {
+ id: keyId,
+ publicKey: keyPair.pubKey,
+ privateKey: keyPair.privKey,
+ };
+
+ this.preKeys[keyId] = data;
+ await window.Signal.Data.createOrUpdatePreKey(data);
+ }
+
+ async removePreKey(keyId: number): Promise {
+ if (!this.preKeys) {
+ throw new Error('removePreKey: this.preKeys not yet cached!');
+ }
+
+ try {
+ this.trigger('removePreKey');
+ } catch (error) {
+ window.log.error(
+ 'removePreKey error triggering removePreKey:',
+ error && error.stack ? error.stack : error
+ );
+ }
+
+ delete this.preKeys[keyId];
+ await window.Signal.Data.removePreKeyById(keyId);
+ }
+
+ async clearPreKeyStore(): Promise {
+ this.preKeys = Object.create(null);
+ await window.Signal.Data.removeAllPreKeys();
+ }
+
+ // Signed PreKeys
+
+ async loadSignedPreKey(
+ keyId: number
+ ): Promise {
+ if (!this.signedPreKeys) {
+ throw new Error('loadSignedPreKey: this.signedPreKeys not yet cached!');
+ }
+
+ const key = this.signedPreKeys[keyId];
+ if (key) {
+ window.log.info('Successfully fetched signed prekey:', key.id);
+ return {
+ pubKey: key.publicKey,
+ privKey: key.privateKey,
+ created_at: key.created_at,
+ keyId: key.id,
+ confirmed: key.confirmed,
+ };
+ }
+
+ window.log.error('Failed to fetch signed prekey:', keyId);
+ return undefined;
+ }
+
+ async loadSignedPreKeys(): Promise> {
+ if (!this.signedPreKeys) {
+ throw new Error('loadSignedPreKeys: this.signedPreKeys not yet cached!');
+ }
+
+ if (arguments.length > 0) {
+ throw new Error('loadSignedPreKeys takes no arguments');
+ }
+
+ const keys = Object.values(this.signedPreKeys);
+ return keys.map(prekey => ({
+ pubKey: prekey.publicKey,
+ privKey: prekey.privateKey,
+ created_at: prekey.created_at,
+ keyId: prekey.id,
+ confirmed: prekey.confirmed,
+ }));
+ }
+
+ async storeSignedPreKey(
+ keyId: number,
+ keyPair: KeyPairType,
+ confirmed?: boolean
+ ): Promise {
+ if (!this.signedPreKeys) {
+ throw new Error('storeSignedPreKey: this.signedPreKeys not yet cached!');
+ }
+
+ const data = {
+ id: keyId,
+ publicKey: keyPair.pubKey,
+ privateKey: keyPair.privKey,
+ created_at: Date.now(),
+ confirmed: Boolean(confirmed),
+ };
+
+ this.signedPreKeys[keyId] = data;
+ await window.Signal.Data.createOrUpdateSignedPreKey(data);
+ }
+
+ async removeSignedPreKey(keyId: number): Promise {
+ if (!this.signedPreKeys) {
+ throw new Error('removeSignedPreKey: this.signedPreKeys not yet cached!');
+ }
+
+ delete this.signedPreKeys[keyId];
+ await window.Signal.Data.removeSignedPreKeyById(keyId);
+ }
+
+ async clearSignedPreKeysStore(): Promise {
+ this.signedPreKeys = Object.create(null);
+ await window.Signal.Data.removeAllSignedPreKeys();
+ }
+
+ // Sessions
+
+ async loadSession(encodedAddress: string): Promise {
+ if (!this.sessions) {
+ throw new Error('loadSession: this.sessions not yet cached!');
+ }
+
+ if (encodedAddress === null || encodedAddress === undefined) {
+ throw new Error('Tried to get session for undefined/null number');
+ }
+
+ try {
+ const id = await normalizeEncodedAddress(encodedAddress);
+ const session = this.sessions[id];
+
+ if (session) {
+ return session.record;
+ }
+ } catch (error) {
+ const errorString = error && error.stack ? error.stack : error;
+ window.log.error(
+ `could not load session ${encodedAddress}: ${errorString}`
+ );
+ }
+
+ return undefined;
+ }
+
+ async storeSession(encodedAddress: string, record: string): Promise {
+ if (!this.sessions) {
+ throw new Error('storeSession: this.sessions not yet cached!');
+ }
+
+ if (encodedAddress === null || encodedAddress === undefined) {
+ throw new Error('Tried to put session for undefined/null number');
+ }
+ const unencoded = window.textsecure.utils.unencodeNumber(encodedAddress);
+ const deviceId = parseInt(unencoded[1], 10);
+
+ try {
+ const id = await normalizeEncodedAddress(encodedAddress);
+ const previousData = this.sessions[id];
+
+ const data = {
+ id,
+ conversationId: window.textsecure.utils.unencodeNumber(id)[0],
+ deviceId,
+ record,
+ };
+
+ // Optimistically update in-memory cache; will revert if save fails.
+ this.sessions[id] = data;
+
+ try {
+ await window.Signal.Data.createOrUpdateSession(data);
+ } catch (e) {
+ if (previousData) {
+ this.sessions[id] = previousData;
+ }
+ throw e;
+ }
+ } catch (error) {
+ const errorString = error && error.stack ? error.stack : error;
+ window.log.error(
+ `could not store session for ${encodedAddress}: ${errorString}`
+ );
+ }
+ }
+
+ async getDeviceIds(identifier: string): Promise> {
+ if (!this.sessions) {
+ throw new Error('getDeviceIds: this.sessions not yet cached!');
+ }
+ if (identifier === null || identifier === undefined) {
+ throw new Error('Tried to get device ids for undefined/null number');
+ }
+
+ try {
+ const id = window.ConversationController.getConversationId(identifier);
+ const allSessions = Object.values(this.sessions);
+ const sessions = allSessions.filter(
+ session => session.conversationId === id
+ );
+ const openSessions = await Promise.all(
+ sessions.map(async session => {
+ const sessionCipher = new window.libsignal.SessionCipher(
+ window.textsecure.storage.protocol,
+ session.id
+ );
+
+ const hasOpenSession = await sessionCipher.hasOpenSession();
+ if (hasOpenSession) {
+ return session;
+ }
+
+ return undefined;
+ })
+ );
+
+ return openSessions.filter(isNotNil).map(item => item.deviceId);
+ } catch (error) {
+ window.log.error(
+ `could not get device ids for identifier ${identifier}`,
+ error && error.stack ? error.stack : error
+ );
+ }
+
+ return [];
+ }
+
+ async removeSession(encodedAddress: string): Promise {
+ if (!this.sessions) {
+ throw new Error('removeSession: this.sessions not yet cached!');
+ }
+
+ window.log.info('removeSession: deleting session for', encodedAddress);
+ try {
+ const id = await normalizeEncodedAddress(encodedAddress);
+ delete this.sessions[id];
+ await window.Signal.Data.removeSessionById(id);
+ } catch (e) {
+ window.log.error(`could not delete session for ${encodedAddress}`);
+ }
+ }
+
+ async removeAllSessions(identifier: string): Promise {
+ if (!this.sessions) {
+ throw new Error('removeAllSessions: this.sessions not yet cached!');
+ }
+
+ if (identifier === null || identifier === undefined) {
+ throw new Error('Tried to remove sessions for undefined/null number');
+ }
+
+ window.log.info('removeAllSessions: deleting sessions for', identifier);
+
+ const id = window.ConversationController.getConversationId(identifier);
+
+ const allSessions = Object.values(this.sessions);
+
+ for (let i = 0, max = allSessions.length; i < max; i += 1) {
+ const session = allSessions[i];
+ if (session.conversationId === id) {
+ delete this.sessions[session.id];
+ }
+ }
+
+ await window.Signal.Data.removeSessionsByConversation(identifier);
+ }
+
+ async archiveSiblingSessions(identifier: string): Promise {
+ if (!this.sessions) {
+ throw new Error('archiveSiblingSessions: this.sessions not yet cached!');
+ }
+
+ window.log.info(
+ 'archiveSiblingSessions: archiving sibling sessions for',
+ identifier
+ );
+
+ const address = window.libsignal.SignalProtocolAddress.fromString(
+ identifier
+ );
+
+ const deviceIds = await this.getDeviceIds(address.getName());
+ const siblings = window._.without(deviceIds, address.getDeviceId());
+
+ await Promise.all(
+ siblings.map(async deviceId => {
+ const sibling = new window.libsignal.SignalProtocolAddress(
+ address.getName(),
+ deviceId
+ );
+ window.log.info(
+ 'archiveSiblingSessions: closing session for',
+ sibling.toString()
+ );
+ const sessionCipher = new window.libsignal.SessionCipher(
+ window.textsecure.storage.protocol,
+ sibling
+ );
+ await sessionCipher.closeOpenSessionForDevice();
+ })
+ );
+ }
+
+ async archiveAllSessions(identifier: string): Promise {
+ if (!this.sessions) {
+ throw new Error('archiveAllSessions: this.sessions not yet cached!');
+ }
+
+ window.log.info(
+ 'archiveAllSessions: archiving all sessions for',
+ identifier
+ );
+
+ const deviceIds = await this.getDeviceIds(identifier);
+
+ await Promise.all(
+ deviceIds.map(async deviceId => {
+ const address = new window.libsignal.SignalProtocolAddress(
+ identifier,
+ deviceId
+ );
+ window.log.info(
+ 'archiveAllSessions: closing session for',
+ address.toString()
+ );
+ const sessionCipher = new window.libsignal.SessionCipher(
+ window.textsecure.storage.protocol,
+ address
+ );
+ await sessionCipher.closeOpenSessionForDevice();
+ })
+ );
+ }
+
+ async clearSessionStore(): Promise {
+ this.sessions = Object.create(null);
+ window.Signal.Data.removeAllSessions();
+ }
+
+ // Identity Keys
+
+ getIdentityRecord(identifier: string): IdentityKeyType | undefined {
+ if (!this.identityKeys) {
+ throw new Error('getIdentityRecord: this.identityKeys not yet cached!');
+ }
+
+ try {
+ const id = window.ConversationController.getConversationId(identifier);
+ if (!id) {
+ throw new Error(
+ `getIdentityRecord: No conversation id for identifier ${identifier}`
+ );
+ }
+
+ const record = this.identityKeys[id];
+
+ if (record) {
+ return record;
+ }
+ } catch (e) {
+ window.log.error(
+ `could not get identity record for identifier ${identifier}`
+ );
+ }
+
+ return undefined;
+ }
+
+ async isTrustedIdentity(
+ encodedAddress: string,
+ publicKey: ArrayBuffer,
+ direction: number
+ ): Promise {
+ if (!this.identityKeys) {
+ throw new Error('getIdentityRecord: this.identityKeys not yet cached!');
+ }
+
+ if (encodedAddress === null || encodedAddress === undefined) {
+ throw new Error('Tried to get identity key for undefined/null key');
+ }
+ const identifier = window.textsecure.utils.unencodeNumber(
+ encodedAddress
+ )[0];
+ const ourNumber = window.textsecure.storage.user.getNumber();
+ const ourUuid = window.textsecure.storage.user.getUuid();
+ const isOurIdentifier =
+ (ourNumber && identifier === ourNumber) ||
+ (ourUuid && identifier === ourUuid);
+
+ const identityRecord = this.getIdentityRecord(identifier);
+
+ if (isOurIdentifier) {
+ if (identityRecord && identityRecord.publicKey) {
+ return constantTimeEqual(identityRecord.publicKey, publicKey);
+ }
+ window.log.warn(
+ 'isTrustedIdentity: No local record for our own identifier. Returning true.'
+ );
+ return true;
+ }
+
+ switch (direction) {
+ case Direction.SENDING:
+ return this.isTrustedForSending(publicKey, identityRecord);
+ case Direction.RECEIVING:
+ return true;
+ default:
+ throw new Error(`Unknown direction: ${direction}`);
+ }
+ }
+
+ isTrustedForSending(
+ publicKey: ArrayBuffer,
+ identityRecord?: IdentityKeyType
+ ): boolean {
+ if (!identityRecord) {
+ window.log.info(
+ 'isTrustedForSending: No previous record, returning true...'
+ );
+ return true;
+ }
+
+ const existing = identityRecord.publicKey;
+
+ if (!existing) {
+ window.log.info('isTrustedForSending: Nothing here, returning true...');
+ return true;
+ }
+ if (!constantTimeEqual(existing, publicKey)) {
+ window.log.info("isTrustedForSending: Identity keys don't match...");
+ return false;
+ }
+ if (identityRecord.verified === VerifiedStatus.UNVERIFIED) {
+ window.log.error('Needs unverified approval!');
+ return false;
+ }
+ if (this.isNonBlockingApprovalRequired(identityRecord)) {
+ window.log.error('isTrustedForSending: Needs non-blocking approval!');
+ return false;
+ }
+
+ return true;
+ }
+
+ async loadIdentityKey(identifier: string): Promise {
+ if (identifier === null || identifier === undefined) {
+ throw new Error('Tried to get identity key for undefined/null key');
+ }
+ const id = window.textsecure.utils.unencodeNumber(identifier)[0];
+ const identityRecord = this.getIdentityRecord(id);
+
+ if (identityRecord) {
+ return identityRecord.publicKey;
+ }
+
+ return undefined;
+ }
+
+ private async _saveIdentityKey(data: IdentityKeyType): Promise {
+ if (!this.identityKeys) {
+ throw new Error('_saveIdentityKey: this.identityKeys not yet cached!');
+ }
+
+ const { id } = data;
+
+ const previousData = this.identityKeys[id];
+
+ // Optimistically update in-memory cache; will revert if save fails.
+ this.identityKeys[id] = data;
+
+ try {
+ await window.Signal.Data.createOrUpdateIdentityKey(data);
+ } catch (error) {
+ if (previousData) {
+ this.identityKeys[id] = previousData;
+ }
+
+ throw error;
+ }
+ }
+
+ async saveIdentity(
+ encodedAddress: string,
+ publicKey: ArrayBuffer,
+ nonblockingApproval: boolean
+ ): Promise {
+ if (!this.identityKeys) {
+ throw new Error('saveIdentity: this.identityKeys not yet cached!');
+ }
+
+ if (encodedAddress === null || encodedAddress === undefined) {
+ throw new Error('Tried to put identity key for undefined/null key');
+ }
+ if (!(publicKey instanceof ArrayBuffer)) {
+ // eslint-disable-next-line no-param-reassign
+ publicKey = fromEncodedBinaryToArrayBuffer(publicKey);
+ }
+ if (typeof nonblockingApproval !== 'boolean') {
+ // eslint-disable-next-line no-param-reassign
+ nonblockingApproval = false;
+ }
+
+ const identifier = window.textsecure.utils.unencodeNumber(
+ encodedAddress
+ )[0];
+ const identityRecord = this.getIdentityRecord(identifier);
+ const id = window.ConversationController.getOrCreate(
+ identifier,
+ 'private'
+ ).get('id');
+
+ if (!identityRecord || !identityRecord.publicKey) {
+ // Lookup failed, or the current key was removed, so save this one.
+ window.log.info('Saving new identity...');
+ await this._saveIdentityKey({
+ id,
+ publicKey,
+ firstUse: true,
+ timestamp: Date.now(),
+ verified: VerifiedStatus.DEFAULT,
+ nonblockingApproval,
+ });
+
+ return false;
+ }
+
+ const oldpublicKey = identityRecord.publicKey;
+ if (!constantTimeEqual(oldpublicKey, publicKey)) {
+ window.log.info('Replacing existing identity...');
+ 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', identifier);
+ } catch (error) {
+ window.log.error(
+ 'saveIdentity error triggering keychange:',
+ error && error.stack ? error.stack : error
+ );
+ }
+ await this.archiveSiblingSessions(encodedAddress);
+
+ return true;
+ }
+ if (this.isNonBlockingApprovalRequired(identityRecord)) {
+ window.log.info('Setting approval status...');
+
+ identityRecord.nonblockingApproval = nonblockingApproval;
+ await this._saveIdentityKey(identityRecord);
+
+ return false;
+ }
+
+ return false;
+ }
+
+ isNonBlockingApprovalRequired(identityRecord: IdentityKeyType): boolean {
+ return (
+ !identityRecord.firstUse &&
+ Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD &&
+ !identityRecord.nonblockingApproval
+ );
+ }
+
+ async saveIdentityWithAttributes(
+ encodedAddress: string,
+ attributes: IdentityKeyType
+ ): Promise {
+ if (encodedAddress === null || encodedAddress === undefined) {
+ throw new Error('Tried to put identity key for undefined/null key');
+ }
+
+ const identifier = window.textsecure.utils.unencodeNumber(
+ encodedAddress
+ )[0];
+ const identityRecord = this.getIdentityRecord(identifier);
+ const conv = window.ConversationController.getOrCreate(
+ identifier,
+ 'private'
+ );
+ const id = conv.get('id');
+
+ const updates = {
+ ...identityRecord,
+ ...attributes,
+ id,
+ };
+
+ const model = new IdentityRecord(updates);
+ if (model.isValid()) {
+ await this._saveIdentityKey(updates);
+ } else {
+ throw model.validationError;
+ }
+ }
+
+ async setApproval(
+ encodedAddress: string,
+ nonblockingApproval: boolean
+ ): Promise {
+ if (encodedAddress === null || encodedAddress === undefined) {
+ throw new Error('Tried to set approval for undefined/null identifier');
+ }
+ if (typeof nonblockingApproval !== 'boolean') {
+ throw new Error('Invalid approval status');
+ }
+
+ const identifier = window.textsecure.utils.unencodeNumber(
+ encodedAddress
+ )[0];
+ const identityRecord = this.getIdentityRecord(identifier);
+
+ if (!identityRecord) {
+ throw new Error(`No identity record for ${identifier}`);
+ }
+
+ identityRecord.nonblockingApproval = nonblockingApproval;
+ await this._saveIdentityKey(identityRecord);
+ }
+
+ async setVerified(
+ encodedAddress: string,
+ verifiedStatus: number,
+ publicKey: ArrayBuffer
+ ): Promise {
+ if (encodedAddress === null || encodedAddress === undefined) {
+ throw new Error('Tried to set verified for undefined/null key');
+ }
+ if (!validateVerifiedStatus(verifiedStatus)) {
+ throw new Error('Invalid verified status');
+ }
+ if (arguments.length > 2 && !(publicKey instanceof ArrayBuffer)) {
+ throw new Error('Invalid public key');
+ }
+
+ const identityRecord = this.getIdentityRecord(encodedAddress);
+
+ if (!identityRecord) {
+ throw new Error(`No identity record for ${encodedAddress}`);
+ }
+
+ if (!publicKey || constantTimeEqual(identityRecord.publicKey, publicKey)) {
+ identityRecord.verified = verifiedStatus;
+
+ const model = new IdentityRecord(identityRecord);
+ if (model.isValid()) {
+ await this._saveIdentityKey(identityRecord);
+ } else if (model.validationError) {
+ throw model.validationError;
+ } else {
+ throw new Error('setVerified: identity record data was invalid');
+ }
+ } else {
+ window.log.info('No identity record for specified publicKey');
+ }
+ }
+
+ async getVerified(identifier: string): Promise {
+ if (identifier === null || identifier === undefined) {
+ throw new Error('Tried to set verified for undefined/null key');
+ }
+
+ const identityRecord = this.getIdentityRecord(identifier);
+ if (!identityRecord) {
+ throw new Error(`No identity record for ${identifier}`);
+ }
+
+ const verifiedStatus = identityRecord.verified;
+ if (validateVerifiedStatus(verifiedStatus)) {
+ return verifiedStatus;
+ }
+
+ return VerifiedStatus.DEFAULT;
+ }
+
+ // Resolves to true if a new identity key was saved
+ processContactSyncVerificationState(
+ identifier: string,
+ verifiedStatus: number,
+ publicKey: ArrayBuffer
+ ): Promise {
+ if (verifiedStatus === VerifiedStatus.UNVERIFIED) {
+ return this.processUnverifiedMessage(
+ identifier,
+ verifiedStatus,
+ publicKey
+ );
+ }
+ return this.processVerifiedMessage(identifier, verifiedStatus, publicKey);
+ }
+
+ // This function encapsulates the non-Java behavior, since the mobile apps don't
+ // currently receive contact syncs and therefore will see a verify sync with
+ // UNVERIFIED status
+ async processUnverifiedMessage(
+ identifier: string,
+ verifiedStatus: number,
+ publicKey?: ArrayBuffer
+ ): Promise {
+ if (identifier === null || identifier === undefined) {
+ throw new Error('Tried to set verified for undefined/null key');
+ }
+ if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) {
+ throw new Error('Invalid public key');
+ }
+
+ const identityRecord = this.getIdentityRecord(identifier);
+
+ let isEqual = false;
+
+ if (identityRecord && publicKey) {
+ isEqual = constantTimeEqual(publicKey, identityRecord.publicKey);
+ }
+
+ if (
+ identityRecord &&
+ isEqual &&
+ identityRecord.verified !== VerifiedStatus.UNVERIFIED
+ ) {
+ await window.textsecure.storage.protocol.setVerified(
+ identifier,
+ verifiedStatus,
+ publicKey
+ );
+ return false;
+ }
+
+ if (publicKey && (!identityRecord || !isEqual)) {
+ await window.textsecure.storage.protocol.saveIdentityWithAttributes(
+ identifier,
+ {
+ publicKey,
+ verified: verifiedStatus,
+ firstUse: false,
+ timestamp: Date.now(),
+ nonblockingApproval: true,
+ }
+ );
+
+ if (identityRecord && !isEqual) {
+ try {
+ this.trigger('keychange', identifier);
+ } catch (error) {
+ window.log.error(
+ 'processUnverifiedMessage error triggering keychange:',
+ error && error.stack ? error.stack : error
+ );
+ }
+
+ await this.archiveAllSessions(identifier);
+
+ return true;
+ }
+ }
+
+ // The situation which could get us here is:
+ // 1. had a previous key
+ // 2. new key is the same
+ // 3. desired new status is same as what we had before
+ // 4. no publicKey was passed into this function
+ return false;
+ }
+
+ // This matches the Java method as of
+ // https://github.com/signalapp/Signal-Android/blob/d0bb68e1378f689e4d10ac6a46014164992ca4e4/src/org/thoughtcrime/securesms/util/IdentityUtil.java#L188
+ async processVerifiedMessage(
+ identifier: string,
+ verifiedStatus: number,
+ publicKey: ArrayBuffer
+ ): Promise {
+ if (identifier === null || identifier === undefined) {
+ throw new Error('Tried to set verified for undefined/null key');
+ }
+ if (!validateVerifiedStatus(verifiedStatus)) {
+ throw new Error('Invalid verified status');
+ }
+ if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) {
+ throw new Error('Invalid public key');
+ }
+
+ const identityRecord = this.getIdentityRecord(identifier);
+
+ let isEqual = false;
+
+ if (identityRecord && publicKey) {
+ isEqual = constantTimeEqual(publicKey, identityRecord.publicKey);
+ }
+
+ if (!identityRecord && verifiedStatus === VerifiedStatus.DEFAULT) {
+ window.log.info('No existing record for default status');
+ return false;
+ }
+
+ if (
+ identityRecord &&
+ isEqual &&
+ identityRecord.verified !== VerifiedStatus.DEFAULT &&
+ verifiedStatus === VerifiedStatus.DEFAULT
+ ) {
+ await window.textsecure.storage.protocol.setVerified(
+ identifier,
+ verifiedStatus,
+ publicKey
+ );
+ return false;
+ }
+
+ if (
+ verifiedStatus === VerifiedStatus.VERIFIED &&
+ (!identityRecord ||
+ (identityRecord && !isEqual) ||
+ (identityRecord && identityRecord.verified !== VerifiedStatus.VERIFIED))
+ ) {
+ await window.textsecure.storage.protocol.saveIdentityWithAttributes(
+ identifier,
+ {
+ publicKey,
+ verified: verifiedStatus,
+ firstUse: false,
+ timestamp: Date.now(),
+ nonblockingApproval: true,
+ }
+ );
+
+ if (identityRecord && !isEqual) {
+ try {
+ this.trigger('keychange', identifier);
+ } catch (error) {
+ window.log.error(
+ 'processVerifiedMessage error triggering keychange:',
+ error && error.stack ? error.stack : error
+ );
+ }
+
+ await this.archiveAllSessions(identifier);
+
+ // true signifies that we overwrote a previous key with a new one
+ return true;
+ }
+ }
+
+ // We get here if we got a new key and the status is DEFAULT. If the
+ // message is out of date, we don't want to lose whatever more-secure
+ // state we had before.
+ return false;
+ }
+
+ isUntrusted(identifier: string): boolean {
+ if (identifier === null || identifier === undefined) {
+ throw new Error('Tried to set verified for undefined/null key');
+ }
+
+ const identityRecord = this.getIdentityRecord(identifier);
+ if (!identityRecord) {
+ throw new Error(`No identity record for ${identifier}`);
+ }
+
+ if (
+ Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD &&
+ !identityRecord.nonblockingApproval &&
+ !identityRecord.firstUse
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ async removeIdentityKey(identifier: string): Promise {
+ if (!this.identityKeys) {
+ throw new Error('removeIdentityKey: this.identityKeys not yet cached!');
+ }
+
+ const id = window.ConversationController.getConversationId(identifier);
+ if (id) {
+ delete this.identityKeys[id];
+ await window.Signal.Data.removeIdentityKeyById(id);
+ await window.textsecure.storage.protocol.removeAllSessions(id);
+ }
+ }
+
+ // Not yet processed messages - for resiliency
+ getUnprocessedCount(): Promise {
+ return window.Signal.Data.getUnprocessedCount();
+ }
+
+ getAllUnprocessed(): Promise> {
+ return window.Signal.Data.getAllUnprocessed();
+ }
+
+ getUnprocessedById(id: string): Promise {
+ return window.Signal.Data.getUnprocessedById(id);
+ }
+
+ addUnprocessed(data: UnprocessedType): Promise {
+ // We need to pass forceSave because the data has an id already, which will cause
+ // an update instead of an insert.
+ return window.Signal.Data.saveUnprocessed(data, {
+ forceSave: true,
+ });
+ }
+
+ addMultipleUnprocessed(array: Array): Promise {
+ // We need to pass forceSave because the data has an id already, which will cause
+ // an update instead of an insert.
+ return window.Signal.Data.saveUnprocesseds(array, {
+ forceSave: true,
+ });
+ }
+
+ updateUnprocessedAttempts(id: string, attempts: number): Promise {
+ return window.Signal.Data.updateUnprocessedAttempts(id, attempts);
+ }
+
+ updateUnprocessedWithData(id: string, data: UnprocessedType): Promise {
+ return window.Signal.Data.updateUnprocessedWithData(id, data);
+ }
+
+ updateUnprocessedsWithData(items: Array): Promise {
+ return window.Signal.Data.updateUnprocessedsWithData(items);
+ }
+
+ removeUnprocessed(idOrArray: string | Array): Promise {
+ return window.Signal.Data.removeUnprocessed(idOrArray);
+ }
+
+ removeAllUnprocessed(): Promise {
+ return window.Signal.Data.removeAllUnprocessed();
+ }
+
+ async removeAllData(): Promise {
+ await window.Signal.Data.removeAll();
+ await this.hydrateCaches();
+
+ window.storage.reset();
+ await window.storage.fetch();
+
+ window.ConversationController.reset();
+ await window.ConversationController.load();
+ }
+
+ async removeAllConfiguration(): Promise {
+ await window.Signal.Data.removeAllConfiguration();
+ await this.hydrateCaches();
+
+ window.storage.reset();
+ await window.storage.fetch();
+ }
+}
+
+window.SignalProtocolStore = SignalProtocolStore;
diff --git a/ts/libsignal.d.ts b/ts/libsignal.d.ts
index e348c8ecc..a9f581b99 100644
--- a/ts/libsignal.d.ts
+++ b/ts/libsignal.d.ts
@@ -201,7 +201,7 @@ declare class SessionBuilderClass {
export declare class SessionCipherClass {
constructor(
storage: StorageType,
- remoteAddress: SignalProtocolAddressClass,
+ remoteAddress: SignalProtocolAddressClass | string,
options?: { messageKeysLimit?: number | boolean }
);
closeOpenSessionForDevice: () => Promise;
diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts
index 14cdc41b9..728dd33ad 100644
--- a/ts/sql/Client.ts
+++ b/ts/sql/Client.ts
@@ -1226,7 +1226,7 @@ async function updateUnprocessedsWithData(array: Array) {
await channels.updateUnprocessedsWithData(array);
}
-async function removeUnprocessed(id: string) {
+async function removeUnprocessed(id: string | Array) {
await channels.removeUnprocessed(id);
}
diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts
index 469325563..848f37ea3 100644
--- a/ts/sql/Interface.ts
+++ b/ts/sql/Interface.ts
@@ -129,7 +129,7 @@ export type DataInterface = {
arrayOfUnprocessed: Array,
options?: { forceSave?: boolean }
) => Promise;
- removeUnprocessed: (id: string) => Promise;
+ removeUnprocessed: (id: string | Array) => Promise;
removeAllUnprocessed: () => Promise;
getNextAttachmentDownloadJobs: (
diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts
index f84965902..97b8bacd6 100644
--- a/ts/sql/Server.ts
+++ b/ts/sql/Server.ts
@@ -3410,7 +3410,7 @@ async function getAllUnprocessed() {
return rows;
}
-async function removeUnprocessed(id: string) {
+async function removeUnprocessed(id: string | Array) {
const db = getInstance();
if (!Array.isArray(id)) {
diff --git a/ts/test-both/util/isNotNil_test.ts b/ts/test-both/util/isNotNil_test.ts
new file mode 100644
index 000000000..1cdcf8343
--- /dev/null
+++ b/ts/test-both/util/isNotNil_test.ts
@@ -0,0 +1,24 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+
+import { isNotNil } from '../../util/isNotNil';
+
+describe('isNotNil', () => {
+ it('returns false if provided null value', () => {
+ assert.isFalse(isNotNil(null));
+ });
+
+ it('returns false is provided undefined value', () => {
+ assert.isFalse(isNotNil(undefined));
+ });
+
+ it('returns false is provided any other value', () => {
+ assert.isTrue(isNotNil(0));
+ assert.isTrue(isNotNil(4));
+ assert.isTrue(isNotNil(''));
+ assert.isTrue(isNotNil('string value'));
+ assert.isTrue(isNotNil({}));
+ });
+});
diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts
index 4543432b5..5e94fc1fd 100644
--- a/ts/textsecure.d.ts
+++ b/ts/textsecure.d.ts
@@ -147,6 +147,7 @@ export type StorageProtocolType = StorageType & {
publicKey?: ArrayBuffer
) => Promise;
removeSignedPreKey: (keyId: number) => Promise;
+ removeAllSessions: (identifier: string) => Promise;
removeAllData: () => Promise;
on: (key: string, callback: () => void) => WhatIsThis;
removeAllConfiguration: () => Promise;
diff --git a/ts/util/isNotNil.ts b/ts/util/isNotNil.ts
new file mode 100644
index 000000000..c60598407
--- /dev/null
+++ b/ts/util/isNotNil.ts
@@ -0,0 +1,9 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+export function isNotNil(value: T | null | undefined): value is T {
+ if (value === null || value === undefined) {
+ return false;
+ }
+ return true;
+}
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index 1872069bd..fd5779a12 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -281,14 +281,6 @@
"updated": "2020-08-21T11:29:29.636Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
- {
- "rule": "jQuery-load(",
- "path": "js/signal_protocol_store.js",
- "line": " await ConversationController.load();",
- "lineNumber": 1035,
- "reasonCategory": "falseMatch",
- "updated": "2020-06-12T14:20:09.936Z"
- },
{
"rule": "DOM-innerHTML",
"path": "js/views/app_view.js",
@@ -14326,6 +14318,22 @@
"reasonCategory": "falseMatch",
"updated": "2020-07-21T18:34:59.251Z"
},
+ {
+ "rule": "jQuery-load(",
+ "path": "ts/LibSignalStore.js",
+ "line": " await window.ConversationController.load();",
+ "lineNumber": 810,
+ "reasonCategory": "falseMatch",
+ "updated": "2021-02-27T00:48:49.313Z"
+ },
+ {
+ "rule": "jQuery-load(",
+ "path": "ts/LibSignalStore.ts",
+ "line": " await window.ConversationController.load();",
+ "lineNumber": 1221,
+ "reasonCategory": "falseMatch",
+ "updated": "2021-02-27T00:48:49.313Z"
+ },
{
"rule": "DOM-innerHTML",
"path": "ts/backbone/views/Lightbox.js",
diff --git a/ts/window.d.ts b/ts/window.d.ts
index 5879559d7..d2b953b39 100644
--- a/ts/window.d.ts
+++ b/ts/window.d.ts
@@ -93,6 +93,7 @@ import { Quote } from './components/conversation/Quote';
import { StagedLinkPreview } from './components/conversation/StagedLinkPreview';
import { MIMEType } from './types/MIME';
import { ElectronLocaleType } from './util/mapToSupportLocale';
+import { SignalProtocolStore } from './LibSignalStore';
export { Long } from 'long';
@@ -238,6 +239,7 @@ declare global {
removeBlockedGroup: (group: string) => void;
removeBlockedNumber: (number: string) => void;
removeBlockedUuid: (uuid: string) => void;
+ reset: () => void;
};
systemTheme: WhatIsThis;
textsecure: TextSecureType;
@@ -512,6 +514,7 @@ declare global {
ConversationController: ConversationController;
Events: WhatIsThis;
MessageController: MessageControllerType;
+ SignalProtocolStore: typeof SignalProtocolStore;
WebAPI: WebAPIConnectType;
Whisper: WhisperType;