Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/backend/app/api/routes/workspaces/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)


Expand All @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions apps/backend/app/api/routes/workspaces/crypto_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}, ...]
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/app/api/routes/workspaces/members_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
7 changes: 7 additions & 0 deletions apps/backend/app/api/routes/workspaces/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/app/settings/workspaces/workspace-section.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -180,6 +180,16 @@ export function WorkspaceSection({ workspace }: { workspace: Workspace }) {

{!workspace.is_personal && hasWorkspaceEncryption(workspace) && workspace.ws_role === "admin" && (
<div className="mt-4 space-y-3">
{workspace.settings?.encryption?.rotationRequired && (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm">
<ShieldAlert className="h-4 w-4 mt-0.5 text-destructive shrink-0" />
<span>
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.
</span>
</div>
)}
<PendingWrapsPrompt workspaceId={workspace.id} />
<RotateKeyButton workspaceId={workspace.id} />
</div>
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/pending-wraps-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<MemberPublicKey[] | null>(null)
Expand All @@ -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
Expand Down Expand Up @@ -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 })
Expand All @@ -79,6 +101,8 @@ export function PendingWrapsPrompt({ workspaceId }: { workspaceId: string }) {
const stillPending = pending.filter((m) => !m.publicKey)

return (
<>
{confirmDialog}
<Card>
<CardContent className="pt-6 flex flex-col gap-3">
<div className="flex items-center gap-2">
Expand All @@ -98,5 +122,6 @@ export function PendingWrapsPrompt({ workspaceId }: { workspaceId: string }) {
)}
</CardContent>
</Card>
</>
)
}
29 changes: 29 additions & 0 deletions apps/web/src/components/rotate-key-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -92,6 +118,8 @@ export function RotateKeyButton({ workspaceId }: { workspaceId: string }) {
}

return (
<>
{confirmDialog}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={working}>
Expand All @@ -118,5 +146,6 @@ export function RotateKeyButton({ workspaceId }: { workspaceId: string }) {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
3 changes: 3 additions & 0 deletions apps/web/src/lib/workspace-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/lib/workspace-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ async function deriveKek(sharedSecretBytes: ArrayBuffer): Promise<CryptoKey> {
// 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,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/workspace-dek-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/store/__tests__/key-pinning-store.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
55 changes: 49 additions & 6 deletions apps/web/src/store/__tests__/workspace-dek-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
})
})

Expand Down
Loading