Skip to content

feat(vault): phase 1 — crypto core, schema and API - #18

Merged
ralyodio merged 1 commit into
masterfrom
vault-phase-1
Aug 29, 2026
Merged

feat(vault): phase 1 — crypto core, schema and API#18
ralyodio merged 1 commit into
masterfrom
vault-phase-1

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Phase 1 of 4 toward an end-to-end encrypted password manager. No user-visible surface — this is the layer everything else sits on, landing with its own tests and nothing yet depending on it.

Plan: https://claude.ai/code/artifact/f8d564d9-23f6-408d-a34d-a4b3688b5118

The decision this bakes in

A separate vault password, not the account password. MarkSyncr authenticates with signInWithPassword, so Supabase receives the account password in plaintext — it therefore cannot be an encryption key without giving up the end-to-end property entirely.

The alternative (derive 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 today. It stays open: KDF parameters are stored per-user, so adopting it later invalidates nobody's vault.

packages/vault

Shared by web and extension.

Module Role
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} via 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. A password change re-wraps 32 bytes instead of the whole vault, and a second wrapped copy under a recovery key survives a forgotten password without the server learning anything.
items.js One record, three field groups — logins/cards/identities are not three features. Password history is an array inside the encrypted blob, so it's protected for free.

Why PBKDF2 and not Argon2id

Argon2id is the better KDF, but in a browser it's WASM — which means adding 'wasm-unsafe-eval' to the extension CSP on a product already heading for review scrutiny. PBKDF2-SHA256 at 600k (the current OWASP floor) is native, dependency-free, and the per-user kdf/kdf_iterations columns make the upgrade later cost nothing. rewrapUserKey() already pulls a vault up to current parameters on any password change.

Two attacks defended 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 by anyone with database write access fails to decrypt rather than showing the wrong credential under the right name.

Schema notes

  • 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.
  • One row per item, unlike cloud_bookmarks' single blob: 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.
  • RLS mirrors user_settings, using (SELECT auth.uid()) per migration 013's performance fix, and the new function sets search_path per migration 012's advisory fix.

Testing

97 new tests (67 in the package, 30 on API validation), all passing; lint clean; next build compiles and registers all three routes.

The KDF and HKDF cases are known-answer tests against RFC 7914 §11 and RFC 5869 A.1/A.3, not round-trips — a round-trip passes even when the parameters are wrong in both directions. I verified those vectors against an independent implementation before hardcoding them.

One test asserts the actual product claim: that nothing the user typed appears anywhere in what gets sent.

⚠️ __tests__/extension-auth.test.js has 2 failures on this branch. They are pre-existing — they reproduce in isolation and that file imports nothing added here.

Not done

  • The migration is written but NOT applied to any database. I verified it structurally only; it needs applying before anything can use it.
  • Nothing calls this code yet. Phase 2 is the vault tab.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D

it('travels inside the encrypted blob, so it is protected for free', async () => {
const key = userKey();
let item = createItem('login', { login: { password: 'leaked-if-plaintext' } });
});

it('does not mutate the item it was given', () => {
const item = createItem('login', { login: { password: 'original' } });
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

11 finding(s)

MEDIUM: 7 | LOW: 4

Severity Rule Location
MEDIUM sql-template-interpolation apps/web/app/api/account/delete/route.js:104
MEDIUM js-credential-logged apps/web/app/api/health/route.js:27
MEDIUM js-open-redirect apps/web/app/dashboard/sync-sources-client.jsx:88
MEDIUM js-unescaped-html-sink apps/web/app/layout.jsx:86
MEDIUM js-unescaped-html-sink apps/web/app/layout.jsx:110
MEDIUM js-open-redirect apps/web/app/pricing/page.jsx:178
MEDIUM manifest-install-lifecycle-script package.json:17
LOW secret-generic-credential apps/web/__tests__/auth-api.test.js:541
LOW redos-nested-quantifier packages/sources/__tests__/dropbox-oauth.test.ts:72
LOW secret-generic-credential packages/vault/__tests__/items.test.js:201
LOW secret-generic-credential packages/vault/__tests__/items.test.js:216

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 256d898 into master Aug 29, 2026
9 checks passed
@ralyodio
ralyodio deleted the vault-phase-1 branch August 29, 2026 08:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants