Store plaintext hash with newly sent or received attachments
This commit is contained in:
parent
48245eeea6
commit
b7ab1d7207
7 changed files with 70 additions and 19 deletions
|
@ -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;
|
||||||
|
|
19
ts/Crypto.ts
19
ts/Crypto.ts
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 & {
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue