logo hsb.horse
← ブログ一覧に戻る

ブログ

Web Crypto でバイトストリームをブラウザ上で暗号化する

Web Crypto は強力な暗号プリミティブを提供するが、ストリーム向けのインターフェースはない。@hsblabs/web-stream-extras の encryption サブパスを使って ReadableStream<Uint8Array> を暗号化する方法 — ファイル暗号化、ストリームごとの鍵管理、パイプライン合成まで。

公開日:

翻訳

Web Crypto API は強力な暗号プリミティブを提供する。ただしストリーム向けのインターフェースはない——encrypt()decrypt()ReadableStream ではなく単一の ArrayBuffer を対象に動作する。

大きなファイルやチャンク化されたアップロードをメモリにすべてバッファリングせずに暗号化するには、フレーミングとレコード分割のロジックを自前で書くことになる。この記事では @hsblabs/web-stream-extrasencryption サブパスを使ってそこをスキップする方法を示す。

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

Node.js ≥22、または Web Streams + Web Crypto に対応したモダンブラウザで動作する。


ストリームを暗号化・復号する

encryptStreamdecryptStreamReadableStream<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 でストリームごとの鍵を管理する

実際のアプリケーションでは次のようなニーズが出てくることが多い:

  1. ファイルごとに異なる暗号化鍵を使いたい
  2. その鍵をファイルレコードと一緒に保存したい
  3. ファイルごとの鍵をマスター鍵で保護して、生の鍵素材が平文でストレージに残らないようにしたい

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",
});

npm · GitHub