diff --git a/apps/backend/app/api/routes/workspaces/api.py b/apps/backend/app/api/routes/workspaces/api.py
index 1588c10f..085c0638 100644
--- a/apps/backend/app/api/routes/workspaces/api.py
+++ b/apps/backend/app/api/routes/workspaces/api.py
@@ -306,9 +306,11 @@ async def get_my_dek_wrap(
if not mem:
raise HTTPException(403)
wrap = mem.get("wrappedDek")
+ enc = (ws.get("settings") or {}).get("encryption") or {}
return DekWrapOut(
wrappedDek=WrappedDekBlob(**wrap) if wrap else None,
wrappedDekVersion=mem.get("wrappedDekVersion", 0),
+ expectedFingerprint=enc.get("dekFingerprint"),
)
@@ -326,6 +328,12 @@ async def post_dek_wrap_for_member(
is_admin = (caller_mem and caller_mem["ws_role"] == "admin") or (org_mem and org_mem["org_role"] in ("owner", "admin"))
if not is_admin:
raise HTTPException(403)
+ # L-1: only wrap for an actual member. set_membership_wrapped_dek uses a
+ # non-upsert update, so a wrap for a non-member would silently no-op and
+ # hide the caller's mistake. Reject it explicitly instead.
+ target_mem = await find_ws_membership(ws_id, body.target_uid)
+ if not target_mem:
+ raise HTTPException(404, "Target user is not a workspace member")
existing = await crypto_repo.get_membership_wrap(ws_id, body.target_uid)
new_version = (existing["wrappedDekVersion"] if existing else 0) + 1
await crypto_repo.set_membership_wrapped_dek(
diff --git a/apps/backend/app/api/routes/workspaces/crypto_repo.py b/apps/backend/app/api/routes/workspaces/crypto_repo.py
index 60d9b9ad..350f089a 100644
--- a/apps/backend/app/api/routes/workspaces/crypto_repo.py
+++ b/apps/backend/app/api/routes/workspaces/crypto_repo.py
@@ -84,6 +84,24 @@ async def set_workspace_encryption(
)
+async def flag_workspace_rotation_required(workspace_id: str) -> bool:
+ """Mark an encrypted workspace as needing a DEK rotation (e.g. after a member
+ is removed). No-op if the workspace has no encryption initialized. Returns
+ True if the flag was set. The flag is cleared on the next rotation because
+ set_workspace_encryption() rewrites settings.encryption without it.
+ """
+ ws = await db_manager.find_one(WORKSPACES, {"_id": workspace_id})
+ enc = (ws.get("settings") or {}).get("encryption") if ws else None
+ if not enc:
+ return False
+ await db_manager.update_one(
+ WORKSPACES,
+ {"_id": workspace_id},
+ {"$set": {"settings.encryption.rotationRequired": True}},
+ )
+ return True
+
+
async def bulk_set_wrapped_deks(
workspace_id: str,
wraps: list[dict], # [{uid, wrapped, version}, ...]
diff --git a/apps/backend/app/api/routes/workspaces/members_service.py b/apps/backend/app/api/routes/workspaces/members_service.py
index cfadf8f6..6829ff09 100644
--- a/apps/backend/app/api/routes/workspaces/members_service.py
+++ b/apps/backend/app/api/routes/workspaces/members_service.py
@@ -148,3 +148,9 @@ async def remove_workspace_member(uid: str, ws_id: str, target_uid: str) -> None
await db_manager.delete_one(
WORKSPACE_MEMBERSHIPS, {"workspace_id": ws_id, "uid": target_uid},
)
+ # H-1: the removed member's client already holds the unwrapped DEK, so deleting
+ # their membership does not revoke decryption. Flag the workspace so an admin
+ # rotates the shared key (which re-encrypts entries under a key the removed
+ # member never receives).
+ from app.api.routes.workspaces import crypto_repo
+ await crypto_repo.flag_workspace_rotation_required(ws_id)
diff --git a/apps/backend/app/api/routes/workspaces/schema.py b/apps/backend/app/api/routes/workspaces/schema.py
index feab9f3c..754f269b 100644
--- a/apps/backend/app/api/routes/workspaces/schema.py
+++ b/apps/backend/app/api/routes/workspaces/schema.py
@@ -20,6 +20,10 @@ class WorkspaceEncryptionInfo(BaseModel):
dekFingerprint: str
createdAt: int
rotatedAt: int | None = None
+ # Set true when a member is removed; cleared on the next DEK rotation.
+ # Signals admins that the shared key must be rotated to revoke the removed
+ # member's decryption capability.
+ rotationRequired: bool = False
class WorkspaceSettings(BaseModel):
@@ -127,6 +131,9 @@ class WrappedDekBlob(BaseModel):
class DekWrapOut(BaseModel):
wrappedDek: WrappedDekBlob | None
wrappedDekVersion: int
+ # SHA-256 fingerprint of the current workspace DEK (from workspace settings).
+ # Client verifies the unwrapped DEK matches this before trusting it (M-1).
+ expectedFingerprint: str | None = None
class DekWrapPostRequest(BaseModel):
diff --git a/apps/web/src/app/settings/workspaces/workspace-section.tsx b/apps/web/src/app/settings/workspaces/workspace-section.tsx
index 8c79cf79..197d1266 100644
--- a/apps/web/src/app/settings/workspaces/workspace-section.tsx
+++ b/apps/web/src/app/settings/workspaces/workspace-section.tsx
@@ -1,7 +1,7 @@
"use client"
import { useState } from "react"
-import { Pencil, Trash2, UserPlus } from "lucide-react"
+import { Pencil, Trash2, UserPlus, ShieldAlert } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -180,6 +180,16 @@ export function WorkspaceSection({ workspace }: { workspace: Workspace }) {
{!workspace.is_personal && hasWorkspaceEncryption(workspace) && workspace.ws_role === "admin" && (
+
+
+ A member was removed. Rotate the encryption key now to revoke their
+ access to workspace secrets — until you do, the removed member can
+ still decrypt existing data.
+
+
+ )}
diff --git a/apps/web/src/components/pending-wraps-prompt.tsx b/apps/web/src/components/pending-wraps-prompt.tsx
index 55030cb3..a522731e 100644
--- a/apps/web/src/components/pending-wraps-prompt.tsx
+++ b/apps/web/src/components/pending-wraps-prompt.tsx
@@ -10,6 +10,8 @@ import { useWorkspaceDekStore } from "@/store/workspace-dek-store"
import { unwrapUserPrivateKey, wrapDekForMember } from "@/lib/workspace-crypto"
import { getKeypair } from "@/lib/user-keypair-api"
import { listPendingWraps, postDekWrap, type MemberPublicKey } from "@/lib/workspace-dek-api"
+import { verifyMemberKeys, trustMemberKeys } from "@/store/key-pinning-store"
+import { useConfirm } from "@/components/confirm-dialog"
export function PendingWrapsPrompt({ workspaceId }: { workspaceId: string }) {
const [pending, setPending] = useState(null)
@@ -18,6 +20,7 @@ export function PendingWrapsPrompt({ workspaceId }: { workspaceId: string }) {
const masterVault = useMasterKeyStore((s) => s.vault)
const userPub = useUserKeypairStore((s) => s.publicKey)
const userPriv = useUserKeypairStore((s) => s.privateKey)
+ const { confirm, dialog: confirmDialog } = useConfirm()
useEffect(() => {
let cancelled = false
@@ -60,6 +63,25 @@ export function PendingWrapsPrompt({ workspaceId }: { workspaceId: string }) {
}
// 3. For each pending member with a publicKey, wrap DEK + POST.
const ready = (pending ?? []).filter((m) => m.publicKey)
+ // H-2: TOFU-verify keys before wrapping. Changed keys need explicit trust.
+ const changed = verifyMemberKeys(ready)
+ if (changed.length > 0) {
+ const names = changed.map((m) => m.email ?? m.uid).join(", ")
+ const ok = await confirm({
+ title: "Member encryption keys changed",
+ description:
+ `These members' public keys differ from what was previously trusted: ${names}. ` +
+ "Expected if they reset their master password, but could indicate a server-substituted " +
+ "key. Only continue if you trust the change.",
+ confirmLabel: "Trust new keys & wrap",
+ destructive: true,
+ })
+ if (!ok) {
+ toast.error("Wrapping cancelled — member key change not trusted")
+ return
+ }
+ trustMemberKeys(changed)
+ }
for (const member of ready) {
const wrapped = await wrapDekForMember(dek, myPriv!, member.publicKey!, myPub!)
await postDekWrap(workspaceId, { target_uid: member.uid, wrapped })
@@ -79,6 +101,8 @@ export function PendingWrapsPrompt({ workspaceId }: { workspaceId: string }) {
const stillPending = pending.filter((m) => !m.publicKey)
return (
+ <>
+ {confirmDialog}
@@ -98,5 +122,6 @@ export function PendingWrapsPrompt({ workspaceId }: { workspaceId: string }) {
)}
+ >
)
}
diff --git a/apps/web/src/components/rotate-key-button.tsx b/apps/web/src/components/rotate-key-button.tsx
index 95cb6ce5..b92d500d 100644
--- a/apps/web/src/components/rotate-key-button.tsx
+++ b/apps/web/src/components/rotate-key-button.tsx
@@ -17,6 +17,8 @@ import {
import { useUserKeypairStore } from "@/store/user-keypair-store"
import { useWorkspaceDekStore } from "@/store/workspace-dek-store"
import { useWorkspaceStore } from "@/store/workspace-store"
+import { verifyMemberKeys, trustMemberKeys } from "@/store/key-pinning-store"
+import { useConfirm } from "@/components/confirm-dialog"
import { generateWorkspaceDek, wrapDekForMember, dekFingerprint } from "@/lib/workspace-crypto"
import { listMemberPublicKeys, rotateDek } from "@/lib/workspace-dek-api"
import { reencryptAllEntries } from "@/lib/dek-rotation"
@@ -28,6 +30,7 @@ export function RotateKeyButton({ workspaceId }: { workspaceId: string }) {
const getDek = useWorkspaceDekStore((s) => s.getDek)
const clearWsDek = useWorkspaceDekStore((s) => s.clearWorkspace)
const reloadStore = useWorkspaceStore((s) => s.loadFromBackend)
+ const { confirm, dialog: confirmDialog } = useConfirm()
async function handleRotate() {
if (!userPriv || !userPub) {
@@ -52,6 +55,29 @@ export function RotateKeyButton({ workspaceId }: { workspaceId: string }) {
)
}
+ // H-2: TOFU-verify member public keys before wrapping the DEK to them.
+ // A changed key may be a legit keypair reset — or a server substituting an
+ // attacker key. Require explicit confirmation before trusting the change.
+ const changed = verifyMemberKeys(ready)
+ if (changed.length > 0) {
+ const names = changed.map((m) => m.email ?? m.uid).join(", ")
+ const ok = await confirm({
+ title: "Member encryption keys changed",
+ description:
+ `These members' public keys differ from what was previously trusted: ${names}. ` +
+ "This is expected if they reset their master password, but could also mean the " +
+ "server substituted a key. Only continue if you trust the change — proceeding wraps " +
+ "the workspace key to the new keys.",
+ confirmLabel: "Trust new keys & rotate",
+ destructive: true,
+ })
+ if (!ok) {
+ toast.error("Rotation cancelled — member key change not trusted")
+ return
+ }
+ trustMemberKeys(changed)
+ }
+
// Phase 1: generate new DEK + wraps, then atomically flip on server.
const newDek = await generateWorkspaceDek()
const wraps = await Promise.all(
@@ -92,6 +118,8 @@ export function RotateKeyButton({ workspaceId }: { workspaceId: string }) {
}
return (
+ <>
+ {confirmDialog}
+ >
)
}
diff --git a/apps/web/src/lib/workspace-api.ts b/apps/web/src/lib/workspace-api.ts
index 1dc1ced7..41d159a5 100644
--- a/apps/web/src/lib/workspace-api.ts
+++ b/apps/web/src/lib/workspace-api.ts
@@ -14,6 +14,9 @@ export type WorkspaceEncryption = {
dekFingerprint: string
createdAt: number
rotatedAt: number | null
+ // Set after a member is removed; cleared on the next rotation. Signals admins
+ // to rotate the shared key to revoke the removed member's access.
+ rotationRequired?: boolean
}
export type Workspace = {
diff --git a/apps/web/src/lib/workspace-crypto.ts b/apps/web/src/lib/workspace-crypto.ts
index f433d2a2..4f442f9c 100644
--- a/apps/web/src/lib/workspace-crypto.ts
+++ b/apps/web/src/lib/workspace-crypto.ts
@@ -127,6 +127,14 @@ async function deriveKek(sharedSecretBytes: ArrayBuffer): Promise {
// Zero salt is intentional: the ECDH shared secret already provides
// sufficient entropy. Using a fixed salt keeps the protocol stateless.
salt: bs(new Uint8Array(32)),
+ // ponytail: info is not bound to workspaceId (L-2). A fully-compromised
+ // server could replay a wrap+fingerprint pair from one workspace into
+ // another (server-induced split-view DoS — NOT a confidentiality break,
+ // the server never holds a plaintext DEK; and the M-1 fingerprint check in
+ // workspace-dek-store already blocks the confidentiality path). Binding
+ // ws_id here would close it but is a breaking HKDF change: needs a
+ // "workspace-dek-wrap-v2" scheme + a forced re-wrap migration for every
+ // member. Upgrade path: version the blob, try v2 then v1 on unwrap, rotate.
info: bs(new TextEncoder().encode("workspace-dek-wrap-v1")),
},
ikm,
diff --git a/apps/web/src/lib/workspace-dek-api.ts b/apps/web/src/lib/workspace-dek-api.ts
index 1369a561..e7adc257 100644
--- a/apps/web/src/lib/workspace-dek-api.ts
+++ b/apps/web/src/lib/workspace-dek-api.ts
@@ -3,6 +3,7 @@ import { backendFetch } from "./backend-auth"
export type DekWrapBlob = {
wrappedDek: { encrypted: string; iv: string; senderPublicKey: string } | null
wrappedDekVersion: number
+ expectedFingerprint?: string | null
}
export type MemberPublicKey = { uid: string; email: string | null; publicKey: string | null }
diff --git a/apps/web/src/store/__tests__/key-pinning-store.test.ts b/apps/web/src/store/__tests__/key-pinning-store.test.ts
new file mode 100644
index 00000000..05ab3e19
--- /dev/null
+++ b/apps/web/src/store/__tests__/key-pinning-store.test.ts
@@ -0,0 +1,53 @@
+/**
+ * Tests for key-pinning-store.ts (H-2 — TOFU public-key pinning).
+ */
+import { useKeyPinningStore, verifyMemberKeys, trustMemberKeys } from "../key-pinning-store"
+
+beforeEach(() => {
+ useKeyPinningStore.getState().clear()
+})
+
+describe("key-pinning-store — check/trust", () => {
+ it("reports 'new' for an unseen uid, 'match' after trust, 'changed' on a different key", () => {
+ const s = useKeyPinningStore.getState()
+ expect(s.check("u1", "pkA")).toBe("new")
+ s.trust("u1", "pkA")
+ expect(useKeyPinningStore.getState().check("u1", "pkA")).toBe("match")
+ expect(useKeyPinningStore.getState().check("u1", "pkB")).toBe("changed")
+ })
+})
+
+describe("verifyMemberKeys", () => {
+ it("auto-pins first-seen keys (TOFU) and returns no changes", () => {
+ const members = [
+ { uid: "a", email: "a@x", publicKey: "pkA" },
+ { uid: "b", email: "b@x", publicKey: "pkB" },
+ ]
+ expect(verifyMemberKeys(members)).toEqual([])
+ // Second pass with identical keys → still no change.
+ expect(verifyMemberKeys(members)).toEqual([])
+ })
+
+ it("returns members whose key changed and does NOT auto-trust them", () => {
+ verifyMemberKeys([{ uid: "a", email: "a@x", publicKey: "pkA" }])
+ const changed = verifyMemberKeys([{ uid: "a", email: "a@x", publicKey: "pkEVIL" }])
+ expect(changed.map((m) => m.uid)).toEqual(["a"])
+ // Still pinned to the original — not silently updated.
+ expect(useKeyPinningStore.getState().check("a", "pkA")).toBe("match")
+ expect(useKeyPinningStore.getState().check("a", "pkEVIL")).toBe("changed")
+ })
+
+ it("ignores members without a published public key", () => {
+ expect(verifyMemberKeys([{ uid: "a", email: null, publicKey: null }])).toEqual([])
+ expect(useKeyPinningStore.getState().pins["a"]).toBeUndefined()
+ })
+
+ it("trustMemberKeys re-pins a changed key after explicit confirmation", () => {
+ verifyMemberKeys([{ uid: "a", email: "a@x", publicKey: "pkA" }])
+ const changed = [{ uid: "a", email: "a@x", publicKey: "pkNEW" }]
+ expect(verifyMemberKeys(changed)).toHaveLength(1) // detected
+ trustMemberKeys(changed) // human confirmed
+ expect(useKeyPinningStore.getState().check("a", "pkNEW")).toBe("match")
+ expect(verifyMemberKeys(changed)).toEqual([]) // no longer flagged
+ })
+})
diff --git a/apps/web/src/store/__tests__/workspace-dek-store.test.ts b/apps/web/src/store/__tests__/workspace-dek-store.test.ts
index 2e3af2ce..8c90299f 100644
--- a/apps/web/src/store/__tests__/workspace-dek-store.test.ts
+++ b/apps/web/src/store/__tests__/workspace-dek-store.test.ts
@@ -11,6 +11,7 @@ jest.mock("@/lib/workspace-dek-api", () => ({
jest.mock("@/lib/workspace-crypto", () => ({
unwrapDek: jest.fn(),
+ dekFingerprint: jest.fn(),
}))
import { useWorkspaceDekStore } from "../workspace-dek-store"
@@ -101,27 +102,69 @@ describe("useWorkspaceDekStore — getDek", () => {
expect(cached!.version).toBe(3)
})
- it("returns the cached DEK on second call without re-hitting backend or unwrapDek", async () => {
+ it("reuses the cached DEK (no re-unwrap) when the server version is unchanged", async () => {
const privateKey = makeFakePrivateKey()
useUserKeypairStore.getState().setKeypair("pub-key", privateKey)
const fakeWrappedDek = { encrypted: "enc", iv: "iv", senderPublicKey: "spk" }
const fakeDek = makeFakeCryptoKey("cached-dek")
- mockGetDekWrap.mockResolvedValueOnce({ wrappedDek: fakeWrappedDek, wrappedDekVersion: 1 })
+ // M-2: getDek always re-fetches the wrap to detect rotations, but only
+ // re-unwraps when the version changed.
+ mockGetDekWrap.mockResolvedValue({ wrappedDek: fakeWrappedDek, wrappedDekVersion: 1 })
mockUnwrapDek.mockResolvedValueOnce(fakeDek)
- // First call — fetches and caches
const first = await useWorkspaceDekStore.getState().getDek("ws-4")
expect(first).toBe(fakeDek)
expect(mockGetDekWrap).toHaveBeenCalledTimes(1)
expect(mockUnwrapDek).toHaveBeenCalledTimes(1)
- // Second call — should return from cache
+ // Second call — same version → reuse cached key, re-fetch wrap, no re-unwrap.
const second = await useWorkspaceDekStore.getState().getDek("ws-4")
expect(second).toBe(fakeDek)
- expect(mockGetDekWrap).toHaveBeenCalledTimes(1) // no additional call
- expect(mockUnwrapDek).toHaveBeenCalledTimes(1) // no additional call
+ expect(mockGetDekWrap).toHaveBeenCalledTimes(2) // re-fetched to check version
+ expect(mockUnwrapDek).toHaveBeenCalledTimes(1) // but NOT re-unwrapped
+ })
+
+ it("re-unwraps when the server version has changed (rotation detected)", async () => {
+ const privateKey = makeFakePrivateKey()
+ useUserKeypairStore.getState().setKeypair("pub-key", privateKey)
+
+ const wrapV1 = { encrypted: "e1", iv: "i1", senderPublicKey: "s1" }
+ const wrapV2 = { encrypted: "e2", iv: "i2", senderPublicKey: "s2" }
+ const dekV1 = makeFakeCryptoKey("dek-v1")
+ const dekV2 = makeFakeCryptoKey("dek-v2")
+
+ mockGetDekWrap
+ .mockResolvedValueOnce({ wrappedDek: wrapV1, wrappedDekVersion: 1 })
+ .mockResolvedValueOnce({ wrappedDek: wrapV2, wrappedDekVersion: 2 })
+ mockUnwrapDek.mockResolvedValueOnce(dekV1).mockResolvedValueOnce(dekV2)
+
+ expect(await useWorkspaceDekStore.getState().getDek("ws-5")).toBe(dekV1)
+ // Version bumped → stale cache invalidated, fresh unwrap.
+ expect(await useWorkspaceDekStore.getState().getDek("ws-5")).toBe(dekV2)
+ expect(mockUnwrapDek).toHaveBeenCalledTimes(2)
+ expect(useWorkspaceDekStore.getState().deks.get("ws-5")?.version).toBe(2)
+ })
+
+ it("throws when the unwrapped DEK fingerprint does not match the workspace", async () => {
+ const privateKey = makeFakePrivateKey()
+ useUserKeypairStore.getState().setKeypair("pub-key", privateKey)
+
+ const fakeWrappedDek = { encrypted: "enc", iv: "iv", senderPublicKey: "spk" }
+ mockGetDekWrap.mockResolvedValueOnce({
+ wrappedDek: fakeWrappedDek,
+ wrappedDekVersion: 1,
+ expectedFingerprint: "EXPECTED_FP",
+ })
+ mockUnwrapDek.mockResolvedValueOnce(makeFakeCryptoKey("wrong-dek"))
+ ;(workspaceCrypto.dekFingerprint as jest.Mock).mockResolvedValueOnce("MISMATCH_FP")
+
+ await expect(useWorkspaceDekStore.getState().getDek("ws-6")).rejects.toThrow(
+ /verification failed/i,
+ )
+ // Must not cache an unverified key.
+ expect(useWorkspaceDekStore.getState().deks.has("ws-6")).toBe(false)
})
})
diff --git a/apps/web/src/store/key-pinning-store.ts b/apps/web/src/store/key-pinning-store.ts
new file mode 100644
index 00000000..1afd7823
--- /dev/null
+++ b/apps/web/src/store/key-pinning-store.ts
@@ -0,0 +1,96 @@
+/**
+ * key-pinning-store.ts
+ *
+ * Trust-on-first-use (TOFU) pinning for workspace member public keys.
+ *
+ * The backend serves member public keys (member-publickeys / pending-wraps).
+ * That directory is NOT authenticated: a compromised server could substitute an
+ * attacker-controlled public key for a member, causing the wrapping admin to
+ * wrap the workspace DEK straight to the attacker — silently defeating E2E.
+ *
+ * We defend like SSH known_hosts: the first time we see a member's public key we
+ * pin it locally; if it ever changes we refuse to wrap until a human explicitly
+ * re-trusts the new key. Legit changes happen (a user resets their master
+ * password and regenerates their keypair), so this warns rather than hard-fails
+ * — but the wrap does not proceed on an unverified change without confirmation.
+ *
+ * Pins live in localStorage (client-side): the server is the untrusted party, so
+ * storing pins server-side would defeat the purpose.
+ */
+
+import { create } from "zustand"
+import { persist } from "zustand/middleware"
+
+export type PinStatus = "new" | "match" | "changed"
+
+type Pin = { publicKey: string; firstSeen: number }
+
+type State = { pins: Record } // key = user uid
+
+type Actions = {
+ /** Compare a fetched public key against the pin without mutating state. */
+ check: (uid: string, publicKey: string) => PinStatus
+ /** Pin (or re-pin) a member's public key as trusted. */
+ trust: (uid: string, publicKey: string) => void
+ clear: () => void
+}
+
+export const useKeyPinningStore = create()(
+ persist(
+ (set, get) => ({
+ pins: {},
+
+ check: (uid, publicKey) => {
+ const existing = get().pins[uid]
+ if (!existing) return "new"
+ return existing.publicKey === publicKey ? "match" : "changed"
+ },
+
+ trust: (uid, publicKey) =>
+ set((state) => ({
+ pins: {
+ ...state.pins,
+ // Preserve firstSeen if we already had a pin (this is a re-trust).
+ [uid]: { publicKey, firstSeen: state.pins[uid]?.firstSeen ?? Date.now() },
+ },
+ })),
+
+ clear: () => set({ pins: {} }),
+ }),
+ { name: "workspace-key-pins", version: 1 },
+ ),
+)
+
+export type MemberKey = { uid: string; email: string | null; publicKey: string | null }
+
+/**
+ * Verify a batch of member public keys against local pins.
+ *
+ * Side effect: auto-pins members whose key is unseen (TOFU). Members whose key
+ * CHANGED from a prior pin are returned so the caller can prompt for explicit
+ * re-trust — they are NOT auto-trusted. Members with no published key are ignored.
+ *
+ * @returns the members whose public key changed since last pinned.
+ */
+export function verifyMemberKeys(members: MemberKey[]): MemberKey[] {
+ const store = useKeyPinningStore.getState()
+ const changed: MemberKey[] = []
+ for (const m of members) {
+ if (!m.publicKey) continue
+ const status = store.check(m.uid, m.publicKey)
+ if (status === "new") {
+ store.trust(m.uid, m.publicKey) // TOFU: pin first sighting
+ } else if (status === "changed") {
+ changed.push(m)
+ }
+ }
+ return changed
+}
+
+/** Explicitly re-trust members whose key changed (after a human confirms). */
+export function trustMemberKeys(members: MemberKey[]): void {
+ const store = useKeyPinningStore.getState()
+ for (const m of members) {
+ if (m.publicKey) store.trust(m.uid, m.publicKey)
+ }
+}
diff --git a/apps/web/src/store/workspace-dek-store.ts b/apps/web/src/store/workspace-dek-store.ts
index 291175f8..bb1cfd1b 100644
--- a/apps/web/src/store/workspace-dek-store.ts
+++ b/apps/web/src/store/workspace-dek-store.ts
@@ -1,6 +1,6 @@
import { create } from "zustand"
import { getWorkspaceDekWrap } from "@/lib/workspace-dek-api"
-import { unwrapDek } from "@/lib/workspace-crypto"
+import { unwrapDek, dekFingerprint } from "@/lib/workspace-crypto"
import { useUserKeypairStore } from "@/store/user-keypair-store"
type CachedDek = { key: CryptoKey; version: number }
@@ -19,16 +19,32 @@ export const useWorkspaceDekStore = create((set, get) => ({
deks: new Map(),
async getDek(workspaceId: string): Promise {
- const cached = get().deks.get(workspaceId)
- if (cached) return cached.key
-
const privateKey = useUserKeypairStore.getState().privateKey
if (!privateKey) return null
+ // Always fetch the current wrap so we detect rotations. Reuse the cached key
+ // only when the server's version still matches (M-2: stale-cache after
+ // rotation would decrypt-fail or write under a dead key).
const wrap = await getWorkspaceDekWrap(workspaceId)
if (!wrap.wrappedDek) return null
+ const cached = get().deks.get(workspaceId)
+ if (cached && cached.version === wrap.wrappedDekVersion) return cached.key
+
const dek = await unwrapDek(wrap.wrappedDek, privateKey)
+
+ // M-1: verify the unwrapped DEK matches the workspace's published fingerprint
+ // before trusting it. A tampered/substituted wrap (buggy or malicious sender)
+ // would otherwise silently yield a DEK divergent from the rest of the team.
+ if (wrap.expectedFingerprint) {
+ const fp = await dekFingerprint(dek)
+ if (fp !== wrap.expectedFingerprint) {
+ throw new Error(
+ "Workspace key verification failed: unwrapped DEK does not match the workspace fingerprint.",
+ )
+ }
+ }
+
set((state) => {
const next = new Map(state.deks)
next.set(workspaceId, { key: dek, version: wrap.wrappedDekVersion })