diff --git a/ts/SignalProtocolStore.ts b/ts/SignalProtocolStore.ts index 72e9efde8fca..f73784024a2a 100644 --- a/ts/SignalProtocolStore.ts +++ b/ts/SignalProtocolStore.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import PQueue from 'p-queue'; -import { isNumber, omit } from 'lodash'; +import { omit } from 'lodash'; import { z } from 'zod'; import { EventEmitter } from 'events'; @@ -27,10 +27,6 @@ import { isNotNil } from './util/isNotNil'; import { drop } from './util/drop'; import { Zone } from './util/Zone'; import { isMoreRecentThan } from './util/timestamp'; -import { - sessionRecordToProtobuf, - sessionStructureToBytes, -} from './util/sessionTranslation'; import type { DeviceType, IdentityKeyType, @@ -182,7 +178,7 @@ async function _fillCaches, HydratedType>( } export function hydrateSession(session: SessionType): SessionRecord { - return SessionRecord.deserialize(Buffer.from(session.record, 'base64')); + return SessionRecord.deserialize(Buffer.from(session.record)); } export function hydratePublicKey(identityKey: IdentityKeyType): PublicKey { return PublicKey.deserialize(Buffer.from(identityKey.publicKey)); @@ -209,9 +205,6 @@ export function hydrateSignedPreKey( ); } -export function freezeSession(session: SessionRecord): string { - return session.serialize().toString('base64'); -} export function freezePublicKey(publicKey: PublicKey): Uint8Array { return publicKey.serialize(); } @@ -1333,9 +1326,14 @@ export class SignalProtocolStore extends EventEmitter { return entry.item; } - // We'll either just hydrate the item or we'll fully migrate the session - // and save it to the database. - return await this._maybeMigrateSession(entry.fromDB, { zone }); + const newItem = { + hydrated: true, + item: hydrateSession(entry.fromDB), + fromDB: entry.fromDB, + }; + map.set(id, newItem); + + return newItem.item; } catch (error) { const errorString = Errors.toLogFormat(error); log.error(`loadSession: failed to load session ${id}: ${errorString}`); @@ -1359,68 +1357,6 @@ export class SignalProtocolStore extends EventEmitter { }); } - private async _maybeMigrateSession( - session: SessionType, - { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} - ): Promise { - if (!this.sessions) { - throw new Error('_maybeMigrateSession: this.sessions not yet cached!'); - } - - // Already migrated, hydrate and update cache - if (session.version === 2) { - const item = hydrateSession(session); - - const map = this.pendingSessions.has(session.id) - ? this.pendingSessions - : this.sessions; - map.set(session.id, { - hydrated: true, - item, - fromDB: session, - }); - - return item; - } - - // Not yet converted, need to translate to new format and save - if (session.version !== undefined) { - throw new Error('_maybeMigrateSession: Unknown session version type!'); - } - - const { ourServiceId } = session; - - const keyPair = this.getIdentityKeyPair(ourServiceId); - if (!keyPair) { - throw new Error('_maybeMigrateSession: No identity key for ourself!'); - } - - const localRegistrationId = await this.getLocalRegistrationId(ourServiceId); - if (!isNumber(localRegistrationId)) { - throw new Error('_maybeMigrateSession: No registration id for ourself!'); - } - - const localUserData = { - identityKeyPublic: keyPair.pubKey, - registrationId: localRegistrationId, - }; - - log.info(`_maybeMigrateSession: Migrating session with id ${session.id}`); - const sessionProto = sessionRecordToProtobuf( - JSON.parse(session.record), - localUserData - ); - const record = SessionRecord.deserialize( - Buffer.from(sessionStructureToBytes(sessionProto)) - ); - - await this.storeSession(QualifiedAddress.parse(session.id), record, { - zone, - }); - - return record; - } - async storeSession( qualifiedAddress: QualifiedAddress, record: SessionRecord, @@ -1454,7 +1390,7 @@ export class SignalProtocolStore extends EventEmitter { conversationId: conversation.id, serviceId, deviceId, - record: record.serialize().toString('base64'), + record: record.serialize(), }; const newSession = { @@ -1533,9 +1469,7 @@ export class SignalProtocolStore extends EventEmitter { return undefined; } - const record = await this._maybeMigrateSession(entry.fromDB, { - zone, - }); + const record = hydrateSession(entry.fromDB); if (record.hasCurrentState()) { return { record, entry }; } @@ -1688,9 +1622,7 @@ export class SignalProtocolStore extends EventEmitter { addr, `_archiveSession(${addr.toString()})`, async () => { - const item = entry.hydrated - ? entry.item - : await this._maybeMigrateSession(entry.fromDB, { zone }); + const item = entry.hydrated ? entry.item : hydrateSession(entry.fromDB); if (!item.hasCurrentState()) { return; diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 02f917903541..ad69d1fe641d 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -206,8 +206,7 @@ export type SessionType = { serviceId: ServiceIdString; conversationId: string; deviceId: number; - record: string; - version?: number; + record: Uint8Array; }; export type SessionIdType = SessionType['id']; export type SignedPreKeyType = { @@ -722,7 +721,6 @@ type WritableInterface = { sessions: Array; unprocessed: Array; }): void; - bulkAddSessions: (array: Array) => void; removeSessionById: (id: SessionIdType) => number; removeSessionsByConversation: (conversationId: string) => void; removeSessionsByServiceId: (serviceId: ServiceIdString) => void; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 592ba1252da4..134596087b86 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -407,7 +407,6 @@ export const DataWriter: ServerWritableInterface = { createOrUpdateSession, createOrUpdateSessions, commitDecryptResult, - bulkAddSessions, removeSessionById, removeSessionsByConversation, removeSessionsByServiceId, @@ -1443,7 +1442,8 @@ function _getAllSentProtoMessageIds(db: ReadableDB): Array { const SESSIONS_TABLE = 'sessions'; function createOrUpdateSession(db: WritableDB, data: SessionType): void { - const { id, conversationId, ourServiceId, serviceId } = data; + const { id, conversationId, ourServiceId, serviceId, deviceId, record } = + data; if (!id) { throw new Error( 'createOrUpdateSession: Provided data did not have a truthy id' @@ -1463,13 +1463,15 @@ function createOrUpdateSession(db: WritableDB, data: SessionType): void { conversationId, ourServiceId, serviceId, - json + deviceId, + record ) values ( $id, $conversationId, $ourServiceId, $serviceId, - $json + $deviceId, + $record ) ` ).run({ @@ -1477,7 +1479,8 @@ function createOrUpdateSession(db: WritableDB, data: SessionType): void { conversationId, ourServiceId, serviceId, - json: objectToJSON(data), + deviceId, + record, }); } @@ -1519,9 +1522,6 @@ function commitDecryptResult( })(); } -function bulkAddSessions(db: WritableDB, array: Array): void { - return bulkAdd(db, SESSIONS_TABLE, array); -} function removeSessionById(db: WritableDB, id: SessionIdType): number { return removeById(db, SESSIONS_TABLE, id); } @@ -1555,7 +1555,7 @@ function removeAllSessions(db: WritableDB): number { return removeAllFromTable(db, SESSIONS_TABLE); } function getAllSessions(db: ReadableDB): Array { - return getAllFromTable(db, SESSIONS_TABLE); + return db.prepare('SELECT * FROM sessions').all(); } // Conversations diff --git a/ts/sql/migrations/1220-blob-sessions.ts b/ts/sql/migrations/1220-blob-sessions.ts new file mode 100644 index 000000000000..97502e39736f --- /dev/null +++ b/ts/sql/migrations/1220-blob-sessions.ts @@ -0,0 +1,218 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import assert from 'assert'; +import z from 'zod'; +import type { Database } from '@signalapp/better-sqlite3'; +import type { LoggerType } from '../../types/Logging'; +import * as Errors from '../../types/errors'; +import { + sessionRecordToProtobuf, + sessionStructureToBytes, +} from '../../util/sessionTranslation'; +import { getOwn } from '../../util/getOwn'; +import { missingCaseError } from '../../util/missingCaseError'; + +export const version = 1220; + +const identityKeyMapSchema = z.record( + z.string(), + z.object({ + privKey: z.string().transform(x => Buffer.from(x, 'base64')), + pubKey: z.string().transform(x => Buffer.from(x, 'base64')), + }) +); + +const registrationIdMapSchema = z.record(z.string(), z.number()); + +type PreviousSessionRowType = Readonly<{ + id: string; + conversationId: string; + ourServiceId: string; + serviceId: string; + json: string; +}>; + +const previousSessionJsonSchema = z.object({ + id: z.string(), + ourServiceId: z.string(), + serviceId: z.string(), + conversationId: z.string(), + deviceId: z.number(), + record: z.string(), + version: z.literal(1).or(z.literal(2)), +}); + +type NextSessionRowType = Readonly<{ + id: string; + conversationId: string; + ourServiceId: string; + serviceId: string; + deviceId: number; + record: Buffer; +}>; + +function migrateSession( + row: PreviousSessionRowType, + identityKeyMap: z.infer, + registrationIdMap: z.infer, + logger: LoggerType +): NextSessionRowType { + const { id, conversationId, ourServiceId, serviceId, json } = row; + const session = previousSessionJsonSchema.parse(JSON.parse(json)); + + assert.strictEqual(session.id, id, 'Invalid id'); + assert.strictEqual( + session.conversationId, + conversationId, + 'Invalid conversationId' + ); + assert.strictEqual( + session.ourServiceId, + ourServiceId, + 'Invalid ourServiceId,' + ); + assert.strictEqual(session.serviceId, serviceId, 'Invalid serviceId'); + + // Previously migrated session + if (session.version === 2) { + return { + id, + conversationId, + ourServiceId, + serviceId, + deviceId: session.deviceId, + record: Buffer.from(session.record, 'base64'), + }; + } + + if (session.version === 1) { + const keyPair = getOwn(identityKeyMap, ourServiceId); + if (!keyPair) { + throw new Error('migrateSession: No identity key for ourself!'); + } + + const localRegistrationId = getOwn(registrationIdMap, ourServiceId); + if (localRegistrationId == null) { + throw new Error('_maybeMigrateSession: No registration id for ourself!'); + } + + const localUserData = { + identityKeyPublic: keyPair.pubKey, + registrationId: localRegistrationId, + }; + + logger.info(`migrateSession: Migrating session with id ${id}`); + const sessionProto = sessionRecordToProtobuf( + JSON.parse(session.record), + localUserData + ); + + return { + id, + conversationId, + ourServiceId, + serviceId, + deviceId: session.deviceId, + record: Buffer.from(sessionStructureToBytes(sessionProto)), + }; + } + + throw missingCaseError(session.version); +} + +export function updateToSchemaVersion1220( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 1220) { + return; + } + + db.transaction(() => { + db.exec(` + ALTER TABLE sessions + RENAME TO old_sessions; + + CREATE TABLE sessions ( + id TEXT NOT NULL PRIMARY KEY, + ourServiceId TEXT NOT NULL, + serviceId TEXT NOT NULL, + conversationId TEXT NOT NULL, + deviceId INTEGER NOT NULL, + record BLOB NOT NULL + ) STRICT; + `); + + const getItem = db + .prepare( + ` + SELECT json -> '$.value' FROM items WHERE id IS ? + ` + ) + .pluck(); + + const identityKeyMapJson = getItem.get('identityKeyMap'); + const registrationIdMapJson = getItem.get('registrationIdMap'); + + // If we don't have private keys - the sessions cannot be used anyway + if (!identityKeyMapJson || !registrationIdMapJson) { + logger.info('updateToSchemaVersion1220: no identity/registration id'); + db.exec('DROP TABLE old_sessions'); + db.pragma('user_version = 1220'); + return; + } + + const identityKeyMap = identityKeyMapSchema.parse( + JSON.parse(identityKeyMapJson) + ); + const registrationIdMap = registrationIdMapSchema.parse( + JSON.parse(registrationIdMapJson) + ); + + const getSessionsPage = db.prepare( + 'DELETE FROM old_sessions RETURNING * LIMIT 1000' + ); + const insertSession = db.prepare(` + INSERT INTO sessions + (id, ourServiceId, serviceId, conversationId, deviceId, record) + VALUES + ($id, $ourServiceId, $serviceId, $conversationId, $deviceId, $record) + `); + + let migrated = 0; + let failed = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + const rows: Array = getSessionsPage.all(); + if (rows.length === 0) { + break; + } + + for (const row of rows) { + try { + insertSession.run( + migrateSession(row, identityKeyMap, registrationIdMap, logger) + ); + migrated += 1; + } catch (error) { + failed += 1; + logger.error( + 'updateToSchemaVersion1220: failed to migrate session', + Errors.toLogFormat(error) + ); + } + } + } + + logger.info( + `updateToSchemaVersion1220: migrated ${migrated} sessions, ` + + `${failed} failed` + ); + + db.exec('DROP TABLE old_sessions'); + db.pragma('user_version = 1220'); + })(); + logger.info('updateToSchemaVersion1220: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index c1b02ed1ddd6..8c5f95ac878e 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -97,10 +97,11 @@ import { updateToSchemaVersion1170 } from './1170-update-call-history-unread-ind import { updateToSchemaVersion1180 } from './1180-add-attachment-download-source'; import { updateToSchemaVersion1190 } from './1190-call-links-storage'; import { updateToSchemaVersion1200 } from './1200-attachment-download-source-index'; +import { updateToSchemaVersion1210 } from './1210-call-history-started-id'; import { - updateToSchemaVersion1210, + updateToSchemaVersion1220, version as MAX_VERSION, -} from './1210-call-history-started-id'; +} from './1220-blob-sessions'; function updateToSchemaVersion1( currentVersion: number, @@ -2067,6 +2068,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion1200, updateToSchemaVersion1210, + updateToSchemaVersion1220, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/test-both/util/sessionTranslation_test.ts b/ts/test-both/util/sessionTranslation_test.ts index cc1db7928ef7..f56c1ffee3e4 100644 --- a/ts/test-both/util/sessionTranslation_test.ts +++ b/ts/test-both/util/sessionTranslation_test.ts @@ -12,6 +12,185 @@ import { sessionRecordToProtobuf } from '../../util/sessionTranslation'; const getRecordCopy = (record: any): any => JSON.parse(JSON.stringify(record)); +export const SESSION_V1_RECORD = { + sessions: { + '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M': { + registrationId: 4243, + currentRatchet: { + rootKey: + 'Ë\u00035/üœÚšg\u0003Xeûú\u0010—\u0000ü\u0002¶»o5\u001cƒ—­¥\u0004Ðÿ«', + lastRemoteEphemeralKey: + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs', + previousCounter: 2, + ephemeralKeyPair: { + privKey: 'ä—ãÅ«ªŠàøí)ˆá\u0005Á"ŒsJM.¨¡\u0012r(N\f9Ô\b', + pubKey: '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1', + }, + }, + indexInfo: { + remoteIdentityKey: '\u0005¨¨©üÏäúo፩êO¢çúxr»Æ¿rœ²GžùiT@', + closed: -1, + baseKey: '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M', + baseKeyType: 2, + }, + oldRatchetList: [ + { + added: 1605579954962, + ephemeralKey: + '\u00050»­\n¨ÊA‘ä\u0006¢Ç´d\u0002\u00129}%î}ΩTc}8€¼\u0011n\\', + }, + { + added: 1605580408250, + ephemeralKey: + '\u0005^Ä\nò›À¢\u0000\u000f­A\\6+Ó\u001a÷&×$¸¬ÑÔ|‘x‚ƒÄÈ?þv~íkx â¬.ðo™òDg\u001eß.\r', + }, + { + added: 1606766530935, + ephemeralKey: + '\u0005\u0014@ž½M†,à\bóó™…}¨`i¿\u0000©I\u0001ôG\u001f”:Ù{ó\u0005 ', + }, + { + added: 1608326293655, + ephemeralKey: '\u0005µÒ\u0014?È¢+ÑR÷ç?3šDºƒ\\@0‹†\u0004®+-\bŽr\t', + }, + { + added: 1609871105317, + ephemeralKey: + '\u0005„±@íN"Í\u0019HS{$ï\u0017”[Ñ\\\u001a*;>P\u0000\u001f\u000eHNaù)', + }, + { + added: 1611707063523, + ephemeralKey: '\u0005Þg”Åkéƒ\u0001\u0013—¡ÿûNXÈ(9\u0006¤’w˜®/عRi‹JI', + }, + { + added: 1612211156372, + ephemeralKey: '\u0005:[ÛOˆ–pd¯ ÂÙç\u0010Oއw{}ý\bw–9Àߝ=“\u0014Z', + }, + ], + '\u00050»­\n¨ÊA‘ä\u0006¢Ç´d\u0002\u00129}%î}ΩTc}8€¼\u0011n\\': { + messageKeys: {}, + chainKey: { + counter: 0, + }, + chainType: 2, + }, + '\u0005^Ä\nò›À¢\u0000\u000f­A\\6+Ó\u001a÷&×$¸¬ÑÔ|‘x‚ƒÄÈ?þv~íkx â¬.ðo™òDg\u001eß.\r': { + messageKeys: { + '4': '©}j›¿Š¼\u0014q\tŠ¥”Á”ñ\u0003: ÷ÞrƒñûÔµ%Æ\u001a', + }, + chainKey: { + counter: 6, + }, + chainType: 2, + }, + '\u0005\u0014@ž½M†,à\bóó™…}¨`i¿\u0000©I\u0001ôG\u001f”:Ù{ó\u0005 ': { + messageKeys: {}, + chainKey: { + counter: 0, + }, + chainType: 2, + }, + '\u0005µÒ\u0014?È¢+ÑR÷ç?3šDºƒ\\@0‹†\u0004®+-\bŽr\t': { + messageKeys: {}, + chainKey: { + counter: 2, + }, + chainType: 2, + }, + '\u0005„±@íN"Í\u0019HS{$ï\u0017”[Ñ\\\u001a*;>P\u0000\u001f\u000eHNaù)': { + messageKeys: { + '0': "1kÏ\u001cí+«<º‚\b'VÌ!×¼«PÃ[üáy;l'ƒ€€Ž", + '2': 'ö\u00047%L-…Wm)†›\u001d£ääíNô.Ô8…ÃÉ4r´ó^2', + '3': '¨¿¦›7T]\u001c\u001c“à4:x\u0019¿\u0002YÉÀ\u001bâjr¸»¤¢0,*', + '5': '™¥\u0006·q“gó4þ\u0011®ˆU4F\u001cl©\bŒäô…»ÊÇƎ[', + }, + chainKey: { + counter: 5, + }, + chainType: 2, + }, + '\u0005Þg”Åkéƒ\u0001\u0013—¡ÿûNXÈ(9\u0006¤’w˜®/عRi‹JI': { + messageKeys: { + '0': "]'8ŽWÄ\u0007…n˜º­Ö{ÿ7]ôäÄ!é\u000btA@°b¢)\u001ar", + '2': '­ÄfGÇjÖxÅö:×RÔi)M\u0019©IE+¨`þKá—;£Û½', + '3': '¦Õhýø`€Ö“PéPs;\u001e\u000bE}¨¿–õ\u0003uªøå\u00062(×G', + '9': 'Ï^—<‘Õú̃\u0001i´;ït¼\u001aÑ?ï\u0014lãàƸƒ\u001a8“/m', + }, + chainKey: { + counter: 11, + }, + chainType: 2, + }, + '\u0005:[ÛOˆ–pd¯ ÂÙç\u0010Oއw{}ý\bw–9Àߝ=“\u0014Z': { + messageKeys: { + '0': '!\u00115\\W~|¯oa2\u001e\u0004Vž8Ï¡d}\u001b\u001a8^QÖfvÕ"‹', + }, + chainKey: { + counter: 1, + }, + chainType: 2, + }, + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs': { + messageKeys: { + '0': 'Îgó¯‘2àvñ‘X_õ\u0014–Ç\u0000öl\u001f4J>ŒÐÏ{`-Ü5¦', + '4': 'c¿<µâ¼Xµƒ!Ù¯µ®[—n<žìîúcoå©n\u0013"l]Ð', + }, + chainKey: { + counter: 5, + key: 'Z{òÙ8سAÝdSZ†k\n×\u001cô¡\u001b[YÒ¶ƒ\u0016a°\u0004<', + }, + chainType: 2, + }, + '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1': { + messageKeys: {}, + chainKey: { + counter: -1, + key: "èB?7\u000f¯\u001e\u0010¨\u0007}:“?¹\u0010$\\ë~ª\u0000gM0՘'£\u0005", + }, + chainType: 1, + }, + }, + }, + version: 'v1', +} as any; + function protoToJSON(value: unknown): unknown { if (value == null) { return value; @@ -169,186 +348,7 @@ describe('sessionTranslation', () => { }); it('Generates expected protobuf with many old receiver chains', () => { - const record: any = { - sessions: { - '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M': { - registrationId: 4243, - currentRatchet: { - rootKey: - 'Ë\u00035/üœÚšg\u0003Xeûú\u0010—\u0000ü\u0002¶»o5\u001cƒ—­¥\u0004Ðÿ«', - lastRemoteEphemeralKey: - '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs', - previousCounter: 2, - ephemeralKeyPair: { - privKey: 'ä—ãÅ«ªŠàøí)ˆá\u0005Á"ŒsJM.¨¡\u0012r(N\f9Ô\b', - pubKey: '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1', - }, - }, - indexInfo: { - remoteIdentityKey: '\u0005¨¨©üÏäúo፩êO¢çúxr»Æ¿rœ²GžùiT@', - closed: -1, - baseKey: '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M', - baseKeyType: 2, - }, - oldRatchetList: [ - { - added: 1605579954962, - ephemeralKey: - '\u00050»­\n¨ÊA‘ä\u0006¢Ç´d\u0002\u00129}%î}ΩTc}8€¼\u0011n\\', - }, - { - added: 1605580408250, - ephemeralKey: - '\u0005^Ä\nò›À¢\u0000\u000f­A\\6+Ó\u001a÷&×$¸¬ÑÔ|‘x‚ƒÄÈ?þv~íkx â¬.ðo™òDg\u001eß.\r', - }, - { - added: 1606766530935, - ephemeralKey: - '\u0005\u0014@ž½M†,à\bóó™…}¨`i¿\u0000©I\u0001ôG\u001f”:Ù{ó\u0005 ', - }, - { - added: 1608326293655, - ephemeralKey: '\u0005µÒ\u0014?È¢+ÑR÷ç?3šDºƒ\\@0‹†\u0004®+-\bŽr\t', - }, - { - added: 1609871105317, - ephemeralKey: - '\u0005„±@íN"Í\u0019HS{$ï\u0017”[Ñ\\\u001a*;>P\u0000\u001f\u000eHNaù)', - }, - { - added: 1611707063523, - ephemeralKey: '\u0005Þg”Åkéƒ\u0001\u0013—¡ÿûNXÈ(9\u0006¤’w˜®/عRi‹JI', - }, - { - added: 1612211156372, - ephemeralKey: '\u0005:[ÛOˆ–pd¯ ÂÙç\u0010Oއw{}ý\bw–9Àߝ=“\u0014Z', - }, - ], - '\u00050»­\n¨ÊA‘ä\u0006¢Ç´d\u0002\u00129}%î}ΩTc}8€¼\u0011n\\': { - messageKeys: {}, - chainKey: { - counter: 0, - }, - chainType: 2, - }, - '\u0005^Ä\nò›À¢\u0000\u000f­A\\6+Ó\u001a÷&×$¸¬ÑÔ|‘x‚ƒÄÈ?þv~íkx â¬.ðo™òDg\u001eß.\r': { - messageKeys: { - '4': '©}j›¿Š¼\u0014q\tŠ¥”Á”ñ\u0003: ÷ÞrƒñûÔµ%Æ\u001a', - }, - chainKey: { - counter: 6, - }, - chainType: 2, - }, - '\u0005\u0014@ž½M†,à\bóó™…}¨`i¿\u0000©I\u0001ôG\u001f”:Ù{ó\u0005 ': { - messageKeys: {}, - chainKey: { - counter: 0, - }, - chainType: 2, - }, - '\u0005µÒ\u0014?È¢+ÑR÷ç?3šDºƒ\\@0‹†\u0004®+-\bŽr\t': { - messageKeys: {}, - chainKey: { - counter: 2, - }, - chainType: 2, - }, - '\u0005„±@íN"Í\u0019HS{$ï\u0017”[Ñ\\\u001a*;>P\u0000\u001f\u000eHNaù)': - { - messageKeys: { - '0': "1kÏ\u001cí+«<º‚\b'VÌ!×¼«PÃ[üáy;l'ƒ€€Ž", - '2': 'ö\u00047%L-…Wm)†›\u001d£ääíNô.Ô8…ÃÉ4r´ó^2', - '3': '¨¿¦›7T]\u001c\u001c“à4:x\u0019¿\u0002YÉÀ\u001bâjr¸»¤¢0,*', - '5': '™¥\u0006·q“gó4þ\u0011®ˆU4F\u001cl©\bŒäô…»ÊÇƎ[', - }, - chainKey: { - counter: 5, - }, - chainType: 2, - }, - '\u0005Þg”Åkéƒ\u0001\u0013—¡ÿûNXÈ(9\u0006¤’w˜®/عRi‹JI': { - messageKeys: { - '0': "]'8ŽWÄ\u0007…n˜º­Ö{ÿ7]ôäÄ!é\u000btA@°b¢)\u001ar", - '2': '­ÄfGÇjÖxÅö:×RÔi)M\u0019©IE+¨`þKá—;£Û½', - '3': '¦Õhýø`€Ö“PéPs;\u001e\u000bE}¨¿–õ\u0003uªøå\u00062(×G', - '9': 'Ï^—<‘Õú̃\u0001i´;ït¼\u001aÑ?ï\u0014lãàƸƒ\u001a8“/m', - }, - chainKey: { - counter: 11, - }, - chainType: 2, - }, - '\u0005:[ÛOˆ–pd¯ ÂÙç\u0010Oއw{}ý\bw–9Àߝ=“\u0014Z': { - messageKeys: { - '0': '!\u00115\\W~|¯oa2\u001e\u0004Vž8Ï¡d}\u001b\u001a8^QÖfvÕ"‹', - }, - chainKey: { - counter: 1, - }, - chainType: 2, - }, - '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs': { - messageKeys: { - '0': 'Îgó¯‘2àvñ‘X_õ\u0014–Ç\u0000öl\u001f4J>ŒÐÏ{`-Ü5¦', - '4': 'c¿<µâ¼Xµƒ!Ù¯µ®[—n<žìîúcoå©n\u0013"l]Ð', - }, - chainKey: { - counter: 5, - key: 'Z{òÙ8سAÝdSZ†k\n×\u001cô¡\u001b[YÒ¶ƒ\u0016a°\u0004<', - }, - chainType: 2, - }, - '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1': { - messageKeys: {}, - chainKey: { - counter: -1, - key: "èB?7\u000f¯\u001e\u0010¨\u0007}:“?¹\u0010$\\ë~ª\u0000gM0՘'£\u0005", - }, - chainType: 1, - }, - }, - }, - version: 'v1', - }; + const record: any = SESSION_V1_RECORD; const expected = { currentSession: { diff --git a/ts/test-node/sql/helpers.ts b/ts/test-node/sql/helpers.ts index 0ea81e623d81..5431c728f418 100644 --- a/ts/test-node/sql/helpers.ts +++ b/ts/test-node/sql/helpers.ts @@ -74,6 +74,10 @@ export function getTableData(db: ReadableDB, table: string): TableRows { if (value == null) { continue; } + if (Buffer.isBuffer(value)) { + result[key] = value.toString('hex'); + continue; + } try { if (typeof value !== 'string') { throw new Error('skip'); diff --git a/ts/test-node/sql/migration_1220_test.ts b/ts/test-node/sql/migration_1220_test.ts new file mode 100644 index 000000000000..e00289281694 --- /dev/null +++ b/ts/test-node/sql/migration_1220_test.ts @@ -0,0 +1,153 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { type WritableDB } from '../../sql/Interface'; +import { + sessionRecordToProtobuf, + sessionStructureToBytes, +} from '../../util/sessionTranslation'; +import { createDB, updateToVersion, insertData, getTableData } from './helpers'; +import { SESSION_V1_RECORD } from '../../test-both/util/sessionTranslation_test'; + +const MAPS = [ + { + id: 'identityKeyMap', + json: { + id: 'identityKeyMap', + value: { + ourAci: { + privKey: 'AAAA', + pubKey: 'AAAA', + }, + }, + }, + }, + { + id: 'registrationIdMap', + json: { + id: 'registrationIdMap', + value: { + ourAci: 123, + }, + }, + }, +]; + +const SESSION_V2 = { + id: 'ourAci:theirAci', + conversationId: 'cid', + ourServiceId: 'ourAci', + serviceId: 'theirAci', + json: { + id: 'ourAci:theirAci', + conversationId: 'cid', + ourServiceId: 'ourAci', + serviceId: 'theirAci', + version: 2, + deviceId: 3, + record: Buffer.from('abc').toString('base64'), + }, +}; + +describe('SQL/updateToSchemaVersion1220', () => { + let db: WritableDB; + + afterEach(() => { + db.close(); + }); + + beforeEach(() => { + db = createDB(); + updateToVersion(db, 1210); + }); + + it('drops sessions without identity key/registration id', () => { + insertData(db, 'sessions', [SESSION_V2]); + updateToVersion(db, 1220); + + assert.deepStrictEqual(getTableData(db, 'sessions'), []); + }); + + it('migrates v1 session', () => { + insertData(db, 'items', MAPS); + insertData(db, 'sessions', [ + { + id: 'ourAci:theirAci', + conversationId: 'cid', + ourServiceId: 'ourAci', + serviceId: 'theirAci', + json: { + id: 'ourAci:theirAci', + conversationId: 'cid', + ourServiceId: 'ourAci', + serviceId: 'theirAci', + version: 1, + deviceId: 3, + record: JSON.stringify(SESSION_V1_RECORD), + }, + }, + ]); + updateToVersion(db, 1220); + + const bytes = sessionStructureToBytes( + sessionRecordToProtobuf(SESSION_V1_RECORD, { + identityKeyPublic: Buffer.from('AAAA', 'base64'), + registrationId: 123, + }) + ); + + assert.deepStrictEqual(getTableData(db, 'sessions'), [ + { + id: 'ourAci:theirAci', + conversationId: 'cid', + ourServiceId: 'ourAci', + serviceId: 'theirAci', + deviceId: 3, + record: Buffer.from(bytes).toString('hex'), + }, + ]); + }); + + it('migrates v2 session', () => { + insertData(db, 'items', MAPS); + insertData(db, 'sessions', [SESSION_V2]); + updateToVersion(db, 1220); + + assert.deepStrictEqual(getTableData(db, 'sessions'), [ + { + id: 'ourAci:theirAci', + conversationId: 'cid', + ourServiceId: 'ourAci', + serviceId: 'theirAci', + deviceId: 3, + record: Buffer.from('abc').toString('hex'), + }, + ]); + }); + + it('drops invalid sessions', () => { + insertData(db, 'items', MAPS); + insertData(db, 'sessions', [ + { + id: 'ourAci:theirAci', + conversationId: 'cid', + ourServiceId: 'ourAci', + serviceId: 'theirAci', + json: { + id: 'ourAci:theirAci2', + conversationId: 'cid', + ourServiceId: 'ourAci', + serviceId: 'theirAci', + version: 1, + deviceId: 3, + record: 'abc', + }, + }, + ]); + updateToVersion(db, 1220); + + assert.deepStrictEqual(getTableData(db, 'sessions'), []); + }); +});