diff --git a/protos/Backups.proto b/protos/Backups.proto index 40900a3de2f..cdb0b309356 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -9,8 +9,7 @@ option java_package = "org.thoughtcrime.securesms.backup.v2.proto"; message BackupInfo { uint64 version = 1; uint64 backupTimeMs = 2; - bytes mediaRootBackupKey = 3; // 32-byte random value generated when the - // backup is uploaded for the first time. + bytes mediaRootBackupKey = 3; // 32-byte random value generated when the backup is uploaded for the first time. } // Frames must follow in the following ordering rules: @@ -112,6 +111,12 @@ message Recipient { } message Contact { + enum IdentityState { + DEFAULT = 0; + VERIFIED = 1; + UNVERIFIED = 2; + } + message Registered { } message NotRegistered { uint64 unregisteredTimestamp = 1; @@ -140,6 +145,8 @@ message Contact { optional string profileGivenName = 11; optional string profileFamilyName = 12; bool hideStory = 13; + optional bytes identityKey = 14; + IdentityState identityState = 15; } message Group { @@ -240,9 +247,9 @@ message Chat { uint64 id = 1; // generated id for reference only within this file uint64 recipientId = 2; bool archived = 3; - uint32 pinnedOrder = 4; // 0 = unpinned, otherwise chat is considered pinned and will be displayed in ascending order - uint64 expirationTimerMs = 5; // 0 = no expire timer. - uint64 muteUntilMs = 6; + optional uint32 pinnedOrder = 4; // will be displayed in ascending order + optional uint64 expirationTimerMs = 5; + optional uint64 muteUntilMs = 6; // UINT64_MAX (2^63 - 1) = "always muted". bool markedUnread = 7; bool dontNotifyForMentionsIfMuted = 8; ChatStyle style = 9; @@ -268,7 +275,7 @@ message CallLink { optional bytes adminKey = 2; // Only present if the user is an admin string name = 3; Restrictions restrictions = 4; - uint64 expirationMs = 5; + optional uint64 expirationMs = 5; } message AdHocCall { @@ -285,6 +292,8 @@ message AdHocCall { } message DistributionListItem { + // distribution ids are UUIDv4s. "My Story" is represented + // by an all-0 UUID (00000000-0000-0000-0000-000000000000). bytes distributionId = 1; // distribution list ids are uuids oneof item { @@ -310,7 +319,7 @@ message DistributionList { message ChatItem { message IncomingMessageDetails { uint64 dateReceived = 1; - uint64 dateServerSent = 2; + optional uint64 dateServerSent = 2; bool read = 3; bool sealedSender = 4; } @@ -325,8 +334,8 @@ message ChatItem { uint64 chatId = 1; // conversation id uint64 authorId = 2; // recipient id uint64 dateSent = 3; - uint64 expireStartDate = 4; // timestamp of when expiration timer started ticking down - uint64 expiresInMs = 5; // how long timer of message is (ms) + optional uint64 expireStartDate = 4; // timestamp of when expiration timer started ticking down + optional uint64 expiresInMs = 5; // how long timer of message is (ms) repeated ChatItem revisions = 6; // ordered from oldest to newest bool sms = 7; @@ -616,7 +625,7 @@ message FilePointer { message AttachmentLocator { string cdnKey = 1; uint32 cdnNumber = 2; - uint64 uploadTimestamp = 3; + optional uint64 uploadTimestamp = 3; bytes key = 4; bytes digest = 5; uint32 size = 6; @@ -650,7 +659,8 @@ message Quote { enum Type { UNKNOWN = 0; NORMAL = 1; - GIFTBADGE = 2; + GIFT_BADGE = 2; + VIEW_ONCE = 3; } message QuotedAttachment { @@ -768,8 +778,7 @@ message GroupCall { optional uint64 ringerRecipientId = 3; optional uint64 startedCallRecipientId = 4; uint64 startedCallTimestamp = 5; - // The time the call ended. 0 indicates an unknown time. - uint64 endedCallTimestamp = 6; + optional uint64 endedCallTimestamp = 6; // The time the call ended. bool read = 7; } @@ -825,7 +834,6 @@ message SessionSwitchoverChatUpdate { message GroupChangeChatUpdate { message Update { - // Note: group expiration timer changes are represented as ExpirationTimerChatUpdate. oneof update { GenericGroupUpdate genericGroupUpdate = 1; GroupCreationUpdate groupCreationUpdate = 2; diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index 77a2dcc75c1..4ded3ec1859 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -17,7 +17,10 @@ import { pauseWriteAccess, resumeWriteAccess, } from '../../sql/Client'; -import type { PageMessagesCursorType } from '../../sql/Interface'; +import type { + PageMessagesCursorType, + IdentityKeyType, +} from '../../sql/Interface'; import * as log from '../../logging/log'; import { GiftBadgeStates } from '../../components/conversation/Message'; import { type CustomColorType } from '../../types/Colors'; @@ -290,6 +293,13 @@ export class BackupExportStream extends Readable { stickerPacks: 0, }; + const identityKeys = await DataReader.getAllIdentityKeys(); + const identityKeysById = new Map( + identityKeys.map(key => { + return [key.id, key]; + }) + ); + for (const { attributes } of window.ConversationController.getAll()) { const recipientId = this.getRecipientId({ id: attributes.id, @@ -297,7 +307,11 @@ export class BackupExportStream extends Readable { e164: attributes.e164, }); - const recipient = this.toRecipient(recipientId, attributes); + const recipient = this.toRecipient( + recipientId, + attributes, + identityKeysById + ); if (recipient === undefined) { // Can't be backed up. continue; @@ -804,7 +818,8 @@ export class BackupExportStream extends Readable { convo: Omit< ConversationAttributesType, 'id' | 'version' | 'expireTimerVersion' - > + >, + identityKeysById?: ReadonlyMap<IdentityKeyType['id'], IdentityKeyType> ): Backups.IRecipient | undefined { const res: Backups.IRecipient = { id: recipientId, @@ -824,6 +839,11 @@ export class BackupExportStream extends Readable { throw missingCaseError(convo.removalStage); } + let identityKey: IdentityKeyType | undefined; + if (identityKeysById != null && convo.serviceId != null) { + identityKey = identityKeysById.get(convo.serviceId); + } + res.contact = { aci: convo.serviceId && convo.serviceId !== convo.pni @@ -856,6 +876,10 @@ export class BackupExportStream extends Readable { profileGivenName: convo.profileName, profileFamilyName: convo.profileFamilyName, hideStory: convo.hideStory === true, + identityKey: identityKey?.publicKey || null, + + // Integer values match so we can use it as is + identityState: identityKey?.verified ?? 0, }; } else if (isGroupV2(convo) && convo.masterKey) { let storySendMode: Backups.Group.StorySendMode; @@ -2092,6 +2116,15 @@ export class BackupExportStream extends Readable { return null; } + let quoteType: Backups.Quote.Type; + if (quote.isGiftBadge) { + quoteType = Backups.Quote.Type.GIFT_BADGE; + } else if (quote.isViewOnce) { + quoteType = Backups.Quote.Type.VIEW_ONCE; + } else { + quoteType = Backups.Quote.Type.NORMAL; + } + return { targetSentTimestamp: Long.fromNumber(quote.id), authorId, @@ -2123,9 +2156,7 @@ export class BackupExportStream extends Readable { } ) ), - type: quote.isGiftBadge - ? Backups.Quote.Type.GIFTBADGE - : Backups.Quote.Type.NORMAL, + type: quoteType, }; } diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index 0e751f188e1..fd12a69d73a 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -14,6 +14,7 @@ import { DataReader, DataWriter } from '../../sql/Client'; import { AttachmentDownloadSource, type StoryDistributionWithMembersType, + type IdentityKeyType, } from '../../sql/Interface'; import * as log from '../../logging/log'; import { GiftBadgeStates } from '../../components/conversation/Message'; @@ -209,6 +210,7 @@ export class BackupImportStream extends Writable { string, ConversationAttributesType >(); + private readonly identityKeys = new Map<ServiceIdString, IdentityKeyType>(); private readonly saveMessageBatch = new Set<MessageAttributesType>(); private readonly stickerPacks = new Array<StickerPackPointerType>(); private ourConversation?: ConversationAttributesType; @@ -489,10 +491,14 @@ export class BackupImportStream extends Writable { const saves = Array.from(this.conversations.values()); this.conversations.clear(); + const identityKeys = Array.from(this.identityKeys.values()); + this.identityKeys.clear(); + // Queue writes at the same time to prevent races. await Promise.all([ DataWriter.saveConversations(saves), DataWriter.updateConversations(updates), + DataWriter.bulkAddIdentityKeys(identityKeys), ]); } @@ -817,11 +823,13 @@ export class BackupImportStream extends Writable { break; } + const serviceId = aci ?? pni; + const attrs: ConversationAttributesType = { id: generateUuid(), type: 'private', version: 2, - serviceId: aci ?? pni, + serviceId, pni, e164, removalStage, @@ -836,6 +844,17 @@ export class BackupImportStream extends Writable { expireTimerVersion: 1, }; + if (serviceId != null && Bytes.isNotEmpty(contact.identityKey)) { + this.identityKeys.set(serviceId, { + id: serviceId, + publicKey: contact.identityKey, + verified: contact.identityState || 0, + firstUse: true, + timestamp: this.now, + nonblockingApproval: true, + }); + } + if (contact.notRegistered) { const timestamp = contact.notRegistered.unregisteredTimestamp?.toNumber(); attrs.discoveredUnregisteredAt = timestamp || this.now; @@ -848,7 +867,6 @@ export class BackupImportStream extends Writable { } if (contact.blocked) { - const serviceId = aci || pni; if (serviceId) { await window.storage.blocked.addBlockedServiceId(serviceId); } @@ -1671,8 +1689,11 @@ export class BackupImportStream extends Writable { type: Backups.Quote.Type | null | undefined ): SignalService.DataMessage.Quote.Type { switch (type) { - case Backups.Quote.Type.GIFTBADGE: + case Backups.Quote.Type.GIFT_BADGE: return SignalService.DataMessage.Quote.Type.GIFT_BADGE; + case Backups.Quote.Type.VIEW_ONCE: + // No special treatment, we'll compute it once we find the message + return SignalService.DataMessage.Quote.Type.NORMAL; case Backups.Quote.Type.NORMAL: case Backups.Quote.Type.UNKNOWN: case null: