Encryption
plasma には、互いに重なり合う 2 つの暗号化ストーリーがあります:
- at-rest、クライアントローカル — ブラウザエンジンが
.encrypted()セルを IDB に格納する前にEnvelopeでラップするため、ブラウザの IDB ストアのデバイス側スナップショットが平文を明かすことは決してありません。 - 運用者に対する end-to-end — サーバーはマークされた column の平文を決して見るべきではありません。mutator 本体が明示的に
encryptField()を呼ぶ必要があります。
両ストーリーは Envelope のワイヤーフォーマットと AES-GCM-256 プリミティブを共有します。異なるのは暗号化境界が どこに 位置するかです。
.encrypted() マーカー
Section titled “.encrypted() マーカー”import { text } from "@sh1n4ps/plasma-core"
const notes = table("notes", { id: id(), title: text(), body: text().encrypted(),}).encrypted() は column に meta.encrypted フラグを設定します。このフラグは、クライアントエンジンが insert / update 時に自動ラップするかを決めるために読むものです。
マーカー単体では何もしません — クライアントを構築するときに DEK も供給する必要があります。
ストーリー 1 — at-rest なクライアントローカル
Section titled “ストーリー 1 — at-rest なクライアントローカル”最もシンプルな接続です。createPlasmaClient に encryption 設定を渡します:
import { createPlasmaClient } from "@sh1n4ps/plasma-client"
const dek = await deriveKeyFromPassword(userPassword)
const plasma = createPlasmaClient({ schema, mutators, endpoint: "/sync", dbName: "notes", schemaVersion: "v1", clientGroupID: user.id, getContext: async () => ({ userId: user.id }),
encryption: { dek, // Uint8Array、32 バイト keyId: "k1", // 将来のキーローテーション用の識別子 },})これで:
- insert / update: クライアントエンジンが
.encrypted()マーカーを見て、IDB に格納する前にencryptField()を介して値をEnvelopeでラップします。デバイス側のダンプがnotes.bodyを読むと、{v:1, alg:"AES-GCM-256", keyId:"k1", nonce:"...", ct:"..."}— 不透明なバイト列を見ます。 - pull:
sync/client.tsのdecryptPatchが受信行のEnvelopeを見て、IDB に着地する前にアンラップします。
これが守るもの
Section titled “これが守るもの”- デバイスの押収 / IDB のスクレイプ。 Chrome の IndexedDB ディレクトリは、ローカルディスクアクセスを持つ誰にとっても world-readable です。
.encrypted()column はその読み手にとって不透明です。
これが守らないもの
Section titled “これが守らないもの”- 侵害された、または悪意のあるサーバー運用者。 クライアントの outbox はワイヤー越しに平文を push します(
argsは宣言どおり)。サーバーは平文を見て、D1 に平文を書き込みます。 - ワイヤー上の MITM。 HTTPS を使ってください; これはコンテンツではなくトランスポートの暗号化です。
- DEK を持つ同じクライアントの別タブ。 same-origin です。
ストーリー 2 — 運用者に対する E2EE(手動)
Section titled “ストーリー 2 — 運用者に対する E2EE(手動)”脅威モデルがサーバーに平文を決して見せないことを要求する場合、値を db.insert() に渡す前に mutator 本体内 で暗号化する必要があります。そうすればクライアントの optimistic 実行とサーバーの canonical 実行の両方が envelope を観測します。
import { encryptField } from "@sh1n4ps/plasma-core"
const notes = table("notes", { id: id(), body: text().encrypted(),})
export const mutators = defineMutators<typeof schema, Ctx>()({ writeNote: async ({ db, args, ctx }) => { // 両側で暗号化する。この mutator のサーバー側実行も // envelope を計算する — クライアントが使ったのと同じ DEK を使う。 // DEK は ctx を通じてスレッドされるため(実際のデプロイでは // ユーザーごとの KMS ルックアップ)。 const envelope = await encryptField( ctx.dek, { v: 1, table: "notes", rowId: args.id, column: "body", keyId: "k1" }, args.body, )
await db.insert(notes).values({ id: args.id, body: envelope, }) },})注意点: ctx.dek は両側から到達可能でなければなりません。クライアントは getContext() を介して、サーバーは auth() を介して提供します。実際の E2EE デプロイでは、サーバーは実際の DEK をラップするユーザーごとの envelope 暗号化キー をフェッチします — 運用者はラップされた DEK ではなく、ラッピングキーを保持します。
Envelope ワイヤーフォーマット
Section titled “Envelope ワイヤーフォーマット”interface Envelope { readonly v: 1 readonly alg: "AES-GCM-256" readonly keyId: string readonly nonce: string // base64 12 バイト readonly ct: string // base64 の暗号文 + タグ}AAD(Authenticated Additional Data)は (table, rowId, column, keyId) から導出され、ある column の有効な envelope が別の column にすり替えられないことを強制します(親付け替え攻撃はデコードで捕捉されます)。
validateEnvelope(env, { maxCiphertextBytes, allowedKeyIds }) は、サーバー側の sync-handler がすべての受信 mutation の args に対して実行するものです — 許可されない keyId や過大な暗号文を運ぶ envelope を持つ PushRequest は拒否されます。
キーローテーション
Section titled “キーローテーション”keyId フィールドがローテーションを可能にします。
- 新しい DEK(
k2)を導入する。 - 新しい書き込みは
k2の下に入ります。k1の既存 envelope はk1キーを保持しているため依然としてデコードできます。 - バックグラウンドジョブが既存の envelope を
k2の下で書き直します — スケジュールされた Worker がdecryptField(k1, ...)を呼び、行ごとにk2で再暗号化します。 - すべての envelope が
k2の下になったら、k1を破棄します。
plasma は再暗号化ループを提供しません; バッチをスキャンする table に対する mutator として書くことになります。
PQ ハイブリッド(crypto-pq)
Section titled “PQ ハイブリッド(crypto-pq)”AES-GCM の上にポスト量子保護を重ねる必要があるデプロイのために、@sh1n4ps/plasma-core は以下も提供します:
PqEnvelope— クラシックなEnvelopeを{ v, kind: "pq-hybrid", kem: { alg, ct }, inner }内にラップしますPqHybridProvider—wrap()/unwrap()を持つプラガブルなインターフェイス。呼び出し側はこのインターフェイスを介して(外部ライブラリから)ML-KEM / X-Wing をプラグインします。encryptFieldPq(provider, aad, value)/decryptFieldPq(provider, aad, envelope)insecurePlaceholderProvider(dek, { acceptInsecure: true })— ステージング専用、暗号学的保護をまったく提供せず、明示的なオプトインなしに throw します
実際の ML-KEM / X-Wing プリミティブは呼び出し側が供給します — plasma はそれをバンドルしません。数メガバイトの依存を引きずり、望ましい KEM の空間が v1.0 で固定するには速く進化しすぎているためです。
次に読むべきもの
Section titled “次に読むべきもの”- Auth and permissions — そもそも誰が暗号化された行を見られるかをゲートするリクエストレベルの auth
- Roadmap — mutator 内暗号化のセレモニーを取り除く args-boundary walker(Phase 4.1)