577 lines
15 KiB
TypeScript
577 lines
15 KiB
TypeScript
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { unlinkSync } from 'fs';
|
|
import { open } from 'fs/promises';
|
|
import {
|
|
createDecipheriv,
|
|
createCipheriv,
|
|
createHash,
|
|
createHmac,
|
|
randomBytes,
|
|
} from 'crypto';
|
|
import type { Decipher, Hash, Hmac } from 'crypto';
|
|
import type { TransformCallback } from 'stream';
|
|
import { Transform } from 'stream';
|
|
import { pipeline } from 'stream/promises';
|
|
import { ensureFile } from 'fs-extra';
|
|
import * as log from './logging/log';
|
|
import { HashType, CipherType } from './types/Crypto';
|
|
import { createName, getRelativePath } from './windows/attachments';
|
|
import { constantTimeEqual, getAttachmentSizeBucket } from './Crypto';
|
|
import { Environment } from './environment';
|
|
import type { AttachmentType } from './types/Attachment';
|
|
import type { ContextType } from './types/Message2';
|
|
import { strictAssert } from './util/assert';
|
|
import * as Errors from './types/errors';
|
|
|
|
// 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.
|
|
|
|
const IV_LENGTH = 16;
|
|
const KEY_LENGTH = 32;
|
|
const DIGEST_LENGTH = 32;
|
|
const HEX_DIGEST_LENGTH = DIGEST_LENGTH * 2;
|
|
const ATTACHMENT_MAC_LENGTH = 32;
|
|
|
|
/** @private */
|
|
export const KEY_SET_LENGTH = KEY_LENGTH + ATTACHMENT_MAC_LENGTH;
|
|
|
|
export function _generateAttachmentIv(): Uint8Array {
|
|
return randomBytes(IV_LENGTH);
|
|
}
|
|
|
|
export type EncryptedAttachmentV2 = {
|
|
path: string;
|
|
digest: Uint8Array;
|
|
plaintextHash: string;
|
|
};
|
|
|
|
export type DecryptedAttachmentV2 = {
|
|
path: string;
|
|
plaintextHash: string;
|
|
};
|
|
|
|
export async function encryptAttachmentV2({
|
|
keys,
|
|
plaintextAbsolutePath,
|
|
size,
|
|
dangerousTestOnlyIv,
|
|
}: {
|
|
keys: Readonly<Uint8Array>;
|
|
plaintextAbsolutePath: string;
|
|
size: number;
|
|
dangerousTestOnlyIv?: Readonly<Uint8Array>;
|
|
}): Promise<EncryptedAttachmentV2> {
|
|
const logId = 'encryptAttachmentV2';
|
|
|
|
// Create random output file
|
|
const relativeTargetPath = getRelativePath(createName());
|
|
const absoluteTargetPath =
|
|
window.Signal.Migrations.getAbsoluteAttachmentPath(relativeTargetPath);
|
|
|
|
const { aesKey, macKey } = splitKeys(keys);
|
|
|
|
if (dangerousTestOnlyIv && window.getEnvironment() !== Environment.Test) {
|
|
throw new Error(`${logId}: Used dangerousTestOnlyIv outside tests!`);
|
|
}
|
|
const iv = dangerousTestOnlyIv || _generateAttachmentIv();
|
|
|
|
const plaintextHash = createHash(HashType.size256);
|
|
const digest = createHash(HashType.size256);
|
|
|
|
let readFd;
|
|
let writeFd;
|
|
try {
|
|
try {
|
|
readFd = await open(plaintextAbsolutePath, 'r');
|
|
} catch (cause) {
|
|
throw new Error(`${logId}: Read path doesn't exist`, { cause });
|
|
}
|
|
try {
|
|
await ensureFile(absoluteTargetPath);
|
|
writeFd = await open(absoluteTargetPath, 'w');
|
|
} catch (cause) {
|
|
throw new Error(`${logId}: Failed to create write path`, { cause });
|
|
}
|
|
|
|
await pipeline(
|
|
readFd.createReadStream(),
|
|
peekAndUpdateHash(plaintextHash),
|
|
appendPadding(size),
|
|
createCipheriv(CipherType.AES256CBC, aesKey, iv),
|
|
prependIv(iv),
|
|
appendMac(macKey),
|
|
peekAndUpdateHash(digest),
|
|
writeFd.createWriteStream()
|
|
);
|
|
} catch (error) {
|
|
log.error(
|
|
`${logId}: Failed to encrypt attachment`,
|
|
Errors.toLogFormat(error)
|
|
);
|
|
safeUnlinkSync(absoluteTargetPath);
|
|
throw error;
|
|
} finally {
|
|
await Promise.all([readFd?.close(), writeFd?.close()]);
|
|
}
|
|
|
|
const ourPlaintextHash = plaintextHash.digest('hex');
|
|
const ourDigest = digest.digest();
|
|
|
|
strictAssert(
|
|
ourPlaintextHash.length === HEX_DIGEST_LENGTH,
|
|
`${logId}: Failed to generate plaintext hash!`
|
|
);
|
|
|
|
strictAssert(
|
|
ourDigest.byteLength === DIGEST_LENGTH,
|
|
`${logId}: Failed to generate ourDigest!`
|
|
);
|
|
|
|
return {
|
|
path: relativeTargetPath,
|
|
digest: ourDigest,
|
|
plaintextHash: ourPlaintextHash,
|
|
};
|
|
}
|
|
|
|
export async function decryptAttachmentV2({
|
|
ciphertextPath,
|
|
id,
|
|
keys,
|
|
size,
|
|
theirDigest,
|
|
}: {
|
|
ciphertextPath: string;
|
|
id: string;
|
|
keys: Readonly<Uint8Array>;
|
|
size: number;
|
|
theirDigest: Readonly<Uint8Array>;
|
|
}): Promise<DecryptedAttachmentV2> {
|
|
const logId = `decryptAttachmentV2(${id})`;
|
|
|
|
// Create random output file
|
|
const relativeTargetPath = getRelativePath(createName());
|
|
const absoluteTargetPath =
|
|
window.Signal.Migrations.getAbsoluteAttachmentPath(relativeTargetPath);
|
|
|
|
const { aesKey, macKey } = splitKeys(keys);
|
|
|
|
const digest = createHash(HashType.size256);
|
|
const hmac = createHmac(HashType.size256, macKey);
|
|
const plaintextHash = createHash(HashType.size256);
|
|
let theirMac = null as Uint8Array | null; // TypeScript shenanigans
|
|
|
|
let readFd;
|
|
let writeFd;
|
|
try {
|
|
try {
|
|
readFd = await open(ciphertextPath, 'r');
|
|
} catch (cause) {
|
|
throw new Error(`${logId}: Read path doesn't exist`, { cause });
|
|
}
|
|
try {
|
|
await ensureFile(absoluteTargetPath);
|
|
writeFd = await open(absoluteTargetPath, 'w');
|
|
} catch (cause) {
|
|
throw new Error(`${logId}: Failed to create write path`, { cause });
|
|
}
|
|
|
|
await pipeline(
|
|
readFd.createReadStream(),
|
|
peekAndUpdateHash(digest),
|
|
getMacAndUpdateHmac(hmac, theirMacValue => {
|
|
theirMac = theirMacValue;
|
|
}),
|
|
getIvAndDecipher(aesKey),
|
|
trimPadding(size),
|
|
peekAndUpdateHash(plaintextHash),
|
|
writeFd.createWriteStream()
|
|
);
|
|
} catch (error) {
|
|
log.error(
|
|
`${logId}: Failed to decrypt attachment`,
|
|
Errors.toLogFormat(error)
|
|
);
|
|
safeUnlinkSync(absoluteTargetPath);
|
|
throw error;
|
|
} finally {
|
|
await Promise.all([readFd?.close(), writeFd?.close()]);
|
|
}
|
|
|
|
const ourMac = hmac.digest();
|
|
const ourDigest = digest.digest();
|
|
const ourPlaintextHash = plaintextHash.digest('hex');
|
|
|
|
strictAssert(
|
|
ourMac.byteLength === ATTACHMENT_MAC_LENGTH,
|
|
`${logId}: Failed to generate ourMac!`
|
|
);
|
|
strictAssert(
|
|
theirMac != null && theirMac.byteLength === ATTACHMENT_MAC_LENGTH,
|
|
`${logId}: Failed to find theirMac!`
|
|
);
|
|
strictAssert(
|
|
ourDigest.byteLength === DIGEST_LENGTH,
|
|
`${logId}: Failed to generate ourDigest!`
|
|
);
|
|
strictAssert(
|
|
ourPlaintextHash.length === HEX_DIGEST_LENGTH,
|
|
`${logId}: Failed to generate file hash!`
|
|
);
|
|
|
|
if (!constantTimeEqual(ourMac, theirMac)) {
|
|
throw new Error(`${logId}: Bad MAC`);
|
|
}
|
|
|
|
if (!constantTimeEqual(ourDigest, theirDigest)) {
|
|
throw new Error(`${logId}: Bad digest`);
|
|
}
|
|
|
|
return {
|
|
path: relativeTargetPath,
|
|
plaintextHash: ourPlaintextHash,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Splits the keys into aes and mac keys.
|
|
*/
|
|
function splitKeys(keys: Uint8Array) {
|
|
strictAssert(
|
|
keys.byteLength === KEY_SET_LENGTH,
|
|
`attachment keys must be ${KEY_SET_LENGTH} bytes, got ${keys.byteLength}`
|
|
);
|
|
const aesKey = keys.subarray(0, KEY_LENGTH);
|
|
const macKey = keys.subarray(KEY_LENGTH, KEY_SET_LENGTH);
|
|
return { aesKey, macKey };
|
|
}
|
|
|
|
/**
|
|
* Updates a hash of the stream without modifying it.
|
|
*/
|
|
function peekAndUpdateHash(hash: Hash) {
|
|
return new Transform({
|
|
transform(chunk, _encoding, callback) {
|
|
try {
|
|
hash.update(chunk);
|
|
callback(null, chunk);
|
|
} catch (error) {
|
|
callback(error);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
function getMacAndUpdateHmac(
|
|
hmac: Hmac,
|
|
onTheirMac: (theirMac: Uint8Array) => void
|
|
) {
|
|
// 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
|
|
// `hmac.update` so we only push what we know is not the mac.
|
|
let maybeMacBytes = Buffer.alloc(0);
|
|
|
|
function updateWithKnownNonMacBytes() {
|
|
let knownNonMacBytes = null;
|
|
if (maybeMacBytes.byteLength > ATTACHMENT_MAC_LENGTH) {
|
|
knownNonMacBytes = maybeMacBytes.subarray(0, -ATTACHMENT_MAC_LENGTH);
|
|
maybeMacBytes = maybeMacBytes.subarray(-ATTACHMENT_MAC_LENGTH);
|
|
hmac.update(knownNonMacBytes);
|
|
}
|
|
return knownNonMacBytes;
|
|
}
|
|
|
|
return new Transform({
|
|
transform(chunk, _encoding, callback) {
|
|
try {
|
|
maybeMacBytes = Buffer.concat([maybeMacBytes, chunk]);
|
|
const knownNonMac = updateWithKnownNonMacBytes();
|
|
callback(null, knownNonMac);
|
|
} catch (error) {
|
|
callback(error);
|
|
}
|
|
},
|
|
flush(callback) {
|
|
try {
|
|
onTheirMac(maybeMacBytes);
|
|
callback(null, null);
|
|
} catch (error) {
|
|
callback(error);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets the IV from the start of the stream and creates a decipher.
|
|
* Then deciphers the rest of the stream.
|
|
*/
|
|
function getIvAndDecipher(aesKey: Uint8Array) {
|
|
let maybeIvBytes: Buffer | null = Buffer.alloc(0);
|
|
let decipher: Decipher | null = null;
|
|
return new Transform({
|
|
transform(chunk, _encoding, callback) {
|
|
try {
|
|
// If we've already initialized the decipher, just pass the chunk through.
|
|
if (decipher != null) {
|
|
callback(null, decipher.update(chunk));
|
|
return;
|
|
}
|
|
|
|
// Wait until we have enough bytes to get the iv to initialize the
|
|
// decipher.
|
|
maybeIvBytes = Buffer.concat([maybeIvBytes, chunk]);
|
|
if (maybeIvBytes.byteLength < IV_LENGTH) {
|
|
callback(null, null);
|
|
return;
|
|
}
|
|
|
|
// Once we have enough bytes, initialize the decipher and pass the
|
|
// remainder of the bytes through.
|
|
const iv = maybeIvBytes.subarray(0, IV_LENGTH);
|
|
const remainder = maybeIvBytes.subarray(IV_LENGTH);
|
|
maybeIvBytes = null; // free memory
|
|
decipher = createDecipheriv(CipherType.AES256CBC, aesKey, iv);
|
|
callback(null, decipher.update(remainder));
|
|
} catch (error) {
|
|
callback(error);
|
|
}
|
|
},
|
|
flush(callback) {
|
|
try {
|
|
strictAssert(decipher != null, 'decipher must be set');
|
|
callback(null, decipher.final());
|
|
} catch (error) {
|
|
callback(error);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Truncates the stream to the target size.
|
|
*/
|
|
function trimPadding(size: number) {
|
|
let total = 0;
|
|
return new Transform({
|
|
transform(chunk, _encoding, callback) {
|
|
const chunkSize = chunk.byteLength;
|
|
const sizeLeft = size - total;
|
|
if (sizeLeft >= chunkSize) {
|
|
total += chunkSize;
|
|
callback(null, chunk);
|
|
} else if (sizeLeft > 0) {
|
|
total += sizeLeft;
|
|
callback(null, chunk.subarray(0, sizeLeft));
|
|
} else {
|
|
callback(null, null);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export function getAttachmentDownloadSize(size: number): number {
|
|
return (
|
|
// Multiply this by 1.05 to allow some variance
|
|
getAttachmentSizeBucket(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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Appends zero-padding to the stream to a target bucket size.
|
|
*/
|
|
function appendPadding(fileSize: number) {
|
|
const iterator = generatePadding(fileSize);
|
|
let bytesWritten = 0;
|
|
let finalCallback: TransformCallback;
|
|
|
|
// Push as much padding as we can. If we reach the end
|
|
// of the padding, call the callback.
|
|
function pushPadding(transform: Transform) {
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
const result = iterator.next();
|
|
if (result.done) {
|
|
break;
|
|
}
|
|
const keepGoing = transform.push(result.value);
|
|
if (!keepGoing) {
|
|
return;
|
|
}
|
|
}
|
|
finalCallback();
|
|
}
|
|
|
|
return new Transform({
|
|
read(size) {
|
|
// When in the process of pushing padding, we pause and wait for
|
|
// read to be called again.
|
|
if (finalCallback != null) {
|
|
pushPadding(this);
|
|
}
|
|
// Always call _read, even if we're done.
|
|
Transform.prototype._read.call(this, size);
|
|
},
|
|
transform(chunk, _encoding, callback) {
|
|
bytesWritten += chunk.byteLength;
|
|
// Once we reach the end of the file, start pushing padding.
|
|
if (bytesWritten >= fileSize) {
|
|
this.push(chunk);
|
|
finalCallback = callback;
|
|
pushPadding(this);
|
|
return;
|
|
}
|
|
callback(null, chunk);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Prepends the iv to the stream.
|
|
*/
|
|
function prependIv(iv: Uint8Array) {
|
|
strictAssert(
|
|
iv.byteLength === IV_LENGTH,
|
|
`prependIv: iv should be ${IV_LENGTH} bytes, got ${iv.byteLength} bytes`
|
|
);
|
|
return new Transform({
|
|
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);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called during message schema migration. New messages downloaded should have
|
|
* plaintextHash added automatically during decryption / writing to file system.
|
|
*/
|
|
export async function addPlaintextHashToAttachment(
|
|
attachment: AttachmentType,
|
|
{ getAbsoluteAttachmentPath }: ContextType
|
|
): Promise<AttachmentType> {
|
|
if (!attachment.path) {
|
|
return attachment;
|
|
}
|
|
|
|
const plaintextHash = await getPlaintextHashForAttachmentOnDisk(
|
|
getAbsoluteAttachmentPath(attachment.path)
|
|
);
|
|
|
|
if (!plaintextHash) {
|
|
log.error('addPlaintextHashToAttachment: Failed to generate hash');
|
|
return attachment;
|
|
}
|
|
|
|
return {
|
|
...attachment,
|
|
plaintextHash,
|
|
};
|
|
}
|
|
|
|
async function getPlaintextHashForAttachmentOnDisk(
|
|
absolutePath: string
|
|
): Promise<string | undefined> {
|
|
let readFd;
|
|
try {
|
|
try {
|
|
readFd = await open(absolutePath, 'r');
|
|
} catch (error) {
|
|
log.error('addPlaintextHashToAttachment: Target path does not exist');
|
|
return undefined;
|
|
}
|
|
const hash = createHash(HashType.size256);
|
|
await pipeline(readFd.createReadStream(), hash);
|
|
const plaintextHash = hash.digest('hex');
|
|
if (!plaintextHash) {
|
|
log.error(
|
|
'addPlaintextHashToAttachment: no hash generated from file; is the file empty?'
|
|
);
|
|
return;
|
|
}
|
|
return plaintextHash;
|
|
} catch (error) {
|
|
log.error('addPlaintextHashToAttachment: error during file read', error);
|
|
return undefined;
|
|
} finally {
|
|
await readFd?.close();
|
|
}
|
|
}
|
|
|
|
export function getPlaintextHashForInMemoryAttachment(
|
|
data: Uint8Array
|
|
): string {
|
|
return createHash(HashType.size256).update(data).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Unlinks a file without throwing an error if it doesn't exist.
|
|
* Throws an error if it fails to unlink for any other reason.
|
|
*/
|
|
export function safeUnlinkSync(filePath: string): void {
|
|
try {
|
|
unlinkSync(filePath);
|
|
} catch (error) {
|
|
// Ignore if file doesn't exist
|
|
if (error.code !== 'ENOENT') {
|
|
log.error('Failed to unlink', error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|