Make finalization part of the stream

This commit is contained in:
Fedor Indutny 2024-07-19 19:17:02 -07:00 committed by GitHub
parent c93a211595
commit 86b4da1ec2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 122 additions and 55 deletions

View file

@ -21,6 +21,7 @@ import { createName, getRelativePath } from './util/attachmentPath';
import { appendPaddingStream, logPadSize } from './util/logPadding';
import { prependStream } from './util/prependStream';
import { appendMacStream } from './util/appendMacStream';
import { finalStream } from './util/finalStream';
import { getIvAndDecipher } from './util/getIvAndDecipher';
import { getMacAndUpdateHmac } from './util/getMacAndUpdateHmac';
import { trimPadding } from './util/trimPadding';
@ -378,6 +379,65 @@ export async function decryptAttachmentV2ToSink(
}),
trimPadding(options.size),
peekAndUpdateHash(plaintextHash),
finalStream(() => {
const ourMac = hmac.digest();
const ourDigest = digest.digest();
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!`
);
if (!constantTimeEqual(ourMac, theirMac)) {
throw new Error(`${logId}: Bad MAC`);
}
const { type } = options;
switch (type) {
case 'local':
case 'backupThumbnail':
log.info(
`${logId}: skipping digest check since this is a ${type} attachment`
);
break;
case 'standard':
if (!constantTimeEqual(ourDigest, options.theirDigest)) {
throw new Error(`${logId}: Bad digest`);
}
break;
default:
throw missingCaseError(type);
}
if (!outerEncryption) {
return;
}
strictAssert(outerHmac, 'outerHmac must exist');
const ourOuterMac = outerHmac.digest();
strictAssert(
ourOuterMac.byteLength === ATTACHMENT_MAC_LENGTH,
`${logId}: Failed to generate ourOuterMac!`
);
strictAssert(
theirOuterMac != null &&
theirOuterMac.byteLength === ATTACHMENT_MAC_LENGTH,
`${logId}: Failed to find theirOuterMac!`
);
if (!constantTimeEqual(ourOuterMac, theirOuterMac)) {
throw new Error(`${logId}: Bad outer encryption MAC`);
}
}),
sink,
].filter(isNotNil)
);
@ -397,72 +457,17 @@ export async function decryptAttachmentV2ToSink(
await readFd?.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`);
}
const { type } = options;
switch (type) {
case 'local':
case 'backupThumbnail':
log.info(
`${logId}: skipping digest check since this is a ${type} attachment`
);
break;
case 'standard':
if (!constantTimeEqual(ourDigest, options.theirDigest)) {
throw new Error(`${logId}: Bad digest`);
}
break;
default:
throw missingCaseError(type);
}
strictAssert(
iv != null && iv.byteLength === IV_LENGTH,
`${logId}: failed to find their iv`
);
if (outerEncryption) {
strictAssert(outerHmac, 'outerHmac must exist');
const ourOuterMac = outerHmac.digest();
strictAssert(
ourOuterMac.byteLength === ATTACHMENT_MAC_LENGTH,
`${logId}: Failed to generate ourOuterMac!`
);
strictAssert(
theirOuterMac != null &&
theirOuterMac.byteLength === ATTACHMENT_MAC_LENGTH,
`${logId}: Failed to find theirOuterMac!`
);
if (!constantTimeEqual(ourOuterMac, theirOuterMac)) {
throw new Error(`${logId}: Bad outer encryption MAC`);
}
}
return {
iv,
plaintextHash: ourPlaintextHash,

View file

@ -0,0 +1,40 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { finalStream } from '../../util/finalStream';
describe('finalStream', () => {
it('should invoke callback before pipeline resolves', async () => {
let called = false;
await pipeline(
Readable.from(['abc']),
finalStream(async () => {
// Forcing next tick
await Promise.resolve();
called = true;
})
);
assert.isTrue(called);
});
it('should propagate errors from callback', async () => {
await assert.isRejected(
pipeline(
Readable.from(['abc']),
finalStream(async () => {
// Forcing next tick
await Promise.resolve();
throw new Error('failure');
})
),
'failure'
);
});
});

22
ts/util/finalStream.ts Normal file
View file

@ -0,0 +1,22 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { Transform } from 'node:stream';
export function finalStream(finalizer: () => Promise<void> | void): Transform {
return new Transform({
transform(data, enc, callback) {
this.push(Buffer.from(data, enc));
callback();
},
async final(callback) {
try {
await finalizer();
} catch (error) {
callback(error);
return;
}
callback(null);
},
});
}