logo hsb.horse
← Back to blog index

Blog

Encrypting a Byte Stream in the Browser with Web Crypto

Web Crypto gives you strong primitives but no stream interface. This post shows how to encrypt ReadableStream<Uint8Array> using the encryption subpath of @hsblabs/web-stream-extras — covering file encryption, per-stream key management, and pipeline composition.

Published:

Translations

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.

Terminal window
npm install @hsblabs/web-stream-extras

Node.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:

  1. A unique encryption key per file
  2. That key stored alongside the file record
  3. 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 record
const 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 encrypt
const compressed = plaintext.pipeThrough(new CompressionStream("gzip"));
const encrypted = encryptStream(key, compressed);
// upload the encrypted stream directly
await fetch("/upload", {
method: "POST",
body: encrypted,
duplex: "half",
});

npm · GitHub