diff --git a/lib/tdf3/src/ciphers/aes-gcm-cipher.ts b/lib/tdf3/src/ciphers/aes-gcm-cipher.ts index 494db1a4b..854f58164 100644 --- a/lib/tdf3/src/ciphers/aes-gcm-cipher.ts +++ b/lib/tdf3/src/ciphers/aes-gcm-cipher.ts @@ -1,6 +1,7 @@ import { Binary } from '../binary.js'; import { Algorithms } from './algorithms.js'; import { SymmetricCipher } from './symmetric-cipher-base.js'; +import { decryptBufferSource } from '../crypto/core/symmetric.js'; import { concatUint8 } from '../utils/index.js'; import { @@ -16,20 +17,17 @@ const IV_LENGTH = 12; type ProcessGcmPayload = { payload: Binary; payloadIv: Binary; - payloadAuthTag: Binary; }; // Should this be a Binary, Buffer, or... both? function processGcmPayload(source: ArrayBuffer): ProcessGcmPayload { // Read the 12 byte IV from the beginning of the stream const payloadIv = Binary.fromArrayBuffer(source.slice(0, 12)); - // Slice the final 16 bytes of the buffer for the authentication tag - const payloadAuthTag = Binary.fromArrayBuffer(source.slice(-16)); - return { - payload: Binary.fromArrayBuffer(source.slice(12, -16)), + // WebCrypto AES-GCM expects ciphertext with the auth tag appended, so keep + // the tag attached instead of splitting and re-concatenating it later. + payload: Binary.fromArrayBuffer(source.slice(12)), payloadIv, - payloadAuthTag, }; } @@ -64,18 +62,25 @@ export class AesGcmCipher extends SymmetricCipher { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars override async decrypt( - buffer: ArrayBuffer, + buffer: ArrayBuffer | Uint8Array, key: SymmetricKey, iv?: Binary ): Promise { - const { payload, payloadIv, payloadAuthTag } = processGcmPayload(buffer); + const input = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + + if (this.cryptoService.name === 'BrowserNativeCryptoService') { + return decryptBufferSource( + input.subarray(12), + key, + input.subarray(0, 12), + Algorithms.AES_256_GCM + ); + } - return this.cryptoService.decrypt( - payload, - key, - payloadIv, - Algorithms.AES_256_GCM, - payloadAuthTag + const { payload, payloadIv } = processGcmPayload( + input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength) ); + + return this.cryptoService.decrypt(payload, key, payloadIv, Algorithms.AES_256_GCM); } } diff --git a/lib/tdf3/src/ciphers/symmetric-cipher-base.ts b/lib/tdf3/src/ciphers/symmetric-cipher-base.ts index 00c391b80..c07ed9602 100644 --- a/lib/tdf3/src/ciphers/symmetric-cipher-base.ts +++ b/lib/tdf3/src/ciphers/symmetric-cipher-base.ts @@ -37,5 +37,9 @@ export abstract class SymmetricCipher { abstract encrypt(payload: Binary, key: SymmetricKey, iv: Binary): Promise; - abstract decrypt(payload: Uint8Array, key: SymmetricKey, iv?: Binary): Promise; + abstract decrypt( + payload: ArrayBuffer | Uint8Array, + key: SymmetricKey, + iv?: Binary + ): Promise; } diff --git a/lib/tdf3/src/crypto/core/symmetric.ts b/lib/tdf3/src/crypto/core/symmetric.ts index 0f016dea7..0125f23c2 100644 --- a/lib/tdf3/src/crypto/core/symmetric.ts +++ b/lib/tdf3/src/crypto/core/symmetric.ts @@ -13,6 +13,16 @@ import { unwrapSymmetricKey, wrapSymmetricKey } from './keys.js'; const ENC_DEC_METHODS: KeyUsage[] = ['encrypt', 'decrypt']; +function asUint8ArrayView(buffer: BufferSource): Uint8Array { + if (buffer instanceof Uint8Array) { + return buffer; + } + if (ArrayBuffer.isView(buffer)) { + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } + return new Uint8Array(buffer); +} + /** * Generate a random symmetric key (opaque). * @param length - Key length in bytes (default 32 for AES-256) @@ -61,7 +71,23 @@ export function decrypt( algorithm?: AlgorithmUrn, authTag?: Binary ): Promise { - return _doDecrypt(payload, key, iv, algorithm, authTag); + return _doDecryptBufferSource( + payload.asArrayBuffer(), + key, + iv.asArrayBuffer(), + algorithm, + authTag?.asArrayBuffer() + ); +} + +export function decryptBufferSource( + payload: BufferSource, + key: SymmetricKey, + iv: BufferSource, + algorithm?: AlgorithmUrn, + authTag?: BufferSource +): Promise { + return _doDecryptBufferSource(payload, key, iv, algorithm, authTag); } /** @@ -115,32 +141,33 @@ async function _doEncrypt( }; } -async function _doDecrypt( - payload: Binary, +async function _doDecryptBufferSource( + payload: BufferSource, key: SymmetricKey, - iv: Binary, + iv: BufferSource, algorithm?: AlgorithmUrn, - authTag?: Binary + authTag?: BufferSource ): Promise { console.assert(payload != null); console.assert(key != null); console.assert(iv != null); - let payloadBuffer = payload.asArrayBuffer(); + let payloadBuffer: BufferSource = payload; // Concat the the auth tag to the payload for decryption if (authTag) { - const authTagBuffer = authTag.asArrayBuffer(); - const gcmPayload = new Uint8Array(payloadBuffer.byteLength + authTagBuffer.byteLength); - gcmPayload.set(new Uint8Array(payloadBuffer), 0); - gcmPayload.set(new Uint8Array(authTagBuffer), payloadBuffer.byteLength); - payloadBuffer = gcmPayload.buffer; + const payloadBytes = asUint8ArrayView(payloadBuffer); + const authTagBytes = asUint8ArrayView(authTag); + const gcmPayload = new Uint8Array(payloadBytes.byteLength + authTagBytes.byteLength); + gcmPayload.set(payloadBytes, 0); + gcmPayload.set(authTagBytes, payloadBytes.byteLength); + payloadBuffer = gcmPayload; } - const algoDomString = getSymmetricAlgoDomString(iv, algorithm); + const ivBytes = asUint8ArrayView(iv); + const algoDomString = getSymmetricAlgoDomStringFromIv(ivBytes, algorithm); const keyBytes = unwrapSymmetricKey(key); const importedKey = await _importKey(keyBytes, algoDomString); - algoDomString.iv = iv.asArrayBuffer(); const decrypted = await crypto.subtle .decrypt(algoDomString, importedKey, payloadBuffer) @@ -168,6 +195,13 @@ function _importKey(keyBytes: Uint8Array, algorithm: AesCbcParams | AesGcmParams function getSymmetricAlgoDomString( iv: Binary, algorithm?: AlgorithmUrn +): AesCbcParams | AesGcmParams { + return getSymmetricAlgoDomStringFromIv(asUint8ArrayView(iv.asArrayBuffer()), algorithm); +} + +function getSymmetricAlgoDomStringFromIv( + iv: Uint8Array, + algorithm?: AlgorithmUrn ): AesCbcParams | AesGcmParams { let nativeAlgorithm = 'AES-CBC'; if (algorithm === Algorithms.AES_256_GCM) { @@ -176,7 +210,7 @@ function getSymmetricAlgoDomString( return { name: nativeAlgorithm, - iv: iv.asArrayBuffer(), + iv, }; } diff --git a/lib/tdf3/src/models/encryption-information.ts b/lib/tdf3/src/models/encryption-information.ts index c869cb8d3..845a9b53d 100644 --- a/lib/tdf3/src/models/encryption-information.ts +++ b/lib/tdf3/src/models/encryption-information.ts @@ -72,7 +72,7 @@ export class SplitKey { return this.cipher.encrypt(contentBinary, key, ivBinary); } - async decrypt(content: Uint8Array, key: SymmetricKey): Promise { + async decrypt(content: ArrayBuffer | Uint8Array, key: SymmetricKey): Promise { return this.cipher.decrypt(content, key); } diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 8d5a02742..4decf7a9b 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -1074,16 +1074,17 @@ async function fetchAndDecryptChunkSlice({ specVersion: string; slice: Chunk[]; }) { + const firstChunk = slice[0]; let buffer!: Uint8Array; + const bufferSize = slice.reduce( + (currentVal, { encryptedSegmentSize }) => currentVal + (encryptedSegmentSize as number), + 0 + ); try { - const bufferSize = slice.reduce( - (currentVal, { encryptedSegmentSize }) => currentVal + (encryptedSegmentSize as number), - 0 - ); buffer = await zipReader.getPayloadSegment( centralDirectory, '0.payload', - slice[0].encryptedOffset, + firstChunk.encryptedOffset, bufferSize ); } catch (error) { @@ -1268,7 +1269,8 @@ export async function sliceAndDecrypt({ specVersion: string; }) { for (const index in slice) { - const { encryptedOffset, encryptedSegmentSize, plainSegmentSize } = slice[index]; + const chunk = slice[index]; + const { encryptedOffset, encryptedSegmentSize, plainSegmentSize } = chunk; const offset = slice[0].encryptedOffset === 0 ? encryptedOffset : encryptedOffset % slice[0].encryptedOffset; @@ -1282,7 +1284,7 @@ export async function sliceAndDecrypt({ const result = await decryptChunk( encryptedChunk, reconstructedKey, - slice[index]['hash'], + chunk.hash, cipher, segmentIntegrityAlgorithm, specVersion, @@ -1293,9 +1295,9 @@ export async function sliceAndDecrypt({ `incorrect segment size: found [${result.payload.length()}], expected [${plainSegmentSize}]` ); } - slice[index].decryptedChunk.set(result); + chunk.decryptedChunk.set(result); } catch (e) { - slice[index].decryptedChunk.reject(e); + chunk.decryptedChunk.reject(e); } } } @@ -1470,10 +1472,17 @@ export async function decryptStreamFrom( const chunk = chunks[nextChunkIndex]; const decryptedSegment = await chunk.decryptedChunk; const encryptedSegmentSize = chunk.encryptedSegmentSize ?? 0; + const plainChunk = new Uint8Array(decryptedSegment.payload.asArrayBuffer()); - controller.enqueue(new Uint8Array(decryptedSegment.payload.asByteArray())); + controller.enqueue(plainChunk); progress += encryptedSegmentSize; cfg.progressHandler?.(progress); + // Release the resolved plaintext held by the consumed mailbox so long + // browser decrypts do not retain every prior segment in memory. + chunks[nextChunkIndex] = { + ...chunk, + decryptedChunk: mailbox(), + }; nextChunkIndex += 1; scheduler?.markConsumed(); }, diff --git a/lib/tdf3/src/utils/zip-reader.ts b/lib/tdf3/src/utils/zip-reader.ts index d3accd6e0..932c02127 100644 --- a/lib/tdf3/src/utils/zip-reader.ts +++ b/lib/tdf3/src/utils/zip-reader.ts @@ -133,8 +133,7 @@ export class ZipReader { cdObj.relativeOffsetOfLocalHeader + cdObj.headerLength + encrpytedSegmentOffset; // TODO: what's the exact byte start? const byteEnd = byteStart + encryptedSegmentSize; - - return await this.getChunk(byteStart, byteEnd); + return this.getChunk(byteStart, byteEnd); } /** diff --git a/lib/tests/mocha/unit/crypto/crypto-service.spec.ts b/lib/tests/mocha/unit/crypto/crypto-service.spec.ts index 4dee9f374..7acd43bfd 100644 --- a/lib/tests/mocha/unit/crypto/crypto-service.spec.ts +++ b/lib/tests/mocha/unit/crypto/crypto-service.spec.ts @@ -1,6 +1,7 @@ import { assert, expect } from 'chai'; import { Algorithms } from '../../../../tdf3/src/ciphers/index.js'; +import { AesGcmCipher } from '../../../../tdf3/src/ciphers/aes-gcm-cipher.js'; import { decrypt, decryptWithPrivateKey, @@ -24,6 +25,7 @@ import { verifyHmac, verify, } from '../../../../tdf3/src/crypto/index.js'; +import * as DefaultCryptoService from '../../../../tdf3/src/crypto/index.js'; import { hex } from '../../../../src/encodings/index.js'; import { Binary } from '../../../../tdf3/src/binary.js'; import { decodeArrayBuffer, encodeArrayBuffer } from '../../../../src/encodings/base64.js'; @@ -243,6 +245,40 @@ describe('Crypto Service', () => { expect(decrypted.payload.asString()).to.be.equal(rawData); }); + it('should decrypt aes_256_gcm ciphertext from Uint8Array input', async () => { + const rawData = 'hello world'; + const payload = Binary.fromString(rawData); + + const keyBytes = new Uint8Array( + decodeArrayBuffer('cvR6X2vLG5ap13ssLxRjOV1KOjJfraYpD8D+97zdtY4=') + ); + const key = await importSymmetricKey(keyBytes); + const iv = Binary.fromArrayBuffer(crypto.getRandomValues(new Uint8Array(12)).buffer); + const cipher = new AesGcmCipher(DefaultCryptoService); + + const encrypted = await cipher.encrypt(payload, key, iv); + const decrypted = await cipher.decrypt(new Uint8Array(encrypted.payload.asArrayBuffer()), key); + + expect(decrypted.payload.asString()).to.equal(rawData); + }); + + it('should decrypt aes_256_gcm ciphertext from ArrayBuffer input', async () => { + const rawData = 'hello world'; + const payload = Binary.fromString(rawData); + + const keyBytes = new Uint8Array( + decodeArrayBuffer('cvR6X2vLG5ap13ssLxRjOV1KOjJfraYpD8D+97zdtY4=') + ); + const key = await importSymmetricKey(keyBytes); + const iv = Binary.fromArrayBuffer(crypto.getRandomValues(new Uint8Array(12)).buffer); + const cipher = new AesGcmCipher(DefaultCryptoService); + + const encrypted = await cipher.encrypt(payload, key, iv); + const decrypted = await cipher.decrypt(encrypted.payload.asArrayBuffer(), key); + + expect(decrypted.payload.asString()).to.equal(rawData); + }); + describe('generateECKeyPair', () => { it('should generate P-256 key pair', async () => { const keyPair = await generateECKeyPair('P-256'); diff --git a/web-app/tests/tests/acts.ts b/web-app/tests/tests/acts.ts index cea409c2d..be9d43bf6 100644 --- a/web-app/tests/tests/acts.ts +++ b/web-app/tests/tests/acts.ts @@ -1,5 +1,7 @@ +export const appUrl = 'http://localhost:65432/'; + export const authorize = async (page: Page) => { - await page.goto('http://localhost:65432/'); + await page.goto(appUrl); // If we are logged in, return early. const sessionState = await page.locator('#sessionState').textContent(); if (sessionState === 'loggedin') { diff --git a/web-app/tests/tests/huge.spec.ts b/web-app/tests/tests/huge.spec.ts index e9822a6e1..497ab6bc1 100644 --- a/web-app/tests/tests/huge.spec.ts +++ b/web-app/tests/tests/huge.spec.ts @@ -1,6 +1,6 @@ import { test, expect, type Page } from '@playwright/test'; import fs from 'node:fs'; -import { authorize, loadFile } from './acts.js'; +import { appUrl, authorize, loadFile } from './acts.js'; test.beforeEach(async ({ page }) => { page.on('pageerror', (err) => { @@ -42,7 +42,7 @@ test('Large File', async ({ page }) => { await page.locator('#randomSelector').clear(); await loadFile(page, cipherTextPath); - const plainDownloadPromise = await page.waitForEvent('download', { timeout: 60000 }); + const plainDownloadPromise = page.waitForEvent('download', { timeout: 60000 }); await page.locator('#fileSink').click(); await page.locator('#decryptButton').click(); const download2 = await plainDownloadPromise;