feat(auth): per-account X25519 identity keypair (Phase A1, #3) #6

Merged
alvaro merged 1 commit from feat/3-phase-a1-x25519-keypair into main 2026-07-19 00:00:29 +00:00
Owner

Summary

Phase A1 of the multi-person sharing ADR (docs/adr/multi-person-sharing.md). Each account now gets an X25519 keypair at registration:

  • public key — stored plaintext (public keys aren't secret); other accounts will wrap profile DEKs to it in Phase B
  • private key — wrapped under the account DEK, stored as opaque ciphertext; the server can't decrypt it

The keypair is generated client-side; the backend adds no crypto deps and stores everything verbatim — the zero-knowledge contract is preserved.

This PR only introduces the keypair and threads it through the auth flows. It is not consumed yet. It unblocks Phase B (sharing) without touching the data model or the ~11 frontend encrypt call sites (that's Phase A2, per-profile DEKs).

Scope decision

The ADR's Phase A bundled two independent changes. Per discussion on #3, this PR does only the keypair (A1). Per-profile DEKs (A2) ship separately so each is reviewable on its own.

Changes

Backend (all new fields are Option<String> → backward compatible):

  • models/user.rs: identity_public_key, identity_private_key_wrapped, identity_private_key_wrapped_iv
  • handlers/auth.rs: RegisterRequest + AuthResponse carry the fields; register + login echo them
  • No changes to change_password/recover_password — the account DEK value is unchanged across both flows, so the wrapped private key is too (verified)
  • 2 new integration tests in auth_tests.rs: round-trip through register/login; optional-fields backward compat

Frontend:

  • crypto/keys.ts: generateIdentityKeyPair, wrapIdentityPrivateKey, unwrapIdentityPrivateKey + in-memory store mirroring the DEK store
  • types/api.ts: AuthTokens + RegisterRequest extended (also removes an existing as any cast)
  • useStore.ts: register generates+wraps, login/UnlockPage unwrap, logout clears
  • 3 new crypto tests (X25519 lifecycle, wrong-DEK rejection, distinct shared secrets); skip gracefully where the runtime lacks X25519

Verification

  • cargo test — all 19 backend tests pass (Mongo available); cargo clippy -- -D warnings clean
  • npm test — all 28 frontend tests pass (3 new X25519 tests skip in this jsdom runtime, which lacks X25519 Web Crypto; they'll run in browser/newer Node)
  • tsc --noEmit — clean

Not in scope (follow-ups)

  • Phase A2: per-profile DEKs (the account DEK → profile DEK → data tier) + multi-profile UI
  • Phase B: actually using the keypair to wrap profile DEKs to recipients
  • Removing the dead AccessClaims struct (#5)

Closes nothing yet (tracking issue stays open for Phase A2/B/C/D). Refs #3.

## Summary Phase A1 of the multi-person sharing ADR ([`docs/adr/multi-person-sharing.md`](../blob/main/docs/adr/multi-person-sharing.md)). Each account now gets an X25519 keypair at registration: - **public key** — stored plaintext (public keys aren't secret); other accounts will wrap profile DEKs to it in Phase B - **private key** — wrapped under the account DEK, stored as opaque ciphertext; the server can't decrypt it The keypair is generated **client-side**; the backend adds no crypto deps and stores everything verbatim — the zero-knowledge contract is preserved. **This PR only introduces the keypair and threads it through the auth flows. It is not consumed yet.** It unblocks Phase B (sharing) without touching the data model or the ~11 frontend encrypt call sites (that's Phase A2, per-profile DEKs). ## Scope decision The ADR's Phase A bundled two independent changes. Per discussion on #3, this PR does **only the keypair** (A1). Per-profile DEKs (A2) ship separately so each is reviewable on its own. ## Changes **Backend** (all new fields are `Option<String>` → backward compatible): - `models/user.rs`: `identity_public_key`, `identity_private_key_wrapped`, `identity_private_key_wrapped_iv` - `handlers/auth.rs`: `RegisterRequest` + `AuthResponse` carry the fields; `register` + `login` echo them - No changes to `change_password`/`recover_password` — the account DEK value is unchanged across both flows, so the wrapped private key is too (verified) - 2 new integration tests in `auth_tests.rs`: round-trip through register/login; optional-fields backward compat **Frontend**: - `crypto/keys.ts`: `generateIdentityKeyPair`, `wrapIdentityPrivateKey`, `unwrapIdentityPrivateKey` + in-memory store mirroring the DEK store - `types/api.ts`: `AuthTokens` + `RegisterRequest` extended (also removes an existing `as any` cast) - `useStore.ts`: `register` generates+wraps, `login`/`UnlockPage` unwrap, `logout` clears - 3 new crypto tests (X25519 lifecycle, wrong-DEK rejection, distinct shared secrets); skip gracefully where the runtime lacks X25519 ## Verification - `cargo test` — all 19 backend tests pass (Mongo available); `cargo clippy -- -D warnings` clean - `npm test` — all 28 frontend tests pass (3 new X25519 tests skip in this jsdom runtime, which lacks X25519 Web Crypto; they'll run in browser/newer Node) - `tsc --noEmit` — clean ## Not in scope (follow-ups) - Phase A2: per-profile DEKs (the `account DEK → profile DEK → data` tier) + multi-profile UI - Phase B: actually *using* the keypair to wrap profile DEKs to recipients - Removing the dead `AccessClaims` struct (#5) Closes nothing yet (tracking issue stays open for Phase A2/B/C/D). Refs #3.
alvaro added 1 commit 2026-07-18 23:00:24 +00:00
feat(auth): add per-account X25519 identity keypair (#3)
Some checks failed
Lint and Build / format (pull_request) Successful in 42s
Lint and Build / clippy (pull_request) Successful in 1m46s
Lint and Build / build (pull_request) Successful in 3m47s
Lint and Build / test (pull_request) Failing after 0s
8ee8012aca
Phase A1 of the multi-person sharing ADR
(docs/adr/multi-person-sharing.md). Each account now gets an X25519
keypair at registration: the public half stored plaintext, the private
half wrapped under the account DEK and stored as opaque ciphertext. The
keypair is generated client-side; the backend adds no crypto deps and
stores everything verbatim, preserving the zero-knowledge contract.

This change only introduces the keypair and threads it through the auth
flows — it is not consumed yet. It unblocks Phase B (profile sharing)
without touching the data model or the ~11 frontend encrypt call sites,
which is Phase A2 (per-profile DEKs).

Backend:
- User model: identity_public_key, identity_private_key_wrapped{,_iv}
  (all Option<String>, backward compatible).
- RegisterRequest/AuthResponse carry the 3 fields; register + login
  echo them. No changes to change_password/recover (DEK value is
  unchanged across both, so the wrapped private key is too).
- 2 new integration tests: round-trip through register/login, and
  optional-fields backward compat.

Frontend:
- crypto/keys.ts: generateIdentityKeyPair, wrapIdentityPrivateKey,
  unwrapIdentityPrivateKey + in-memory identity store mirroring the DEK.
- types/api.ts: AuthTokens + RegisterRequest extended (removes an
  existing `as any` cast).
- useStore register/login/logout + UnlockPage unwrap the private key
  alongside the DEK.
- 3 new crypto tests (X25519 lifecycle, wrong-DEK rejection, distinct
  shared secrets); skip gracefully where the runtime lacks X25519.

Backend: cargo test + clippy green. Frontend: npm test + tsc green.

Also gitignore .zcode/ (local tooling artifact).
alvaro merged commit 6d3642ee13 into main 2026-07-19 00:00:29 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: alvaro/normogen#6
No description provided.