Store IV when encrypting or decrypting attachments

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-05-28 20:53:43 -05:00 committed by GitHub
parent 65ee31efa5
commit 26b699d1cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 136 additions and 11 deletions

View file

@ -27,6 +27,7 @@ import type { ContextType } from './types/Message2';
import { strictAssert } from './util/assert'; import { strictAssert } from './util/assert';
import * as Errors from './types/errors'; import * as Errors from './types/errors';
import { isNotNil } from './util/isNotNil'; import { isNotNil } from './util/isNotNil';
import { missingCaseError } from './util/missingCaseError';
// This file was split from ts/Crypto.ts because it pulls things in from node, and // This file was split from ts/Crypto.ts because it pulls things in from node, and
// too many things pull in Crypto.ts, so it broke storybook. // too many things pull in Crypto.ts, so it broke storybook.
@ -46,12 +47,14 @@ export function _generateAttachmentIv(): Uint8Array {
export type EncryptedAttachmentV2 = { export type EncryptedAttachmentV2 = {
digest: Uint8Array; digest: Uint8Array;
iv: Uint8Array;
plaintextHash: string; plaintextHash: string;
ciphertextSize: number; ciphertextSize: number;
}; };
export type DecryptedAttachmentV2 = { export type DecryptedAttachmentV2 = {
path: string; path: string;
iv: Uint8Array;
plaintextHash: string; plaintextHash: string;
}; };
@ -59,10 +62,21 @@ export type PlaintextSourceType =
| { data: Uint8Array } | { data: Uint8Array }
| { absolutePath: string }; | { absolutePath: string };
export type HardcodedIVForEncryptionType =
| {
reason: 'test';
iv: Uint8Array;
}
| {
reason: 'reencrypting-for-backup';
iv: Uint8Array;
digestToMatch: Uint8Array;
};
type EncryptAttachmentV2PropsType = { type EncryptAttachmentV2PropsType = {
plaintext: PlaintextSourceType; plaintext: PlaintextSourceType;
keys: Readonly<Uint8Array>; keys: Readonly<Uint8Array>;
dangerousTestOnlyIv?: Readonly<Uint8Array>; dangerousIv?: HardcodedIVForEncryptionType;
dangerousTestOnlySkipPadding?: boolean; dangerousTestOnlySkipPadding?: boolean;
}; };
@ -96,7 +110,7 @@ export async function encryptAttachmentV2ToDisk(
export async function encryptAttachmentV2({ export async function encryptAttachmentV2({
keys, keys,
plaintext, plaintext,
dangerousTestOnlyIv, dangerousIv,
dangerousTestOnlySkipPadding, dangerousTestOnlySkipPadding,
sink, sink,
}: EncryptAttachmentV2PropsType & { }: EncryptAttachmentV2PropsType & {
@ -106,9 +120,26 @@ export async function encryptAttachmentV2({
const { aesKey, macKey } = splitKeys(keys); const { aesKey, macKey } = splitKeys(keys);
if (dangerousTestOnlyIv && window.getEnvironment() !== Environment.Test) { if (dangerousIv) {
throw new Error(`${logId}: Used dangerousTestOnlyIv outside tests!`); if (dangerousIv.reason === 'test') {
if (window.getEnvironment() !== Environment.Test) {
throw new Error(
`${logId}: Used dangerousIv with reason test outside tests!`
);
}
} else if (dangerousIv.reason === 'reencrypting-for-backup') {
strictAssert(
dangerousIv.digestToMatch.byteLength === DIGEST_LENGTH,
`${logId}: Must provide valid digest to match if providing iv for re-encryption`
);
log.info(
`${logId}: using hardcoded iv because we are re-encrypting for backup`
);
} else {
throw missingCaseError(dangerousIv);
}
} }
if ( if (
dangerousTestOnlySkipPadding && dangerousTestOnlySkipPadding &&
window.getEnvironment() !== Environment.Test window.getEnvironment() !== Environment.Test
@ -117,7 +148,8 @@ export async function encryptAttachmentV2({
`${logId}: Used dangerousTestOnlySkipPadding outside tests!` `${logId}: Used dangerousTestOnlySkipPadding outside tests!`
); );
} }
const iv = dangerousTestOnlyIv || _generateAttachmentIv();
const iv = dangerousIv?.iv || _generateAttachmentIv();
const plaintextHash = createHash(HashType.size256); const plaintextHash = createHash(HashType.size256);
const digest = createHash(HashType.size256); const digest = createHash(HashType.size256);
@ -167,8 +199,16 @@ export async function encryptAttachmentV2({
strictAssert(ciphertextSize != null, 'Failed to measure ciphertext size!'); strictAssert(ciphertextSize != null, 'Failed to measure ciphertext size!');
if (dangerousIv?.reason === 'reencrypting-for-backup') {
if (!constantTimeEqual(ourDigest, dangerousIv.digestToMatch)) {
throw new Error(
`${logId}: iv was hardcoded for backup re-encryption, but digest does not match`
);
}
}
return { return {
digest: ourDigest, digest: ourDigest,
iv,
plaintextHash: ourPlaintextHash, plaintextHash: ourPlaintextHash,
ciphertextSize, ciphertextSize,
}; };
@ -230,6 +270,7 @@ export async function decryptAttachmentV2(
let readFd; let readFd;
let writeFd; let writeFd;
let iv: Uint8Array | undefined;
try { try {
try { try {
readFd = await open(ciphertextPath, 'r'); readFd = await open(ciphertextPath, 'r');
@ -252,7 +293,9 @@ export async function decryptAttachmentV2(
getMacAndUpdateHmac(hmac, theirMacValue => { getMacAndUpdateHmac(hmac, theirMacValue => {
theirMac = theirMacValue; theirMac = theirMacValue;
}), }),
getIvAndDecipher(aesKey), getIvAndDecipher(aesKey, theirIv => {
iv = theirIv;
}),
trimPadding(options.size), trimPadding(options.size),
peekAndUpdateHash(plaintextHash), peekAndUpdateHash(plaintextHash),
writeFd.createWriteStream(), writeFd.createWriteStream(),
@ -297,6 +340,11 @@ export async function decryptAttachmentV2(
throw new Error(`${logId}: Bad digest`); throw new Error(`${logId}: Bad digest`);
} }
strictAssert(
iv != null && iv.byteLength === IV_LENGTH,
`${logId}: failed to find their iv`
);
if (outerEncryption) { if (outerEncryption) {
strictAssert(outerHmac, 'outerHmac must exist'); strictAssert(outerHmac, 'outerHmac must exist');
@ -318,6 +366,7 @@ export async function decryptAttachmentV2(
return { return {
path: relativeTargetPath, path: relativeTargetPath,
iv,
plaintextHash: ourPlaintextHash, plaintextHash: ourPlaintextHash,
}; };
} }
@ -404,7 +453,10 @@ export 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.
*/ */
export function getIvAndDecipher(aesKey: Uint8Array): Transform { export function getIvAndDecipher(
aesKey: Uint8Array,
onFoundIv?: (iv: Buffer) => void
): 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({
@ -428,6 +480,7 @@ export function getIvAndDecipher(aesKey: Uint8Array): Transform {
// remainder of the bytes through. // remainder of the bytes through.
const iv = maybeIvBytes.subarray(0, IV_LENGTH); const iv = maybeIvBytes.subarray(0, IV_LENGTH);
const remainder = maybeIvBytes.subarray(IV_LENGTH); const remainder = maybeIvBytes.subarray(IV_LENGTH);
onFoundIv?.(iv);
maybeIvBytes = null; // free memory maybeIvBytes = null; // free memory
decipher = createDecipheriv(CipherType.AES256CBC, aesKey, iv); decipher = createDecipheriv(CipherType.AES256CBC, aesKey, iv);
callback(null, decipher.update(remainder)); callback(null, decipher.update(remainder));

View file

@ -37,6 +37,7 @@ import {
CipherType, CipherType,
} from '../Crypto'; } from '../Crypto';
import { import {
type HardcodedIVForEncryptionType,
KEY_SET_LENGTH, KEY_SET_LENGTH,
_generateAttachmentIv, _generateAttachmentIv,
decryptAttachmentV2, decryptAttachmentV2,
@ -608,19 +609,24 @@ describe('Crypto', () => {
path, path,
data, data,
plaintextHash, plaintextHash,
encryptionKeys,
dangerousIv,
}: { }: {
path?: string; path?: string;
data: Uint8Array; data: Uint8Array;
plaintextHash: Uint8Array; plaintextHash: Uint8Array;
encryptionKeys?: Uint8Array;
dangerousIv?: HardcodedIVForEncryptionType;
}): Promise<void> { }): Promise<void> {
let plaintextPath; let plaintextPath;
let ciphertextPath; let ciphertextPath;
const keys = generateAttachmentKeys(); const keys = encryptionKeys ?? generateAttachmentKeys();
try { try {
const encryptedAttachment = await encryptAttachmentV2ToDisk({ const encryptedAttachment = await encryptAttachmentV2ToDisk({
keys, keys,
plaintext: path ? { absolutePath: path } : { data }, plaintext: path ? { absolutePath: path } : { data },
dangerousIv,
}); });
ciphertextPath = window.Signal.Migrations.getAbsoluteAttachmentPath( ciphertextPath = window.Signal.Migrations.getAbsoluteAttachmentPath(
@ -639,6 +645,21 @@ describe('Crypto', () => {
); );
const plaintext = readFileSync(plaintextPath); const plaintext = readFileSync(plaintextPath);
assert.deepStrictEqual(
encryptedAttachment.iv,
decryptedAttachment.iv
);
if (dangerousIv) {
assert.deepStrictEqual(encryptedAttachment.iv, dangerousIv.iv);
if (dangerousIv.reason === 'reencrypting-for-backup') {
assert.deepStrictEqual(
encryptedAttachment.digest,
dangerousIv.digestToMatch
);
}
}
assert.isTrue(constantTimeEqual(data, plaintext)); assert.isTrue(constantTimeEqual(data, plaintext));
assert.strictEqual( assert.strictEqual(
encryptedAttachment.ciphertextSize, encryptedAttachment.ciphertextSize,
@ -711,6 +732,52 @@ describe('Crypto', () => {
plaintextHash, plaintextHash,
}); });
}); });
describe('dangerousIv', () => {
it('uses hardcodedIv in tests', async () => {
await testV2RoundTripData({
data: FILE_CONTENTS,
plaintextHash: FILE_HASH,
dangerousIv: {
reason: 'test',
iv: _generateAttachmentIv(),
},
});
});
it('uses hardcodedIv when re-encrypting for backup', async () => {
const keys = generateAttachmentKeys();
const previouslyEncrypted = await encryptAttachmentV2ToDisk({
keys,
plaintext: { data: FILE_CONTENTS },
});
await testV2RoundTripData({
data: FILE_CONTENTS,
plaintextHash: FILE_HASH,
encryptionKeys: keys,
dangerousIv: {
reason: 'reencrypting-for-backup',
iv: previouslyEncrypted.iv,
digestToMatch: previouslyEncrypted.digest,
},
});
// If the digest is wrong, it should throw
await assert.isRejected(
testV2RoundTripData({
data: FILE_CONTENTS,
plaintextHash: FILE_HASH,
encryptionKeys: keys,
dangerousIv: {
reason: 'reencrypting-for-backup',
iv: previouslyEncrypted.iv,
digestToMatch: getRandomBytes(32),
},
}),
'iv was hardcoded for backup re-encryption, but digest does not match'
);
});
});
}); });
it('v2 -> v1 (disk -> memory)', async () => { it('v2 -> v1 (disk -> memory)', async () => {
@ -774,7 +841,7 @@ describe('Crypto', () => {
const encryptedAttachmentV2 = await encryptAttachmentV2ToDisk({ const encryptedAttachmentV2 = await encryptAttachmentV2ToDisk({
keys, keys,
plaintext: { absolutePath: FILE_PATH }, plaintext: { absolutePath: FILE_PATH },
dangerousTestOnlyIv, dangerousIv: { iv: dangerousTestOnlyIv, reason: 'test' },
}); });
ciphertextPath = window.Signal.Migrations.getAbsoluteAttachmentPath( ciphertextPath = window.Signal.Migrations.getAbsoluteAttachmentPath(
encryptedAttachmentV2.path encryptedAttachmentV2.path

View file

@ -132,7 +132,7 @@ export async function downloadAttachment(
window.Signal.Migrations.getAbsoluteAttachmentPath(downloadedPath); window.Signal.Migrations.getAbsoluteAttachmentPath(downloadedPath);
const { aesKey, macKey } = splitKeys(Bytes.fromBase64(key)); const { aesKey, macKey } = splitKeys(Bytes.fromBase64(key));
const { path, plaintextHash } = await decryptAttachmentV2({ const { path, plaintextHash, iv } = await decryptAttachmentV2({
ciphertextPath: cipherTextAbsolutePath, ciphertextPath: cipherTextAbsolutePath,
idForLogging: logId, idForLogging: logId,
aesKey, aesKey,
@ -155,6 +155,7 @@ export async function downloadAttachment(
? MIME.stringToMIMEType(contentType) ? MIME.stringToMIMEType(contentType)
: MIME.APPLICATION_OCTET_STREAM, : MIME.APPLICATION_OCTET_STREAM,
plaintextHash, plaintextHash,
iv: Bytes.toBase64(iv),
}; };
} }

View file

@ -74,6 +74,7 @@ export type AttachmentType = {
cdnId?: string; cdnId?: string;
cdnKey?: string; cdnKey?: string;
key?: string; key?: string;
iv?: string;
data?: Uint8Array; data?: Uint8Array;
textAttachment?: TextAttachmentType; textAttachment?: TextAttachmentType;
wasTooBig?: boolean; wasTooBig?: boolean;
@ -97,6 +98,7 @@ export type UploadedAttachmentType = Proto.IAttachmentPointer &
Readonly<{ Readonly<{
// Required fields // Required fields
cdnKey: string; cdnKey: string;
iv: Uint8Array;
key: Uint8Array; key: Uint8Array;
size: number; size: number;
digest: Uint8Array; digest: Uint8Array;

View file

@ -77,7 +77,7 @@ export const downscaleOutgoingAttachment = async (
export type CdnFieldsType = Pick< export type CdnFieldsType = Pick<
AttachmentType, AttachmentType,
'cdnId' | 'cdnKey' | 'cdnNumber' | 'key' | 'digest' | 'plaintextHash' 'cdnId' | 'cdnKey' | 'cdnNumber' | 'key' | 'digest' | 'iv' | 'plaintextHash'
>; >;
export function copyCdnFields( export function copyCdnFields(
@ -91,6 +91,7 @@ export function copyCdnFields(
cdnKey: uploaded.cdnKey, cdnKey: uploaded.cdnKey,
cdnNumber: dropNull(uploaded.cdnNumber), cdnNumber: dropNull(uploaded.cdnNumber),
key: Bytes.toBase64(uploaded.key), key: Bytes.toBase64(uploaded.key),
iv: Bytes.toBase64(uploaded.iv),
digest: Bytes.toBase64(uploaded.digest), digest: Bytes.toBase64(uploaded.digest),
plaintextHash: uploaded.plaintextHash, plaintextHash: uploaded.plaintextHash,
}; };

View file

@ -40,6 +40,7 @@ export async function uploadAttachment(
cdnKey, cdnKey,
cdnNumber, cdnNumber,
key: keys, key: keys,
iv: encrypted.iv,
size: attachment.data.byteLength, size: attachment.data.byteLength,
digest: encrypted.digest, digest: encrypted.digest,
plaintextHash: encrypted.plaintextHash, plaintextHash: encrypted.plaintextHash,