The Web Crypto API gives you strong encryption primitives. What it doesn’t give you is a stream-friendly interface — encrypt() and decrypt() operate on a single ArrayBuffer, not on a ReadableStream.
Encrypting a large file or a chunked upload without buffering everything in memory first means writing your own framing and record-splitting logic. This post shows how to skip that using the encryption subpath of @hsblabs/web-stream-extras.
npm install @hsblabs/web-stream-extrasNode.js ≥22 or any modern browser with Web Streams + Web Crypto support.
Encrypt and decrypt a stream
encryptStream and decryptStream accept and return ReadableStream<Uint8Array>, so they fit into an existing pipeline without adapters.
import { readableFromChunks, readAllBytes, stringToBinary, binaryToString,} from "@hsblabs/web-stream-extras";import { encryptStream, decryptStream,} from "@hsblabs/web-stream-extras/encryption";
const key = crypto.getRandomValues(new Uint8Array(32));
const plaintext = readableFromChunks([stringToBinary("secret payload")]);const encrypted = encryptStream(key, plaintext);const decrypted = decryptStream(key, encrypted);
console.log(binaryToString(await readAllBytes(decrypted))); // "secret payload"The encrypted output is a standard ReadableStream<Uint8Array>. Pipe it to a fetch body, write it to the File System Access API, pass it anywhere a stream consumer is expected.
Encrypting a file the user picks
File.stream() already returns a ReadableStream<Uint8Array>, so no conversion needed:
async function encryptFile(file: File, key: Uint8Array): Promise<Uint8Array> { const plaintext = file.stream() as ReadableStream<Uint8Array>; const encrypted = encryptStream(key, plaintext); return readAllBytes(encrypted);}The result is just bytes — store it, upload it, or pass it into another stream.
Per-stream key management with webCryptoStream
In a real application you often want:
- A unique encryption key per file
- That key stored alongside the file record
- The per-file key protected under a master key, so raw key material never sits in plaintext storage
webCryptoStream wraps that pattern. It takes a non-extractable AES-GCM CryptoKey and returns helpers that create and manage per-stream keys as base64url strings.
import { webCryptoStream } from "@hsblabs/web-stream-extras/encryption";
const masterKey = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],);
const crypto$ = webCryptoStream(masterKey);Encrypting a file:
// streamKey is a base64url string — store it with your file recordconst streamKey = await crypto$.createStreamKey();
const plaintext = file.stream() as ReadableStream<Uint8Array>;const encrypted = await crypto$.encrypt(streamKey, plaintext);const encryptedBytes = await readAllBytes(encrypted);
// persist: { encryptedBytes, streamKey }Decrypting later:
const encrypted = readableFromChunks([encryptedBytes]);const decrypted = await crypto$.decrypt(streamKey, encrypted);const result = await readAllBytes(decrypted);The streamKey string is itself AES-GCM encrypted under your master key. Storing it in a database is safe — no raw key material in plaintext.
What the library handles, and what it doesn’t
Handled internally:
- Splitting the plaintext stream into fixed-size records
- Deriving a unique key and nonce per record via HKDF
- Authenticating each record with AES-GCM so tampering is detected on decryption
Not handled — these belong to your application:
- Key storage and persistence
- Password-based key derivation (bring your own PBKDF2 / Argon2)
- User authentication or access control
The library stays at the stream transformation layer.
Composing with other transforms
Because input and output are plain ReadableStream<Uint8Array>, composition is straightforward:
// compress, then encryptconst compressed = plaintext.pipeThrough(new CompressionStream("gzip"));const encrypted = encryptStream(key, compressed);// upload the encrypted stream directlyawait fetch("/upload", { method: "POST", body: encrypted, duplex: "half",});
hsb.horse