動画を残しておきたい。でも、あとから誰でも開ける動画ファイルを残したいわけではない。
この少し変わった要求から、ブラウザで映像と音声を取得し、録画の断片を受け取るたびに暗号化して、端末に保存する仕組みを作りました。通常の撮影では、映像・音声だけでなく、暗号化した動画そのものもサーバへ送信しません。
ただし、通信がないわけではありません。サーバへ送るのは、メール確認や撮影開始の情報、そして暗号文のハッシュです。サーバは復号用の秘密鍵と、署名付きの受信記録を保管します。
この記事では、録画、暗号化、保存、通信、復号をどうつないだのかと、この設計で守れる範囲を説明します。実装の基準は、v0.2.2の配布ZIPに入っているコード・仕様・検証記録です。後続の追加パッケージの機能を混ぜずに説明します。確認日は2026年10月4日です。
動画を「残す」と「見られる」を分けたかった
普通に撮影して動画を保存すれば、見返すことも、コピーして渡すこともできます。多くの用途では、その使いやすさこそが便利です。
一方、非常にプライベートな場面や、後日のトラブルに備えて残したい記録では、すぐ再生できる状態で手元に置くこと自体が気になります。端末内のファイル管理や、別の保存先へのコピーまで考える必要が出てきます。
完成した動画をあとから暗号化すると、その前に平文の動画ファイルができます。そこで考えたのが、完成した生動画をアプリが通常の保存先へ書き出す段階を作らず、録画中に受け取った断片から暗号化する方法でした。
さらに今回は、自分のパスワードでいつでも再生する動画保管庫ではなく、「記録は自分が保管するが、通常の操作では撮影者自身も再生できない」仕組みにしています。必要な場合には、運営が要請を確認し、利用者から受け取ったファイルを復号する、という運用を想定しました。この選択が鍵の設計を決めています。
全体像:動画の経路と記録の経路を分ける
フロントエンドはJavaScript、バックエンドはPHPとOpenSSLです。撮影APIのための追加DBや常駐Node.jsプロセスは使っていません。
矢印のない部分も大事です。通常の録画経路には、MediaStream、録画Blob、暗号化したメディア断片をサーバへアップロードする処理がありません。サーバへの経路に乗るのは、後述するJSONの記録情報です。
録画は約1秒の断片単位。ただし「毎秒ぴったり」ではない
getUserMedia()で、選択したカメラとマイクからMediaStreamを取得します。カメラの候補はenumerateDevices()が返す実際の機器で、選択にはdeviceIdを使います。
録画形式はMediaRecorder.isTypeSupported()で確認し、MP4のH.264/AAC指定、WebMのVP8/Opus指定、汎用のMP4、WebMの順に選びます。カメラには1280×720、24fpsを希望値として指定しますが、実際の取得条件を保証する指定ではありません。
本体のapp.jsから、録画の設定だけを抜き出すと次の形です。
recorder = new MediaRecorder(stream, {
mimeType: type,
videoBitsPerSecond: 1000000,
audioBitsPerSecond: 64000,
videoKeyFrameIntervalDuration: 1000
});
recorder.addEventListener('dataavailable', onData);
recorder.start(1000);
暗号化の単位は映像の1フレームではなく、dataavailableで届く録画データのBlobです。start(1000)は約1秒ごとの受け取りを要求する指定で、正確な1秒間隔を保証しません。個々のBlobも単独で再生可能とは限らず、復号後は元の順番に連結します。この点はMediaStream Recording仕様にも沿っています。
BlobをarrayBuffer()で読み、postMessage()のtransfer listにそのArrayBufferを指定してWorkerへ渡します。暗号化、ハッシュ計算、OPFSへの保存はWorkerが担当します。
なお、プレビューだけの操作では録画ファイルや撮影IDを作りません。それでもカメラ映像は画面に表示されるため、覗き見から保護されるわけではありません。
平文が存在する場所を曖昧にしない
「撮影しながら暗号化する」は、「平文が一瞬も存在しない」という意味ではありません。
カメラから取得した映像・音声、ブラウザの録画処理、dataavailableのBlob、変換したArrayBuffer、Worker内の入力バイト列には、暗号化前のデータが存在します。暗号化待ちのキューにも、まだ暗号化していないBlobが残ります。
アプリがOPFSに書き込むメディアは暗号文です。完成した平文のMP4/WebMを写真アプリやダウンロード先へ書き出す処理は、通常の撮影にはありません。
ただし、ブラウザやOS内部の一時保存、スワップ、内部コピーまで監査したわけではありません。Workerでは処理後に入力バイト列をゼロで上書きしますが、全てのコピーの消去を保証するものではありません。「端末のどこにも平文が残らない」とは言えない設計です。
鍵:P-256 ECDHから撮影ごとのAES鍵を作る
メディアの暗号化はAES-256-GCM、認証タグは128ビットです。動画用AES鍵は、P-256 ECDHとHKDF-SHA-256で導出します。Web Cryptography仕様のAPIを利用しています。
鍵の役割を整理すると、次のようになります。
| 鍵・材料 | 生成・保持する場所 | 永続保存・ファイルへの格納 |
|---|---|---|
| サーバのP-256鍵ペア | PHP/OpenSSL。新規撮影では確認済みメールごとに同じ鍵ペアを使用 | 秘密鍵はサーバの非公開領域。公開鍵はブラウザに渡し、ヘッダーにも格納 |
| 端末の一時P-256 ECDH鍵ペア | 撮影開始時のWorker | 一時秘密鍵を永続保存しない。一時公開鍵はヘッダーへ |
| ECDH共有秘密 | Workerで32バイト導出 | 永続保存しない。HKDF入力として使用 |
| 動画用AES鍵 | HKDFで256ビット導出 | Worker内のCryptoKey。書き出し不可・暗号化専用。ファイルには格納しない |
| 受信記録用P-256 ECDSA鍵ペア | 運営サーバ。撮影用とは別の鍵 | 秘密鍵は運営が保管。公開鍵はファイルへ |
サーバでは秘密鍵をPEM形式で保存します。標準構成の保存先は公開ディレクトリ外の/var/lib/recryptorで、新規のメール別秘密鍵はその下のemail-keys/EK-…/private.pemに置きます。コードはファイルを0600、鍵用ディレクトリを0700にします。パス名の識別子にメールのSHA-256を使いますが、メールアドレス自体も連絡先としてサーバに保存します。匿名化したサービスではありません。
HSMや独立した復号所は使っていません。秘密鍵は、管理者が扱えるサーバ上のファイルです。ここでのアクセス制限を、管理者からも鍵を隠す仕組みと混同しないことが大切です。
Worker側の導出処理から、中心部分を抜粋します。
const shared = new Uint8Array(await crypto.subtle.deriveBits(
{ name: 'ECDH', public: serverKey }, pair.privateKey, 256
)); pair = null;
const base = await crypto.subtle.importKey(
'raw', shared, 'HKDF', false, ['deriveKey']
); shared.fill(0);
const salt = crypto.getRandomValues(new Uint8Array(32));
key = await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt,
info: encoder.encode(`semilive:1:${record.id}:media`) },
base, { name: 'AES-GCM', length: 256 }, false, ['encrypt']
);
同じメールのサーバ鍵を使っても、撮影ごとに端末の一時鍵、32バイトのsalt、撮影IDが変わります。全動画で同じAES鍵を使い回す設計ではありません。旧版には撮影ごとのサーバ鍵もあり、既存ファイルの鍵を新方式に付け替えてはいません。
extractable: falseと['encrypt']は、正規のアプリから鍵を書き出したり復号に使ったりしないための制限です。しかし、改造されたJavaScriptが暗号化前の動画を別に保存することまでは防げません。また、サーバの長期秘密鍵が漏れ、対応する暗号文も入手されれば、ヘッダーの一時公開鍵から過去の動画用鍵を導出できます。端末の一時鍵を捨てるだけで、この構成に前方秘匿性が付くわけではありません。
暗号化する断片に、順番とヘッダーを結び付ける
GCMのIVは12バイトです。撮影ごとのランダムな8バイト接頭辞に、断片番号seqの4バイト表現を連結します。数値はbig endianです。
const iv = concat(unb64(header.ivPrefix), u32(seq));
const encrypted = new Uint8Array(await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv,
additionalData: aad(header.id, headerHash, seq, previousHash),
tagLength: 128 }, key, raw
));
const body = concat(new Uint8Array([1]), u32(seq), encrypted);
const hash = await digest(concat(unhex(previousHash), body));
write(concat(u32(body.length), body));
AADは、撮影ID、ヘッダーJSONのハッシュ、断片番号、直前の鎖のハッシュを含む文字列です。最初のpreviousHashにはヘッダーハッシュを使います。
ここでサーバへ送るhashは、単なる「今回の暗号文だけのハッシュ」ではなく、SHA-256(直前ハッシュの32バイト値 || 今回のメディアフレーム本文)です。順序を含めた照合に使います。GCM認証、連番、ハッシュ鎖、署名付き受信記録、サーバ側の記録を合わせて検証します。
同じ撮影をページ再起動後に再開する機能はありません。新しい撮影IDと一時鍵で始めます。連番の上限も設け、同じAES鍵でIVを再利用するような再開を避けています。
保存先はOPFS。録画全部をメモリに積まない
Workerからnavigator.storage.getDirectory()を使い、OPFS内のsemilive-v1フォルダに保存します。フォルダ名に旧名称が残るのは、以前の暗号文を回収できるようにするためです。
メディアファイルにはcreateSyncAccessHandle()で書き込み、各書き込み後にflush()します。録画を全てメモリにためて最後に巨大なBlobを作る構成にはしていません。ただし、断片や暗号化待ちのデータはメモリに存在します。
OPFSはブラウザのoriginに属する保存領域です。端末内でも、通常のダウンロードフォルダとは別です。撮影後は.recryptorを利用者が管理する場所へ書き出します。保存先選択APIが利用できれば、その書き込み完了を確認します。それ以外はBlob URLとダウンロードリンクを使います。
通常のダウンロードでは、サイトが「ユーザーの保存先にファイルが残った」と確認できません。そのため、自動保存を試しただけでOPFS内の控えを削除せず、保存先の確認を促す設計に変えました。storage.persist()も要求するだけで、ブラウザによる消去を無条件に防ぐ保証ではありません。
.recryptorは、暗号文と受信記録のコンテナ
拡張子を変えたMP4ではなく、独自のv1コンテナです。
SEMLIVE1 8バイトの識別子
headerLength 4バイト、big endian
header JSON UTF-8、平文
frameLength + type=1 + seq + ciphertext + GCM tag
frameLength + type=2 + 署名付き断片受信記録JSON
... 複数のフレーム
frameLength + type=3 + 署名付き終了記録JSON
ヘッダーには撮影ID、暗号スイート、サーバ公開鍵と指紋、端末の一時公開鍵、salt、IV接頭辞、メディア形式、受信記録の公開鍵、ID発行時刻を入れます。メールアドレス、撮影トークン、秘密鍵、AES鍵は入れません。
ヘッダーや受信記録まで全て暗号化しているわけではなく、ファイルから撮影IDやメディア形式などは読めます。内容の秘匿とメタデータの秘匿は別の話です。
種類2の受信記録は、対応するメディアフレームより後に入りますが、通信と保存を別キューにしているため、必ず直後に入るとは限りません。最後の種類3は、正常終了した記録です。
基準にしたv0.2.2 ZIPでは、拡張子が.recryptorでも先頭の識別子はSEMLIVE1です。旧拡張子.semiliveも扱います。暗号化・署名対象のJSON、HKDFのinfo、AADにも、互換性のためsemiliveの文字列を残しています。先頭の識別子8バイト自体はGCMの認証や受信署名の対象に含めていません。何を認証しているかは、ファイル全体の見た目からではなく、この対象範囲で説明する必要があります。
本当に映像をサーバへ送っていないのか?
この疑問には、「暗号化しています」という説明だけでは答えられません。暗号化した動画をアップロードするサービスもあるからです。
今回の実装では、録画のBlobを渡す先はWorkerです。撮影中のAPI呼び出しは、次の短いJSONです。
const receipt = await api('chunk', {
id: session.id,
token: session.token,
seq: chunk.seq,
hash: chunk.hash,
previousHash: chunk.previousHash
}, { retry: true });
暗号化メディアのバイト列を、このJSONに入れていません。api()は同一originのapi.phpへapplication/jsonのPOSTを送ります。公開ソースの撮影APIは本文を24,000バイトまでに制限しています。ただし、小さいリクエストでもデータを送ることはできるので、サイズ制限だけを「動画を送っていない証明」とは扱いません。送信項目と、その値を作る経路の確認が必要です。
通常の処理でやり取りする情報は次のとおりです。
| 処理 | ブラウザから送るもの | 主な応答 |
|---|---|---|
| メール確認 | メールアドレス、確認コード。セッションCookieも利用 | 確認状況・有効期限 |
| 撮影ID発行 | 確認済みセッションからの開始要求 | ID、撮影トークン、公開鍵、時刻 |
| 鍵の保管確認 | ID、ランダムnonce | 現在の鍵状態、公開鍵、署名 |
| 撮影開始 | ID、トークン、ヘッダーJSONのbase64url表現 | ヘッダーハッシュ |
| 録画中 | ID、トークン、断片番号、今回と直前のハッシュ | 署名付き受信記録 |
| 正常終了 | ID、トークン、最後の番号とハッシュ | 署名付き終了記録 |
サーバは連絡先、鍵、撮影IDと対応情報、ハッシュ・受信時刻・署名を保存します。ブラウザにはページやJavaScriptなども配信します。運用コードには撮影IDと連絡先を運営のGmailへ控えとして送る処理もあります。映像を送らないことと、個人情報や利用状況を何も送らないことは同じではありません。Webサーバのアクセスログ等には、構成次第でIPやアクセス時刻も残り得ます。
一方、通常の撮影では、平文の映像・音声と、暗号化メディア本体を送らず、動画ファイルとしてサーバ保存しない経路です。開発時には合成カメラで録画し、送信内容、保存した暗号文、PHPでの復号と再生を確認しています。
利用者がChrome DevToolsで確かめる手順
パソコンのChromeでは、次のように確認できます。センシティブな映像を使う必要はなく、机などを映す短いテストで十分です。
- 撮影を始める前に開発者ツールのNetworkを開き、Preserve logを有効にします。読み込み時から確認したければ、その状態でページを再読み込みします。
- メール確認を済ませ、短い撮影を開始して停止します。
-
api.phpへのPOSTを選び、Payloadでactionと送信項目を見ます。chunkなら上のJSONと対応するID・番号・ハッシュ等を確認できます。 -
Responseでは、受信記録の
payloadとsignatureを確認します。 -
api.phpだけでなく、Allで別の宛先へのリクエストやWebSocket等も確認します。送信があるなら、宛先と本文・フレームの内容を調べます。 -
Sourcesで、BlobがWorkerへ渡る箇所、Worker内の暗号化とファイル書き込み、
fetchに渡す本文を照合します。
Networkの見方はChrome公式の説明も参考になります。HARや画面の共有には、メール、Cookie、撮影トークンなどが混ざり得るため、確認結果を公開するときは取り除いてください。
この方法で確認できるのは、その試験中に観測したブラウザの通信です。将来配信されるJavaScript、ブラウザ外の通信、端末の侵害、サーバ内部の保存処理まで保証するものではありません。公開ソースも、実行中のサーバと必ず同一であることを遠隔から証明する仕組みではありません。
復号するときは、鍵とファイルが再びそろう
通常の利用者向け画面には復号APIも、自分用の再生機能もありません。運営が警察・裁判所からの要請を確認し、撮影者から暗号文ファイルを受け取った場合に復号する運用です。
復号コードでは次の処理を行います。
- 識別子、ヘッダー、撮影IDを読み、サーバの登録情報と照合する。
- サーバ秘密鍵と、ファイルの端末一時公開鍵でECDH共有秘密を導出する。
- 同じsaltとinfoでHKDFを実行し、動画用AES鍵を再導出する。
- 連番、IV、AAD、GCMタグを検証して各断片を復号し、元の順に連結する。
- ハッシュ鎖、受信署名、終了記録、サーバの受信記録を照合する。
- 全検証に成功した場合に出力を確定する。失敗時は未確定の平文一時ファイルを削除する。
復号処理中には運営側に平文が存在し、復号後の動画も取り扱うことになります。「通常の撮影では動画をアップロードしない」という説明を、復号の場面にもそのまま広げることはできません。
基準にしたZIPの復号は管理者用CLIで行います。担当者と要請根拠の入力を求め、操作を記録します。管理者用Web復号画面や、動画・時刻記録をまとめるZIP出力は別の追加パッケージで、この記事で扱うv0.2.2本体の機能には含めません。実際のサーバの鍵保存領域や保管状況を、外から直接確認できたわけでもありません。
ブラウザで作るうえで難しかった点
暗号化と通信を一つの待ち行列にしない
録画の断片保存と、ハッシュの通信には別々のPromiseキューを使っています。通信の応答を待つたびに暗号化保存まで止まる構成を避けつつ、それぞれの順序を保つためです。
とはいえ、追いつかない処理を際限なくためることはできません。暗号化前の1断片は16 MiB、暗号化待ち合計は32 MiB、受信記録待ちは30件、保存ファイルは1 GiBを上限にしました。撮影時間は2時間が上限です。上限超過や再試行後の通信失敗では中断し、保存済み部分を取り出せるようにします。
正常終了と中断を区別する
タブが非表示になる、カメラやマイクのtrackがmute・endedになる、Workerが失敗する場合は中断扱いにします。画面を表示したまま使う前提です。Screen Wake Lockは可能なら要求しますが、バックグラウンド録画の保証ではありません。
正常終了では暗号化キューと受信記録キューの完了を待ち、サーバの終了記録を検証してからファイルを閉じます。終了記録がないファイルは通常の復号で拒否し、CLIで--allow-partialを明示した場合だけ扱います。中断部分が再生できることや、未記録の断片の受信時刻は保証しません。
Web CryptoとOpenSSLで署名の表現が違う
受信記録の署名はP-256 ECDSA/SHA-256です。OpenSSLが返すASN.1 DER形式の署名を、Web Cryptoで検証する前に64バイトのr || sへ変換しています。署名するのはJSONの実際の文字列で、パース後に再シリアライズした文字列ではありません。
暗号方式が同じでも、署名のバイト表現とJSONの扱いまでそろえないと相互運用できません。プロトコルを仕様に起こした理由の一つです。
鍵の再生成は、修復に見えて別の破損になる
当初の説明にはRSAで動画鍵を包む案がありましたが、実装では既存PHPとの互換性を考えてP-256 ECDH+HKDFへ具体化しました。現在のコードにRSAによる鍵ラップはありません。
また、新規のサーバ鍵はメール単位に整理しましたが、既存鍵を失ったときに自動生成し直す処理は入れていません。新しい鍵では古い暗号文を復号できないからです。鍵の消失を検出したら撮影を拒否します。名称・識別子を変更するときも、過去のファイルと鍵との対応を保つことを優先しました。
脅威モデル:何を守り、何を守れないか
狙っているのは、正規のアプリで作った保存済みの暗号文が、通常の利用者操作や一般的な動画プレーヤーでは再生できず、通常の録画経路で運営に動画を預けないことです。前提は、配信コード、端末、ブラウザ、暗号実装と鍵管理が侵害されていないことです。
| 状況・脅威 | 対応できる範囲と限界 |
|---|---|
| 秘密鍵を持たない第三者が保存済みファイルだけを入手 | 映像・音声は暗号化されている。撮影IDなどの平文メタデータは読める |
| 保存済み断片の改変・順序変更・欠落 | 認証、連番、ハッシュ鎖、受信記録・終了記録の検証で拒否する。ファイル全体の全バイトを一つの署名で覆う形式ではない |
| 運営が通常の撮影内容を見たい | 通常の経路では動画本体を受け取らない。ただし配信コードの改変を技術的に封じてはいない |
| 管理者が秘密鍵と暗号文の両方を入手 | 技術的には復号できる。要請の確認や担当者・根拠の記録は運営手続で、管理者による回避を防がない |
| 端末の侵害・拡張機能・改造アプリ・別カメラ | 防げない。撮影中の平文や画面を取得される可能性がある |
| 利用者がファイルを削除/鍵を紛失 | 復元を保証しない。メール別秘密鍵の紛失は、その鍵に対応する複数撮影に影響する |
| 時刻や映像の真正性 | 署名付きハッシュ受信時刻を照合できるが、撮影時刻、場所、出来事、法的評価を保証しない |
受信時刻の信頼の起点は単一の運営サーバです。独立した第三者タイムスタンプは使っておらず、運営が時計や署名処理を変更することも技術的には防ぎません。サーバは受信時に動画を見ないため、ハッシュを受け付けた時点で映像の内容が正当だと審査することもできません。
同じドメインのWiki等とはoriginを共有します。パスを分けるだけではOPFS等を完全に隔離できません。サーバでも同じPHP実行ユーザーの別アプリが侵害されれば、保管鍵への影響があります。
鍵の保管は「同じメールの最後の撮影から1年」を原則とする運営方針で、1年後に鍵・記録・全バックアップを自動削除する処理ではありません。停止・障害で期間内にも復号できなくなる可能性があります。継続性は暗号アルゴリズムだけでは解決できません。
検証したことと、これから改善したいこと
ZIP内の検証記録では、PHP統合テストの41項目、合成カメラを使うブラウザ撮影の34項目が成功しています。新旧拡張子の互換性、復号したバイト列の一致、動画・音声の再生、改ざん・順序変更・欠落の拒否、保存キャンセル、鍵消失、バックアップからの復号などを確認しました。ここでの件数は2026年10月2日の開発環境の記録であり、本番全体の監査結果ではありません。
実機iPhone・Android、長時間録画、着信、容量不足、発熱、実VPSでの耐障害性を網羅した確認と、外部の暗号・セキュリティ監査は未実施です。
今後改善したいのは、専用originとPHP実行ユーザーへの分離、鍵のバックアップ・復元運用、実機での中断と保存の検証、第三者が通信と配信コードを比較しやすい公開方法です。受信時刻の信頼を強めるには、外部の時刻認証なども検討対象になります。いずれも現在実装済みの機能とは区別しています。
ブラウザで試せる形にした
この仕組みを実際にブラウザで試せるものとして、RECryptorを公開しました。
便利さを増やすために「いつでも再生」を足すのではなく、保存する人、鍵を持つ人、記録の中身を受け取る場面を分ける。そのために、録画APIだけでなく、鍵の寿命、ファイル形式、中断、保存確認まで設計する必要がありました。
まずはセンシティブでない短い映像で、ファイル保存とNetworkの内容を確かめてもらえるとうれしいです。
実装を追いたい方は、録画のコード、暗号化・保存のWorker、暗号・通信の共通処理、サーバ側プロトコルの公開ソース、通信の検証方法を確認できます。公開ソースには設定・資格情報・鍵・利用者記録を含めていません。
なお、確認日に公開URLで取得したコードには、基準ZIPより後の差分もありました。例えば公開中のcrypto.jsは新識別子RCRPTR01と旧SEMLIVE1の両方を扱いますが、この記事のファイル構造の説明は基準ZIPのSEMLIVE1に合わせています。録画・暗号化・保存のWorkerと暗号スイートは一致しています。公開されたサーバ側ソースはリリースの控えで、稼働中のサーバ内部を証明するものではありません。