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" && (
+ {workspace.settings?.encryption?.rotationRequired && ( +
+ + + 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}