Backup encryption and improvements
This commit is contained in:
parent
d2850bdbd9
commit
87ea909ae9
15 changed files with 775 additions and 295 deletions
|
@ -18,8 +18,7 @@ message Frame {
|
||||||
Recipient recipient = 2;
|
Recipient recipient = 2;
|
||||||
Chat chat = 3;
|
Chat chat = 3;
|
||||||
ChatItem chatItem = 4;
|
ChatItem chatItem = 4;
|
||||||
Call call = 5;
|
StickerPack stickerPack = 5;
|
||||||
StickerPack stickerPack = 6;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -167,32 +166,6 @@ message Identity {
|
||||||
bool nonblockingApproval = 6;
|
bool nonblockingApproval = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message Call {
|
|
||||||
enum Type {
|
|
||||||
UNKNOWN_TYPE = 0;
|
|
||||||
AUDIO_CALL = 1;
|
|
||||||
VIDEO_CALL = 2;
|
|
||||||
GROUP_CALL = 3;
|
|
||||||
AD_HOC_CALL = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum State {
|
|
||||||
UNKNOWN_EVENT = 0;
|
|
||||||
COMPLETED = 1; // A call that was successfully completed or was accepted and in-progress at the time of the backup.
|
|
||||||
DECLINED_BY_USER = 2; // An incoming call that was manually declined by the user.
|
|
||||||
DECLINED_BY_NOTIFICATION_PROFILE = 3; // An incoming call that was automatically declined by an active notification profile.
|
|
||||||
MISSED = 4; // An incoming call that either expired, was cancelled by the sender, or was auto-rejected due to already being in a different call.
|
|
||||||
}
|
|
||||||
|
|
||||||
uint64 callId = 1;
|
|
||||||
uint64 conversationRecipientId = 2;
|
|
||||||
Type type = 3;
|
|
||||||
bool outgoing = 4;
|
|
||||||
uint64 timestamp = 5;
|
|
||||||
optional uint64 ringerRecipientId = 6;
|
|
||||||
State state = 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ChatItem {
|
message ChatItem {
|
||||||
message IncomingMessageDetails {
|
message IncomingMessageDetails {
|
||||||
uint64 dateReceived = 1;
|
uint64 dateReceived = 1;
|
||||||
|
@ -384,6 +357,7 @@ message MessageAttachment {
|
||||||
|
|
||||||
FilePointer pointer = 1;
|
FilePointer pointer = 1;
|
||||||
Flag flag = 2;
|
Flag flag = 2;
|
||||||
|
bool wasDownloaded = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message FilePointer {
|
message FilePointer {
|
||||||
|
@ -391,6 +365,9 @@ message FilePointer {
|
||||||
message BackupLocator {
|
message BackupLocator {
|
||||||
string mediaName = 1;
|
string mediaName = 1;
|
||||||
uint32 cdnNumber = 2;
|
uint32 cdnNumber = 2;
|
||||||
|
bytes key = 3;
|
||||||
|
bytes digest = 4;
|
||||||
|
uint32 size = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// References attachments in the transit storage tier.
|
// References attachments in the transit storage tier.
|
||||||
|
@ -401,37 +378,33 @@ message FilePointer {
|
||||||
string cdnKey = 1;
|
string cdnKey = 1;
|
||||||
uint32 cdnNumber = 2;
|
uint32 cdnNumber = 2;
|
||||||
uint64 uploadTimestamp = 3;
|
uint64 uploadTimestamp = 3;
|
||||||
|
bytes key = 4;
|
||||||
|
bytes digest = 5;
|
||||||
|
uint32 size = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An attachment that was copied from the transit storage tier
|
// References attachments that are invalid in such a way where download
|
||||||
// to the backup (media) storage tier up without being downloaded.
|
// cannot be attempted. Could range from missing digests to missing
|
||||||
// Its MediaName should be generated as “{sender_aci}_{cdn_attachment_key}”,
|
// CDN keys or anything else that makes download attempts impossible.
|
||||||
// but should eventually transition to a BackupLocator with mediaName
|
// This serves as a 'tombstone' so that the UX can show that an attachment
|
||||||
// being the content hash once it is downloaded.
|
// did exist, but for whatever reason it's not retrievable.
|
||||||
message UndownloadedBackupLocator {
|
message InvalidAttachmentLocator {
|
||||||
bytes senderAci = 1;
|
|
||||||
string cdnKey = 2;
|
|
||||||
uint32 cdnNumber = 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
oneof locator {
|
oneof locator {
|
||||||
BackupLocator backupLocator = 1;
|
BackupLocator backupLocator = 1;
|
||||||
AttachmentLocator attachmentLocator= 2;
|
AttachmentLocator attachmentLocator= 2;
|
||||||
UndownloadedBackupLocator undownloadedBackupLocator = 3;
|
InvalidAttachmentLocator invalidAttachmentLocator = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
optional bytes key = 5;
|
optional string contentType = 4;
|
||||||
optional string contentType = 6;
|
optional bytes incrementalMac = 5;
|
||||||
// Size of fullsize decrypted media blob in bytes.
|
optional uint32 incrementalMacChunkSize = 6;
|
||||||
// Can be ignored if unset/unavailable.
|
optional string fileName = 7;
|
||||||
optional uint32 size = 7;
|
optional uint32 width = 8;
|
||||||
optional bytes incrementalMac = 8;
|
optional uint32 height = 9;
|
||||||
optional uint32 incrementalMacChunkSize = 9;
|
optional string caption = 10;
|
||||||
optional string fileName = 10;
|
optional string blurHash = 11;
|
||||||
optional uint32 width = 11;
|
|
||||||
optional uint32 height = 12;
|
|
||||||
optional string caption = 13;
|
|
||||||
optional string blurHash = 14;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message Quote {
|
message Quote {
|
||||||
|
@ -495,13 +468,40 @@ message ChatUpdateMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
message CallChatUpdate{
|
message CallChatUpdate{
|
||||||
oneof call {
|
Call call = 1;
|
||||||
uint64 callId = 1; // maps to id of Call from call log
|
|
||||||
|
oneof chatUpdate {
|
||||||
IndividualCallChatUpdate callMessage = 2;
|
IndividualCallChatUpdate callMessage = 2;
|
||||||
GroupCallChatUpdate groupCall = 3;
|
GroupCallChatUpdate groupCall = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message Call {
|
||||||
|
enum Type {
|
||||||
|
UNKNOWN_TYPE = 0;
|
||||||
|
AUDIO_CALL = 1;
|
||||||
|
VIDEO_CALL = 2;
|
||||||
|
GROUP_CALL = 3;
|
||||||
|
AD_HOC_CALL = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
UNKNOWN_EVENT = 0;
|
||||||
|
COMPLETED = 1; // A call that was successfully completed or was accepted and in-progress at the time of the backup.
|
||||||
|
DECLINED_BY_USER = 2; // An incoming call that was manually declined by the user.
|
||||||
|
DECLINED_BY_NOTIFICATION_PROFILE = 3; // An incoming call that was automatically declined by an active notification profile.
|
||||||
|
MISSED = 4; // An incoming call that either expired, was cancelled by the sender, or was auto-rejected due to already being in a different call.
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64 callId = 1;
|
||||||
|
uint64 conversationRecipientId = 2;
|
||||||
|
Type type = 3;
|
||||||
|
bool outgoing = 4;
|
||||||
|
uint64 timestamp = 5;
|
||||||
|
optional uint64 ringerRecipientId = 6;
|
||||||
|
State state = 7;
|
||||||
|
}
|
||||||
|
|
||||||
message IndividualCallChatUpdate {
|
message IndividualCallChatUpdate {
|
||||||
enum Type {
|
enum Type {
|
||||||
UNKNOWN = 0;
|
UNKNOWN = 0;
|
||||||
|
|
|
@ -12,13 +12,15 @@ import {
|
||||||
} from 'crypto';
|
} from 'crypto';
|
||||||
import type { Decipher, Hash, Hmac } from 'crypto';
|
import type { Decipher, Hash, Hmac } from 'crypto';
|
||||||
import { Transform } from 'stream';
|
import { Transform } from 'stream';
|
||||||
import type { Readable } from 'stream';
|
|
||||||
import { pipeline } from 'stream/promises';
|
import { pipeline } from 'stream/promises';
|
||||||
import { ensureFile } from 'fs-extra';
|
import { ensureFile } from 'fs-extra';
|
||||||
import * as log from './logging/log';
|
import * as log from './logging/log';
|
||||||
import { HashType, CipherType } from './types/Crypto';
|
import { HashType, CipherType } from './types/Crypto';
|
||||||
import { createName, getRelativePath } from './windows/attachments';
|
import { createName, getRelativePath } from './windows/attachments';
|
||||||
import { constantTimeEqual, getAttachmentSizeBucket } from './Crypto';
|
import { constantTimeEqual } from './Crypto';
|
||||||
|
import { appendPaddingStream, logPadSize } from './util/logPadding';
|
||||||
|
import { prependStream } from './util/prependStream';
|
||||||
|
import { appendMacStream } from './util/appendMacStream';
|
||||||
import { Environment } from './environment';
|
import { Environment } from './environment';
|
||||||
import type { AttachmentType } from './types/Attachment';
|
import type { AttachmentType } from './types/Attachment';
|
||||||
import type { ContextType } from './types/Message2';
|
import type { ContextType } from './types/Message2';
|
||||||
|
@ -96,10 +98,10 @@ export async function encryptAttachmentV2({
|
||||||
await pipeline(
|
await pipeline(
|
||||||
readFd.createReadStream(),
|
readFd.createReadStream(),
|
||||||
peekAndUpdateHash(plaintextHash),
|
peekAndUpdateHash(plaintextHash),
|
||||||
appendPadding(),
|
appendPaddingStream(),
|
||||||
createCipheriv(CipherType.AES256CBC, aesKey, iv),
|
createCipheriv(CipherType.AES256CBC, aesKey, iv),
|
||||||
prependIv(iv),
|
prependIv(iv),
|
||||||
appendMac(macKey),
|
appendMacStream(macKey),
|
||||||
peekAndUpdateHash(digest),
|
peekAndUpdateHash(digest),
|
||||||
writeFd.createWriteStream()
|
writeFd.createWriteStream()
|
||||||
);
|
);
|
||||||
|
@ -266,10 +268,10 @@ function peekAndUpdateHash(hash: Hash) {
|
||||||
* Updates an hmac with the stream except for the last ATTACHMENT_MAC_LENGTH
|
* Updates an hmac with the stream except for the last ATTACHMENT_MAC_LENGTH
|
||||||
* bytes. The last ATTACHMENT_MAC_LENGTH bytes are passed to the callback.
|
* bytes. The last ATTACHMENT_MAC_LENGTH bytes are passed to the callback.
|
||||||
*/
|
*/
|
||||||
function getMacAndUpdateHmac(
|
export function getMacAndUpdateHmac(
|
||||||
hmac: Hmac,
|
hmac: Hmac,
|
||||||
onTheirMac: (theirMac: Uint8Array) => void
|
onTheirMac: (theirMac: Uint8Array) => void
|
||||||
) {
|
): Transform {
|
||||||
// Because we don't have a view of the entire stream, we don't know when we're
|
// Because we don't have a view of the entire stream, we don't know when we're
|
||||||
// at the end. We need to omit the last ATTACHMENT_MAC_LENGTH bytes from
|
// at the end. We need to omit the last ATTACHMENT_MAC_LENGTH bytes from
|
||||||
// `hmac.update` so we only push what we know is not the mac.
|
// `hmac.update` so we only push what we know is not the mac.
|
||||||
|
@ -310,7 +312,7 @@ function getMacAndUpdateHmac(
|
||||||
* Gets the IV from the start of the stream and creates a decipher.
|
* Gets the IV from the start of the stream and creates a decipher.
|
||||||
* Then deciphers the rest of the stream.
|
* Then deciphers the rest of the stream.
|
||||||
*/
|
*/
|
||||||
function getIvAndDecipher(aesKey: Uint8Array) {
|
export function getIvAndDecipher(aesKey: Uint8Array): Transform {
|
||||||
let maybeIvBytes: Buffer | null = Buffer.alloc(0);
|
let maybeIvBytes: Buffer | null = Buffer.alloc(0);
|
||||||
let decipher: Decipher | null = null;
|
let decipher: Decipher | null = null;
|
||||||
return new Transform({
|
return new Transform({
|
||||||
|
@ -377,83 +379,10 @@ function trimPadding(size: number) {
|
||||||
export function getAttachmentDownloadSize(size: number): number {
|
export function getAttachmentDownloadSize(size: number): number {
|
||||||
return (
|
return (
|
||||||
// Multiply this by 1.05 to allow some variance
|
// Multiply this by 1.05 to allow some variance
|
||||||
getAttachmentSizeBucket(size) * 1.05 + IV_LENGTH + ATTACHMENT_MAC_LENGTH
|
logPadSize(size) * 1.05 + IV_LENGTH + ATTACHMENT_MAC_LENGTH
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PADDING_CHUNK_SIZE = 64 * 1024;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates iterator that yields zero-filled padding chunks.
|
|
||||||
*/
|
|
||||||
function* generatePadding(size: number) {
|
|
||||||
const targetLength = getAttachmentSizeBucket(size);
|
|
||||||
const paddingSize = targetLength - size;
|
|
||||||
const paddingChunks = Math.floor(paddingSize / PADDING_CHUNK_SIZE);
|
|
||||||
const paddingChunk = new Uint8Array(PADDING_CHUNK_SIZE); // zero-filled
|
|
||||||
const paddingRemainder = new Uint8Array(paddingSize % PADDING_CHUNK_SIZE);
|
|
||||||
for (let i = 0; i < paddingChunks; i += 1) {
|
|
||||||
yield paddingChunk;
|
|
||||||
}
|
|
||||||
if (paddingRemainder.byteLength > 0) {
|
|
||||||
yield paddingRemainder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push as much padding as we can. If we reach the end
|
|
||||||
// of the padding, return true.
|
|
||||||
function pushPadding(
|
|
||||||
paddingIterator: Iterator<Uint8Array>,
|
|
||||||
readable: Readable
|
|
||||||
): boolean {
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
|
||||||
const result = paddingIterator.next();
|
|
||||||
if (result.done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const keepGoing = readable.push(result.value);
|
|
||||||
if (!keepGoing) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends zero-padding to the stream to a target bucket size.
|
|
||||||
*/
|
|
||||||
function appendPadding() {
|
|
||||||
let onReadableDrained: undefined | (() => void);
|
|
||||||
let fileSize = 0;
|
|
||||||
|
|
||||||
return new Transform({
|
|
||||||
read(size) {
|
|
||||||
// When in the process of pushing padding, we pause and wait for
|
|
||||||
// read to be called again.
|
|
||||||
if (onReadableDrained != null) {
|
|
||||||
onReadableDrained();
|
|
||||||
}
|
|
||||||
// Always call _read, even if we're done.
|
|
||||||
Transform.prototype._read.call(this, size);
|
|
||||||
},
|
|
||||||
transform(chunk, _encoding, callback) {
|
|
||||||
fileSize += chunk.byteLength;
|
|
||||||
callback(null, chunk);
|
|
||||||
},
|
|
||||||
flush(callback) {
|
|
||||||
const iterator = generatePadding(fileSize);
|
|
||||||
|
|
||||||
onReadableDrained = () => {
|
|
||||||
if (pushPadding(iterator, this)) {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
onReadableDrained();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepends the iv to the stream.
|
* Prepends the iv to the stream.
|
||||||
*/
|
*/
|
||||||
|
@ -462,43 +391,7 @@ function prependIv(iv: Uint8Array) {
|
||||||
iv.byteLength === IV_LENGTH,
|
iv.byteLength === IV_LENGTH,
|
||||||
`prependIv: iv should be ${IV_LENGTH} bytes, got ${iv.byteLength} bytes`
|
`prependIv: iv should be ${IV_LENGTH} bytes, got ${iv.byteLength} bytes`
|
||||||
);
|
);
|
||||||
return new Transform({
|
return prependStream(iv);
|
||||||
construct(callback) {
|
|
||||||
this.push(iv);
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
transform(chunk, _encoding, callback) {
|
|
||||||
callback(null, chunk);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends the mac to the end of the stream.
|
|
||||||
*/
|
|
||||||
function appendMac(macKey: Uint8Array) {
|
|
||||||
strictAssert(
|
|
||||||
macKey.byteLength === KEY_LENGTH,
|
|
||||||
`macKey should be ${KEY_LENGTH} bytes, got ${macKey.byteLength} bytes`
|
|
||||||
);
|
|
||||||
const hmac = createHmac(HashType.size256, macKey);
|
|
||||||
return new Transform({
|
|
||||||
transform(chunk, _encoding, callback) {
|
|
||||||
try {
|
|
||||||
hmac.update(chunk);
|
|
||||||
callback(null, chunk);
|
|
||||||
} catch (error) {
|
|
||||||
callback(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
flush(callback) {
|
|
||||||
try {
|
|
||||||
callback(null, hmac.digest());
|
|
||||||
} catch (error) {
|
|
||||||
callback(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
84
ts/Crypto.ts
84
ts/Crypto.ts
|
@ -7,9 +7,10 @@ import { HKDF } from '@signalapp/libsignal-client';
|
||||||
|
|
||||||
import * as Bytes from './Bytes';
|
import * as Bytes from './Bytes';
|
||||||
import { calculateAgreement, generateKeyPair } from './Curve';
|
import { calculateAgreement, generateKeyPair } from './Curve';
|
||||||
import { HashType, CipherType } from './types/Crypto';
|
import { HashType, CipherType, UUID_BYTE_SIZE } from './types/Crypto';
|
||||||
import { ProfileDecryptError } from './types/errors';
|
import { ProfileDecryptError } from './types/errors';
|
||||||
import { getBytesSubarray } from './util/uuidToBytes';
|
import { getBytesSubarray } from './util/uuidToBytes';
|
||||||
|
import { logPadSize } from './util/logPadding';
|
||||||
import { Environment } from './environment';
|
import { Environment } from './environment';
|
||||||
|
|
||||||
export { HashType, CipherType };
|
export { HashType, CipherType };
|
||||||
|
@ -140,6 +141,78 @@ export function deriveStorageManifestKey(
|
||||||
return hmacSha256(storageServiceKey, Bytes.fromString(`Manifest_${version}`));
|
return hmacSha256(storageServiceKey, Bytes.fromString(`Manifest_${version}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BACKUP_KEY_LEN = 32;
|
||||||
|
const BACKUP_KEY_INFO = '20231003_Signal_Backups_GenerateBackupKey';
|
||||||
|
|
||||||
|
export function deriveBackupKey(masterKey: Uint8Array): Uint8Array {
|
||||||
|
const hkdf = HKDF.new(3);
|
||||||
|
return hkdf.deriveSecrets(
|
||||||
|
BACKUP_KEY_LEN,
|
||||||
|
Buffer.from(masterKey),
|
||||||
|
Buffer.from(BACKUP_KEY_INFO),
|
||||||
|
Buffer.alloc(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BACKUP_ID_LEN = 16;
|
||||||
|
const BACKUP_ID_INFO = '20231003_Signal_Backups_GenerateBackupId';
|
||||||
|
|
||||||
|
export function deriveBackupId(
|
||||||
|
backupKey: Uint8Array,
|
||||||
|
aciBytes: Uint8Array
|
||||||
|
): Uint8Array {
|
||||||
|
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
|
||||||
|
throw new Error('deriveBackupId: invalid backup key length');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aciBytes.byteLength !== UUID_BYTE_SIZE) {
|
||||||
|
throw new Error('deriveBackupId: invalid aci length');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hkdf = HKDF.new(3);
|
||||||
|
return hkdf.deriveSecrets(
|
||||||
|
BACKUP_ID_LEN,
|
||||||
|
Buffer.from(backupKey),
|
||||||
|
Buffer.from(BACKUP_ID_INFO),
|
||||||
|
Buffer.from(aciBytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupKeyMaterialType = Readonly<{
|
||||||
|
macKey: Uint8Array;
|
||||||
|
aesKey: Uint8Array;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const BACKUP_AES_KEY_LEN = 32;
|
||||||
|
const BACKUP_MAC_KEY_LEN = 32;
|
||||||
|
const BACKUP_MATERIAL_INFO = '20231003_Signal_Backups_EncryptMessageBackup';
|
||||||
|
|
||||||
|
export function deriveBackupKeyMaterial(
|
||||||
|
backupKey: Uint8Array,
|
||||||
|
backupId: Uint8Array
|
||||||
|
): BackupKeyMaterialType {
|
||||||
|
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
|
||||||
|
throw new Error('deriveBackupId: invalid backup key length');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupId.byteLength !== BACKUP_ID_LEN) {
|
||||||
|
throw new Error('deriveBackupId: invalid backup id length');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hkdf = HKDF.new(3);
|
||||||
|
const material = hkdf.deriveSecrets(
|
||||||
|
BACKUP_AES_KEY_LEN + BACKUP_MAC_KEY_LEN,
|
||||||
|
Buffer.from(backupKey),
|
||||||
|
Buffer.from(BACKUP_MATERIAL_INFO),
|
||||||
|
Buffer.from(backupId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
macKey: material.slice(0, BACKUP_MAC_KEY_LEN),
|
||||||
|
aesKey: material.slice(BACKUP_MAC_KEY_LEN),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function deriveStorageItemKey(
|
export function deriveStorageItemKey(
|
||||||
storageServiceKey: Uint8Array,
|
storageServiceKey: Uint8Array,
|
||||||
itemID: string
|
itemID: string
|
||||||
|
@ -462,13 +535,6 @@ export function encryptAttachment({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAttachmentSizeBucket(size: number): number {
|
|
||||||
return Math.max(
|
|
||||||
541,
|
|
||||||
Math.floor(1.05 ** Math.ceil(Math.log(size) / Math.log(1.05)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function padAndEncryptAttachment({
|
export function padAndEncryptAttachment({
|
||||||
plaintext,
|
plaintext,
|
||||||
keys,
|
keys,
|
||||||
|
@ -479,7 +545,7 @@ export function padAndEncryptAttachment({
|
||||||
dangerousTestOnlyIv?: Readonly<Uint8Array>;
|
dangerousTestOnlyIv?: Readonly<Uint8Array>;
|
||||||
}): EncryptedAttachment {
|
}): EncryptedAttachment {
|
||||||
const size = plaintext.byteLength;
|
const size = plaintext.byteLength;
|
||||||
const paddedSize = getAttachmentSizeBucket(size);
|
const paddedSize = logPadSize(size);
|
||||||
const padding = getZeroes(paddedSize - size);
|
const padding = getZeroes(paddedSize - size);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -184,9 +184,17 @@ export class BackupExportStream extends Readable {
|
||||||
stats.distributionLists += 1;
|
stats.distributionLists += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pinnedConversationIds =
|
||||||
|
window.storage.get('pinnedConversationIds') || [];
|
||||||
|
|
||||||
for (const { attributes } of window.ConversationController.getAll()) {
|
for (const { attributes } of window.ConversationController.getAll()) {
|
||||||
const recipientId = this.getRecipientId(attributes);
|
const recipientId = this.getRecipientId(attributes);
|
||||||
|
|
||||||
|
let pinnedOrder: number | null = null;
|
||||||
|
if (attributes.isPinned) {
|
||||||
|
pinnedOrder = Math.max(0, pinnedConversationIds.indexOf(attributes.id));
|
||||||
|
}
|
||||||
|
|
||||||
this.pushFrame({
|
this.pushFrame({
|
||||||
chat: {
|
chat: {
|
||||||
// We don't have to use separate identifiers
|
// We don't have to use separate identifiers
|
||||||
|
@ -194,7 +202,7 @@ export class BackupExportStream extends Readable {
|
||||||
recipientId,
|
recipientId,
|
||||||
|
|
||||||
archived: attributes.isArchived === true,
|
archived: attributes.isArchived === true,
|
||||||
pinnedOrder: attributes.isPinned === true ? 1 : null,
|
pinnedOrder,
|
||||||
expirationTimerMs:
|
expirationTimerMs:
|
||||||
attributes.expireTimer != null
|
attributes.expireTimer != null
|
||||||
? Long.fromNumber(
|
? Long.fromNumber(
|
||||||
|
@ -588,7 +596,10 @@ export class BackupExportStream extends Readable {
|
||||||
const { sendStateByConversationId = {} } = message;
|
const { sendStateByConversationId = {} } = message;
|
||||||
for (const [id, entry] of Object.entries(sendStateByConversationId)) {
|
for (const [id, entry] of Object.entries(sendStateByConversationId)) {
|
||||||
const target = window.ConversationController.get(id);
|
const target = window.ConversationController.get(id);
|
||||||
strictAssert(target != null, 'Send target not found');
|
if (!target) {
|
||||||
|
log.warn(`backups: no send target for a message ${message.sent_at}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let deliveryStatus: Backups.SendStatus.Status;
|
let deliveryStatus: Backups.SendStatus.Status;
|
||||||
switch (entry.status) {
|
switch (entry.status) {
|
||||||
|
|
|
@ -28,6 +28,8 @@ import {
|
||||||
import { incrementMessageCounter } from '../../util/incrementMessageCounter';
|
import { incrementMessageCounter } from '../../util/incrementMessageCounter';
|
||||||
import { isAciString } from '../../util/isAciString';
|
import { isAciString } from '../../util/isAciString';
|
||||||
import { createBatcher } from '../../util/batcher';
|
import { createBatcher } from '../../util/batcher';
|
||||||
|
import { PhoneNumberDiscoverability } from '../../util/phoneNumberDiscoverability';
|
||||||
|
import { PhoneNumberSharingMode } from '../../util/phoneNumberSharingMode';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
import { SendStatus } from '../../messages/MessageSendState';
|
import { SendStatus } from '../../messages/MessageSendState';
|
||||||
import type { SendStateByConversationId } from '../../messages/MessageSendState';
|
import type { SendStateByConversationId } from '../../messages/MessageSendState';
|
||||||
|
@ -97,6 +99,7 @@ export class BackupImportStream extends Writable {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
private ourConversation?: ConversationAttributesType;
|
private ourConversation?: ConversationAttributesType;
|
||||||
|
private pinnedConversations = new Array<[number, string]>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ objectMode: true });
|
super({ objectMode: true });
|
||||||
|
@ -157,6 +160,15 @@ export class BackupImportStream extends Writable {
|
||||||
{ concurrency: MAX_CONCURRENCY }
|
{ concurrency: MAX_CONCURRENCY }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await window.storage.put(
|
||||||
|
'pinnedConversationIds',
|
||||||
|
this.pinnedConversations
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
return a - b;
|
||||||
|
})
|
||||||
|
.map(([, id]) => id)
|
||||||
|
);
|
||||||
|
|
||||||
done();
|
done();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
done(error);
|
done(error);
|
||||||
|
@ -226,10 +238,145 @@ export class BackupImportStream extends Writable {
|
||||||
this.saveMessageBatcher.add(attributes);
|
this.saveMessageBatcher.add(attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fromAccount(_account: Backups.IAccountData): Promise<void> {
|
private async fromAccount({
|
||||||
|
profileKey,
|
||||||
|
username,
|
||||||
|
usernameLink,
|
||||||
|
givenName,
|
||||||
|
familyName,
|
||||||
|
avatarUrlPath,
|
||||||
|
subscriberId,
|
||||||
|
subscriberCurrencyCode,
|
||||||
|
accountSettings,
|
||||||
|
}: Backups.IAccountData): Promise<void> {
|
||||||
strictAssert(this.ourConversation === undefined, 'Duplicate AccountData');
|
strictAssert(this.ourConversation === undefined, 'Duplicate AccountData');
|
||||||
this.ourConversation =
|
const me =
|
||||||
window.ConversationController.getOurConversationOrThrow().attributes;
|
window.ConversationController.getOurConversationOrThrow().attributes;
|
||||||
|
this.ourConversation = me;
|
||||||
|
|
||||||
|
const { storage } = window;
|
||||||
|
|
||||||
|
strictAssert(Bytes.isNotEmpty(profileKey), 'Missing profile key');
|
||||||
|
await storage.put('profileKey', profileKey);
|
||||||
|
|
||||||
|
if (username != null) {
|
||||||
|
me.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usernameLink != null) {
|
||||||
|
const { entropy, serverId, color } = usernameLink;
|
||||||
|
if (Bytes.isNotEmpty(entropy) && Bytes.isNotEmpty(serverId)) {
|
||||||
|
await storage.put('usernameLink', {
|
||||||
|
entropy,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same numeric value, no conversion needed
|
||||||
|
await storage.put('usernameLinkColor', color ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (givenName != null) {
|
||||||
|
me.profileName = givenName;
|
||||||
|
}
|
||||||
|
if (familyName != null) {
|
||||||
|
me.profileFamilyName = familyName;
|
||||||
|
}
|
||||||
|
if (avatarUrlPath != null) {
|
||||||
|
await storage.put('avatarUrl', avatarUrlPath);
|
||||||
|
}
|
||||||
|
if (subscriberId != null) {
|
||||||
|
await storage.put('subscriberId', subscriberId);
|
||||||
|
}
|
||||||
|
if (subscriberCurrencyCode != null) {
|
||||||
|
await storage.put('subscriberCurrencyCode', subscriberCurrencyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.put(
|
||||||
|
'read-receipt-setting',
|
||||||
|
accountSettings?.readReceipts === true
|
||||||
|
);
|
||||||
|
await storage.put(
|
||||||
|
'sealedSenderIndicators',
|
||||||
|
accountSettings?.sealedSenderIndicators === true
|
||||||
|
);
|
||||||
|
await storage.put(
|
||||||
|
'typingIndicators',
|
||||||
|
accountSettings?.typingIndicators === true
|
||||||
|
);
|
||||||
|
await storage.put('linkPreviews', accountSettings?.linkPreviews === true);
|
||||||
|
await storage.put(
|
||||||
|
'preferContactAvatars',
|
||||||
|
accountSettings?.preferContactAvatars === true
|
||||||
|
);
|
||||||
|
await storage.put(
|
||||||
|
'displayBadgesOnProfile',
|
||||||
|
accountSettings?.displayBadgesOnProfile === true
|
||||||
|
);
|
||||||
|
await storage.put(
|
||||||
|
'keepMutedChatsArchived',
|
||||||
|
accountSettings?.keepMutedChatsArchived === true
|
||||||
|
);
|
||||||
|
await storage.put(
|
||||||
|
'hasSetMyStoriesPrivacy',
|
||||||
|
accountSettings?.hasSetMyStoriesPrivacy === true
|
||||||
|
);
|
||||||
|
await storage.put(
|
||||||
|
'hasViewedOnboardingStory',
|
||||||
|
accountSettings?.hasViewedOnboardingStory === true
|
||||||
|
);
|
||||||
|
await storage.put(
|
||||||
|
'hasStoriesDisabled',
|
||||||
|
accountSettings?.storiesDisabled === true
|
||||||
|
);
|
||||||
|
await storage.put(
|
||||||
|
'storyViewReceiptsEnabled',
|
||||||
|
accountSettings?.storyViewReceiptsEnabled === true
|
||||||
|
);
|
||||||
|
await storage.put(
|
||||||
|
'hasCompletedUsernameOnboarding',
|
||||||
|
accountSettings?.hasCompletedUsernameOnboarding === true
|
||||||
|
);
|
||||||
|
await storage.put(
|
||||||
|
'preferredReactionEmoji',
|
||||||
|
accountSettings?.preferredReactionEmoji || []
|
||||||
|
);
|
||||||
|
await storage.put(
|
||||||
|
'preferredReactionEmoji',
|
||||||
|
accountSettings?.preferredReactionEmoji || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const { PhoneNumberSharingMode: BackupMode } = Backups.AccountData;
|
||||||
|
switch (accountSettings?.phoneNumberSharingMode) {
|
||||||
|
case BackupMode.EVERYBODY:
|
||||||
|
await storage.put(
|
||||||
|
'phoneNumberSharingMode',
|
||||||
|
PhoneNumberSharingMode.Everybody
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case BackupMode.UNKNOWN:
|
||||||
|
case BackupMode.NOBODY:
|
||||||
|
default:
|
||||||
|
await storage.put(
|
||||||
|
'phoneNumberSharingMode',
|
||||||
|
PhoneNumberSharingMode.Nobody
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountSettings?.notDiscoverableByPhoneNumber) {
|
||||||
|
await window.storage.put(
|
||||||
|
'phoneNumberDiscoverability',
|
||||||
|
PhoneNumberDiscoverability.NotDiscoverable
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await window.storage.put(
|
||||||
|
'phoneNumberDiscoverability',
|
||||||
|
PhoneNumberDiscoverability.Discoverable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateConversation(me);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fromContact(
|
private async fromContact(
|
||||||
|
@ -342,15 +489,7 @@ export class BackupImportStream extends Writable {
|
||||||
this.updateConversation(conversation);
|
this.updateConversation(conversation);
|
||||||
|
|
||||||
if (chat.pinnedOrder != null) {
|
if (chat.pinnedOrder != null) {
|
||||||
const pinnedConversationIds = new Set(
|
this.pinnedConversations.push([chat.pinnedOrder, conversation.id]);
|
||||||
window.storage.get('pinnedConversationIds', new Array<string>())
|
|
||||||
);
|
|
||||||
|
|
||||||
pinnedConversationIds.add(conversation.id);
|
|
||||||
|
|
||||||
await window.storage.put('pinnedConversationIds', [
|
|
||||||
...pinnedConversationIds,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,20 +2,53 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { pipeline } from 'stream/promises';
|
import { pipeline } from 'stream/promises';
|
||||||
import { Readable } from 'stream';
|
import { PassThrough } from 'stream';
|
||||||
|
import type { Readable, Writable } from 'stream';
|
||||||
import { createWriteStream } from 'fs';
|
import { createWriteStream } from 'fs';
|
||||||
|
import { createGzip, createGunzip } from 'zlib';
|
||||||
|
import { createCipheriv, createHmac, randomBytes } from 'crypto';
|
||||||
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import * as Bytes from '../../Bytes';
|
import * as Bytes from '../../Bytes';
|
||||||
import { DelimitedStream } from '../../util/DelimitedStream';
|
import { DelimitedStream } from '../../util/DelimitedStream';
|
||||||
|
import { appendPaddingStream } from '../../util/logPadding';
|
||||||
|
import { prependStream } from '../../util/prependStream';
|
||||||
|
import { appendMacStream } from '../../util/appendMacStream';
|
||||||
|
import { toAciObject } from '../../util/ServiceId';
|
||||||
|
import { CipherType, HashType } from '../../types/Crypto';
|
||||||
import * as Errors from '../../types/errors';
|
import * as Errors from '../../types/errors';
|
||||||
|
import {
|
||||||
|
deriveBackupKey,
|
||||||
|
deriveBackupId,
|
||||||
|
deriveBackupKeyMaterial,
|
||||||
|
constantTimeEqual,
|
||||||
|
} from '../../Crypto';
|
||||||
|
import type { BackupKeyMaterialType } from '../../Crypto';
|
||||||
|
import { getIvAndDecipher, getMacAndUpdateHmac } from '../../AttachmentCrypto';
|
||||||
import { BackupExportStream } from './export';
|
import { BackupExportStream } from './export';
|
||||||
import { BackupImportStream } from './import';
|
import { BackupImportStream } from './import';
|
||||||
|
|
||||||
|
const IV_LENGTH = 16;
|
||||||
|
|
||||||
|
function getKeyMaterial(): BackupKeyMaterialType {
|
||||||
|
const masterKey = window.storage.get('masterKey');
|
||||||
|
if (!masterKey) {
|
||||||
|
throw new Error('Master key not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const aci = toAciObject(window.storage.user.getCheckedAci());
|
||||||
|
const aciBytes = aci.getServiceIdBinary();
|
||||||
|
|
||||||
|
const backupKey = deriveBackupKey(Bytes.fromBase64(masterKey));
|
||||||
|
const backupId = deriveBackupId(backupKey, aciBytes);
|
||||||
|
return deriveBackupKeyMaterial(backupKey, backupId);
|
||||||
|
}
|
||||||
|
|
||||||
export class BackupsService {
|
export class BackupsService {
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
|
||||||
public exportBackup(): Readable {
|
public async exportBackup(sink: Writable): Promise<void> {
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
throw new Error('BackupService is already running');
|
throw new Error('BackupService is already running');
|
||||||
}
|
}
|
||||||
|
@ -23,37 +56,43 @@ export class BackupsService {
|
||||||
log.info('exportBackup: starting...');
|
log.info('exportBackup: starting...');
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
|
||||||
const stream = new BackupExportStream();
|
try {
|
||||||
const cleanup = () => {
|
const { aesKey, macKey } = getKeyMaterial();
|
||||||
// Don't fire twice
|
|
||||||
stream.removeListener('end', cleanup);
|
|
||||||
stream.removeListener('error', cleanup);
|
|
||||||
|
|
||||||
|
const recordStream = new BackupExportStream();
|
||||||
|
recordStream.run();
|
||||||
|
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
|
await pipeline(
|
||||||
|
recordStream,
|
||||||
|
createGzip(),
|
||||||
|
appendPaddingStream(),
|
||||||
|
createCipheriv(CipherType.AES256CBC, aesKey, iv),
|
||||||
|
prependStream(iv),
|
||||||
|
appendMacStream(macKey),
|
||||||
|
sink
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
log.info('exportBackup: finished...');
|
log.info('exportBackup: finished...');
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
};
|
}
|
||||||
|
|
||||||
stream.once('end', cleanup);
|
|
||||||
stream.once('error', cleanup);
|
|
||||||
|
|
||||||
stream.run();
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test harness
|
// Test harness
|
||||||
public async exportBackupData(): Promise<Uint8Array> {
|
public async exportBackupData(): Promise<Uint8Array> {
|
||||||
|
const sink = new PassThrough();
|
||||||
|
|
||||||
const chunks = new Array<Uint8Array>();
|
const chunks = new Array<Uint8Array>();
|
||||||
for await (const chunk of this.exportBackup()) {
|
sink.on('data', chunk => chunks.push(chunk));
|
||||||
chunks.push(chunk);
|
await this.exportBackup(sink);
|
||||||
}
|
|
||||||
|
|
||||||
return Bytes.concatenate(chunks);
|
return Bytes.concatenate(chunks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test harness
|
// Test harness
|
||||||
public async exportToDisk(path: string): Promise<void> {
|
public async exportToDisk(path: string): Promise<void> {
|
||||||
await pipeline(this.exportBackup(), createWriteStream(path));
|
await this.exportBackup(createWriteStream(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test harness
|
// Test harness
|
||||||
|
@ -68,7 +107,7 @@ export class BackupsService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async importBackup(backup: Uint8Array): Promise<void> {
|
public async importBackup(createBackupStream: () => Readable): Promise<void> {
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
throw new Error('BackupService is already running');
|
throw new Error('BackupService is already running');
|
||||||
}
|
}
|
||||||
|
@ -77,11 +116,48 @@ export class BackupsService {
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { aesKey, macKey } = getKeyMaterial();
|
||||||
|
|
||||||
|
// First pass - don't decrypt, only verify mac
|
||||||
|
let hmac = createHmac(HashType.size256, macKey);
|
||||||
|
let theirMac: Uint8Array | undefined;
|
||||||
|
|
||||||
|
const sink = new PassThrough();
|
||||||
|
// Discard the data in the first pass
|
||||||
|
sink.resume();
|
||||||
|
|
||||||
await pipeline(
|
await pipeline(
|
||||||
Readable.from(backup),
|
createBackupStream(),
|
||||||
|
getMacAndUpdateHmac(hmac, theirMacValue => {
|
||||||
|
theirMac = theirMacValue;
|
||||||
|
}),
|
||||||
|
sink
|
||||||
|
);
|
||||||
|
|
||||||
|
if (theirMac == null) {
|
||||||
|
throw new Error('importBackup: Missing MAC');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!constantTimeEqual(hmac.digest(), theirMac)) {
|
||||||
|
throw new Error('importBackup: Bad MAC');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass - decrypt (but still check the mac at the end)
|
||||||
|
hmac = createHmac(HashType.size256, macKey);
|
||||||
|
|
||||||
|
await pipeline(
|
||||||
|
createBackupStream(),
|
||||||
|
getMacAndUpdateHmac(hmac, noop),
|
||||||
|
getIvAndDecipher(aesKey),
|
||||||
|
createGunzip(),
|
||||||
new DelimitedStream(),
|
new DelimitedStream(),
|
||||||
new BackupImportStream()
|
new BackupImportStream()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!constantTimeEqual(hmac.digest(), theirMac)) {
|
||||||
|
throw new Error('importBackup: Bad MAC, second pass');
|
||||||
|
}
|
||||||
|
|
||||||
log.info('importBackup: finished...');
|
log.info('importBackup: finished...');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.info(`importBackup: failed, error: ${Errors.toLogFormat(error)}`);
|
log.info(`importBackup: failed, error: ${Errors.toLogFormat(error)}`);
|
||||||
|
|
47
ts/test-both/util/appendMacStream_test.ts
Normal file
47
ts/test-both/util/appendMacStream_test.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { randomBytes, createHmac } from 'crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
appendMacStream,
|
||||||
|
MAC_KEY_SIZE,
|
||||||
|
MAC_SIZE,
|
||||||
|
} from '../../util/appendMacStream';
|
||||||
|
import { generateConfigMatrix } from '../../util/generateConfigMatrix';
|
||||||
|
|
||||||
|
describe('appendMacStream', () => {
|
||||||
|
generateConfigMatrix({
|
||||||
|
size: [23, 1024, 1024 * 1024],
|
||||||
|
}).forEach(({ size }) => {
|
||||||
|
it(`should append mac to a ${size} byte stream`, async () => {
|
||||||
|
const macKey = randomBytes(MAC_KEY_SIZE);
|
||||||
|
|
||||||
|
const plaintext = randomBytes(size);
|
||||||
|
|
||||||
|
const stream = appendMacStream(macKey);
|
||||||
|
stream.end(plaintext);
|
||||||
|
|
||||||
|
const chunks = new Array<Buffer>();
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
const hmac = createHmac('sha256', macKey);
|
||||||
|
hmac.update(plaintext);
|
||||||
|
const expectedMac = hmac.digest();
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
buf.slice(0, -MAC_SIZE).toString('hex'),
|
||||||
|
plaintext.toString('hex')
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
buf.slice(-MAC_SIZE).toString('hex'),
|
||||||
|
expectedMac.toString('hex')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
123
ts/test-both/util/logPadding_test.ts
Normal file
123
ts/test-both/util/logPadding_test.ts
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
import { logPadSize, appendPaddingStream } from '../../util/logPadding';
|
||||||
|
|
||||||
|
const BUCKET_SIZES = [
|
||||||
|
541, 568, 596, 626, 657, 690, 725, 761, 799, 839, 881, 925, 972, 1020, 1071,
|
||||||
|
1125, 1181, 1240, 1302, 1367, 1436, 1507, 1583, 1662, 1745, 1832, 1924, 2020,
|
||||||
|
2121, 2227, 2339, 2456, 2579, 2708, 2843, 2985, 3134, 3291, 3456, 3629, 3810,
|
||||||
|
4001, 4201, 4411, 4631, 4863, 5106, 5361, 5629, 5911, 6207, 6517, 6843, 7185,
|
||||||
|
7544, 7921, 8318, 8733, 9170, 9629, 10110, 10616, 11146, 11704, 12289, 12903,
|
||||||
|
13549, 14226, 14937, 15684, 16469, 17292, 18157, 19065, 20018, 21019, 22070,
|
||||||
|
23173, 24332, 25549, 26826, 28167, 29576, 31054, 32607, 34238, 35950, 37747,
|
||||||
|
39634, 41616, 43697, 45882, 48176, 50585, 53114, 55770, 58558, 61486, 64561,
|
||||||
|
67789, 71178, 74737, 78474, 82398, 86518, 90843, 95386, 100155, 105163,
|
||||||
|
110421, 115942, 121739, 127826, 134217, 140928, 147975, 155373, 163142,
|
||||||
|
171299, 179864, 188858, 198300, 208215, 218626, 229558, 241036, 253087,
|
||||||
|
265742, 279029, 292980, 307629, 323011, 339161, 356119, 373925, 392622,
|
||||||
|
412253, 432866, 454509, 477234, 501096, 526151, 552458, 580081, 609086,
|
||||||
|
639540, 671517, 705093, 740347, 777365, 816233, 857045, 899897, 944892,
|
||||||
|
992136, 1041743, 1093831, 1148522, 1205948, 1266246, 1329558, 1396036,
|
||||||
|
1465838, 1539130, 1616086, 1696890, 1781735, 1870822, 1964363, 2062581,
|
||||||
|
2165710, 2273996, 2387695, 2507080, 2632434, 2764056, 2902259, 3047372,
|
||||||
|
3199740, 3359727, 3527714, 3704100, 3889305, 4083770, 4287958, 4502356,
|
||||||
|
4727474, 4963848, 5212040, 5472642, 5746274, 6033588, 6335268, 6652031,
|
||||||
|
6984633, 7333864, 7700558, 8085585, 8489865, 8914358, 9360076, 9828080,
|
||||||
|
10319484, 10835458, 11377231, 11946092, 12543397, 13170567, 13829095,
|
||||||
|
14520550, 15246578, 16008907, 16809352, 17649820, 18532311, 19458926,
|
||||||
|
20431872, 21453466, 22526139, 23652446, 24835069, 26076822, 27380663,
|
||||||
|
28749697, 30187181, 31696540, 33281368, 34945436, 36692708, 38527343,
|
||||||
|
40453710, 42476396, 44600216, 46830227, 49171738, 51630325, 54211841,
|
||||||
|
56922433, 59768555, 62756983, 65894832, 69189573, 72649052, 76281505,
|
||||||
|
80095580, 84100359, 88305377, 92720646, 97356678, 102224512, 107335738,
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('logPadSize', () => {
|
||||||
|
it('properly calculates first bucket', () => {
|
||||||
|
for (let size = 0, max = BUCKET_SIZES[0]; size < max; size += 1) {
|
||||||
|
assert.strictEqual(BUCKET_SIZES[0], logPadSize(size));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('properly calculates entire table', () => {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
const failures = new Array<string>();
|
||||||
|
for (let i = 0, max = BUCKET_SIZES.length - 1; i < max; i += 1) {
|
||||||
|
// Exact
|
||||||
|
if (BUCKET_SIZES[i] !== logPadSize(BUCKET_SIZES[i])) {
|
||||||
|
count += 1;
|
||||||
|
failures.push(
|
||||||
|
`${BUCKET_SIZES[i]} does not equal ${logPadSize(BUCKET_SIZES[i])}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just under
|
||||||
|
if (BUCKET_SIZES[i] !== logPadSize(BUCKET_SIZES[i] - 1)) {
|
||||||
|
count += 1;
|
||||||
|
failures.push(
|
||||||
|
`${BUCKET_SIZES[i]} does not equal ${logPadSize(BUCKET_SIZES[i] - 1)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just over
|
||||||
|
if (BUCKET_SIZES[i + 1] !== logPadSize(BUCKET_SIZES[i] + 1)) {
|
||||||
|
count += 1;
|
||||||
|
failures.push(
|
||||||
|
`${BUCKET_SIZES[i + 1]} does not equal ` +
|
||||||
|
`${logPadSize(BUCKET_SIZES[i] + 1)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(count, 0, failures.join('\n'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('appendPaddingStream', () => {
|
||||||
|
async function check(
|
||||||
|
inputs: ReadonlyArray<string>,
|
||||||
|
expectedSize: number
|
||||||
|
): Promise<void> {
|
||||||
|
const stream = appendPaddingStream();
|
||||||
|
|
||||||
|
Readable.from(inputs).pipe(stream);
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Determine padding length
|
||||||
|
let padding = 0;
|
||||||
|
for (; padding < buf.length; padding += 1) {
|
||||||
|
if (buf[buf.length - padding - 1] !== 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(buf.slice(0, -padding).toString(), inputs.join(''));
|
||||||
|
assert.strictEqual(buf.length, expectedSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should append padding to a short input', async () => {
|
||||||
|
await check(['hello'], BUCKET_SIZES[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append padding to a longer input', async () => {
|
||||||
|
await check('test.'.repeat(1024).split('.'), BUCKET_SIZES[42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append padding to a very long input', async () => {
|
||||||
|
await check(
|
||||||
|
`${'a'.repeat(64 * 1024)}.`.repeat(1024).split('.'),
|
||||||
|
BUCKET_SIZES[241]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
21
ts/test-both/util/prependStream_test.ts
Normal file
21
ts/test-both/util/prependStream_test.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import { prependStream } from '../../util/prependStream';
|
||||||
|
|
||||||
|
describe('prependStream', () => {
|
||||||
|
it('should prepend stream with a prefix', async () => {
|
||||||
|
const stream = prependStream(Buffer.from('prefix:'));
|
||||||
|
stream.end('hello');
|
||||||
|
|
||||||
|
const chunks = new Array<Buffer>();
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = Buffer.concat(chunks);
|
||||||
|
assert.strictEqual(buf.toString(), 'prefix:hello');
|
||||||
|
});
|
||||||
|
});
|
|
@ -15,7 +15,6 @@ import {
|
||||||
decryptProfileName,
|
decryptProfileName,
|
||||||
encryptProfile,
|
encryptProfile,
|
||||||
decryptProfile,
|
decryptProfile,
|
||||||
getAttachmentSizeBucket,
|
|
||||||
getRandomBytes,
|
getRandomBytes,
|
||||||
constantTimeEqual,
|
constantTimeEqual,
|
||||||
generateRegistrationId,
|
generateRegistrationId,
|
||||||
|
@ -45,36 +44,6 @@ import {
|
||||||
import { createTempDir, deleteTempDir } from '../updater/common';
|
import { createTempDir, deleteTempDir } from '../updater/common';
|
||||||
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
|
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
|
||||||
|
|
||||||
const BUCKET_SIZES = [
|
|
||||||
541, 568, 596, 626, 657, 690, 725, 761, 799, 839, 881, 925, 972, 1020, 1071,
|
|
||||||
1125, 1181, 1240, 1302, 1367, 1436, 1507, 1583, 1662, 1745, 1832, 1924, 2020,
|
|
||||||
2121, 2227, 2339, 2456, 2579, 2708, 2843, 2985, 3134, 3291, 3456, 3629, 3810,
|
|
||||||
4001, 4201, 4411, 4631, 4863, 5106, 5361, 5629, 5911, 6207, 6517, 6843, 7185,
|
|
||||||
7544, 7921, 8318, 8733, 9170, 9629, 10110, 10616, 11146, 11704, 12289, 12903,
|
|
||||||
13549, 14226, 14937, 15684, 16469, 17292, 18157, 19065, 20018, 21019, 22070,
|
|
||||||
23173, 24332, 25549, 26826, 28167, 29576, 31054, 32607, 34238, 35950, 37747,
|
|
||||||
39634, 41616, 43697, 45882, 48176, 50585, 53114, 55770, 58558, 61486, 64561,
|
|
||||||
67789, 71178, 74737, 78474, 82398, 86518, 90843, 95386, 100155, 105163,
|
|
||||||
110421, 115942, 121739, 127826, 134217, 140928, 147975, 155373, 163142,
|
|
||||||
171299, 179864, 188858, 198300, 208215, 218626, 229558, 241036, 253087,
|
|
||||||
265742, 279029, 292980, 307629, 323011, 339161, 356119, 373925, 392622,
|
|
||||||
412253, 432866, 454509, 477234, 501096, 526151, 552458, 580081, 609086,
|
|
||||||
639540, 671517, 705093, 740347, 777365, 816233, 857045, 899897, 944892,
|
|
||||||
992136, 1041743, 1093831, 1148522, 1205948, 1266246, 1329558, 1396036,
|
|
||||||
1465838, 1539130, 1616086, 1696890, 1781735, 1870822, 1964363, 2062581,
|
|
||||||
2165710, 2273996, 2387695, 2507080, 2632434, 2764056, 2902259, 3047372,
|
|
||||||
3199740, 3359727, 3527714, 3704100, 3889305, 4083770, 4287958, 4502356,
|
|
||||||
4727474, 4963848, 5212040, 5472642, 5746274, 6033588, 6335268, 6652031,
|
|
||||||
6984633, 7333864, 7700558, 8085585, 8489865, 8914358, 9360076, 9828080,
|
|
||||||
10319484, 10835458, 11377231, 11946092, 12543397, 13170567, 13829095,
|
|
||||||
14520550, 15246578, 16008907, 16809352, 17649820, 18532311, 19458926,
|
|
||||||
20431872, 21453466, 22526139, 23652446, 24835069, 26076822, 27380663,
|
|
||||||
28749697, 30187181, 31696540, 33281368, 34945436, 36692708, 38527343,
|
|
||||||
40453710, 42476396, 44600216, 46830227, 49171738, 51630325, 54211841,
|
|
||||||
56922433, 59768555, 62756983, 65894832, 69189573, 72649052, 76281505,
|
|
||||||
80095580, 84100359, 88305377, 92720646, 97356678, 102224512, 107335738,
|
|
||||||
];
|
|
||||||
|
|
||||||
const GHOST_KITTY_HASH =
|
const GHOST_KITTY_HASH =
|
||||||
'7bc77f27d92d00b4a1d57c480ca86dacc43d57bc318339c92119d1fbf6b557a5';
|
'7bc77f27d92d00b4a1d57c480ca86dacc43d57bc318339c92119d1fbf6b557a5';
|
||||||
|
|
||||||
|
@ -556,55 +525,6 @@ describe('Crypto', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAttachmentSizeBucket', () => {
|
|
||||||
it('properly calculates first bucket', () => {
|
|
||||||
for (let size = 0, max = BUCKET_SIZES[0]; size < max; size += 1) {
|
|
||||||
assert.strictEqual(BUCKET_SIZES[0], getAttachmentSizeBucket(size));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('properly calculates entire table', () => {
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
const failures = new Array<string>();
|
|
||||||
for (let i = 0, max = BUCKET_SIZES.length - 1; i < max; i += 1) {
|
|
||||||
// Exact
|
|
||||||
if (BUCKET_SIZES[i] !== getAttachmentSizeBucket(BUCKET_SIZES[i])) {
|
|
||||||
count += 1;
|
|
||||||
failures.push(
|
|
||||||
`${BUCKET_SIZES[i]} does not equal ${getAttachmentSizeBucket(
|
|
||||||
BUCKET_SIZES[i]
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Just under
|
|
||||||
if (BUCKET_SIZES[i] !== getAttachmentSizeBucket(BUCKET_SIZES[i] - 1)) {
|
|
||||||
count += 1;
|
|
||||||
failures.push(
|
|
||||||
`${BUCKET_SIZES[i]} does not equal ${getAttachmentSizeBucket(
|
|
||||||
BUCKET_SIZES[i] - 1
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Just over
|
|
||||||
if (
|
|
||||||
BUCKET_SIZES[i + 1] !== getAttachmentSizeBucket(BUCKET_SIZES[i] + 1)
|
|
||||||
) {
|
|
||||||
count += 1;
|
|
||||||
failures.push(
|
|
||||||
`${BUCKET_SIZES[i + 1]} does not equal ${getAttachmentSizeBucket(
|
|
||||||
BUCKET_SIZES[i] + 1
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.strictEqual(count, 0, failures.join('\n'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('attachments', () => {
|
describe('attachments', () => {
|
||||||
const FILE_PATH = join(__dirname, '../../fixtures/ghost-kitty.mp4');
|
const FILE_PATH = join(__dirname, '../../fixtures/ghost-kitty.mp4');
|
||||||
const FILE_CONTENTS = readFileSync(FILE_PATH);
|
const FILE_CONTENTS = readFileSync(FILE_PATH);
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import createDebug from 'debug';
|
import createDebug from 'debug';
|
||||||
import Long from 'long';
|
import Long from 'long';
|
||||||
|
import { StorageState } from '@signalapp/mock-server';
|
||||||
|
|
||||||
import * as durations from '../../util/durations';
|
import * as durations from '../../util/durations';
|
||||||
import type { App } from '../playwright';
|
import type { App } from '../playwright';
|
||||||
|
@ -19,6 +20,34 @@ describe('backups', function (this: Mocha.Suite) {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
bootstrap = new Bootstrap();
|
bootstrap = new Bootstrap();
|
||||||
await bootstrap.init();
|
await bootstrap.init();
|
||||||
|
|
||||||
|
let state = StorageState.getEmpty();
|
||||||
|
|
||||||
|
const { phone, contacts } = bootstrap;
|
||||||
|
const [friend, pinned] = contacts;
|
||||||
|
|
||||||
|
state = state.updateAccount({
|
||||||
|
profileKey: phone.profileKey.serialize(),
|
||||||
|
e164: phone.device.number,
|
||||||
|
givenName: phone.profileName,
|
||||||
|
readReceipts: true,
|
||||||
|
hasCompletedUsernameOnboarding: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
state = state.addContact(friend, {
|
||||||
|
identityKey: friend.publicKey.serialize(),
|
||||||
|
profileKey: friend.profileKey.serialize(),
|
||||||
|
});
|
||||||
|
|
||||||
|
state = state.addContact(pinned, {
|
||||||
|
identityKey: pinned.publicKey.serialize(),
|
||||||
|
profileKey: pinned.profileKey.serialize(),
|
||||||
|
});
|
||||||
|
|
||||||
|
state = state.pin(pinned);
|
||||||
|
|
||||||
|
await phone.setStorageState(state);
|
||||||
|
|
||||||
app = await bootstrap.link();
|
app = await bootstrap.link();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -34,7 +63,19 @@ describe('backups', function (this: Mocha.Suite) {
|
||||||
|
|
||||||
it('exports and imports backup', async function () {
|
it('exports and imports backup', async function () {
|
||||||
const { contacts, phone, desktop, server } = bootstrap;
|
const { contacts, phone, desktop, server } = bootstrap;
|
||||||
const [friend] = contacts;
|
const [friend, pinned] = contacts;
|
||||||
|
|
||||||
|
debug('wait for storage service sync to finish');
|
||||||
|
{
|
||||||
|
const window = await app.getWindow();
|
||||||
|
|
||||||
|
const leftPane = window.locator('#LeftPane');
|
||||||
|
await leftPane
|
||||||
|
.locator(
|
||||||
|
`[data-testid="${pinned.device.aci}"] >> "${pinned.profileName}"`
|
||||||
|
)
|
||||||
|
.waitFor();
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 5; i += 1) {
|
for (let i = 0; i < 5; i += 1) {
|
||||||
const theirTimestamp = bootstrap.getTimestamp();
|
const theirTimestamp = bootstrap.getTimestamp();
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import { isNumber, omit, orderBy } from 'lodash';
|
import { isNumber, omit, orderBy } from 'lodash';
|
||||||
import type { KyberPreKeyRecord } from '@signalapp/libsignal-client';
|
import type { KyberPreKeyRecord } from '@signalapp/libsignal-client';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
import EventTarget from './EventTarget';
|
import EventTarget from './EventTarget';
|
||||||
import type {
|
import type {
|
||||||
|
@ -1331,7 +1332,7 @@ export default class AccountManager extends EventTarget {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (backupFile !== undefined) {
|
if (backupFile !== undefined) {
|
||||||
await backupsService.importBackup(backupFile);
|
await backupsService.importBackup(() => Readable.from(backupFile));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
37
ts/util/appendMacStream.ts
Normal file
37
ts/util/appendMacStream.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { createHmac } from 'crypto';
|
||||||
|
import { Transform } from 'stream';
|
||||||
|
import type { Duplex } from 'stream';
|
||||||
|
|
||||||
|
import { HashType } from '../types/Crypto';
|
||||||
|
|
||||||
|
export const MAC_KEY_SIZE = 32;
|
||||||
|
|
||||||
|
export const MAC_SIZE = 32;
|
||||||
|
|
||||||
|
export function appendMacStream(macKey: Uint8Array): Duplex {
|
||||||
|
if (macKey.byteLength !== MAC_KEY_SIZE) {
|
||||||
|
throw new Error('appendMacStream: invalid macKey length');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hmac = createHmac(HashType.size256, macKey);
|
||||||
|
return new Transform({
|
||||||
|
transform(chunk, _encoding, callback) {
|
||||||
|
try {
|
||||||
|
hmac.update(chunk);
|
||||||
|
callback(null, chunk);
|
||||||
|
} catch (error) {
|
||||||
|
callback(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
flush(callback) {
|
||||||
|
try {
|
||||||
|
callback(null, hmac.digest());
|
||||||
|
} catch (error) {
|
||||||
|
callback(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
88
ts/util/logPadding.ts
Normal file
88
ts/util/logPadding.ts
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { Transform } from 'stream';
|
||||||
|
import type { Duplex, Readable } from 'stream';
|
||||||
|
|
||||||
|
const PADDING_CHUNK_SIZE = 64 * 1024;
|
||||||
|
|
||||||
|
export function logPadSize(size: number): number {
|
||||||
|
return Math.max(
|
||||||
|
541,
|
||||||
|
Math.floor(1.05 ** Math.ceil(Math.log(size) / Math.log(1.05)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates iterator that yields zero-filled padding chunks.
|
||||||
|
*/
|
||||||
|
function* generatePadding(size: number) {
|
||||||
|
const targetLength = logPadSize(size);
|
||||||
|
const paddingSize = targetLength - size;
|
||||||
|
const paddingChunks = Math.floor(paddingSize / PADDING_CHUNK_SIZE);
|
||||||
|
const paddingChunk = new Uint8Array(PADDING_CHUNK_SIZE); // zero-filled
|
||||||
|
for (let i = 0; i < paddingChunks; i += 1) {
|
||||||
|
yield paddingChunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddingRemainder = new Uint8Array(paddingSize % PADDING_CHUNK_SIZE);
|
||||||
|
if (paddingRemainder.byteLength > 0) {
|
||||||
|
yield paddingRemainder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push as much padding as we can. If we reach the end
|
||||||
|
// of the padding, return true.
|
||||||
|
function pushPadding(
|
||||||
|
paddingIterator: Iterator<Uint8Array>,
|
||||||
|
readable: Readable
|
||||||
|
): boolean {
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
const result = paddingIterator.next();
|
||||||
|
if (result.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const keepGoing = readable.push(result.value);
|
||||||
|
if (!keepGoing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends zero-padding to the stream to a target bucket size.
|
||||||
|
*/
|
||||||
|
export function appendPaddingStream(): Duplex {
|
||||||
|
let onReadableDrained: undefined | (() => void);
|
||||||
|
let fileSize = 0;
|
||||||
|
|
||||||
|
return new Transform({
|
||||||
|
read(size) {
|
||||||
|
// When in the process of pushing padding, we pause and wait for
|
||||||
|
// read to be called again.
|
||||||
|
if (onReadableDrained != null) {
|
||||||
|
onReadableDrained();
|
||||||
|
}
|
||||||
|
// Always call _read, even if we're done.
|
||||||
|
Transform.prototype._read.call(this, size);
|
||||||
|
},
|
||||||
|
transform(chunk, _encoding, callback) {
|
||||||
|
fileSize += chunk.byteLength;
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
|
flush(callback) {
|
||||||
|
const iterator = generatePadding(fileSize);
|
||||||
|
|
||||||
|
onReadableDrained = () => {
|
||||||
|
if (!pushPadding(iterator, this)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
onReadableDrained();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
17
ts/util/prependStream.ts
Normal file
17
ts/util/prependStream.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { Transform } from 'stream';
|
||||||
|
import type { Duplex } from 'stream';
|
||||||
|
|
||||||
|
export function prependStream(data: Uint8Array): Duplex {
|
||||||
|
return new Transform({
|
||||||
|
construct(callback) {
|
||||||
|
this.push(data);
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
transform(chunk, _encoding, callback) {
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue