Fix storage service handling of contact status

This commit is contained in:
Fedor Indutny 2022-11-07 15:21:12 -08:00 committed by GitHub
parent 38c6a872f4
commit 9ce8d5e68f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 297 additions and 549 deletions

View file

@ -120,6 +120,11 @@ export type VerifyAlternateIdentityOptionsType = Readonly<{
signature: Uint8Array; signature: Uint8Array;
}>; }>;
export type SetVerifiedExtra = Readonly<{
firstUse?: boolean;
nonblockingApproval?: boolean;
}>;
export const GLOBAL_ZONE = new Zone('GLOBAL_ZONE'); export const GLOBAL_ZONE = new Zone('GLOBAL_ZONE');
async function _fillCaches<ID, T extends HasIdType<ID>, HydratedType>( async function _fillCaches<ID, T extends HasIdType<ID>, HydratedType>(
@ -1483,6 +1488,7 @@ export class SignalProtocolStore extends EventEmitter {
return newRecord; return newRecord;
} }
// https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L128
async isTrustedIdentity( async isTrustedIdentity(
encodedAddress: Address, encodedAddress: Address,
publicKey: Uint8Array, publicKey: Uint8Array,
@ -1495,8 +1501,9 @@ export class SignalProtocolStore extends EventEmitter {
if (encodedAddress == null) { if (encodedAddress == null) {
throw new Error('isTrustedIdentity: encodedAddress was undefined/null'); throw new Error('isTrustedIdentity: encodedAddress was undefined/null');
} }
const ourUuid = window.textsecure.storage.user.getCheckedUuid(); const isOurIdentifier = window.textsecure.storage.user.isOurUuid(
const isOurIdentifier = encodedAddress.uuid.isEqual(ourUuid); encodedAddress.uuid
);
const identityRecord = await this.getOrMigrateIdentityRecord( const identityRecord = await this.getOrMigrateIdentityRecord(
encodedAddress.uuid encodedAddress.uuid
@ -1522,6 +1529,7 @@ export class SignalProtocolStore extends EventEmitter {
} }
} }
// https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L233
isTrustedForSending( isTrustedForSending(
publicKey: Uint8Array, publicKey: Uint8Array,
identityRecord?: IdentityKeyType identityRecord?: IdentityKeyType
@ -1597,6 +1605,7 @@ export class SignalProtocolStore extends EventEmitter {
}); });
} }
// https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L69
async saveIdentity( async saveIdentity(
encodedAddress: Address, encodedAddress: Address,
publicKey: Uint8Array, publicKey: Uint8Array,
@ -1640,8 +1649,21 @@ export class SignalProtocolStore extends EventEmitter {
return false; return false;
} }
const oldpublicKey = identityRecord.publicKey; const identityKeyChanged = !constantTimeEqual(
if (!constantTimeEqual(oldpublicKey, publicKey)) { identityRecord.publicKey,
publicKey
);
if (identityKeyChanged) {
const isOurIdentifier = window.textsecure.storage.user.isOurUuid(
encodedAddress.uuid
);
if (isOurIdentifier && identityKeyChanged) {
log.warn('saveIdentity: ignoring identity for ourselves');
return false;
}
log.info('saveIdentity: Replacing existing identity...'); log.info('saveIdentity: Replacing existing identity...');
const previousStatus = identityRecord.verified; const previousStatus = identityRecord.verified;
let verifiedStatus; let verifiedStatus;
@ -1663,6 +1685,8 @@ export class SignalProtocolStore extends EventEmitter {
nonblockingApproval, nonblockingApproval,
}); });
// See `addKeyChange` in `ts/models/conversations.ts` for sender key info
// update caused by this.
try { try {
this.emit('keychange', encodedAddress.uuid); this.emit('keychange', encodedAddress.uuid);
} catch (error) { } catch (error) {
@ -1692,7 +1716,10 @@ export class SignalProtocolStore extends EventEmitter {
return false; return false;
} }
isNonBlockingApprovalRequired(identityRecord: IdentityKeyType): boolean { // https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L257
private isNonBlockingApprovalRequired(
identityRecord: IdentityKeyType
): boolean {
return ( return (
!identityRecord.firstUse && !identityRecord.firstUse &&
isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) && isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) &&
@ -1746,10 +1773,12 @@ export class SignalProtocolStore extends EventEmitter {
await this._saveIdentityKey(identityRecord); await this._saveIdentityKey(identityRecord);
} }
// https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L215
// and https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.java#L544
async setVerified( async setVerified(
uuid: UUID, uuid: UUID,
verifiedStatus: number, verifiedStatus: number,
publicKey?: Uint8Array extra: SetVerifiedExtra = {}
): Promise<void> { ): Promise<void> {
if (uuid == null) { if (uuid == null) {
throw new Error('setVerified: uuid was undefined/null'); throw new Error('setVerified: uuid was undefined/null');
@ -1764,14 +1793,12 @@ export class SignalProtocolStore extends EventEmitter {
throw new Error(`setVerified: No identity record for ${uuid.toString()}`); throw new Error(`setVerified: No identity record for ${uuid.toString()}`);
} }
if (!publicKey || constantTimeEqual(identityRecord.publicKey, publicKey)) { if (validateIdentityKey(identityRecord)) {
identityRecord.verified = verifiedStatus; await this._saveIdentityKey({
...identityRecord,
if (validateIdentityKey(identityRecord)) { ...extra,
await this._saveIdentityKey(identityRecord); verified: verifiedStatus,
} });
} else {
log.info('setVerified: No identity record for specified publicKey');
} }
} }
@ -1793,59 +1820,63 @@ export class SignalProtocolStore extends EventEmitter {
return VerifiedStatus.DEFAULT; return VerifiedStatus.DEFAULT;
} }
// See https://github.com/signalapp/Signal-iOS-Private/blob/e32c2dff0d03f67467b4df621d84b11412d50cdb/SignalServiceKit/src/Messages/OWSIdentityManager.m#L317 // See https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/database/IdentityDatabase.java#L184
// for reference. async updateIdentityAfterSync(
async processVerifiedMessage(
uuid: UUID, uuid: UUID,
verifiedStatus: number, verifiedStatus: number,
publicKey?: Uint8Array publicKey: Uint8Array
): Promise<boolean> { ): Promise<boolean> {
if (uuid == null) { strictAssert(
throw new Error('processVerifiedMessage: uuid was undefined/null'); validateVerifiedStatus(verifiedStatus),
} `Invalid verified status: ${verifiedStatus}`
if (!validateVerifiedStatus(verifiedStatus)) { );
throw new Error('processVerifiedMessage: Invalid verified status');
}
if (publicKey !== undefined && !(publicKey instanceof Uint8Array)) {
throw new Error('processVerifiedMessage: Invalid public key');
}
const identityRecord = await this.getOrMigrateIdentityRecord(uuid); const identityRecord = await this.getOrMigrateIdentityRecord(uuid);
const hadEntry = identityRecord !== undefined;
const keyMatches = Boolean(
identityRecord?.publicKey &&
constantTimeEqual(publicKey, identityRecord.publicKey)
);
const statusMatches =
keyMatches && verifiedStatus === identityRecord?.verified;
let isEqual = false; if (!keyMatches || !statusMatches) {
await this.saveIdentityWithAttributes(uuid, {
if (identityRecord && publicKey) { publicKey,
isEqual = constantTimeEqual(publicKey, identityRecord.publicKey); verified: verifiedStatus,
firstUse: !hadEntry,
timestamp: Date.now(),
nonblockingApproval: true,
});
} }
// Just update verified status if the key is the same or not present if (hadEntry && !keyMatches) {
if (isEqual || !publicKey) {
await this.setVerified(uuid, verifiedStatus, publicKey);
return false;
}
await this.saveIdentityWithAttributes(uuid, {
publicKey,
verified: verifiedStatus,
firstUse: false,
timestamp: Date.now(),
nonblockingApproval: verifiedStatus === VerifiedStatus.VERIFIED,
});
if (identityRecord) {
try { try {
this.emit('keychange', uuid); this.emit('keychange', uuid);
} catch (error) { } catch (error) {
log.error( log.error(
'processVerifiedMessage error triggering keychange:', 'updateIdentityAfterSync: error triggering keychange:',
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
} }
// true signifies that we overwrote a previous key with a new one
return true;
} }
// See: https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt#L921-L936
if (
verifiedStatus === VerifiedStatus.VERIFIED &&
(!hadEntry || identityRecord?.verified !== VerifiedStatus.VERIFIED)
) {
// Needs a notification.
return true;
}
if (
verifiedStatus !== VerifiedStatus.VERIFIED &&
hadEntry &&
identityRecord?.verified === VerifiedStatus.VERIFIED
) {
// Needs a notification.
return true;
}
return false; return false;
} }

1
ts/model-types.d.ts vendored
View file

@ -437,7 +437,6 @@ export type GroupV2PendingAdminApprovalType = {
export type VerificationOptions = { export type VerificationOptions = {
key?: null | Uint8Array; key?: null | Uint8Array;
viaStorageServiceSync?: boolean;
}; };
export type ShallowChallengeError = CustomError & { export type ShallowChallengeError = CustomError & {

View file

@ -215,8 +215,6 @@ export class ConversationModel extends window.Backbone
typingPauseTimer?: NodeJS.Timer | null; typingPauseTimer?: NodeJS.Timer | null;
verifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus;
intlCollator = new Intl.Collator(undefined, { sensitivity: 'base' }); intlCollator = new Intl.Collator(undefined, { sensitivity: 'base' });
lastSuccessfulGroupFetch?: number; lastSuccessfulGroupFetch?: number;
@ -235,6 +233,8 @@ export class ConversationModel extends window.Backbone
private isInReduxBatch = false; private isInReduxBatch = false;
private privVerifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus;
override defaults(): Partial<ConversationAttributesType> { override defaults(): Partial<ConversationAttributesType> {
return { return {
unreadCount: 0, unreadCount: 0,
@ -286,7 +286,7 @@ export class ConversationModel extends window.Backbone
this.storeName = 'conversations'; this.storeName = 'conversations';
this.verifiedEnum = window.textsecure.storage.protocol.VerifiedStatus; this.privVerifiedEnum = window.textsecure.storage.protocol.VerifiedStatus;
// This may be overridden by window.ConversationController.getOrCreate, and signify // This may be overridden by window.ConversationController.getOrCreate, and signify
// our first save to the database. Or first fetch from the database. // our first save to the database. Or first fetch from the database.
@ -397,6 +397,11 @@ export class ConversationModel extends window.Backbone
}; };
} }
private get verifiedEnum(): typeof window.textsecure.storage.protocol.VerifiedStatus {
strictAssert(this.privVerifiedEnum, 'ConversationModel not initialize');
return this.privVerifiedEnum;
}
private isMemberRequestingToJoin(uuid: UUID): boolean { private isMemberRequestingToJoin(uuid: UUID): boolean {
if (!isGroupV2(this.attributes)) { if (!isGroupV2(this.attributes)) {
return false; return false;
@ -2633,13 +2638,14 @@ export class ConversationModel extends window.Backbone
async safeGetVerified(): Promise<number> { async safeGetVerified(): Promise<number> {
const uuid = this.getUuid(); const uuid = this.getUuid();
if (!uuid) { if (!uuid) {
return window.textsecure.storage.protocol.VerifiedStatus.DEFAULT; return this.verifiedEnum.DEFAULT;
} }
const promise = window.textsecure.storage.protocol.getVerified(uuid); try {
return promise.catch( return await window.textsecure.storage.protocol.getVerified(uuid);
() => window.textsecure.storage.protocol.VerifiedStatus.DEFAULT } catch {
); return this.verifiedEnum.DEFAULT;
}
} }
async updateVerified(): Promise<void> { async updateVerified(): Promise<void> {
@ -2668,24 +2674,21 @@ export class ConversationModel extends window.Backbone
} }
setVerifiedDefault(options?: VerificationOptions): Promise<boolean> { setVerifiedDefault(options?: VerificationOptions): Promise<boolean> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { DEFAULT } = this.verifiedEnum;
const { DEFAULT } = this.verifiedEnum!;
return this.queueJob('setVerifiedDefault', () => return this.queueJob('setVerifiedDefault', () =>
this._setVerified(DEFAULT, options) this._setVerified(DEFAULT, options)
); );
} }
setVerified(options?: VerificationOptions): Promise<boolean> { setVerified(options?: VerificationOptions): Promise<boolean> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { VERIFIED } = this.verifiedEnum;
const { VERIFIED } = this.verifiedEnum!;
return this.queueJob('setVerified', () => return this.queueJob('setVerified', () =>
this._setVerified(VERIFIED, options) this._setVerified(VERIFIED, options)
); );
} }
setUnverified(options: VerificationOptions): Promise<boolean> { setUnverified(options: VerificationOptions): Promise<boolean> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { UNVERIFIED } = this.verifiedEnum;
const { UNVERIFIED } = this.verifiedEnum!;
return this.queueJob('setUnverified', () => return this.queueJob('setUnverified', () =>
this._setVerified(UNVERIFIED, options) this._setVerified(UNVERIFIED, options)
); );
@ -2697,12 +2700,10 @@ export class ConversationModel extends window.Backbone
): Promise<boolean> { ): Promise<boolean> {
const options = providedOptions || {}; const options = providedOptions || {};
window._.defaults(options, { window._.defaults(options, {
viaStorageServiceSync: false,
key: null, key: null,
}); });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { VERIFIED, DEFAULT } = this.verifiedEnum;
const { VERIFIED, DEFAULT } = this.verifiedEnum!;
if (!isDirectConversation(this.attributes)) { if (!isDirectConversation(this.attributes)) {
throw new Error( throw new Error(
@ -2712,55 +2713,35 @@ export class ConversationModel extends window.Backbone
} }
const uuid = this.getUuid(); const uuid = this.getUuid();
const beginningVerified = this.get('verified'); const beginningVerified = this.get('verified') ?? DEFAULT;
let keyChange = false; const keyChange = false;
if (options.viaStorageServiceSync) { if (uuid) {
strictAssert( if (verified === this.verifiedEnum.DEFAULT) {
uuid, await window.textsecure.storage.protocol.setVerified(uuid, verified);
`Sync message didn't update uuid for conversation: ${this.id}` } else {
); await window.textsecure.storage.protocol.setVerified(uuid, verified, {
firstUse: false,
// handle the incoming key from the sync messages - need different nonblockingApproval: true,
// behavior if that key doesn't match the current key });
keyChange = }
await window.textsecure.storage.protocol.processVerifiedMessage(
uuid,
verified,
options.key || undefined
);
} else if (uuid) {
await window.textsecure.storage.protocol.setVerified(uuid, verified);
} else { } else {
log.warn(`_setVerified(${this.id}): no uuid to update protocol storage`); log.warn(`_setVerified(${this.id}): no uuid to update protocol storage`);
} }
this.set({ verified }); this.set({ verified });
// We will update the conversation during storage service sync window.Signal.Data.updateConversation(this.attributes);
if (!options.viaStorageServiceSync) {
window.Signal.Data.updateConversation(this.attributes);
}
if (!options.viaStorageServiceSync) { if (beginningVerified !== verified) {
if (keyChange) { this.captureChange(`verified from=${beginningVerified} to=${verified}`);
this.captureChange('keyChange');
}
if (beginningVerified !== verified) {
this.captureChange(`verified from=${beginningVerified} to=${verified}`);
}
} }
const didVerifiedChange = beginningVerified !== verified; const didVerifiedChange = beginningVerified !== verified;
const isExplicitUserAction = !options.viaStorageServiceSync; const isExplicitUserAction = true;
const shouldShowFromStorageSync =
options.viaStorageServiceSync && verified !== DEFAULT;
if ( if (
// The message came from an explicit verification in a client (not // The message came from an explicit verification in a client (not
// storage service sync) // storage service sync)
(didVerifiedChange && isExplicitUserAction) || (didVerifiedChange && isExplicitUserAction) ||
// The verification value received by the storage sync is different from what we
// have on record (and it's not a transition to UNVERIFIED)
(didVerifiedChange && shouldShowFromStorageSync) ||
// Our local verification status is VERIFIED and it hasn't changed, but the key did // Our local verification status is VERIFIED and it hasn't changed, but the key did
// change (Key1/VERIFIED -> Key2/VERIFIED), but we don't want to show DEFAULT -> // change (Key1/VERIFIED -> Key2/VERIFIED), but we don't want to show DEFAULT ->
// DEFAULT or UNVERIFIED -> UNVERIFIED // DEFAULT or UNVERIFIED -> UNVERIFIED
@ -2819,8 +2800,7 @@ export class ConversationModel extends window.Backbone
isVerified(): boolean { isVerified(): boolean {
if (isDirectConversation(this.attributes)) { if (isDirectConversation(this.attributes)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion return this.get('verified') === this.verifiedEnum.VERIFIED;
return this.get('verified') === this.verifiedEnum!.VERIFIED;
} }
if (!this.contactCollection?.length) { if (!this.contactCollection?.length) {
@ -2839,10 +2819,8 @@ export class ConversationModel extends window.Backbone
if (isDirectConversation(this.attributes)) { if (isDirectConversation(this.attributes)) {
const verified = this.get('verified'); const verified = this.get('verified');
return ( return (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion verified !== this.verifiedEnum.VERIFIED &&
verified !== this.verifiedEnum!.VERIFIED && verified !== this.verifiedEnum.DEFAULT
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
verified !== this.verifiedEnum!.DEFAULT
); );
} }
@ -3071,48 +3049,67 @@ export class ConversationModel extends window.Backbone
} }
async addKeyChange(keyChangedId: UUID): Promise<void> { async addKeyChange(keyChangedId: UUID): Promise<void> {
log.info( const keyChangedIdString = keyChangedId.toString();
'adding key change advisory for', return this.queueJob(`addKeyChange(${keyChangedIdString})`, async () => {
this.idForLogging(), log.info(
keyChangedId.toString(), 'adding key change advisory for',
this.get('timestamp') this.idForLogging(),
); keyChangedIdString,
this.get('timestamp')
);
const timestamp = Date.now(); const timestamp = Date.now();
const message = { const message = {
conversationId: this.id, conversationId: this.id,
type: 'keychange', type: 'keychange',
sent_at: this.get('timestamp'), sent_at: this.get('timestamp'),
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp, received_at_ms: timestamp,
key_changed: keyChangedId.toString(), key_changed: keyChangedIdString,
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen, seenStatus: SeenStatus.Unseen,
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to // this type does not fully implement the interface it is expected to
} as unknown as MessageAttributesType; } as unknown as MessageAttributesType;
const id = await window.Signal.Data.saveMessage(message, { const id = await window.Signal.Data.saveMessage(message, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
}); });
const model = window.MessageController.register( const model = window.MessageController.register(
id,
new window.Whisper.Message({
...message,
id, id,
}) new window.Whisper.Message({
); ...message,
id,
})
);
const isUntrusted = await this.isUntrusted(); const isUntrusted = await this.isUntrusted();
this.trigger('newmessage', model); this.trigger('newmessage', model);
const uuid = this.get('uuid'); const uuid = this.get('uuid');
// Group calls are always with folks that have a UUID // Group calls are always with folks that have a UUID
if (isUntrusted && uuid) { if (isUntrusted && uuid) {
window.reduxActions.calling.keyChanged({ uuid }); window.reduxActions.calling.keyChanged({ uuid });
} }
// Drop a member from sender key distribution list.
const senderKeyInfo = this.get('senderKeyInfo');
if (senderKeyInfo) {
const updatedSenderKeyInfo = {
...senderKeyInfo,
memberDevices: senderKeyInfo.memberDevices.filter(
({ identifier }) => {
return identifier !== keyChangedIdString;
}
),
};
this.set('senderKeyInfo', updatedSenderKeyInfo);
window.Signal.Data.updateConversation(this.attributes);
}
});
} }
async addVerifiedChange( async addVerifiedChange(

View file

@ -91,6 +91,22 @@ function toRecordVerified(verified: number): Proto.ContactRecord.IdentityState {
} }
} }
function fromRecordVerified(
verified: Proto.ContactRecord.IdentityState
): number {
const VERIFIED_ENUM = window.textsecure.storage.protocol.VerifiedStatus;
const STATE_ENUM = Proto.ContactRecord.IdentityState;
switch (verified) {
case STATE_ENUM.VERIFIED:
return VERIFIED_ENUM.VERIFIED;
case STATE_ENUM.UNVERIFIED:
return VERIFIED_ENUM.UNVERIFIED;
default:
return VERIFIED_ENUM.DEFAULT;
}
}
function addUnknownFields( function addUnknownFields(
record: RecordClass, record: RecordClass,
conversation: ConversationModel, conversation: ConversationModel,
@ -991,35 +1007,34 @@ export async function mergeContactRecord(
systemFamilyName: dropNull(contactRecord.systemFamilyName), systemFamilyName: dropNull(contactRecord.systemFamilyName),
}); });
// https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt#L921-L936
if (contactRecord.identityKey) { if (contactRecord.identityKey) {
const verified = await conversation.safeGetVerified(); const verified = await conversation.safeGetVerified();
const storageServiceVerified = contactRecord.identityState || 0; const newVerified = fromRecordVerified(contactRecord.identityState ?? 0);
const verifiedOptions = {
key: contactRecord.identityKey,
viaStorageServiceSync: true,
};
const STATE_ENUM = Proto.ContactRecord.IdentityState;
if (verified !== storageServiceVerified) { const needsNotification =
details.push(`updating verified state to=${verified}`); await window.textsecure.storage.protocol.updateIdentityAfterSync(
new UUID(uuid),
newVerified,
contactRecord.identityKey
);
if (verified !== newVerified) {
details.push(
`updating verified state from=${verified} to=${newVerified}`
);
conversation.set({ verified: newVerified });
} }
// Update verified status unconditionally to make sure we will take the const VERIFIED_ENUM = window.textsecure.storage.protocol.VerifiedStatus;
// latest identity key from the manifest. if (needsNotification) {
let keyChange: boolean; details.push('adding a verified notification');
switch (storageServiceVerified) { await conversation.addVerifiedChange(
case STATE_ENUM.VERIFIED: conversation.id,
keyChange = await conversation.setVerified(verifiedOptions); newVerified === VERIFIED_ENUM.VERIFIED,
break; { local: false }
case STATE_ENUM.UNVERIFIED: );
keyChange = await conversation.setUnverified(verifiedOptions);
break;
default:
keyChange = await conversation.setVerifiedDefault(verifiedOptions);
}
if (keyChange) {
details.push('key changed');
} }
} }

View file

@ -641,11 +641,7 @@ describe('SignalProtocolStore', () => {
describe('with the current public key', () => { describe('with the current public key', () => {
before(saveRecordDefault); before(saveRecordDefault);
it('updates the verified status', async () => { it('updates the verified status', async () => {
await store.setVerified( await store.setVerified(theirUuid, store.VerifiedStatus.VERIFIED);
theirUuid,
store.VerifiedStatus.VERIFIED,
testKey.pubKey
);
const identity = await window.Signal.Data.getIdentityKeyById( const identity = await window.Signal.Data.getIdentityKeyById(
theirUuid.toString() theirUuid.toString()
@ -658,405 +654,111 @@ describe('SignalProtocolStore', () => {
assert.isTrue(constantTimeEqual(identity.publicKey, testKey.pubKey)); assert.isTrue(constantTimeEqual(identity.publicKey, testKey.pubKey));
}); });
}); });
describe('with a mismatching public key', () => {
const newIdentity = getPublicKey();
before(saveRecordDefault);
it('does not change the record.', async () => {
await store.setVerified(
theirUuid,
store.VerifiedStatus.VERIFIED,
newIdentity
);
const identity = await window.Signal.Data.getIdentityKeyById(
theirUuid.toString()
);
if (!identity) {
throw new Error('Missing identity!');
}
assert.strictEqual(identity.verified, store.VerifiedStatus.DEFAULT);
assert.isTrue(constantTimeEqual(identity.publicKey, testKey.pubKey));
});
});
}); });
describe('processVerifiedMessage', () => {
describe('updateIdentityAfterSync', () => {
const newIdentity = getPublicKey(); const newIdentity = getPublicKey();
let keychangeTriggered: number; let keychangeTriggered: number;
beforeEach(() => { beforeEach(async () => {
keychangeTriggered = 0; keychangeTriggered = 0;
store.on('keychange', () => { store.on('keychange', () => {
keychangeTriggered += 1; keychangeTriggered += 1;
}); });
await window.Signal.Data.createOrUpdateIdentityKey({
id: theirUuid.toString(),
publicKey: testKey.pubKey,
timestamp: Date.now() - 10 * 1000 * 60,
verified: store.VerifiedStatus.DEFAULT,
firstUse: false,
nonblockingApproval: false,
});
await store.hydrateCaches();
}); });
afterEach(() => { afterEach(() => {
store.removeAllListeners('keychange'); store.removeAllListeners('keychange');
}); });
describe('when the new verified status is DEFAULT', () => { it('should create an identity and set verified to DEFAULT', async () => {
describe('when there is no existing record', () => { const newUuid = UUID.generate();
before(async () => {
await window.Signal.Data.removeIdentityKeyById(theirUuid.toString());
await store.hydrateCaches();
});
it('sets the identity key', async () => { const needsNotification = await store.updateIdentityAfterSync(
await store.processVerifiedMessage( newUuid,
theirUuid, store.VerifiedStatus.DEFAULT,
store.VerifiedStatus.DEFAULT, newIdentity
newIdentity );
); assert.isFalse(needsNotification);
assert.strictEqual(keychangeTriggered, 0);
const identity = await window.Signal.Data.getIdentityKeyById( const identity = await window.Signal.Data.getIdentityKeyById(
theirUuid.toString() newUuid.toString()
); );
assert.isTrue( if (!identity) {
identity?.publicKey && throw new Error('Missing identity!');
constantTimeEqual(identity.publicKey, newIdentity) }
); assert.strictEqual(identity.verified, store.VerifiedStatus.DEFAULT);
assert.strictEqual(keychangeTriggered, 0); assert.isTrue(constantTimeEqual(identity.publicKey, newIdentity));
});
});
describe('when the record exists', () => {
describe('when the existing key is different', () => {
before(async () => {
await window.Signal.Data.createOrUpdateIdentityKey({
id: theirUuid.toString(),
publicKey: testKey.pubKey,
firstUse: true,
timestamp: Date.now(),
verified: store.VerifiedStatus.VERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('updates the identity', async () => {
await store.processVerifiedMessage(
theirUuid,
store.VerifiedStatus.DEFAULT,
newIdentity
);
const identity = await window.Signal.Data.getIdentityKeyById(
theirUuid.toString()
);
if (!identity) {
throw new Error('Missing identity!');
}
assert.strictEqual(identity.verified, store.VerifiedStatus.DEFAULT);
assert.isTrue(constantTimeEqual(identity.publicKey, newIdentity));
assert.strictEqual(keychangeTriggered, 1);
});
});
describe('when the existing key is the same but VERIFIED', () => {
before(async () => {
await window.Signal.Data.createOrUpdateIdentityKey({
id: theirUuid.toString(),
publicKey: testKey.pubKey,
firstUse: true,
timestamp: Date.now(),
verified: store.VerifiedStatus.VERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('updates the verified status', async () => {
await store.processVerifiedMessage(
theirUuid,
store.VerifiedStatus.DEFAULT,
testKey.pubKey
);
const identity = await window.Signal.Data.getIdentityKeyById(
theirUuid.toString()
);
if (!identity) {
throw new Error('Missing identity!');
}
assert.strictEqual(identity.verified, store.VerifiedStatus.DEFAULT);
assert.isTrue(
constantTimeEqual(identity.publicKey, testKey.pubKey)
);
assert.strictEqual(keychangeTriggered, 0);
});
});
describe('when the existing key is the same and already DEFAULT', () => {
before(async () => {
await window.Signal.Data.createOrUpdateIdentityKey({
id: theirUuid.toString(),
publicKey: testKey.pubKey,
firstUse: true,
timestamp: Date.now(),
verified: store.VerifiedStatus.DEFAULT,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('does not hang', async () => {
await store.processVerifiedMessage(
theirUuid,
store.VerifiedStatus.DEFAULT,
testKey.pubKey
);
assert.strictEqual(keychangeTriggered, 0);
});
});
});
}); });
describe('when the new verified status is UNVERIFIED', () => {
describe('when there is no existing record', () => {
before(async () => {
await window.Signal.Data.removeIdentityKeyById(theirUuid.toString());
await store.hydrateCaches();
});
it('saves the new identity and marks it UNVERIFIED', async () => { it('should create an identity and set verified to VERIFIED', async () => {
await store.processVerifiedMessage( const newUuid = UUID.generate();
theirUuid,
store.VerifiedStatus.UNVERIFIED,
newIdentity
);
const identity = await window.Signal.Data.getIdentityKeyById( const needsNotification = await store.updateIdentityAfterSync(
theirUuid.toString() newUuid,
); store.VerifiedStatus.VERIFIED,
if (!identity) { newIdentity
throw new Error('Missing identity!'); );
} assert.isTrue(needsNotification);
assert.strictEqual(keychangeTriggered, 0);
assert.strictEqual( const identity = await window.Signal.Data.getIdentityKeyById(
identity.verified, newUuid.toString()
store.VerifiedStatus.UNVERIFIED );
); if (!identity) {
assert.isTrue(constantTimeEqual(identity.publicKey, newIdentity)); throw new Error('Missing identity!');
assert.strictEqual(keychangeTriggered, 0); }
}); assert.strictEqual(identity.verified, store.VerifiedStatus.VERIFIED);
}); assert.isTrue(constantTimeEqual(identity.publicKey, newIdentity));
describe('when the record exists', () => {
describe('when the existing key is different', () => {
before(async () => {
await window.Signal.Data.createOrUpdateIdentityKey({
id: theirUuid.toString(),
publicKey: testKey.pubKey,
firstUse: true,
timestamp: Date.now(),
verified: store.VerifiedStatus.VERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('saves the new identity and marks it UNVERIFIED', async () => {
await store.processVerifiedMessage(
theirUuid,
store.VerifiedStatus.UNVERIFIED,
newIdentity
);
const identity = await window.Signal.Data.getIdentityKeyById(
theirUuid.toString()
);
if (!identity) {
throw new Error('Missing identity!');
}
assert.strictEqual(
identity.verified,
store.VerifiedStatus.UNVERIFIED
);
assert.isTrue(constantTimeEqual(identity.publicKey, newIdentity));
assert.strictEqual(keychangeTriggered, 1);
});
});
describe('when the key exists and is DEFAULT', () => {
before(async () => {
await window.Signal.Data.createOrUpdateIdentityKey({
id: theirUuid.toString(),
publicKey: testKey.pubKey,
firstUse: true,
timestamp: Date.now(),
verified: store.VerifiedStatus.DEFAULT,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('updates the verified status', async () => {
await store.processVerifiedMessage(
theirUuid,
store.VerifiedStatus.UNVERIFIED,
testKey.pubKey
);
const identity = await window.Signal.Data.getIdentityKeyById(
theirUuid.toString()
);
if (!identity) {
throw new Error('Missing identity!');
}
assert.strictEqual(
identity.verified,
store.VerifiedStatus.UNVERIFIED
);
assert.isTrue(
constantTimeEqual(identity.publicKey, testKey.pubKey)
);
assert.strictEqual(keychangeTriggered, 0);
});
});
describe('when the key exists and is already UNVERIFIED', () => {
before(async () => {
await window.Signal.Data.createOrUpdateIdentityKey({
id: theirUuid.toString(),
publicKey: testKey.pubKey,
firstUse: true,
timestamp: Date.now(),
verified: store.VerifiedStatus.UNVERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('does not hang', async () => {
await store.processVerifiedMessage(
theirUuid,
store.VerifiedStatus.UNVERIFIED,
testKey.pubKey
);
assert.strictEqual(keychangeTriggered, 0);
});
});
});
}); });
describe('when the new verified status is VERIFIED', () => {
describe('when there is no existing record', () => {
before(async () => {
await window.Signal.Data.removeIdentityKeyById(theirUuid.toString());
await store.hydrateCaches();
});
it('saves the new identity and marks it verified', async () => { it('should update public key without verified change', async () => {
await store.processVerifiedMessage( const needsNotification = await store.updateIdentityAfterSync(
theirUuid, theirUuid,
store.VerifiedStatus.VERIFIED, store.VerifiedStatus.DEFAULT,
newIdentity newIdentity
); );
const identity = await window.Signal.Data.getIdentityKeyById( assert.isFalse(needsNotification);
theirUuid.toString() assert.strictEqual(keychangeTriggered, 1);
);
if (!identity) {
throw new Error('Missing identity!');
}
assert.strictEqual(identity.verified, store.VerifiedStatus.VERIFIED); const identity = await window.Signal.Data.getIdentityKeyById(
assert.isTrue(constantTimeEqual(identity.publicKey, newIdentity)); theirUuid.toString()
assert.strictEqual(keychangeTriggered, 0); );
}); if (!identity) {
}); throw new Error('Missing identity!');
describe('when the record exists', () => { }
describe('when the existing key is different', () => { assert.strictEqual(identity.verified, store.VerifiedStatus.DEFAULT);
before(async () => { assert.isTrue(constantTimeEqual(identity.publicKey, newIdentity));
await window.Signal.Data.createOrUpdateIdentityKey({ });
id: theirUuid.toString(),
publicKey: testKey.pubKey,
firstUse: true,
timestamp: Date.now(),
verified: store.VerifiedStatus.VERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('saves the new identity and marks it VERIFIED', async () => { it('should update verified without public key change', async () => {
await store.processVerifiedMessage( const needsNotification = await store.updateIdentityAfterSync(
theirUuid, theirUuid,
store.VerifiedStatus.VERIFIED, store.VerifiedStatus.VERIFIED,
newIdentity testKey.pubKey
); );
assert.isTrue(needsNotification);
assert.strictEqual(keychangeTriggered, 0);
const identity = await window.Signal.Data.getIdentityKeyById( const identity = await window.Signal.Data.getIdentityKeyById(
theirUuid.toString() theirUuid.toString()
); );
if (!identity) { if (!identity) {
throw new Error('Missing identity!'); throw new Error('Missing identity!');
} }
assert.strictEqual(identity.verified, store.VerifiedStatus.VERIFIED);
assert.strictEqual( assert.isTrue(constantTimeEqual(identity.publicKey, testKey.pubKey));
identity.verified,
store.VerifiedStatus.VERIFIED
);
assert.isTrue(constantTimeEqual(identity.publicKey, newIdentity));
assert.strictEqual(keychangeTriggered, 1);
});
});
describe('when the existing key is the same but UNVERIFIED', () => {
before(async () => {
await window.Signal.Data.createOrUpdateIdentityKey({
id: theirUuid.toString(),
publicKey: testKey.pubKey,
firstUse: true,
timestamp: Date.now(),
verified: store.VerifiedStatus.UNVERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('saves the identity and marks it verified', async () => {
await store.processVerifiedMessage(
theirUuid,
store.VerifiedStatus.VERIFIED,
testKey.pubKey
);
const identity = await window.Signal.Data.getIdentityKeyById(
theirUuid.toString()
);
if (!identity) {
throw new Error('Missing identity!');
}
assert.strictEqual(
identity.verified,
store.VerifiedStatus.VERIFIED
);
assert.isTrue(
constantTimeEqual(identity.publicKey, testKey.pubKey)
);
assert.strictEqual(keychangeTriggered, 0);
});
});
describe('when the existing key is the same and already VERIFIED', () => {
before(async () => {
await window.Signal.Data.createOrUpdateIdentityKey({
id: theirUuid.toString(),
publicKey: testKey.pubKey,
firstUse: true,
timestamp: Date.now(),
verified: store.VerifiedStatus.VERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('does not hang', async () => {
await store.processVerifiedMessage(
theirUuid,
store.VerifiedStatus.VERIFIED,
testKey.pubKey
);
assert.strictEqual(keychangeTriggered, 0);
});
});
});
}); });
}); });

View file

@ -106,6 +106,10 @@ export class User {
return UUIDKind.Unknown; return UUIDKind.Unknown;
} }
public isOurUuid(uuid: UUID): boolean {
return this.getOurUuidKind(uuid) !== UUIDKind.Unknown;
}
public getDeviceId(): number | undefined { public getDeviceId(): number | undefined {
const value = this._getDeviceIdFromUuid() || this._getDeviceIdFromNumber(); const value = this._getDeviceIdFromUuid() || this._getDeviceIdFromNumber();
if (value === undefined) { if (value === undefined) {