From cbb9a06cf08fb9ee95a374c5ea8a608af0f4ce87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:59:05 +0000 Subject: [PATCH 01/11] docs(security): align tech.md crypto reference with code - AAD: replace the non-existent buildAAD(parts[]) length-prefixed model with the real factor-wrap.ts string builders (nodea:v1\x1f\x1f) - guard: document the two-pass scoped-HMAC derivation, not a single HMAC - collections registry path: collections/registry.ts -> collections.ts - rate-limit catalog: rebuild exhaustively from the 35 rateLimit() sites (add /records, change-email, recover-kek/verify, modules-config, user-preferences; fix mfa/bypass + passkey + login/reset paths) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CpnWVGG39KuHDA6paXsqY6 --- .../web/src/app/pages/docs/content/tech.md | 145 +++++++++++------- 1 file changed, 87 insertions(+), 58 deletions(-) diff --git a/packages/web/src/app/pages/docs/content/tech.md b/packages/web/src/app/pages/docs/content/tech.md index 7dc61d8e..768e823e 100644 --- a/packages/web/src/app/pages/docs/content/tech.md +++ b/packages/web/src/app/pages/docs/content/tech.md @@ -76,18 +76,25 @@ Trois étages, deux blobs structurels par utilisateur·ice. Aucune clé n'appara ### AAD (Additional Authenticated Data) -Chaque blob AES-GCM est lié à son contexte par une AAD. Un swap de blob entre utilisateurs ou entre facteurs fait échouer l'auth-tag au déchiffrement. +Chaque blob AES-GCM qui **enveloppe une clé** (KEK, main-key, passkey, label d'appareil) est lié à son contexte par une AAD. Un swap de blob entre utilisateurs ou entre facteurs fait échouer l'auth-tag au déchiffrement. (Les payloads des lignes de module ne portent pas d'AAD — seuls les blobs d'enveloppe en ont ; voir `core/crypto/aes.ts`.) -Construction canonique via `buildAAD(parts: Uint8Array[])` (dans `packages/shared/src/crypto-types.ts`) — chaque part préfixée par sa longueur en u16 big-endian, puis concaténation. Format non ambigu même avec parts de longueur variable (notamment `credential_id` WebAuthn, 16 à 1023 bytes). +Construction canonique dans `packages/web/src/core/crypto/factor-wrap.ts`, sous forme de **chaîne UTF-8** préfixée par version et jointe par le séparateur `\x1f` (Unit Separator, rare en texte et impossible dans un UUID) : -| Blob | AAD = `buildAAD([...])` | -|---|---| -| `wrapped_main_key` | `[users.id]` (UUID, 16 bytes raw) | -| `wrapped_kek_password` | `[users.id, utf8("password")]` | -| `wrapped_kek_passkey_` | `[users.id, utf8("passkey"), credential_id]` | -| `wrapped_kek_recovery` | `[users.id, utf8("recovery")]` | +```text +nodea:v1\x1f\x1f +``` -Aucune autre construction n'est autorisée dans le code applicatif — toute exception est un bug à fail-loud. +Le préfixe `nodea:v1` permet à un futur bump de schéma (v2) de rester volontairement incompatible avec les ciphertexts v1. + +| Blob | Builder | AAD | +|---|---|---| +| `wrapped_main_key` | `buildMainKeyAAD(userId)` | `nodea:v1\x1f\x1fmain` | +| `wrapped_kek_password` | `buildKekAAD(userId, 'password')` | `nodea:v1\x1f\x1fpassword` | +| `wrapped_kek_passkey_` | `buildPasskeyAAD(userId, credIdB64Url)` | `nodea:v1\x1f\x1fpasskey\x1f` | +| `wrapped_kek_recovery` | `buildKekAAD(userId, 'recovery')` | `nodea:v1\x1f\x1frecovery` | +| label d'appareil (session) | `buildSessionDeviceLabelAAD(userId)` | `nodea:v1\x1f\x1fsession-device-label` | + +`credIdB64Url` est le base64url canonique des bytes bruts du `credential_id` (même encodage que `auth_factors.credential_id` côté serveur). Aucune autre construction n'est autorisée dans le code applicatif — toute exception est un bug à fail-loud. ## Authentification @@ -106,7 +113,7 @@ Cinq routes peuvent être abusées pour énumérer les comptes ; toutes répond - `/auth/register/finish` — sur invite-bound, l'invite est consommée ou refusée selon que l'email matche le token, jamais selon que le compte existe déjà ailleurs. - `/auth/login/start` — un email inconnu reçoit une réponse OPAQUE syntaxiquement valide mais cryptographiquement morte (`finishLogin` côté client retourne `undefined`). - `/auth/recover-kek/start` — emails inconnus (ou emails connus sans recovery code) reçoivent des `wrappedKekRecovery` aléatoires de la bonne taille (48 bytes ciphertext + 12 bytes IV) ; le client échoue silencieusement à les unwrap. -- `/auth/mfa-bypass/request` — pendant un `mfa_pending`, la requête de bypass renvoie toujours 200, qu'un `mfa_bypass_request` soit créé en DB ou non. +- `/auth/mfa/bypass/request` — pendant un `mfa_pending`, la requête de bypass renvoie toujours 200, qu'un `mfa_bypass_request` soit créé en DB ou non. - `/auth/request-reset` — toujours 200, qu'un email matche ou non. ## Cycle de vie d'un compte @@ -179,7 +186,7 @@ Signature counter `signCount` vérifié strict côté serveur — un counter qui ### Bypass MFA par email -Auth-Spec §7.8. Quand l'utilisateur·ice perd son TOTP (téléphone perdu, app effacée…) et n'a pas de backup code sous la main, il/elle peut demander un bypass via `/auth/mfa-bypass/request`. Le serveur envoie un email avec un lien `/auth/bypass/confirm?t=` ; cliquer démarre un délai de **7 jours** pendant lesquels la requête peut être annulée par n'importe quelle connexion réussie. Après 7 jours, la prochaine connexion consomme le bypass : le facteur perdu est nettoyé (TOTP désactivé + backup codes purgés, ou toutes les passkeys supprimées) et le mode rétrogradé si nécessaire. +Auth-Spec §7.8. Quand l'utilisateur·ice perd son TOTP (téléphone perdu, app effacée…) et n'a pas de backup code sous la main, il/elle peut demander un bypass via `/auth/mfa/bypass/request`. Le serveur envoie un email avec un lien `/auth/mfa/bypass/confirm?t=` ; cliquer démarre un délai de **7 jours** pendant lesquels la requête peut être annulée par n'importe quelle connexion réussie. Après 7 jours, la prochaine connexion consomme le bypass : le facteur perdu est nettoyé (TOTP désactivé + backup codes purgés, ou toutes les passkeys supprimées) et le mode rétrogradé si nécessaire. Eligibility gate §6.2 : en mode `maximum`, un bypass d'un facteur exige que l'autre facteur soit prouvé dans la session `mfa_pending` avant d'émettre la requête. Sans ça, on perdrait deux facteurs d'un coup → reset destructif. @@ -222,12 +229,13 @@ Toutes les requêtes DB passent par Drizzle (`eq(users.email, value)`, etc.). ** ### Guards HMAC obligatoires sur les mutations -Toute table d'entrée chiffrée (`mood_entries`, `goals_entries`, etc.) requiert un guard HMAC pour UPDATE et DELETE. Le middleware `requireGuard(table)` valide le tuple `(user, sid, guard)` dans une seule passe centralisée. La liste des collections est dans `packages/api/src/collections/registry.ts` — le route factory itère cette liste pour monter les routes ; impossible d'enregistrer une collection sans validation. +Toute table d'entrée chiffrée (`mood_entries`, `goals_entries`, etc.) requiert un guard HMAC pour UPDATE et DELETE. Le middleware `requireGuard` valide le tuple `(user, sid, guard)` dans une seule passe centralisée. La liste des collections est le tableau `COLLECTIONS` dans `packages/api/src/collections.ts` — `createRecordsRoutes` itère cette liste pour monter les routes ; impossible d'enregistrer une collection sans validation. -**Formule du guard.** Déterministe, calculé côté client : +**Formule du guard.** Déterministe, calculée côté client en **deux passes HMAC-SHA-256** (`core/crypto/guard-derivation.ts`) — une sous-clé scopée au module, puis le tag sur l'`id` de la ligne : ```text -guard = "g_" + hex( HMAC(hmacKey, `${module_user_id}:${record_id}`) ) +scopedKey = HMAC(hmacKey, "guard:" + module_user_id) +guard = "g_" + hex( HMAC(scopedKey, record_id) ) ``` **Création en deux temps.** À la création, le client n'a pas encore l'`id` de la ligne (généré côté serveur). Il envoie donc `guard: "init"` au `POST`, reçoit l'`id` retourné, recalcule le vrai `guard`, puis fait immédiatement un `PATCH` avec les en-têtes `X-Sid: `, `X-Guard: init` et le vrai guard dans le body. Le serveur promeut `"init"` → vrai guard atomiquement. @@ -238,53 +246,74 @@ Tables 1:1 (`modules_config`, `user_preferences`) — pas de guard : le user *e ### Rate-limit catalog -22 limiters actifs au total. Format : `max / fenêtre`. Tous keyed-IP (premier hop `x-forwarded-for`, fallback `x-real-ip` puis `unknown`) et indépendants — un même IP peut consommer chaque bucket séparément. Implémenté en mémoire process (`packages/api/src/middleware/rate-limit.ts`). Single-instance ; scaling out = swap vers Redis. +35 limiters au total. Format : `max / fenêtre`. Tous keyed-IP (premier hop `x-forwarded-for`, fallback `x-real-ip` puis `unknown`), sauf le stepped-MFA (keyed-session) et `change-email` (keyed-user) ; chaque bucket est indépendant. Implémenté en mémoire process (`packages/api/src/middleware/rate-limit.ts`). Single-instance ; scaling out = swap vers Redis. Catalogue exhaustif — tout nouveau `rateLimit({…})` s'ajoute ici dans le même commit. -#### Pré-auth (`/auth/*` accessibles sans cookie de session) +#### Authentification & récupération (sans cookie, ou login en cours) -| Route | Limite | `keyPrefix` | Justification | -|---|---|---|---| -| `POST /auth/register/start` | 10 / 1h | `register-start` | OPAQUE start, accepte 10 essais/heure pour permettre la correction d'erreurs sans ouvrir un boulevard à l'enrôlement automatisé | -| `POST /auth/register/finish` | 5 / 1h | `register-finish` | Étape coûteuse (création user + opaque_record + invite consumption en transaction) | -| `POST /auth/register/activate` | 20 / 1h | `register-activate` | Click sur le magic-link — tolérant à un user qui clique plusieurs fois | -| `GET /auth/register/invite-info` | 30 / 1h | `register-invite-info` | Pré-rendu de l'écran de register (peeks au token sans le consommer) | -| `POST /auth/login` (legacy hashed) | 10 / 1min | `login` | Court-fenêtré pour limiter le brute-force, tolérant à un user qui se trompe | -| `POST /auth/login/start` + `/finish` | — | — | Pas de limiter dédié : OPAQUE est déjà coûteux côté serveur et `client.finishLogin` retourne `undefined` avant tout aller-retour réseau pour un mot de passe faux | -| `POST /auth/request-reset` | 5 / 1h | `request-reset` | Anti-spam mailer, 5 demandes par IP par heure suffisent à un user honnête | -| `POST /auth/reset` | 10 / 1min | `reset` | Mild cap pour ralentir un brute-force sur un token volé | -| `POST /auth/recover-kek/start` | 5 / 1h | `recover-kek` | Anti-énumération : 5 emails testés par heure max | -| `POST /auth/recover-kek/finish` | 5 / 1h | `recover-kek` | Partage le bucket avec `/start` | -| `POST /auth/mfa-bypass/confirm` | 20 / 1h | `mfa-bypass-link` | Click sur le magic-link de confirmation | - -#### Authentifié (cookie de session requis) - -| Route | Limite | `keyPrefix` | Justification | -|---|---|---|---| -| `POST /auth/reauth/password/{start,finish}` | 10 / 15min | `reauth` | Re-prove password gate — assez large pour les ré-auths légitimes, assez serré pour ne pas devenir un canal de devinette du mot de passe | -| `POST /auth/security-mode/change` | 10 / 15min | `security-mode-change` | Mutation sensible mais déjà gardée par re-auth | -| `POST /auth/mfa/totp` | 10 / 5min | `mfa-totp-verify` | Stepped-MFA après login : 10 tentatives/5min coupent le brute-force d'un code à 6 chiffres | -| `POST /auth/mfa/passkey/{options,verify}` | 10 / 5min | `mfa-passkey` | Stepped-MFA passkey : challenge à usage unique côté serveur | -| `POST /auth/mfa-bypass/request` | 3 / 1h | `mfa-bypass-request` | Anti-spam : trois mails maximum par heure pour le bypass MFA | -| `POST /auth/totp/enroll/{start,verify}` | 10 / 15min | `totp-enroll` | Setup d'un nouveau secret — utilisé une fois en pratique | -| `POST /auth/totp/{disable,…}` | 30 / 15min | `totp-manage` | Lecture/écriture régulière depuis Settings, plafond confortable | -| `POST /auth/security/recovery-code` | 5 / 1h | `recovery-code-setup` | Setup ou regenerate du code de récupération — opération rare | -| `POST /auth/passkeys/enroll/{options,finish}` | 10 / 15min | `passkey-enroll` | Setup d'un nouveau passkey, idem TOTP | -| `POST /auth/passkeys/{login-options,login-finish}` | 20 / 15min | `passkey-login` | Login passkey-first : tolérant aux annulations utilisateur sur le prompt OS | -| `* /auth/passkeys/:id/...` (rename, remove) | 30 / 15min | `passkey-manage` | Lecture/écriture Settings | - -#### Non-auth (lookup externes) - -| Route | Limite | `keyPrefix` | Justification | -|---|---|---|---| -| `GET /library/lookup/isbn/:isbn` | 30 / 1min | `library-lookup-isbn` | Proxy ISBN — protège l'API tierce | -| `GET /library/lookup/query` | 30 / 1min | `library-lookup-query` | Recherche fuzzy | -| `GET /library/lookup/cover/:hash` | 60 / 1min | `library-lookup-cover` | Couvertures d'images, ratio plus haut car appelé en batch sur une page de résultats | +| Route | Limite | `keyPrefix` | +|---|---|---| +| `POST /auth/register/start` | 10 / 1h | `register-start` | +| `POST /auth/register/finish` | 5 / 1h | `register-finish` | +| `POST /auth/register/activate` | 20 / 1h | `register-activate` | +| `GET /auth/register/invite-info` | 30 / 1h | `register-invite-info` | +| `POST /auth/login/start` + `/finish` | 10 / 1min | `login` | +| `POST /auth/passkeys/login/start` + `/finish` | 20 / 15min | `passkey-login` | +| `POST /auth/request-reset` | 5 / 1h | `request-reset` | +| `POST /auth/reset/start` + `/finish` | 10 / 1min | `reset` | +| `POST /auth/recover-kek/start` + `/finish` | 5 / 1h | `recover-kek` | +| `POST /auth/recover-kek/verify` | 3 / 1h | `recover-kek-verify` | + +#### MFA en cours de login (cookie `mfa_pending`) + +| Route | Limite | `keyPrefix` | +|---|---|---| +| `POST /auth/mfa/totp/verify` | 10 / 5min | `mfa-totp-verify` (keyed-session) | +| `POST /auth/mfa/passkey/start` + `/finish` | 10 / 5min | `mfa-passkey` (keyed-session) | +| `POST /auth/mfa/bypass/request` | 3 / 1h | `mfa-bypass-request` | +| `GET /auth/mfa/bypass/confirm` | 20 / 1h | `mfa-bypass-link` | + +#### Gestion du compte (cookie de session `full`) + +| Route | Limite | `keyPrefix` | +|---|---|---| +| `POST /auth/reauth/{password,passkey}/{start,finish}` | 10 / 15min | `reauth` | +| `POST /auth/change-password/start` + `/finish` | 5 / 1h | `change-password` | +| `PATCH /auth/email` | 1 / 24h | `rl:change-email` (keyed-user) | +| `POST /auth/security-mode/change` | 10 / 15min | `security-mode-change` | +| `POST /auth/totp/enroll/{start,verify}` | 10 / 15min | `totp-enroll` | +| `POST /auth/totp/{disable,backup-codes/regenerate}` | 30 / 15min | `totp-manage` | +| `POST /auth/security/recovery-code` | 5 / 1h | `recovery-code-setup` | +| `POST /auth/passkeys/enroll/{start,finish}` | 10 / 15min | `passkey-enroll` | +| `PATCH /auth/passkeys/{id}/label`, `POST …/remove` | 30 / 15min | `passkey-manage` | +| `PUT /modules-config` | 30 / 1min | `modules-config-put` | +| `PUT /user-preferences` | 60 / 1min | `user-preferences-put` | + +#### Mutations de données chiffrées (`/records*`, cookie + guard) + +| Route | Limite | `keyPrefix` | +|---|---|---| +| `POST /records` | 600 / 1min | `records-create` | +| `PATCH /records/{id}` | 600 / 1min | `records-update` | +| `DELETE /records/{id}` | 600 / 1min | `records-delete` | +| `GET /records` | 300 / 1min | `records-list` | +| `POST /records/bulk` | 60 / 1min | `records-bulk-create` | +| `POST /records/promote-guards` | 60 / 1min | `records-bulk-promote` | +| `POST /records/wipe` | 10 / 1min | `records-wipe` | + +#### Lookups externes (`/library/lookup/*`) + +| Route | Limite | `keyPrefix` | +|---|---|---| +| `POST /library/lookup/by-isbn` | 30 / 1min | `library-lookup-isbn` | +| `POST /library/lookup/by-query/stream` | 30 / 1min | `library-lookup-query` | +| `GET /library/lookup/cover-fetch` | 60 / 1min | `library-lookup-cover` | -**Politique implicite — trois familles de durée :** +**Politique implicite — familles de durée :** -- **5 minutes** pour les codes courts (TOTP, passkey en stepped-MFA) — borne le brute-force d'un secret 6 chiffres. -- **15 minutes** pour la gestion sensible (re-auth, security-mode, enroll d'un facteur). -- **1 heure** pour les actions à coût mailer ou serveur élevé (register, reset, recovery, bypass). +- **1 minute** pour les endpoints à fort débit légitime (login, reset, mutations `/records`, lookups, `modules-config`/`user-preferences`). +- **5 minutes** pour les codes courts (TOTP, passkey en stepped-MFA) — borne le brute-force d'un secret à 6 chiffres. +- **15 minutes** pour la gestion sensible (re-auth, security-mode, enroll d'un facteur, gestion des passkeys). +- **1 heure** (24 h pour le change-email) pour les actions à coût mailer/serveur élevé (register, request-reset, recovery, bypass, change-password). Si tu ajoutes une nouvelle route `/auth/*`, choisis la fenêtre selon ces familles plutôt que d'inventer une nouvelle valeur ; les exceptions doivent être justifiées en commentaire au-dessus du `rateLimit({…})`. @@ -299,7 +328,7 @@ Liste prescriptive utilisée à la revue de PR. La version exhaustive (avec rati **À faire :** - Générer un IV frais par chiffrement AES-GCM (`crypto.getRandomValues`). -- Construire toute AAD via `buildAAD(parts: Uint8Array[])` (dans `packages/shared/src/crypto-types.ts`) — jamais à la main. +- Construire toute AAD via les builders de `core/crypto/factor-wrap.ts` (`buildMainKeyAAD`, `buildKekAAD`, `buildPasskeyAAD`, `buildSessionDeviceLabelAAD`) — jamais à la main. - Dériver les sous-clés AES et HMAC via HKDF avec labels distincts (`"nodea:aes"`, `"nodea:hmac"`). - Utiliser les branded types (`AesMainKey`, `HmacMainKey`, `Base64`, `CipherIV`…) — mélanger les primitives doit échouer à la compilation. - Pour un nouveau module : réutiliser `createCollectionClient`, ne pas réimplémenter le POST/PATCH dance. From e46cecda1d86d6f4e24cf659f6b6657aa4d83fb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 17:01:35 +0000 Subject: [PATCH 02/11] docs(architecture,db): align structure, guard formula and counts with code Architecture.md: - routes: /{collection}+collection-factory.ts -> unified /records+records.ts - collections registry path -> src/collections.ts (COLLECTIONS array) - schema path -> db/schema/.ts barrel - cookie SameSite=Lax -> Strict (matches auth/cookies.ts + SEC-08) - store slice list -> the 8 real slices (drop mobileMenuOpen/composer) - guard derivation -> two-pass scoped HMAC; otplib 13.4.0 -> 13.4.1 - HRT: note hrt_suppliers_entries carries the hrt_products payload - drop brittle hard test counts Database.md: - 9 entry tables -> 13; factory in schema/entries.ts; collections.ts - guard formula -> two-pass; schema path -> db/schema/.ts - sessions: add device_label_cipher/_iv, mark ip_hash/user_agent deprecated Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CpnWVGG39KuHDA6paXsqY6 --- docs/Architecture.md | 48 +++++++++++++++++++++++++------------------- docs/Database.md | 28 ++++++++++++++++---------- 2 files changed, 44 insertions(+), 32 deletions(-) diff --git a/docs/Architecture.md b/docs/Architecture.md index edd70aec..0461a63d 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -38,14 +38,16 @@ Docker-compose deployment bundle. ### Runtime - **Hono** on `@hono/node-server`, Node 24 ESM. -- **Drizzle ORM** against **PostgreSQL 16**. Schema: - [`packages/api/src/db/schema.ts`](../packages/api/src/db/schema.ts). +- **Drizzle ORM** against **PostgreSQL 16**. Table DDL lives under + [`packages/api/src/db/schema/`](../packages/api/src/db/schema/) (split + by domain: `users`, `auth`, `admin`, `entries`, `modules`, `enums`), + re-exported through `db/schema.ts`. Migrations in [`packages/api/drizzle/`](../packages/api/drizzle/). Run `pnpm --filter @nodea/api db:generate` then `db:migrate` to create and apply. - **Zod** at every request boundary, sharing schemas with the web package via `@nodea/shared`. -- **Session cookies** (HttpOnly, Signed, `SameSite=Lax`, `Secure` in +- **Session cookies** (HttpOnly, Signed, `SameSite=Strict`, `Secure` in prod). Backing `sessions` table; revoking a row kills the session immediately. - **Argon2id** password hashing via `@node-rs/argon2`, server-side. @@ -61,19 +63,21 @@ Docker-compose deployment bundle. /announcements → routes/announcements.ts (requireUser, public read) /modules-config → routes/modules-config.ts (1:1 per user, encrypted blob) /user-preferences → routes/user-preferences.ts (1:1 per user, encrypted blob) -/{collection} → routes/collection-factory.ts (one per entry table) +/records → routes/records.ts (unified, all entry tables) ``` -`/{collection}` is driven by `src/collections/registry.ts` — adding a -new module = adding an entry in that array, which is the single source -the factory loops over. There is nowhere to forget a guard. +`/records` is driven by the `COLLECTIONS` array in `src/collections.ts` +— adding a new module = adding an entry in that array, which is the +single source `createRecordsRoutes` loops over. The target collection +travels in the `X-Collection` header (issue #67), never in the URL. +There is nowhere to forget a guard. ### Middleware - `requireUser` — resolves the session cookie to a row on the `users` table and `c.set('user', …)`. - `requireAdmin` — stacks on `requireUser` and 403s non-admin roles. -- `requireGuard` — inside `collection-factory`, validates the +- `requireGuard` — inside `records.ts`, validates the `(moduleUserId, guard)` headers on update/delete operations. **No `user_id` involvement** : entry rows carry no FK to `users`, the server cannot link a row to a specific user. The @@ -318,7 +322,7 @@ the factory loops over. There is nowhere to forget a guard. sessions — the client unwraps the KEK + main key locally while the session is still pending (Auth-Spec §7.2.bis: no leak because no full cookie = no data routes accessible). - - Helpers: `auth/totp.ts` wraps `otplib@13.4.0` with the spec + - Helpers: `auth/totp.ts` wraps `otplib@13.4.1` with the spec params (SHA-1 / 6 / 30s, ±1 window skew, returns matched window for anti-replay). `auth/totp-backup-codes.ts` generates 10 × 120-bit base32 codes with 4-4-4-4-4-4 hyphenation, @@ -406,8 +410,8 @@ sequential to avoid row-level interference. Setup under [`packages/api/src/test/setup.ts`](../packages/api/src/test/setup.ts) runs `TRUNCATE … CASCADE` before each test, and forces `EMAIL_SERVICE_IMPL=recording` (cf. `vitest.config.ts`) so suites can -assert on outgoing mail without spinning up Mailpit. 383 integration -tests at the time of writing, covering register / login / activation +assert on outgoing mail without spinning up Mailpit. Several hundred +integration tests cover register / login / activation gates, OPAQUE round-trips, OPAQUE re-auth, change-password / reset / change-email / delete-self, recovery-code KEK, passkey enroll + login + PRF unwrap, TOTP enroll + verify, stepped MFA (TOTP + @@ -435,9 +439,9 @@ end Playwright smoke + TOTP scenarios live in `packages/e2e/`. covers only the i18n provider. - **Zustand** is the single application store, see [`src/core/store/nodea-store.ts`](../packages/web/src/core/store/nodea-store.ts). - Slices: `auth`, `crypto`, `modules`, `preferences`, `notifications`, - `mobileMenuOpen`, `flow`. There is **no** parallel singleton or Context - reducer. + Slices (8): `auth`, `crypto`, `modules`, `preferences`, + `notifications`, `ui`, `flow`, `versions`. There is **no** parallel + singleton or Context reducer. - **React Hook Form + Zod** for every form that ships to the server — resolver built from the shared schema. - **Routing**: URL stays at `/flow` regardless of the active module. @@ -496,9 +500,10 @@ end Playwright smoke + TOTP scenarios live in `packages/e2e/`. - **Branded types** (`AesMainKey`, `HmacMainKey`, `Base64`, `CipherIV`, `EncryptedBlob`) from `@nodea/shared/crypto-types` prevent mixing domains at compile time. -- **Guard derivation** (`guard-derivation.ts`): deterministic - HMAC-SHA-256 over `moduleUserId || ':' || recordId` with the HMAC - sub-key. No network round-trip. +- **Guard derivation** (`guard-derivation.ts`): deterministic, two + HMAC-SHA-256 passes with the HMAC sub-key — first a key scoped per + module (`HMAC(hmacKey, "guard:" + moduleUserId)`), then the tag over + `recordId`. No network round-trip. - **Two-layer wrap** (`factor-wrap.ts`): the main key is wrapped under a random KEK (label `nodea:wrap-main`), the KEK is wrapped under an HKDF sub-key of the OPAQUE `exportKey` (label @@ -541,8 +546,8 @@ stays a Tailwind class (universal CSS, no token needed). Vitest + jsdom. Crypto round-trips (AES, HKDF, factor-wrap, guard derivation, passkey-PRF unwrap), base64 encoders, the typed HTTP -client (mocked fetch), and the Zustand store. 489 unit tests at the -time of writing. +client (mocked fetch), and the Zustand store — several hundred unit +tests. --- @@ -605,8 +610,9 @@ knob (Postgres, cookie secret, SMTP, `WEB_BASE_URL`, web port). The seven modules (Mood, Goals, Journal, Habits, Library, Review, HRT) all build on the same encrypted technical base: one `*_entries` table per module (HRT spreads across four : `hrt_admin_logs_entries`, -`hrt_lab_results_entries`, `hrt_suppliers_entries`, -`hrt_schedules_entries`), two-phase creation with HMAC validation, +`hrt_lab_results_entries`, `hrt_suppliers_entries` (whose payload and +export plugin are named `hrt_products` — renamed at the payload layer +only), `hrt_schedules_entries`), two-phase creation with HMAC validation, server-unreadable data. See [`docs/Modules/.md`](./Modules/) for each module's cleartext payload and module-specific rules. diff --git a/docs/Database.md b/docs/Database.md index 08467966..e76cffb9 100644 --- a/docs/Database.md +++ b/docs/Database.md @@ -1,7 +1,9 @@ # Database -PostgreSQL 16 schema defined via **Drizzle ORM** in -[`packages/api/src/db/schema.ts`](../packages/api/src/db/schema.ts). +PostgreSQL 16 schema defined via **Drizzle ORM**. Table DDL lives under +[`packages/api/src/db/schema/`](../packages/api/src/db/schema/) (split +by domain: `users`, `auth`, `admin`, `entries`, `modules`, `enums`), +re-exported through `db/schema.ts`. Migrations generated by `drizzle-kit` live under [`packages/api/drizzle/`](../packages/api/drizzle/) and are applied automatically by the API container on boot. @@ -69,8 +71,10 @@ id; rights and TTL live here so logout/revocation is immediate. | `mfa_totp_verified` | `bool` | Stepped-MFA flag set after `/auth/mfa/totp/verify` succeeds. | | `pending_webauthn_challenge` | `text?` | WebAuthn challenge persisted on the row for `/auth/passkeys/{enroll,login}/finish` and `/auth/reauth/passkey/finish`. | | `pending_webauthn_challenge_at` | `ts+tz?` | TTL anchor on the challenge — 5 min, after which the value is rejected. | -| `ip_hash` | `text?` | Per-deployment salted hash. Audit trail only. | -| `user_agent` | `text?` | Audit trail only. | +| `ip_hash` | `text?` | **Deprecated** — never written since auth-v2; nullable for back-compat. | +| `user_agent` | `text?` | **Deprecated** — superseded by `device_label_cipher`. | +| `device_label_cipher` | `text?` | AES-GCM ciphertext of the client-computed device label (« MacBook », « iPhone »…) for the active-sessions UI. AAD-bound to `users.id + "session-device-label"`; server never sees cleartext. Issue #47. | +| `device_label_iv` | `text?` | IV for `device_label_cipher`. Both null until the client PATCHes a label. | | `last_seen_at` | `ts+tz?` | Debounced touch on each request (≤ 1/min/session). | | `expires_at` | `ts+tz` | Checked on every request. 7-day fixed (no slide). | | `created_at` | `ts+tz` | `defaultNow()`. | @@ -245,10 +249,11 @@ for the public feed query. ### Entry tables (one per module) -All 9 entry tables share the exact same shape, produced by the -`createEntryTable` factory in `schema.ts`. The structural uniformity -is what lets the route factory loop over a single array of collections -(`src/collections/registry.ts`) without per-module branches. +All 13 entry tables share the exact same shape, produced by the +`createEntryTable` factory in `schema/entries.ts`. The structural +uniformity is what lets `createRecordsRoutes` loop over a single array +of collections (the `COLLECTIONS` array in `src/collections.ts`) +without per-module branches. **Minimum-readable-surface design.** The server is never told which entry belongs to which user — access is scoped by @@ -281,7 +286,7 @@ Each row carries the strict minimum of plaintext columns: | `module_user_id` | `text` | Opaque per-module sub-identifier (sid). Client-generated. **Sole access scope** ; the user→sids mapping lives encrypted in `modules_config.payload`. | | `cipher_iv` | `text` | AES-GCM IV (96 bits, base64). Required to decrypt `payload`. | | `payload` | `text` | AES-GCM ciphertext of the cleartext JSON. Modules that need application-level timestamps (`updated_at`, etc.) put them here, inside the encrypted blob. | -| `guard` | `text` | HMAC-SHA-256 over `sid:id`. `"init"` on creation, promoted to `g_<64 hex>` via a second PATCH. Never returned in read responses. | +| `guard` | `text` | Two-pass HMAC-SHA-256 `HMAC(HMAC(hmacKey, "guard:"+sid), id)`. `"init"` on creation, promoted to `g_<64 hex>` via a second PATCH. Never returned in read responses. | **No `user_id`, no `created_at`, no `updated_at`.** The server cannot link a row to a user, and cannot order rows chronologically — clients @@ -354,8 +359,9 @@ The pair `(cipher_iv, payload)` is an AES-GCM ciphertext keyed by the AES sub-key derived from the user's main key via HKDF label `"nodea:aes"`. -`guard` is a deterministic HMAC-SHA-256 over `moduleUserId || ':' || -recordId`, keyed by the HMAC sub-key (HKDF label `"nodea:hmac"`). It +`guard` is a deterministic two-pass HMAC-SHA-256 keyed by the HMAC +sub-key (HKDF label `"nodea:hmac"`): a module-scoped sub-key +`HMAC(hmacKey, "guard:" + moduleUserId)`, then the tag over `recordId`. It is **hidden** in read responses and compared on update/delete via the `X-Guard` request header (post-SEC-01 ; never as a query parameter, which would land in `hono/logger()` output). The server From 1503d0220ace09fa8ff79c37ab3f7963f7934324 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 17:05:30 +0000 Subject: [PATCH 03/11] docs(adr): add Update notes for client growth and composer removal - ADR-0007: client is now 15 files / ~71 functions, not 14; decision stands - ADR-0013: the composer slice was removed; the store has 8 slices now Respects ADR immutability via dated Update sections (same convention as ADR-0016), rather than rewriting the historical decisions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CpnWVGG39KuHDA6paXsqY6 --- docs/adr/0007-hand-rolled-api-client.md | 9 +++++++++ docs/adr/0013-zustand-slice-pattern.md | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/docs/adr/0007-hand-rolled-api-client.md b/docs/adr/0007-hand-rolled-api-client.md index 15ad0c37..3b9a9124 100644 --- a/docs/adr/0007-hand-rolled-api-client.md +++ b/docs/adr/0007-hand-rolled-api-client.md @@ -30,6 +30,15 @@ why didn't we adopt it? **Keep the hand-rolled dedicated functions, don't adopt `hc`.** +## Update (2026-06) — the client grew, the decision stands + +The "14" in the title is historical. The client is now **15 files** in +`core/api/` exposing **~71 `api*` functions** (`auth.ts` alone holds +~28). The decision is unchanged: wrappers stay hand-rolled over a +shared `request()`, typed against `@nodea/shared` schemas rather +than `hc`. Read "14" as "one `request()` wrapper + one +dedicated function per endpoint". + ## Consequences **Positive:** diff --git a/docs/adr/0013-zustand-slice-pattern.md b/docs/adr/0013-zustand-slice-pattern.md index 35a528ed..c1d79fbb 100644 --- a/docs/adr/0013-zustand-slice-pattern.md +++ b/docs/adr/0013-zustand-slice-pattern.md @@ -81,6 +81,16 @@ The `resetAll` action stays in the assembly file, not in a slice: it touches every slice at once and that's precisely where ADR-0006's atomicity guarantee lives. +## Update (2026-06) — the `composer` slice was removed + +The store now has **8 slices**, not nine: `composer.ts` was dropped +when the message-composer state moved into the form components +(`ui/dirk/forms/*`). Current slices on disk: `auth`, `crypto`, +`modules`, `preferences`, `notifications`, `ui`, `flow`, `versions`. +This ADR is the system-of-record for the slice inventory; the passing +`composer` mentions in ADR-0002/0006 predate the removal. The slice +pattern and every other point of this ADR are unchanged. + ## Consequences **Positive:** From 24aeb337a4eff9a10335c58e5b74067dfd877596 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 17:05:30 +0000 Subject: [PATCH 04/11] fix(comments): repair stale doc/code references in comments - drop dead pointers to untracked docs/roadmap/health.md (x4), docs/security-audit.md and docs/Operations.md - modules_list.tsx -> modules-registry.tsx (module-ids, Navigation) - store header: nine -> eight slice creators - Navigation: module comes from the flow slice, not a :moduleId URL Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CpnWVGG39KuHDA6paXsqY6 --- packages/api/src/test/auth-register-v2.test.ts | 4 ++-- packages/shared/src/error-codes.ts | 2 -- packages/shared/src/module-ids.ts | 4 +--- packages/web/src/app/pages/Docs.tsx | 5 ++--- packages/web/src/app/pages/docs/DocsSelfHost.tsx | 3 +-- packages/web/src/core/api/error-message.ts | 3 +-- packages/web/src/core/store/nodea-store.ts | 2 +- packages/web/src/ui/layout/navigation/Navigation.ts | 5 +++-- packages/web/vitest.config.ts | 1 - 9 files changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/api/src/test/auth-register-v2.test.ts b/packages/api/src/test/auth-register-v2.test.ts index c0823de7..4b051502 100644 --- a/packages/api/src/test/auth-register-v2.test.ts +++ b/packages/api/src/test/auth-register-v2.test.ts @@ -405,8 +405,8 @@ describe('POST /auth/register/finish — invited path', () => { }); /** - * Closes Finding 2 of `docs/security-audit.md` — invite atomicity - * under concurrent consumption. + * Invite atomicity under concurrent consumption (audit finding — + * the same invite code must never create two accounts). * * The implementation in `auth/invites.ts` wraps the consumption in * a `db.transaction(...)` with `SELECT … FOR UPDATE` so that two diff --git a/packages/shared/src/error-codes.ts b/packages/shared/src/error-codes.ts index c5078f29..3d76f44a 100644 --- a/packages/shared/src/error-codes.ts +++ b/packages/shared/src/error-codes.ts @@ -15,8 +15,6 @@ * The list is generated by grepping `{ error: 'xxx' }` in * `packages/api/src/routes/`. Keep it sorted alphabetically and * add a comment block when intent is non-obvious. - * - * Reference: `docs/roadmap/health.md` Tier B.4. */ export const KNOWN_API_ERROR_CODES = [ diff --git a/packages/shared/src/module-ids.ts b/packages/shared/src/module-ids.ts index 60736930..51b64e47 100644 --- a/packages/shared/src/module-ids.ts +++ b/packages/shared/src/module-ids.ts @@ -13,9 +13,7 @@ * `settings` (alias to `account`) is intentionally absent — it was * killed alongside the URL-routing rework. `home` is the cold-start * default ; `account` and `admin` are reachable but hidden from the - * public module list (`display: false` in `modules_list.tsx`). - * - * Reference: `docs/roadmap/health.md` Tier B.5. + * public module list (`display: false` in `modules-registry.tsx`). */ export const MODULE_IDS = [ diff --git a/packages/web/src/app/pages/Docs.tsx b/packages/web/src/app/pages/Docs.tsx index a18019bc..73d61ae1 100644 --- a/packages/web/src/app/pages/Docs.tsx +++ b/packages/web/src/app/pages/Docs.tsx @@ -41,9 +41,8 @@ import DocsSelfHost, { * `CONTRIBUTING.md` du repo qui couvre le workflow upstream * (PR, conventions de commit) — audience différente. * - **Auto-héberger** (`/docs/self-host`) — install Docker, env - * vars, reverse proxy, mises à jour, backups. Source de vérité - * progressive : transfère depuis `docs/Operations.md` + le root - * README. + * vars, reverse proxy, mises à jour, backups. Reprend et + * complète le README racine du repo. * * Le `/flow` privacy invariant ne s'applique pas ici : `/docs` est * public, l'audience varie d'un onglet à l'autre, et les URLs par diff --git a/packages/web/src/app/pages/docs/DocsSelfHost.tsx b/packages/web/src/app/pages/docs/DocsSelfHost.tsx index a1cb15e4..c053a622 100644 --- a/packages/web/src/app/pages/docs/DocsSelfHost.tsx +++ b/packages/web/src/app/pages/docs/DocsSelfHost.tsx @@ -8,8 +8,7 @@ import { MarkdownTier, parseToc } from './primitives'; * (sur VPS, NAS, machine perso). Couvre l'install Docker, les * variables d'environnement critiques, le reverse proxy, les * mises à jour, le backup et le diagnostic en panne. Le contenu - * est progressivement transféré depuis `docs/Operations.md` et - * le README racine du repo vers cette page. + * reprend et complète le README racine du repo. */ // eslint-disable-next-line react-refresh/only-export-components diff --git a/packages/web/src/core/api/error-message.ts b/packages/web/src/core/api/error-message.ts index de4fb437..c4bf8386 100644 --- a/packages/web/src/core/api/error-message.ts +++ b/packages/web/src/core/api/error-message.ts @@ -7,8 +7,7 @@ * back to a generic « unexpected error » entry when the code isn't * known. * - * Reference : `docs/roadmap/health.md` Tier B.4 + - * `docs/Internationalisation.md` § « Codes erreur API ». + * Reference : `docs/Internationalisation.md` § « Codes erreur API ». */ import { isKnownApiErrorCode } from '@nodea/shared'; diff --git a/packages/web/src/core/store/nodea-store.ts b/packages/web/src/core/store/nodea-store.ts index 412b0402..ac3cf88d 100644 --- a/packages/web/src/core/store/nodea-store.ts +++ b/packages/web/src/core/store/nodea-store.ts @@ -1,7 +1,7 @@ /** * Nodea's single application store (Zustand). * - * One `create()` call, nine slice creators spread across + * One `create()` call, eight slice creators spread across * `./slices/*.ts`. The slice pattern keeps the file ceiling honest * (factor-early rule) without giving up the atomicity guarantees * documented in ADR-0006 — every slice still shares the same `set` diff --git a/packages/web/src/ui/layout/navigation/Navigation.ts b/packages/web/src/ui/layout/navigation/Navigation.ts index bb220d98..8b4cf86b 100644 --- a/packages/web/src/ui/layout/navigation/Navigation.ts +++ b/packages/web/src/ui/layout/navigation/Navigation.ts @@ -13,8 +13,9 @@ export interface NavItem { /** * Flat projection of MODULES used by Layout / Header / Sidebar. * The `element` field is the lazy-wrapped module component from - * modules_list.tsx — rendered directly by Layout when its module - * matches the current `:moduleId` URL segment. + * modules-registry.tsx — rendered by Layout when its id matches the + * active module in the Zustand `flow` slice (never a URL segment: the + * `/flow` privacy invariant keeps the module out of the URL). */ export const nav: NavItem[] = MODULES.map((m) => ({ id: m.id, diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index 15c9cc05..01a747cc 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -51,7 +51,6 @@ export default defineConfig({ // ≥ 90 % bar (we measured 93.54 % at the Tier A.3 baseline). // The rest of the codebase stays in monitoring mode — no // hard threshold yet, the coverage report is informational. - // Tier 9 of `docs/roadmap/health.md`. thresholds: { 'src/core/crypto/**/*.ts': { lines: 90, From bfb5c89b38c95084d952b814a72887f0e788b630 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 17:09:31 +0000 Subject: [PATCH 05/11] docs(diagrams): correct auth endpoint paths in security diagrams - MfaBypass: POST /mfa-bypass/request -> /auth/mfa/bypass/request - OpaqueFlow: /login/finish returns { needsMfa, id }; wrap blobs come from a follow-up GET /auth/me/crypto (not inlined) - SteppedMfa: /auth/mfa/totp -> /verify; /auth/mfa/passkey -> {start,finish} Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CpnWVGG39KuHDA6paXsqY6 --- .../web/src/app/pages/docs/diagrams/MfaBypassDiagram.tsx | 2 +- .../web/src/app/pages/docs/diagrams/OpaqueFlowDiagram.tsx | 6 +++--- .../web/src/app/pages/docs/diagrams/SteppedMfaDiagram.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/web/src/app/pages/docs/diagrams/MfaBypassDiagram.tsx b/packages/web/src/app/pages/docs/diagrams/MfaBypassDiagram.tsx index 30c7ffcc..ba40e336 100644 --- a/packages/web/src/app/pages/docs/diagrams/MfaBypassDiagram.tsx +++ b/packages/web/src/app/pages/docs/diagrams/MfaBypassDiagram.tsx @@ -148,7 +148,7 @@ export default function MfaBypassDiagram() { width={150} height={hMain} label="Demande" - sub="POST /mfa-bypass/request" + sub="POST /auth/mfa/bypass/request" /> {/* Arrow 1→2 */} diff --git a/packages/web/src/app/pages/docs/diagrams/OpaqueFlowDiagram.tsx b/packages/web/src/app/pages/docs/diagrams/OpaqueFlowDiagram.tsx index 1523540a..993b392e 100644 --- a/packages/web/src/app/pages/docs/diagrams/OpaqueFlowDiagram.tsx +++ b/packages/web/src/app/pages/docs/diagrams/OpaqueFlowDiagram.tsx @@ -217,7 +217,7 @@ export default function OpaqueFlowDiagram() { y={350} direction="left" label="200 OK + Set-Cookie nodea_session=…" - body="{ wrappedMainKey, wrappedKekPassword, … }" + body="{ needsMfa: false, id }" /> {/* Step 7 — client unwraps locally */} @@ -225,8 +225,8 @@ export default function OpaqueFlowDiagram() { side="client" yTop={390} height={70} - label="HKDF(exportKey, "nodea:wrap-kek")" - sub="→ AES-GCM-decrypt → KEK → main_key → AES + HMAC sub-keys" + label="GET /auth/me/crypto → HKDF(exportKey, "nodea:wrap-kek")" + sub="→ wrap blobs → AES-GCM-decrypt → KEK → main_key → AES + HMAC sub-keys" /> {/* Footer note */} diff --git a/packages/web/src/app/pages/docs/diagrams/SteppedMfaDiagram.tsx b/packages/web/src/app/pages/docs/diagrams/SteppedMfaDiagram.tsx index 8db632ee..fb86a091 100644 --- a/packages/web/src/app/pages/docs/diagrams/SteppedMfaDiagram.tsx +++ b/packages/web/src/app/pages/docs/diagrams/SteppedMfaDiagram.tsx @@ -217,7 +217,7 @@ export default function SteppedMfaDiagram() {