From 503d77d42cc7030a871aa51392ddfcd501d4f979 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 08:11:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(vault):=20phase=201=20=E2=80=94=20crypto?= =?UTF-8?q?=20core,=20schema=20and=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of four phases toward an end-to-end encrypted password manager. No user-visible surface yet: this is the layer everything else sits on, so it lands with its own tests and nothing depending on it. Decision taken (section 01 of the plan): a SEPARATE vault password, not the account password. MarkSyncr authenticates with signInWithPassword, so Supabase receives the account password in plaintext and it therefore cannot be an encryption key without giving up the end-to-end property. The alternative — deriving an auth hash client-side so the real password is never transmitted — is better UX and a stronger story, but it rewrites the live login path and migrates every existing user, and that risk lands on the bookmark sync that already works. It stays open: KDF parameters are stored per user, so adopting it later invalidates nobody's vault. packages/vault is shared by web and extension: primitives.js Thin WebCrypto wrappers and nothing else — PBKDF2-SHA256, HKDF-SHA256, AES-256-GCM, constant-time compare. Nothing in the vault may implement a construction not in this file. kdf.js master password -> masterKey -> {wrapKey, authHash} by HKDF under distinct labels, so holding the auth hash does not help decrypt anything. Labels are versioned and append-only: editing one makes every existing vault unopenable. vault-key.js A random user key encrypts items; the password only ever encrypts that key. So a password change re-wraps 32 bytes instead of re-encrypting the vault, and a second wrapped copy under a recovery key survives a forgotten password without the server learning anything. items.js One record with three field groups — logins, cards, identities are not three features. Password history is an array inside the encrypted blob, so it is protected for free rather than needing its own table. PBKDF2 at 600k iterations rather than Argon2id: Argon2id is the better KDF but is WASM in a browser, which means 'wasm-unsafe-eval' in the extension CSP on a product already heading for review scrutiny. PBKDF2 is native, has no dependency, and the per-user kdf/kdf_iterations columns mean the upgrade later costs nothing. rewrapUserKey() already pulls a vault up to current parameters on any password change. Two attacks the code defends against explicitly, both tested: KDF downgrade. Parameters arrive from the server, so a compromised server could serve iterations: 1 and make captured auth hashes cheap to attack offline. The client refuses to derive below its floor; the API and a CHECK constraint refuse to store one. Ciphertext relocation. Each item's id is bound in as AES-GCM additional authenticated data, so a blob moved between rows fails to decrypt instead of showing the wrong credential under the right name. The migration stores base64 TEXT, not BYTEA — bytea round-trips through PostgREST as an escaped hex string and invites encoding mistakes on exactly the values that must not be corrupted. vault_items is one row per item, unlike cloud_bookmarks' single blob, because two devices editing two DIFFERENT passwords must not cost anyone a credential; a per-row revision turns that into a 409 the client can merge. Tests: 97 new (67 in the package, 30 on the API validation). The KDF and HKDF cases are known-answer tests against RFC 7914 §11 and RFC 5869 A.1/A.3 rather than round-trips — a round-trip passes even when the parameters are wrong in both directions. One test asserts the actual product claim: that nothing the user typed appears anywhere in what is sent. Not done here: the migration is written but NOT applied to any database, and nothing calls this code yet. Phase 2 is the vault tab. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D --- apps/web/__tests__/vault-validation.test.js | 155 ++++++++++++ apps/web/app/api/vault/items/[id]/route.js | 181 ++++++++++++++ apps/web/app/api/vault/items/route.js | 118 ++++++++++ apps/web/app/api/vault/meta/route.js | 145 ++++++++++++ apps/web/lib/vault-validation.js | 85 +++++++ packages/vault/__tests__/items.test.js | 220 +++++++++++++++++ packages/vault/__tests__/primitives.test.js | 181 ++++++++++++++ packages/vault/__tests__/vault-key.test.js | 210 +++++++++++++++++ packages/vault/eslint.config.mjs | 23 ++ packages/vault/package.json | 24 ++ packages/vault/src/index.js | 73 ++++++ packages/vault/src/items.js | 231 ++++++++++++++++++ packages/vault/src/kdf.js | 122 ++++++++++ packages/vault/src/primitives.js | 205 ++++++++++++++++ packages/vault/src/vault-key.js | 236 +++++++++++++++++++ packages/vault/vitest.config.js | 14 ++ pnpm-lock.yaml | 12 +- supabase/migrations/20260829120000_vault.sql | 189 +++++++++++++++ 18 files changed, 2421 insertions(+), 3 deletions(-) create mode 100644 apps/web/__tests__/vault-validation.test.js create mode 100644 apps/web/app/api/vault/items/[id]/route.js create mode 100644 apps/web/app/api/vault/items/route.js create mode 100644 apps/web/app/api/vault/meta/route.js create mode 100644 apps/web/lib/vault-validation.js create mode 100644 packages/vault/__tests__/items.test.js create mode 100644 packages/vault/__tests__/primitives.test.js create mode 100644 packages/vault/__tests__/vault-key.test.js create mode 100644 packages/vault/eslint.config.mjs create mode 100644 packages/vault/package.json create mode 100644 packages/vault/src/index.js create mode 100644 packages/vault/src/items.js create mode 100644 packages/vault/src/kdf.js create mode 100644 packages/vault/src/primitives.js create mode 100644 packages/vault/src/vault-key.js create mode 100644 packages/vault/vitest.config.js create mode 100644 supabase/migrations/20260829120000_vault.sql diff --git a/apps/web/__tests__/vault-validation.test.js b/apps/web/__tests__/vault-validation.test.js new file mode 100644 index 0000000..983d1d5 --- /dev/null +++ b/apps/web/__tests__/vault-validation.test.js @@ -0,0 +1,155 @@ +/** + * Tests for vault payload validation. + * + * The server cannot inspect what it stores, so these checks are the entire + * defence against a malformed or hostile write reaching the table. The + * KDF-floor case matters most: it stops one client creating a weak vault that + * every other client then has to open. + * @module __tests__/vault-validation.test + */ + +import { describe, it, expect } from 'vitest'; +import { + isUuid, + isSaneBlob, + validateItemPayload, + validateVaultMeta, + MAX_CIPHERTEXT_LENGTH, + MIN_KDF_ITERATIONS, +} from '@/lib/vault-validation'; + +const validItem = () => ({ + id: '3f2504e0-4f89-41d3-9a0c-0305e82c3301', + type: 1, + ciphertext: 'aGVsbG8gd29ybGQ=', + iv: 'YWJjZGVmZ2hpams=', +}); + +const validMeta = () => ({ + kdf: 'pbkdf2-sha256', + iterations: 600000, + salt: 'c2FsdHNhbHQ=', + protectedUserKey: 'a2V5', + protectedUserKeyIv: 'aXY=', + authHash: 'aGFzaA==', +}); + +describe('isUuid', () => { + it('accepts a v4 uuid', () => { + expect(isUuid('3f2504e0-4f89-41d3-9a0c-0305e82c3301')).toBe(true); + }); + + it.each([['not-a-uuid'], [''], [null], [42], ['3f2504e04f8941d39a0c0305e82c3301']])( + 'rejects %s', + (value) => { + expect(isUuid(value)).toBe(false); + } + ); +}); + +describe('isSaneBlob', () => { + it('accepts base64', () => { + expect(isSaneBlob('aGVsbG8=')).toBe(true); + }); + + it('rejects non-base64 characters', () => { + expect(isSaneBlob('not base64!')).toBe(false); + expect(isSaneBlob('