diff --git a/ts/AttachmentCrypto.ts b/ts/AttachmentCrypto.ts index 9207cbeb173..4b3eaecf585 100644 --- a/ts/AttachmentCrypto.ts +++ b/ts/AttachmentCrypto.ts @@ -43,6 +43,12 @@ export const ATTACHMENT_MAC_LENGTH = 32; export type EncryptedAttachmentV2 = { path: string; digest: Uint8Array; + plaintextHash: string; +}; + +export type DecryptedAttachmentV2 = { + path: string; + plaintextHash: string; }; export async function encryptAttachmentV2({ @@ -82,6 +88,7 @@ export async function encryptAttachmentV2({ } const iv = dangerousTestOnlyIv || getRandomBytes(16); + const plaintextHashTransform = new DigestTransform(); const addPaddingTransform = new AddPaddingTransform(size); const cipherTransform = new CipherTransform(iv, aesKey); const addIvTransform = new AddIvTransform(iv); @@ -91,6 +98,7 @@ export async function encryptAttachmentV2({ try { await pipeline( readStream, + plaintextHashTransform, addPaddingTransform, cipherTransform, addIvTransform, @@ -116,7 +124,12 @@ export async function encryptAttachmentV2({ throw error; } - const { ourDigest } = digestTransform; + const { digest: plaintextHash } = plaintextHashTransform; + if (!plaintextHash || !plaintextHash.byteLength) { + throw new Error(`${logId}: Failed to generate plaintext hash!`); + } + + const { digest: ourDigest } = digestTransform; if (!ourDigest || !ourDigest.byteLength) { throw new Error(`${logId}: Failed to generate ourDigest!`); } @@ -127,6 +140,7 @@ export async function encryptAttachmentV2({ return { path: relativeTargetPath, digest: ourDigest, + plaintextHash: Buffer.from(plaintextHash).toString('hex'), }; } @@ -142,7 +156,7 @@ export async function decryptAttachmentV2({ keys: Readonly; size: number; theirDigest: Readonly; -}): Promise { +}): Promise { const logId = `decryptAttachmentV2(${id})`; if (keys.byteLength !== KEY_LENGTH * 2) { throw new Error(`${logId}: Got invalid length attachment keys`); @@ -171,6 +185,7 @@ export async function decryptAttachmentV2({ decipherTransform ); const limitLengthTransform = new LimitLengthTransform(size); + const plaintextHashTransform = new DigestTransform(); try { await pipeline( @@ -180,6 +195,7 @@ export async function decryptAttachmentV2({ coreDecryptionTransform, decipherTransform, limitLengthTransform, + plaintextHashTransform, writeStream ); } catch (error) { @@ -212,7 +228,7 @@ export async function decryptAttachmentV2({ throw new Error(`${logId}: Bad MAC`); } - const { ourDigest } = digestTransform; + const { digest: ourDigest } = digestTransform; if (!ourDigest || !ourDigest.byteLength) { throw new Error(`${logId}: Failed to generate ourDigest!`); } @@ -220,17 +236,25 @@ export async function decryptAttachmentV2({ throw new Error(`${logId}: Bad digest`); } + const { digest: plaintextHash } = plaintextHashTransform; + if (!plaintextHash || !plaintextHash.byteLength) { + throw new Error(`${logId}: Failed to generate file hash!`); + } + writeStream.close(); readStream.close(); - return relativeTargetPath; + return { + path: relativeTargetPath, + plaintextHash: Buffer.from(plaintextHash).toString('hex'), + }; } // A very simple transform that doesn't modify the stream, but does calculate a digest // across all data it gets. class DigestTransform extends Transform { private digestBuilder: Hash; - public ourDigest: Uint8Array | undefined; + public digest: Uint8Array | undefined; constructor() { super(); @@ -239,7 +263,7 @@ class DigestTransform extends Transform { override _flush(done: (error?: Error) => void) { try { - this.ourDigest = this.digestBuilder.digest(); + this.digest = this.digestBuilder.digest(); } catch (error) { done(error); return; diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 9747cf422bd..bf9dc8fe02a 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -28,6 +28,7 @@ export const PaddedLengths = { export type EncryptedAttachment = { ciphertext: Uint8Array; digest: Uint8Array; + plaintextHash: string; }; export function generateRegistrationId(): number { @@ -426,7 +427,7 @@ export function encryptAttachment({ plaintext: Readonly; keys: Readonly; dangerousTestOnlyIv?: Readonly; -}): EncryptedAttachment { +}): Omit { const logId = 'encryptAttachment'; if (!(plaintext instanceof Uint8Array)) { throw new TypeError( @@ -481,11 +482,17 @@ export function padAndEncryptAttachment({ const paddedSize = getAttachmentSizeBucket(size); const padding = getZeroes(paddedSize - size); - return encryptAttachment({ - plaintext: Bytes.concatenate([plaintext, padding]), - keys, - dangerousTestOnlyIv, - }); + return { + ...encryptAttachment({ + plaintext: Bytes.concatenate([plaintext, padding]), + keys, + dangerousTestOnlyIv, + }), + // We generate the plaintext hash here for forwards-compatibility with streaming + // attachment encryption, which may be the only place that the whole attachment flows + // through memory + plaintextHash: Buffer.from(sha256(plaintext)).toString('hex'), + }; } export function encryptProfile(data: Uint8Array, key: Uint8Array): Uint8Array { diff --git a/ts/test-electron/Crypto_test.ts b/ts/test-electron/Crypto_test.ts index 44be4fd2d74..ede168bc9cc 100644 --- a/ts/test-electron/Crypto_test.ts +++ b/ts/test-electron/Crypto_test.ts @@ -70,6 +70,9 @@ const BUCKET_SIZES = [ 80095580, 84100359, 88305377, 92720646, 97356678, 102224512, 107335738, ]; +const GHOST_KITTY_HASH = + '7bc77f27d92d00b4a1d57c480ca86dacc43d57bc318339c92119d1fbf6b557a5'; + describe('Crypto', () => { describe('encrypting and decrypting profile data', () => { const NAME_PADDED_LENGTH = 53; @@ -638,9 +641,11 @@ describe('Crypto', () => { plaintext: FILE_CONTENTS, keys, }); + assert.strictEqual(encryptedAttachment.plaintextHash, GHOST_KITTY_HASH); + writeFileSync(ciphertextPath, encryptedAttachment.ciphertext); - const plaintextRelativePath = await decryptAttachmentV2({ + const decryptedAttachment = await decryptAttachmentV2({ ciphertextPath, id: 'test', keys, @@ -648,11 +653,15 @@ describe('Crypto', () => { theirDigest: encryptedAttachment.digest, }); plaintextPath = window.Signal.Migrations.getAbsoluteAttachmentPath( - plaintextRelativePath + decryptedAttachment.path ); const plaintext = readFileSync(plaintextPath); assert.isTrue(constantTimeEqual(FILE_CONTENTS, plaintext)); + assert.strictEqual( + encryptedAttachment.plaintextHash, + decryptedAttachment.plaintextHash + ); } finally { if (plaintextPath) { unlinkSync(plaintextPath); @@ -675,7 +684,7 @@ describe('Crypto', () => { ciphertextPath = window.Signal.Migrations.getAbsoluteAttachmentPath( encryptedAttachment.path ); - const plaintextRelativePath = await decryptAttachmentV2({ + const decryptedAttachment = await decryptAttachmentV2({ ciphertextPath, id: 'test', keys, @@ -683,11 +692,17 @@ describe('Crypto', () => { theirDigest: encryptedAttachment.digest, }); plaintextPath = window.Signal.Migrations.getAbsoluteAttachmentPath( - plaintextRelativePath + decryptedAttachment.path ); const plaintext = readFileSync(plaintextPath); assert.isTrue(constantTimeEqual(FILE_CONTENTS, plaintext)); + + assert.strictEqual(encryptedAttachment.plaintextHash, GHOST_KITTY_HASH); + assert.strictEqual( + decryptedAttachment.plaintextHash, + encryptedAttachment.plaintextHash + ); } finally { if (plaintextPath) { unlinkSync(plaintextPath); diff --git a/ts/textsecure/downloadAttachment.ts b/ts/textsecure/downloadAttachment.ts index f961b849667..44115ffd973 100644 --- a/ts/textsecure/downloadAttachment.ts +++ b/ts/textsecure/downloadAttachment.ts @@ -116,7 +116,7 @@ export async function downloadAttachmentV2( const cipherTextAbsolutePath = window.Signal.Migrations.getAbsoluteAttachmentPath(cipherTextRelativePath); - const relativePath = await decryptAttachmentV2({ + const { path, plaintextHash } = await decryptAttachmentV2({ ciphertextPath: cipherTextAbsolutePath, id: cdn, keys: Bytes.fromBase64(key), @@ -130,11 +130,12 @@ export async function downloadAttachmentV2( return { ...omit(attachment, 'key'), - path: relativePath, + path, size, contentType: contentType ? MIME.stringToMIMEType(contentType) : MIME.APPLICATION_OCTET_STREAM, + plaintextHash, }; } diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 7dbbb0c7e25..58cfc3011a1 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -46,6 +46,7 @@ export type AttachmentType = { contentType: MIME.MIMEType; digest?: string; fileName?: string; + plaintextHash?: string; uploadTimestamp?: number; /** Not included in protobuf, needs to be pulled from flags */ isVoiceMessage?: boolean; @@ -94,6 +95,7 @@ export type UploadedAttachmentType = Proto.IAttachmentPointer & size: number; digest: Uint8Array; contentType: string; + plaintextHash: string; }>; export type AttachmentWithHydratedData = AttachmentType & { diff --git a/ts/util/attachments.ts b/ts/util/attachments.ts index bad7fbd8d67..4e08af029b9 100644 --- a/ts/util/attachments.ts +++ b/ts/util/attachments.ts @@ -93,7 +93,7 @@ export async function autoOrientJPEG( export type CdnFieldsType = Pick< AttachmentType, - 'cdnId' | 'cdnKey' | 'cdnNumber' | 'key' | 'digest' + 'cdnId' | 'cdnKey' | 'cdnNumber' | 'key' | 'digest' | 'plaintextHash' >; export function copyCdnFields( @@ -108,5 +108,6 @@ export function copyCdnFields( cdnNumber: dropNull(uploaded.cdnNumber), key: Bytes.toBase64(uploaded.key), digest: Bytes.toBase64(uploaded.digest), + plaintextHash: uploaded.plaintextHash, }; } diff --git a/ts/util/uploadAttachment.ts b/ts/util/uploadAttachment.ts index 555510a034f..1b50d2b30a2 100644 --- a/ts/util/uploadAttachment.ts +++ b/ts/util/uploadAttachment.ts @@ -30,6 +30,7 @@ export async function uploadAttachment( key: keys, size, digest: encrypted.digest, + plaintextHash: encrypted.plaintextHash, contentType: MIMETypeToString(attachment.contentType), fileName: attachment.fileName,