Skip to content

Commit

Permalink
Store plaintext hash with newly sent or received attachments
Browse files Browse the repository at this point in the history
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
  • Loading branch information
automated-signal and trevor-signal committed Nov 20, 2023
1 parent 82943df commit a0c09af
Show file tree
Hide file tree
Showing 7 changed files with 70 additions and 19 deletions.
36 changes: 30 additions & 6 deletions ts/AttachmentCrypto.ts
Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand All @@ -91,6 +98,7 @@ export async function encryptAttachmentV2({
try {
await pipeline(
readStream,
plaintextHashTransform,
addPaddingTransform,
cipherTransform,
addIvTransform,
Expand All @@ -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!`);
}
Expand All @@ -127,6 +140,7 @@ export async function encryptAttachmentV2({
return {
path: relativeTargetPath,
digest: ourDigest,
plaintextHash: Buffer.from(plaintextHash).toString('hex'),
};
}

Expand All @@ -142,7 +156,7 @@ export async function decryptAttachmentV2({
keys: Readonly<Uint8Array>;
size: number;
theirDigest: Readonly<Uint8Array>;
}): Promise<string> {
}): Promise<DecryptedAttachmentV2> {
const logId = `decryptAttachmentV2(${id})`;
if (keys.byteLength !== KEY_LENGTH * 2) {
throw new Error(`${logId}: Got invalid length attachment keys`);
Expand Down Expand Up @@ -171,6 +185,7 @@ export async function decryptAttachmentV2({
decipherTransform
);
const limitLengthTransform = new LimitLengthTransform(size);
const plaintextHashTransform = new DigestTransform();

try {
await pipeline(
Expand All @@ -180,6 +195,7 @@ export async function decryptAttachmentV2({
coreDecryptionTransform,
decipherTransform,
limitLengthTransform,
plaintextHashTransform,
writeStream
);
} catch (error) {
Expand Down Expand Up @@ -212,25 +228,33 @@ 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!`);
}
if (!constantTimeEqual(ourDigest, theirDigest)) {
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();
Expand All @@ -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;
Expand Down
19 changes: 13 additions & 6 deletions ts/Crypto.ts
Expand Up @@ -28,6 +28,7 @@ export const PaddedLengths = {
export type EncryptedAttachment = {
ciphertext: Uint8Array;
digest: Uint8Array;
plaintextHash: string;
};

export function generateRegistrationId(): number {
Expand Down Expand Up @@ -426,7 +427,7 @@ export function encryptAttachment({
plaintext: Readonly<Uint8Array>;
keys: Readonly<Uint8Array>;
dangerousTestOnlyIv?: Readonly<Uint8Array>;
}): EncryptedAttachment {
}): Omit<EncryptedAttachment, 'plaintextHash'> {
const logId = 'encryptAttachment';
if (!(plaintext instanceof Uint8Array)) {
throw new TypeError(
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 19 additions & 4 deletions ts/test-electron/Crypto_test.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -638,21 +641,27 @@ 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,
size: FILE_CONTENTS.byteLength,
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);
Expand All @@ -675,19 +684,25 @@ describe('Crypto', () => {
ciphertextPath = window.Signal.Migrations.getAbsoluteAttachmentPath(
encryptedAttachment.path
);
const plaintextRelativePath = await decryptAttachmentV2({
const decryptedAttachment = await decryptAttachmentV2({
ciphertextPath,
id: 'test',
keys,
size: FILE_CONTENTS.byteLength,
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);
Expand Down
5 changes: 3 additions & 2 deletions ts/textsecure/downloadAttachment.ts
Expand Up @@ -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),
Expand All @@ -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,
};
}

Expand Down
2 changes: 2 additions & 0 deletions ts/types/Attachment.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -94,6 +95,7 @@ export type UploadedAttachmentType = Proto.IAttachmentPointer &
size: number;
digest: Uint8Array;
contentType: string;
plaintextHash: string;
}>;

export type AttachmentWithHydratedData = AttachmentType & {
Expand Down
3 changes: 2 additions & 1 deletion ts/util/attachments.ts
Expand Up @@ -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(
Expand All @@ -108,5 +108,6 @@ export function copyCdnFields(
cdnNumber: dropNull(uploaded.cdnNumber),
key: Bytes.toBase64(uploaded.key),
digest: Bytes.toBase64(uploaded.digest),
plaintextHash: uploaded.plaintextHash,
};
}
1 change: 1 addition & 0 deletions ts/util/uploadAttachment.ts
Expand Up @@ -30,6 +30,7 @@ export async function uploadAttachment(
key: keys,
size,
digest: encrypted.digest,
plaintextHash: encrypted.plaintextHash,

contentType: MIMETypeToString(attachment.contentType),
fileName: attachment.fileName,
Expand Down

0 comments on commit a0c09af

Please sign in to comment.