0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【WebAssembly】ブラウザ内でPDFをAES-256暗号化・復号する qpdf WASM 実装メモ(locateFile 回避策、MEMFS、キャッシュ戦略)

0
Posted at

※ これは自分が個人開発しているサービス(DofuPDF)の実装メモです。自作の技術的な解説を主目的とした記事です。

PDFの暗号化をブラウザ内だけで完結させる Web アプリを作りました。C/C++ 製の PDF エンジン qpdf を WebAssembly として動かす構成で、ネットワークへのファイル送信は一切ありません。

本記事では、その実装で実際にハマった点と解決策を、できるだけ再利用可能な形で残します。Next.js のバージョン特有の話は避け、WASM まわりの話に絞ります。

全体構成

ユーザーのPDF (File オブジェクト)
   │
   ▼
Web Worker 内の qpdf (WASM)
   │  ├─ Emscripten の仮想ファイルシステム(MEMFS)に input.pdf として書き込み
   │  ├─ ネイティブと同じ argv で暗号化/復号を実行
   │  └─ 出来上がりを output.pdf として読み出し
   ▼
Blob → ユーザーがローカルに保存

PDF 処理そのものはすべてこの Worker の中で完結します。メインスレッドは進捗表示だけ。

ハマった点①:Emscripten の locateFile

@jspawn/qpdf-wasm のような Emscripten ビルドには、最初に大きな罠があります。Module に wasmBinary を渡しても無視されて、必ず locateFile で解決した URL から fetch しようとします。ホスト側でバイトを用意していても、結局 URL 経由の取得が走ってしまうのです。

対策は、WASM バイナリを自分でバイト列として取得し、Blob URL を作って locateFile に渡すことです。

async function engineBlobUrl(path: string): Promise<string> {
  const res = await fetch(`/wasm/${path}`);
  if (!res.ok) throw new Error(`wasm fetch failed: ${path}`);

  const bytes = await res.arrayBuffer();
  const head = new Uint8Array(bytes, 0, 4);
  // キャッシュ破損やデプロイミスを拾うため、WASM の魔数を検証する
  if (!(head[0] === 0x00 && head[1] === 0x61 && head[2] === 0x73 && head[3] === 0x6d)) {
    throw new Error(`invalid wasm binary: ${path}`);
  }
  return URL.createObjectURL(new Blob([bytes], { type: 'application/wasm' }));
}

const mod = await createQpdf({
  locateFile: (path: string) => engineBlobUrl(path),
});

なお locateFile の戻り値は非同期でも問題ありません(Promise を返せます)。

MEMFS との入出力

Emscripten の仮想ファイルシステムは起動時に空っぽです。処理したいファイルを書き込み、実行後に読み出すだけの単純な使い方ですが、argv の組み立てにいくつか注意点があります。

復号は --password= のキーバリュー形式を使います。パスワードがそのまま位置引数になると、- や -- で始まるパスワードをオプションと誤解釈される可能性があるためです(なお暗号化側は qpdf --encrypt の仕様上、ユーザーパスワードとオーナーパスワードが位置引数なので、そちらは UI 側で - 始まりを拒否しています)。

mod.FS.writeFile('input.pdf', new Uint8Array(pdfBytes));

const code = mod.callMain([
  '--decrypt',
  `--password=${password}`,
  'input.pdf',
  'output.pdf',
]);

// qpdf は exit code 3 を「警告付き成功」と返す。これをエラーと誤認しない
if (code !== 0 && code !== 3) {
  throw new Error(`qpdf exited with ${code}`);
}

const out = mod.FS.readFile('output.pdf');

暗号化は AES-256(256)に加えて、PDF の権限制限ビットをフラグで渡します。フォーム入力の権限は --annotate とは別に --form も同期させる必要があります。注釈だけ禁止してもフォーム入力が許可されたままになる罠です。

const code = mod.callMain([
  '--encrypt', userPassword, ownerPassword, '256',
  `--print=${permissions.print}`,
  `--modify=${permissions.modify}`,
  `--extract=${yn(permissions.extract)}`,
  `--annotate=${yn(permissions.annotate)}`,
  `--form=${yn(permissions.annotate)}`,  // ← これを忘れるとフォーム入力が開いたまま
  `--assemble=${yn(permissions.assemble)}`,
  `--accessibility=${yn(permissions.accessibility)}`,
  '--',
  'input.pdf',
  'output.pdf',
]);

バイナリのキャッシュ戦略

WASM エンジンは数 MB あるので毎回ダウンロードは避けたい。一方で、キャッシュの stale は Engine 起動失敗に直結するので慎重に設計しました。

結論として、こうしています。

  • manifest.json(エンジン一覧の数十分の1KB):キャッシュしない。毎回取り直す。 stale しても被害ゼロなので
  • WASM バイナリ:Cache Storage にキャッシュ。ただし使用時に魔数検証し、壊れていれば削除して再取得する

「とにかくキャッシュする」より「キャッシュしても壊れていれば自己修復する」のほうが運用で幸せになれます。

HTTP キャッシュも _headers で immutable を効かせていますが、Emscripten が URL 経由で取りに来る都合上、Service Worker / Cache Storage 側で最終防衛線を持っておくのが安心です。

ハマった点②:CSP と Blob URL

connect-src に blob: がないと、Blob URL 経由の WASM ロードが CSP にブロックされます。今回のような「ファイルを外部に送らない」設計では、connect-src 'self' blob: に留めるのが要点です。ここに外部ドメインを足すたびにプライバシー設計を見直す、という運用にしています。

ハマった点③:TypeScript 5.7 の Uint8Array

TS 5.7 以降、Uint8Array はそのまま BlobPart に代入できなくなりました。

// これは型エラーになる
new Blob([uint8]);

// こうする
new Blob([uint8 as unknown as ArrayBuffer]); // または .buffer を正しく扱う

FS.readFile の戻り値は Uint8Array なので、ダウンロード用 Blob を作る箇所で一度踏みます。utility 化しておくと二度と悩みません。

まとめ

  • Emscripten 製ライブラリは wasmBinary を無視する。locateFile に Blob URL を渡す
  • MEMFS は「書き込み → callMain → 読み出し」の単純構造。exit code 3 に注意
  • --form を --annotate と同期させる
  • キャッシュは「小さいものはキャッシュしない、大きいものは壊れても自己修復する」
  • CSP の connect-src に blob: が必要

WebAssembly が成熟したおかげで、「サーバーにアップロードして処理してもらう」前提のネイティブツールを、ほぼそのままユーザーの端末に持ち込める時代になりました。機密性の高い文書ほどこの構成が向いていると思っています。

これを製品化したのが DofuPDF(GitHub、AGPL-3.0)です。ブラウザの DevTools で Network タブを見ながら使うと、通信ゼロで処理が完結しているのが確認できます。関連実装は src/workers/pdf-heavy.worker.ts にあります。

0
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?