コンテンツにスキップ

Encryption

plasma には、互いに重なり合う 2 つの暗号化ストーリーがあります:

  1. at-rest、クライアントローカル — ブラウザエンジンが .encrypted() セルを IDB に格納する前に Envelope でラップするため、ブラウザの IDB ストアのデバイス側スナップショットが平文を明かすことは決してありません。
  2. 運用者に対する end-to-end — サーバーはマークされた column の平文を決して見るべきではありません。mutator 本体が明示的に encryptField() を呼ぶ必要があります。

両ストーリーは Envelope のワイヤーフォーマットと AES-GCM-256 プリミティブを共有します。異なるのは暗号化境界が どこに 位置するかです。

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 なクライアントローカル”

最もシンプルな接続です。createPlasmaClientencryption 設定を渡します:

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.tsdecryptPatch が受信行の Envelope を見て、IDB に着地する前にアンラップします。
  • デバイスの押収 / IDB のスクレイプ。 Chrome の IndexedDB ディレクトリは、ローカルディスクアクセスを持つ誰にとっても world-readable です。.encrypted() column はその読み手にとって不透明です。
  • 侵害された、または悪意のあるサーバー運用者。 クライアントの 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 ではなく、ラッピングキーを保持します。

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 は拒否されます。

keyId フィールドがローテーションを可能にします。

  1. 新しい DEK(k2)を導入する。
  2. 新しい書き込みは k2 の下に入ります。k1 の既存 envelope は k1 キーを保持しているため依然としてデコードできます。
  3. バックグラウンドジョブが既存の envelope を k2 の下で書き直します — スケジュールされた Worker が decryptField(k1, ...) を呼び、行ごとに k2 で再暗号化します。
  4. すべての envelope が k2 の下になったら、k1 を破棄します。

plasma は再暗号化ループを提供しません; バッチをスキャンする table に対する mutator として書くことになります。

AES-GCM の上にポスト量子保護を重ねる必要があるデプロイのために、@sh1n4ps/plasma-core は以下も提供します:

  • PqEnvelope — クラシックな Envelope{ v, kind: "pq-hybrid", kem: { alg, ct }, inner } 内にラップします
  • PqHybridProviderwrap() / 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 で固定するには速く進化しすぎているためです。

  • Auth and permissions — そもそも誰が暗号化された行を見られるかをゲートするリクエストレベルの auth
  • Roadmap — mutator 内暗号化のセレモニーを取り除く args-boundary walker(Phase 4.1)