Reset expire timer version after unlink

This commit is contained in:
trevor-signal 2025-04-29 16:35:05 -04:00 committed by GitHub
commit 07a938ec98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 137 additions and 26 deletions

View file

@ -193,6 +193,7 @@ import { cleanupMessages } from '../util/cleanup';
import { MessageModel } from './messages'; import { MessageModel } from './messages';
import { applyNewAvatar } from '../groups'; import { applyNewAvatar } from '../groups';
import { safeSetTimeout } from '../util/timeout'; import { safeSetTimeout } from '../util/timeout';
import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
@ -313,7 +314,7 @@ export class ConversationModel extends window.Backbone
verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT, verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT,
messageCount: 0, messageCount: 0,
sentMessageCount: 0, sentMessageCount: 0,
expireTimerVersion: 1, expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION,
}; };
} }
@ -4667,6 +4668,21 @@ export class ConversationModel extends window.Backbone
); );
} }
// If this is the initial sync, we want to use the provided expire timer & version and
// disregard our local version. We might be re-linking after the primary has
// re-registered and their expireTimerVersion may have been reset, but we don't want
// to ignore it; our local version is out of date.
if (
isInitialSync &&
this.get('expireTimerVersion') !== INITIAL_EXPIRE_TIMER_VERSION
) {
log.warn(
'updateExpirationTimer: Resetting expireTimerVersion since this is initialSync'
);
// This is reset after unlink, but we do it here as well to recover from errors
this.set('expireTimerVersion', INITIAL_EXPIRE_TIMER_VERSION);
}
let expireTimer: DurationInSeconds | undefined = providedExpireTimer; let expireTimer: DurationInSeconds | undefined = providedExpireTimer;
let source = providedSource; let source = providedSource;
if (this.get('left')) { if (this.get('left')) {
@ -4687,7 +4703,7 @@ export class ConversationModel extends window.Backbone
`updateExpirationTimer(${this.idForLogging()}, ` + `updateExpirationTimer(${this.idForLogging()}, ` +
`${expireTimer || 'disabled'}, version=${version || 0}) ` + `${expireTimer || 'disabled'}, version=${version || 0}) ` +
`source=${source ?? '?'} localValue=${this.get('expireTimer')} ` + `source=${source ?? '?'} localValue=${this.get('expireTimer')} ` +
`localVersion=${localVersion}, reason=${reason}`; `localVersion=${localVersion}, reason=${reason}, isInitialSync=${isInitialSync}`;
if (isSetByOther) { if (isSetByOther) {
if (version) { if (version) {

View file

@ -212,6 +212,7 @@ import {
replaceAllEndorsementsForGroup, replaceAllEndorsementsForGroup,
} from './server/groupSendEndorsements'; } from './server/groupSendEndorsements';
import type { GifType } from '../components/fun/panels/FunPanelGifs'; import type { GifType } from '../components/fun/panels/FunPanelGifs';
import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer';
type ConversationRow = Readonly<{ type ConversationRow = Readonly<{
json: string; json: string;
@ -1663,7 +1664,9 @@ function saveConversation(db: WritableDB, data: ConversationType): void {
` `
).run({ ).run({
id, id,
json: objectToJSON(omit(data, ['profileLastFetchedAt'])), json: objectToJSON(
omit(data, ['profileLastFetchedAt', 'expireTimerVersion'])
),
e164: e164 || null, e164: e164 || null,
serviceId: serviceId || null, serviceId: serviceId || null,
@ -1799,14 +1802,20 @@ function getConversationById(
id: string id: string
): ConversationType | undefined { ): ConversationType | undefined {
const row = db const row = db
.prepare('SELECT json FROM conversations WHERE id = $id;') .prepare(
.get<{ json: string }>({ id }); `
SELECT json, profileLastFetchedAt, expireTimerVersion
FROM conversations
WHERE id = $id
`
)
.get<ConversationRow>({ id });
if (!row) { if (!row) {
return undefined; return undefined;
} }
return jsonToObject(row.json); return rowToConversation(row);
} }
function getAllConversations(db: ReadableDB): Array<ConversationType> { function getAllConversations(db: ReadableDB): Array<ConversationType> {
@ -6952,19 +6961,25 @@ function removeAllConfiguration(db: WritableDB): void {
db.exec( db.exec(
` `
UPDATE storyDistributions SET senderKeyInfoJson = NULL;
`
);
/** Update conversations */
const [updateConversationsQuery, updateConversationsParams] = sql`
UPDATE conversations UPDATE conversations
SET SET
expireTimerVersion = ${INITIAL_EXPIRE_TIMER_VERSION},
json = json_remove( json = json_remove(
json, json,
'$.senderKeyInfo', '$.senderKeyInfo',
'$.storageID', '$.storageID',
'$.needsStorageServiceSync', '$.needsStorageServiceSync',
'$.storageUnknownFields' '$.storageUnknownFields',
); '$.expireTimerVersion'
UPDATE storyDistributions SET senderKeyInfoJson = NULL;
`
); );
`;
db.prepare(updateConversationsQuery).run(updateConversationsParams);
})(); })();
} }

View file

@ -9,6 +9,7 @@ import { SendStatus } from '../../messages/MessageSendState';
import { IMAGE_PNG } from '../../types/MIME'; import { IMAGE_PNG } from '../../types/MIME';
import { generateAci, generatePni } from '../../types/ServiceId'; import { generateAci, generatePni } from '../../types/ServiceId';
import { MessageModel } from '../../models/messages'; import { MessageModel } from '../../models/messages';
import { DurationInSeconds } from '../../util/durations';
describe('Conversations', () => { describe('Conversations', () => {
async function resetConversationController(): Promise<void> { async function resetConversationController(): Promise<void> {
@ -16,15 +17,20 @@ describe('Conversations', () => {
await window.ConversationController.load(); await window.ConversationController.load();
} }
beforeEach(resetConversationController); beforeEach(async () => {
await DataWriter.removeAll();
afterEach(resetConversationController); await window.textsecure.storage.user.setCredentials({
number: '+15550000000',
aci: generateAci(),
pni: generatePni(),
deviceId: 2,
deviceName: 'my device',
password: 'password',
});
await resetConversationController();
});
it('updates lastMessage even in race conditions with db', async () => { it('updates lastMessage even in race conditions with db', async () => {
const ourNumber = '+15550000000';
const ourAci = generateAci();
const ourPni = generatePni();
// Creating a fake conversation // Creating a fake conversation
const conversation = new window.Whisper.Conversation({ const conversation = new window.Whisper.Conversation({
avatars: [], avatars: [],
@ -44,14 +50,6 @@ describe('Conversations', () => {
lastMessage: 'starting value', lastMessage: 'starting value',
}); });
await window.textsecure.storage.user.setCredentials({
number: ourNumber,
aci: ourAci,
pni: ourPni,
deviceId: 2,
deviceName: 'my device',
password: 'password',
});
await window.ConversationController.load(); await window.ConversationController.load();
await window.ConversationController.getOrCreateAndWait( await window.ConversationController.getOrCreateAndWait(
conversation.attributes.e164 ?? null, conversation.attributes.e164 ?? null,
@ -163,4 +161,39 @@ describe('Conversations', () => {
assert.equal(resultWithImage.contentType, 'image/png'); assert.equal(resultWithImage.contentType, 'image/png');
assert.equal(resultWithImage.fileName, null); assert.equal(resultWithImage.fileName, null);
}); });
describe('updateExpirationTimer', () => {
it('always updates if `isInitialSync` is true', async () => {
const conversation =
await window.ConversationController.getOrCreateAndWait(
generateUuid(),
'private',
{
expireTimerVersion: 42,
expireTimer: DurationInSeconds.WEEK,
}
);
// Without isInitialSync, ignores
await conversation.updateExpirationTimer(DurationInSeconds.DAY, {
reason: 'test',
source: 'test',
version: 3,
});
assert.equal(conversation.getExpireTimerVersion(), 42);
assert.equal(conversation.get('expireTimer'), DurationInSeconds.WEEK);
// With isInitialSync, overwrites
await conversation.updateExpirationTimer(DurationInSeconds.DAY, {
reason: 'test',
source: 'test',
version: 3,
isInitialSync: true,
});
assert.equal(conversation.getExpireTimerVersion(), 3);
assert.equal(conversation.get('expireTimer'), DurationInSeconds.DAY);
});
});
}); });

View file

@ -0,0 +1,45 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { v4 as generateGuid } from 'uuid';
import { DataWriter, DataReader } from '../../sql/Client';
describe('Remove all configuration test', () => {
beforeEach(async () => {
await DataWriter.removeAll();
});
it('Removes conversation-specific configuration', async () => {
const { attributes } =
await window.ConversationController.getOrCreateAndWait(
generateGuid(),
'private',
{
expireTimerVersion: 3,
senderKeyInfo: {
createdAtDate: Date.now(),
distributionId: generateGuid(),
memberDevices: [],
},
storageID: 'storageId',
needsStorageServiceSync: true,
storageUnknownFields: 'base64==',
name: 'Name (and all other fields) should be preserved',
}
);
await DataWriter.removeAllConfiguration();
const convoAfter = await DataReader.getConversationById(attributes.id);
assert.strictEqual(convoAfter?.expireTimerVersion, 1);
assert.isUndefined(convoAfter?.storageID);
assert.isUndefined(convoAfter?.needsStorageServiceSync);
assert.isUndefined(convoAfter?.storageUnknownFields);
assert.isUndefined(convoAfter?.senderKeyInfo);
assert.strictEqual(
convoAfter?.name,
'Name (and all other fields) should be preserved'
);
});
});

View file

@ -7,6 +7,8 @@ import { isNumber } from 'lodash';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { SECOND, DurationInSeconds } from './durations'; import { SECOND, DurationInSeconds } from './durations';
export const INITIAL_EXPIRE_TIMER_VERSION = 1;
const SECONDS_PER_WEEK = 604800; const SECONDS_PER_WEEK = 604800;
export const DEFAULT_DURATIONS_IN_SECONDS: ReadonlyArray<DurationInSeconds> = [ export const DEFAULT_DURATIONS_IN_SECONDS: ReadonlyArray<DurationInSeconds> = [
DurationInSeconds.ZERO, DurationInSeconds.ZERO,