Store plaintext hash with newly sent or received attachments

This commit is contained in:
trevor-signal 2023-11-17 15:02:02 -05:00 committed by GitHub
parent 48245eeea6
commit b7ab1d7207
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 70 additions and 19 deletions

View file

@ -43,6 +43,12 @@ export const ATTACHMENT_MAC_LENGTH = 32;
export type EncryptedAttachmentV2 = { export type EncryptedAttachmentV2 = {
path: string; path: string;
digest: Uint8Array; digest: Uint8Array;
plaintextHash: string;
};
export type DecryptedAttachmentV2 = {
path: string;
plaintextHash: string;
}; };
export async function encryptAttachmentV2({ export async function encryptAttachmentV2({
@ -82,6 +88,7 @@ export async function encryptAttachmentV2({
} }
const iv = dangerousTestOnlyIv || getRandomBytes(16); const iv = dangerousTestOnlyIv || getRandomBytes(16);
const plaintextHashTransform = new DigestTransform();
const addPaddingTransform = new AddPaddingTransform(size); const addPaddingTransform = new AddPaddingTransform(size);
const cipherTransform = new CipherTransform(iv, aesKey); const cipherTransform = new CipherTransform(iv, aesKey);
const addIvTransform = new AddIvTransform(iv); const addIvTransform = new AddIvTransform(iv);
@ -91,6 +98,7 @@ export async function encryptAttachmentV2({
try { try {
await pipeline( await pipeline(
readStream, readStream,
plaintextHashTransform,
addPaddingTransform, addPaddingTransform,
cipherTransform, cipherTransform,
addIvTransform, addIvTransform,
@ -116,7 +124,12 @@ export async function encryptAttachmentV2({
throw error; 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) { if (!ourDigest || !ourDigest.byteLength) {
throw new Error(`${logId}: Failed to generate ourDigest!`); throw new Error(`${logId}: Failed to generate ourDigest!`);
} }
@ -127,6 +140,7 @@ export async function encryptAttachmentV2({
return { return {
path: relativeTargetPath, path: relativeTargetPath,
digest: ourDigest, digest: ourDigest,
plaintextHash: Buffer.from(plaintextHash).toString('hex'),
}; };
} }
@ -142,7 +156,7 @@ export async function decryptAttachmentV2({
keys: Readonly<Uint8Array>; keys: Readonly<Uint8Array>;
size: number; size: number;
theirDigest: Readonly<Uint8Array>; theirDigest: Readonly<Uint8Array>;
}): Promise<string> { }): Promise<DecryptedAttachmentV2> {
const logId = `decryptAttachmentV2(${id})`; const logId = `decryptAttachmentV2(${id})`;
if (keys.byteLength !== KEY_LENGTH * 2) { if (keys.byteLength !== KEY_LENGTH * 2) {
throw new Error(`${logId}: Got invalid length attachment keys`); throw new Error(`${logId}: Got invalid length attachment keys`);
@ -171,6 +185,7 @@ export async function decryptAttachmentV2({
decipherTransform decipherTransform
); );
const limitLengthTransform = new LimitLengthTransform(size); const limitLengthTransform = new LimitLengthTransform(size);
const plaintextHashTransform = new DigestTransform();
try { try {
await pipeline( await pipeline(
@ -180,6 +195,7 @@ export async function decryptAttachmentV2({
coreDecryptionTransform, coreDecryptionTransform,
decipherTransform, decipherTransform,
limitLengthTransform, limitLengthTransform,
plaintextHashTransform,
writeStream writeStream
); );
} catch (error) { } catch (error) {
@ -212,7 +228,7 @@ export async function decryptAttachmentV2({
throw new Error(`${logId}: Bad MAC`); throw new Error(`${logId}: Bad MAC`);
} }
const { ourDigest } = digestTransform; const { digest: ourDigest } = digestTransform;
if (!ourDigest || !ourDigest.byteLength) { if (!ourDigest || !ourDigest.byteLength) {
throw new Error(`${logId}: Failed to generate ourDigest!`); throw new Error(`${logId}: Failed to generate ourDigest!`);
} }
@ -220,17 +236,25 @@ export async function decryptAttachmentV2({
throw new Error(`${logId}: Bad digest`); 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(); writeStream.close();
readStream.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 // A very simple transform that doesn't modify the stream, but does calculate a digest
// across all data it gets. // across all data it gets.
class DigestTransform extends Transform { class DigestTransform extends Transform {
private digestBuilder: Hash; private digestBuilder: Hash;
public ourDigest: Uint8Array | undefined; public digest: Uint8Array | undefined;
constructor() { constructor() {
super(); super();
@ -239,7 +263,7 @@ class DigestTransform extends Transform {
override _flush(done: (error?: Error) => void) { override _flush(done: (error?: Error) => void) {
try { try {
this.ourDigest = this.digestBuilder.digest(); this.digest = this.digestBuilder.digest();
} catch (error) { } catch (error) {
done(error); done(error);
return; return;

View file

@ -28,6 +28,7 @@ export const PaddedLengths = {
export type EncryptedAttachment = { export type EncryptedAttachment = {
ciphertext: Uint8Array; ciphertext: Uint8Array;
digest: Uint8Array; digest: Uint8Array;
plaintextHash: string;
}; };
export function generateRegistrationId(): number { export function generateRegistrationId(): number {
@ -426,7 +427,7 @@ export function encryptAttachment({
plaintext: Readonly<Uint8Array>; plaintext: Readonly<Uint8Array>;
keys: Readonly<Uint8Array>; keys: Readonly<Uint8Array>;
dangerousTestOnlyIv?: Readonly<Uint8Array>; dangerousTestOnlyIv?: Readonly<Uint8Array>;
}): EncryptedAttachment { }): Omit<EncryptedAttachment, 'plaintextHash'> {
const logId = 'encryptAttachment'; const logId = 'encryptAttachment';
if (!(plaintext instanceof Uint8Array)) { if (!(plaintext instanceof Uint8Array)) {
throw new TypeError( throw new TypeError(
@ -481,11 +482,17 @@ export function padAndEncryptAttachment({
const paddedSize = getAttachmentSizeBucket(size); const paddedSize = getAttachmentSizeBucket(size);
const padding = getZeroes(paddedSize - size); const padding = getZeroes(paddedSize - size);
return encryptAttachment({ return {
plaintext: Bytes.concatenate([plaintext, padding]), ...encryptAttachment({
keys, plaintext: Bytes.concatenate([plaintext, padding]),
dangerousTestOnlyIv, 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 { export function encryptProfile(data: Uint8Array, key: Uint8Array): Uint8Array {

View file

@ -70,6 +70,9 @@ const BUCKET_SIZES = [
80095580, 84100359, 88305377, 92720646, 97356678, 102224512, 107335738, 80095580, 84100359, 88305377, 92720646, 97356678, 102224512, 107335738,
]; ];
const GHOST_KITTY_HASH =
'7bc77f27d92d00b4a1d57c480ca86dacc43d57bc318339c92119d1fbf6b557a5';
describe('Crypto', () => { describe('Crypto', () => {
describe('encrypting and decrypting profile data', () => { describe('encrypting and decrypting profile data', () => {
const NAME_PADDED_LENGTH = 53; const NAME_PADDED_LENGTH = 53;
@ -638,9 +641,11 @@ describe('Crypto', () => {
plaintext: FILE_CONTENTS, plaintext: FILE_CONTENTS,
keys, keys,
}); });
assert.strictEqual(encryptedAttachment.plaintextHash, GHOST_KITTY_HASH);
writeFileSync(ciphertextPath, encryptedAttachment.ciphertext); writeFileSync(ciphertextPath, encryptedAttachment.ciphertext);
const plaintextRelativePath = await decryptAttachmentV2({ const decryptedAttachment = await decryptAttachmentV2({
ciphertextPath, ciphertextPath,
id: 'test', id: 'test',
keys, keys,
@ -648,11 +653,15 @@ describe('Crypto', () => {
theirDigest: encryptedAttachment.digest, theirDigest: encryptedAttachment.digest,
}); });
plaintextPath = window.Signal.Migrations.getAbsoluteAttachmentPath( plaintextPath = window.Signal.Migrations.getAbsoluteAttachmentPath(
plaintextRelativePath decryptedAttachment.path
); );
const plaintext = readFileSync(plaintextPath); const plaintext = readFileSync(plaintextPath);
assert.isTrue(constantTimeEqual(FILE_CONTENTS, plaintext)); assert.isTrue(constantTimeEqual(FILE_CONTENTS, plaintext));
assert.strictEqual(
encryptedAttachment.plaintextHash,
decryptedAttachment.plaintextHash
);
} finally { } finally {
if (plaintextPath) { if (plaintextPath) {
unlinkSync(plaintextPath); unlinkSync(plaintextPath);
@ -675,7 +684,7 @@ describe('Crypto', () => {
ciphertextPath = window.Signal.Migrations.getAbsoluteAttachmentPath( ciphertextPath = window.Signal.Migrations.getAbsoluteAttachmentPath(
encryptedAttachment.path encryptedAttachment.path
); );
const plaintextRelativePath = await decryptAttachmentV2({ const decryptedAttachment = await decryptAttachmentV2({
ciphertextPath, ciphertextPath,
id: 'test', id: 'test',
keys, keys,
@ -683,11 +692,17 @@ describe('Crypto', () => {
theirDigest: encryptedAttachment.digest, theirDigest: encryptedAttachment.digest,
}); });
plaintextPath = window.Signal.Migrations.getAbsoluteAttachmentPath( plaintextPath = window.Signal.Migrations.getAbsoluteAttachmentPath(
plaintextRelativePath decryptedAttachment.path
); );
const plaintext = readFileSync(plaintextPath); const plaintext = readFileSync(plaintextPath);
assert.isTrue(constantTimeEqual(FILE_CONTENTS, plaintext)); assert.isTrue(constantTimeEqual(FILE_CONTENTS, plaintext));
assert.strictEqual(encryptedAttachment.plaintextHash, GHOST_KITTY_HASH);
assert.strictEqual(
decryptedAttachment.plaintextHash,
encryptedAttachment.plaintextHash
);
} finally { } finally {
if (plaintextPath) { if (plaintextPath) {
unlinkSync(plaintextPath); unlinkSync(plaintextPath);

View file

@ -116,7 +116,7 @@ export async function downloadAttachmentV2(
const cipherTextAbsolutePath = const cipherTextAbsolutePath =
window.Signal.Migrations.getAbsoluteAttachmentPath(cipherTextRelativePath); window.Signal.Migrations.getAbsoluteAttachmentPath(cipherTextRelativePath);
const relativePath = await decryptAttachmentV2({ const { path, plaintextHash } = await decryptAttachmentV2({
ciphertextPath: cipherTextAbsolutePath, ciphertextPath: cipherTextAbsolutePath,
id: cdn, id: cdn,
keys: Bytes.fromBase64(key), keys: Bytes.fromBase64(key),
@ -130,11 +130,12 @@ export async function downloadAttachmentV2(
return { return {
...omit(attachment, 'key'), ...omit(attachment, 'key'),
path: relativePath, path,
size, size,
contentType: contentType contentType: contentType
? MIME.stringToMIMEType(contentType) ? MIME.stringToMIMEType(contentType)
: MIME.APPLICATION_OCTET_STREAM, : MIME.APPLICATION_OCTET_STREAM,
plaintextHash,
}; };
} }

View file

@ -46,6 +46,7 @@ export type AttachmentType = {
contentType: MIME.MIMEType; contentType: MIME.MIMEType;
digest?: string; digest?: string;
fileName?: string; fileName?: string;
plaintextHash?: string;
uploadTimestamp?: number; uploadTimestamp?: number;
/** Not included in protobuf, needs to be pulled from flags */ /** Not included in protobuf, needs to be pulled from flags */
isVoiceMessage?: boolean; isVoiceMessage?: boolean;
@ -94,6 +95,7 @@ export type UploadedAttachmentType = Proto.IAttachmentPointer &
size: number; size: number;
digest: Uint8Array; digest: Uint8Array;
contentType: string; contentType: string;
plaintextHash: string;
}>; }>;
export type AttachmentWithHydratedData = AttachmentType & { export type AttachmentWithHydratedData = AttachmentType & {

View file

@ -93,7 +93,7 @@ export async function autoOrientJPEG(
export type CdnFieldsType = Pick< export type CdnFieldsType = Pick<
AttachmentType, AttachmentType,
'cdnId' | 'cdnKey' | 'cdnNumber' | 'key' | 'digest' 'cdnId' | 'cdnKey' | 'cdnNumber' | 'key' | 'digest' | 'plaintextHash'
>; >;
export function copyCdnFields( export function copyCdnFields(
@ -108,5 +108,6 @@ export function copyCdnFields(
cdnNumber: dropNull(uploaded.cdnNumber), cdnNumber: dropNull(uploaded.cdnNumber),
key: Bytes.toBase64(uploaded.key), key: Bytes.toBase64(uploaded.key),
digest: Bytes.toBase64(uploaded.digest), digest: Bytes.toBase64(uploaded.digest),
plaintextHash: uploaded.plaintextHash,
}; };
} }

View file

@ -30,6 +30,7 @@ export async function uploadAttachment(
key: keys, key: keys,
size, size,
digest: encrypted.digest, digest: encrypted.digest,
plaintextHash: encrypted.plaintextHash,
contentType: MIMETypeToString(attachment.contentType), contentType: MIMETypeToString(attachment.contentType),
fileName: attachment.fileName, fileName: attachment.fileName,