Web Crypto API は強力な暗号プリミティブを提供する。ただしストリーム向けのインターフェースはない——encrypt() と decrypt() は ReadableStream ではなく単一の ArrayBuffer を対象に動作する。
大きなファイルやチャンク化されたアップロードをメモリにすべてバッファリングせずに暗号化するには、フレーミングとレコード分割のロジックを自前で書くことになる。この記事では @hsblabs/web-stream-extras の encryption サブパスを使ってそこをスキップする方法を示す。
npm install @hsblabs/web-stream-extrasNode.js ≥22、または Web Streams + Web Crypto に対応したモダンブラウザで動作する。
ストリームを暗号化・復号する
encryptStream と decryptStream は ReadableStream<Uint8Array> を受け取り返すので、既存のパイプラインにアダプターなしで組み込める。
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"暗号化後の出力は標準の ReadableStream<Uint8Array> だ。fetch のボディにパイプしても、File System Access API に書いても、ストリームコンシューマーを期待する場所ならどこにでも渡せる。
ユーザーが選んだファイルを暗号化する
File.stream() はすでに ReadableStream<Uint8Array> を返すので変換不要だ:
async function encryptFile(file: File, key: Uint8Array): Promise<Uint8Array> { const plaintext = file.stream() as ReadableStream<Uint8Array>; const encrypted = encryptStream(key, plaintext); return readAllBytes(encrypted);}結果はただのバイト列だ——保存しても、アップロードしても、別のストリームに渡してもいい。
webCryptoStream でストリームごとの鍵を管理する
実際のアプリケーションでは次のようなニーズが出てくることが多い:
- ファイルごとに異なる暗号化鍵を使いたい
- その鍵をファイルレコードと一緒に保存したい
- ファイルごとの鍵をマスター鍵で保護して、生の鍵素材が平文でストレージに残らないようにしたい
webCryptoStream はそのパターンをラップする。取り出し不可の AES-GCM CryptoKey を受け取り、ストリームごとの鍵を base64url 文字列として作成・管理するヘルパーを返す。
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);ファイルを暗号化する:
// streamKey は base64url 文字列——ファイルレコードと一緒に保存するconst streamKey = await crypto$.createStreamKey();
const plaintext = file.stream() as ReadableStream<Uint8Array>;const encrypted = await crypto$.encrypt(streamKey, plaintext);const encryptedBytes = await readAllBytes(encrypted);
// 保存: { encryptedBytes, streamKey }後から復号する:
const encrypted = readableFromChunks([encryptedBytes]);const decrypted = await crypto$.decrypt(streamKey, encrypted);const result = await readAllBytes(decrypted);streamKey 文字列はマスター鍵で AES-GCM 暗号化されている。データベースへの保存は安全で、生の鍵素材が平文で現れることはない。
ライブラリが担うこと、担わないこと
内部で処理すること:
- 平文ストリームを固定サイズのレコードに分割する
- HKDF でレコードごとに固有の鍵とノンスを導出する
- AES-GCM で各レコードを認証し、復号時に改ざんを検知する
処理しないこと——これらはアプリケーション側の責任:
- 鍵の保存と永続化
- パスワードベースの鍵導出(PBKDF2 / Argon2 は自分で持ち込む)
- ユーザー認証やアクセス制御
ライブラリはストリーム変換の層にとどまる。
他のトランスフォームと組み合わせる
入出力がどちらも ReadableStream<Uint8Array> なので、合成は素直にできる:
// 圧縮してから暗号化するconst compressed = plaintext.pipeThrough(new CompressionStream("gzip"));const encrypted = encryptStream(key, compressed);// 暗号化したストリームをそのままアップロードするawait fetch("/upload", { method: "POST", body: encrypted, duplex: "half",});
hsb.horse