UNPKG

@transcend-io/encrypt-web-streams

Version:

WebAssembly-powered streaming AES-256-GCM encryption and decryption with a web-native TransformStream API.

234 lines 11.1 kB
import initWasm, { Decryptor, Encryptor, } from '../wasm/aes_gcm_stream_wasm.js'; import { promiseWithResolvers } from './helpers.js'; /** The required length of the authentication tag in bytes. */ const AUTH_TAG_LENGTH = 16; let _wasmReady; /** * Initialize the WebAssembly module. * * @returns A promise that resolves when the Wasm module has been initialized. */ export async function init() { _wasmReady ??= await initWasm(); } /** * Create a native TransformStream that encrypts via a Wasm AES-GCM encryption * implementation. * * @param {Uint8Array} key - 32-byte encryption key * @param {Uint8Array} iv - 12-byte iv (recommended) * @param {Object} options - Optional options * @param {Uint8Array} options.additionalData - Optional additional * authenticated data * @param {boolean} [options.detachAuthTag=false] - If `true`, the * authentication tag will not be appended to the ciphertext and must be * retrieved with `getAuthTag()` after the stream is complete. Default is * `false` * @returns {EncryptionStream} An `EncryptionStream`, which is a * `TransformStream` with an added `getAuthTag()` method */ export function createEncryptionStream(key, iv, { additionalData, detachAuthTag = false, } = {}) { try { if (!_wasmReady) { throw new TypeError('The Wasm module has not been initialized. Make sure to call `await init()` before creating an encryption stream.'); } const enc = new Encryptor(key, iv); if (additionalData) enc.init_adata(additionalData); let detachedAuthTag; let streamFinished = false; const stream = new TransformStream({ transform(chunk, controller) { const buf = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); // Process in smaller chunks to avoid a single large allocation in Wasm const wasmChunkSize = 65_536; // 64 KiB for (let index = 0; index < buf.length; index += wasmChunkSize) { const out = enc.update(buf.subarray(index, index + wasmChunkSize)); if (out.length > 0) { controller.enqueue(out); } } }, flush(controller) { try { const final = enc.finalize(); const remainingBytes = final.slice(0, -AUTH_TAG_LENGTH); const finalAuthTag = final.slice(-AUTH_TAG_LENGTH); // Enqueue remaining bytes if (remainingBytes.length > 0) { controller.enqueue(remainingBytes); } if (detachAuthTag) { // Store auth tag separately detachedAuthTag = finalAuthTag; } else { // Append auth tag controller.enqueue(finalAuthTag); } } finally { streamFinished = true; } }, }); // Augment the stream with the getAuthTag() method const encryptionStream = stream; encryptionStream.getAuthTag = () => { if (!detachAuthTag) { throw new TypeError('The authentication tag is not available when `detachAuthTag` is false.' + '\nThe authentication tag will be appended to the ciphertext.'); } if (!streamFinished) { throw new Error('The authentication tag is not available until the encryption stream has finished.'); } if (!detachedAuthTag) { // This error should be unreachable throw new Error('The authentication tag is missing.'); } return detachedAuthTag; }; return encryptionStream; } catch (error) { if (error instanceof Error) { throw new TypeError(`Failed to create encrypt stream: ${error.message}`, { cause: error, }); } throw new TypeError(`Failed to create encrypt stream: ${String(error)}`); } } /** * Create a native TransformStream that decrypts via a Wasm AES-GCM decryption * implementation. * * @param {Uint8Array} key - 32-byte encryption key * @param {Uint8Array} iv - 12-byte iv (recommended) * @param {Object} options - Optional options * @param {Uint8Array} options.additionalData - Optional additional * authenticated data * @param {Uint8Array} options.detachedAuthTag - Optional detached * authentication tag to append to ciphertext, if the ciphertext does not * already contain an appended authentication tag. * @returns {TransformStream} A `TransformStream` that decrypts the ciphertext * and verifies the authentication tag. */ export function createDecryptionStream(key, iv, { additionalData, authTag: originalAuthTagArgument, __dangerouslyIgnoreAuthTag = false, } = {}) { try { if (!_wasmReady) { throw new TypeError('The Wasm module has not been initialized. Make sure to call `await init()` before creating a decryption stream.'); } const dec = new Decryptor(key, iv); if (additionalData) dec.init_adata(additionalData); // Validate options.authTag if (originalAuthTagArgument !== undefined && originalAuthTagArgument !== 'defer' && !(originalAuthTagArgument instanceof Uint8Array)) { throw new TypeError(`\`options.authTag\` must be a Uint8Array with ${AUTH_TAG_LENGTH.toString()} bytes, undefined, or "defer".`); } const { promise: authTagPromise, resolve: resolveAuthTag, reject: rejectAuthTag, } = promiseWithResolvers(); // Resolve the authTag result right away if the auth tag is not deferred. let authTagIsDeferred = false; if (originalAuthTagArgument === 'defer') { authTagIsDeferred = true; } else { resolveAuthTag(originalAuthTagArgument); } if (__dangerouslyIgnoreAuthTag) { console.warn('`__dangerouslyIgnoreAuthTag` was provided. This is dangerous and should only be used for testing.'); } let streamFinished = false; const stream = new TransformStream({ transform(chunk, controller) { const buf = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); // Process in smaller chunks to avoid a single large allocation in Wasm const wasmChunkSize = 65_536; // 64 KiB for (let index = 0; index < buf.length; index += wasmChunkSize) { const out = dec.update(buf.subarray(index, index + wasmChunkSize)); if (out.length > 0) { controller.enqueue(out); } } }, async flush(controller) { let timeout; try { // Wait for the auth tag to be set, if not already timeout = setTimeout(() => { console.warn('The decryption stream finished 10 seconds ago, but the authentication tag has still not been set.'); }, 10_000); const authTag = await authTagPromise; clearTimeout(timeout); // If the user supplied a detached auth tag... if (authTag !== undefined) { // Append the auth tag as the final chunk (else assume it's already appended to the ciphertext) const out = dec.update(authTag); if (out.length > 0) { controller.enqueue(out); } } if (__dangerouslyIgnoreAuthTag && authTag === undefined) { console.warn('`__dangerouslyIgnoreAuthTag` was provided, but the authentication tag was not set.', 'This will assume there is an authentication tag appended to the ciphertext.', 'If there is not, you will receive fewer bytes than expected, and you should pass a mock authTag to the stream.'); } // Note: `finalize()` throws on failure of the authentication tag try { const last = dec.finalize(__dangerouslyIgnoreAuthTag); if (last.length > 0) { controller.enqueue(last); } } catch (error) { if (error instanceof Error) { // eslint-disable-next-line unicorn/prefer-type-error throw new Error(`Failed to finalize decryption stream: ${error.message}`, { cause: error }); } throw new Error(`Failed to finalize decryption stream: ${String(error)}`); } } finally { // If the stream is aborted, clear the timeout clearTimeout(timeout); streamFinished = true; } }, }); const decryptionStream = stream; decryptionStream.setAuthTag = (authTag) => { try { if (streamFinished) { throw new Error('The decryption stream has already finished, so the authentication tag cannot be set.'); } if (!authTagIsDeferred) { throw new TypeError('Unexpected call to `setAuthTag()`, the `authTag` passed to `createDecryptionStream()` must be "defer" when using this library in the "defer" mode.'); } // Validate the authTag const deferredAuthTag = authTag; if (!(deferredAuthTag instanceof Uint8Array) || deferredAuthTag.length !== AUTH_TAG_LENGTH) { throw new TypeError(`The \`authTag\` must be a Uint8Array with ${AUTH_TAG_LENGTH.toString()} bytes.`); } // From this point on, the auth tag is no longer deferred authTagIsDeferred = false; // Resolve the promise for the detached authentication tag, allowing the decipher to finalize. resolveAuthTag(deferredAuthTag); } catch (error) { rejectAuthTag(error); // Reject the promise to error the stream throw error; // Also throw synchronously for the caller } }; return decryptionStream; } catch (error) { if (error instanceof Error) { throw new TypeError(`Failed to create decrypt stream: ${error.message}`, { cause: error, }); } throw new TypeError(`Failed to create decrypt stream: ${String(error)}`); } } //# sourceMappingURL=stream.js.map