A privacy-first encrypted vault that requires three trusted guardians (a quorum) to unseal sensitive information. The server cannot read vault contents, cannot reconstruct the data-encryption key, and cannot bypass the quorum.
The "open it" gate is layered:
| # | Control | Where it lives |
|---|---|---|
| 1 | AES-GCM-256 envelope encryption of the payload | Browser, before any data leaves the device |
| 2 | Shamir Secret Sharing of the data-encryption key (k-of-n) |
Browser, splits key into N fragments — k are required to reconstruct |
| 3 | Per-guardian ECDH(P-256) → HKDF-SHA256 → AES-GCM key wrapping of each fragment | Each fragment is sealed to one guardian's public key. Only that guardian's private key can read it. |
To unseal, a guardian must explicitly approve, decrypt their fragment locally, and re-wrap it to the requester's ephemeral public key so only the requester's device can combine the fragments. The server only ever sees ciphertext.
src/
app/ Next.js App Router pages
page.tsx vault dashboard
vaults/new/ seal a new vault
vaults/[id]/ vault detail (ciphertext, quorum gauge, audit log)
vaults/[id]/recover/ initiate a recovery request
guardian/ guardian inbox
guardian/[requestId]/ guardian approval screen
components/ shell, identity provider, onboarding
lib/
crypto/ AES-GCM, Shamir, ECDH/HKDF wrap — all browser-side
store/ storage abstraction (localStorage demo + Supabase)
supabase/ Supabase client
supabase/migrations/ 0001_init.sql — schema with RLS
scripts/smoke.mjs end-to-end crypto round-trip test (Node)
A live preview is deployed at https://triskele-6ct.pages.dev on Cloudflare Pages.
npm install
npm run dev
# open http://localhost:3000The app builds with @cloudflare/next-on-pages — static pages are prerendered, the three
dynamic routes (/app/vaults/[id], /app/vaults/[id]/recover, /app/guardian/[requestId])
ship as edge functions.
# one-shot deploy from your local checkout (needs `wrangler login` once)
npm run pages:deploy
# or just produce the artifact for inspection
npm run pages:build # writes .vercel/output/static
npm run pages:preview # serves it locally with wranglerFor continuous deploys, connect this repo in the Cloudflare dashboard with:
- Build command:
npx @cloudflare/next-on-pages - Build output dir:
.vercel/output/static - Compatibility flag:
nodejs_compat
The app starts in demo mode (localStorage). Provision four identities — Vault Owner + G-01/G-02/G-03 — from onboarding. Switch identities from the top bar to play through the flow:
- As Vault Owner, "Provision a new vault" → choose the three guardians → seal.
- As Vault Owner, click Open vault to dispatch a recovery request.
- Switch to each Guardian, open their inbox, Approve & re-wrap.
- Switch back to Vault Owner, return to the vault, click Reconstruct & decrypt.
When NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are set, the app
switches from demo mode to Supabase mode:
/loginand/signupuse Supabase Auth (email+password and magic-link supported)./app/*is gated — unauthenticated visits redirect to/login?next=….- Each authenticated user gets one device-bound ECDH keypair (created on first sign-in, stored locally). The top-bar identity dropdown is hidden — the auth session is the identity.
Setup steps:
- Create a Supabase project (or use an existing one).
- Add the env vars locally (
.env.local) and on the deploy (Cloudflare Pages → Settings → Environment variables → Production):NEXT_PUBLIC_SUPABASE_URL=https://<ref>.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=... - Apply the schema (vaults, guardians, recovery requests, fragment responses, audit
events, all with RLS):
supabase link --project-ref <ref> supabase db push # or paste supabase/migrations/0001_init.sql into the SQL editor
- (Optional) Enable email confirmation under Authentication → Providers → Email so the signup confirmation flow lights up.
The Supabase-backed VaultStore is a follow-up — for now, vault metadata still lives in
localStorage even in Supabase mode. Migration is mechanical: implement the VaultStore
interface in src/lib/store/types.ts against Supabase tables and swap the import in
the pages.
npx tsx scripts/smoke.mjsThis exercises the full seal→recover round-trip and asserts:
- Plaintext recovers exactly after a
kofnquorum. - Sub-threshold combinations fail.
- A guardian's wrapped fragment cannot be opened by a different guardian's key.
Can:
- Store opaque AES-GCM ciphertext blobs.
- Coordinate guardian approval workflows.
- Append immutable events to a transparency log (RLS-enforced).
Cannot:
- Read vault contents (client-side encrypted).
- Reconstruct the master key (requires
kplaintext fragments held only by guardians). - Bypass the quorum via admin override (no DB column makes this possible).
- Demo mode stores guardian private keys in localStorage so a single browser can play all roles. In production, each guardian's private key never leaves their own device (IndexedDB-backed, ideally a hardware-bound key).
- The recovery request uses the requester's persistent public key. A production build would generate an ephemeral keypair per request for forward-secrecy.
- No real auth: identity = a label on the device. A production build would map identities to Supabase Auth users.