Enregistre l'état du registre de tickets : #77 entre en QA, #78 ouvre la dette UX de langue de l'écran Settings et #79 le test flaky relevé en cours de route. État runtime uniquement : aucun code de feature n'est touché. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
153
.ideai/tickets/77/carnet.md
Normal file
153
.ideai/tickets/77/carnet.md
Normal file
@ -0,0 +1,153 @@
|
||||
---
|
||||
issueRef: "#77"
|
||||
version: 4
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784287453378
|
||||
---
|
||||
# Carnet #77 — cadrage UX + Architecture (2026-07-17)
|
||||
|
||||
Le design produit est dans la description du ticket. Ce carnet porte le **cadrage**, validé UX puis Architect. UX est passée avant Architect (la forme conditionnait les DTO).
|
||||
|
||||
---
|
||||
|
||||
## Arbitrages Main
|
||||
|
||||
**Affichage du code — groupement visuel uniquement.** UX proposait `AB12-CD34`. **Refusé.** Le code réel n'a pas de séparateur, et la normalisation frontend de #75 supprime les espaces mais **pas les tirets** : l'utilisateur recopierait le tiret et l'appairage échouerait. Groupement en deux blocs de 4 par espacement typographique/CSS, valeur copiée = `AB12CD34`.
|
||||
|
||||
**#76 est absorbé dans ce lot** (Architect, confirmé Main) : normalisation serveur (espaces, tirets, puis uppercase) avant comparaison. La sécurité ne doit pas dépendre de la normalisation frontend.
|
||||
|
||||
**`already_used` n'est pas exposé** (tranché par Architect, périmètre sécurité). L'API répond `invalid_or_expired` pour code absent, expiré, déjà consommé, remplacé ou incorrect — pas d'oracle pour un attaquant. Les logs internes peuvent distinguer ; le DTO public jamais.
|
||||
|
||||
**TTL du code : 10 minutes** (UX, confirmé Architect).
|
||||
|
||||
---
|
||||
|
||||
## Spec UX — surface « Appareils »
|
||||
|
||||
Écran unique, mobile-first, monté dans **l'UI web ET l'app desktop**. Navigation : `Paramètres → Appareils`. Même écran, même vocabulaire, mêmes actions des deux côtés.
|
||||
|
||||
**Liste** — par ligne : nom (niveau principal), badge `Cet appareil` pour la session courante, dernière activité (`Actif à l'instant`, `Aujourd'hui à 14:32`, `Hier`, puis date courte), date d'appairage en secondaire discret. **Jamais** de User-Agent brut, jamais d'IP.
|
||||
|
||||
**Appairer** — action primaire en haut. Panneau : code grand et lisible, bouton `Copier`, `Saisissez ce code sur le nouvel appareil.`, `Expire dans 10 min`. À expiration : `Ce code a expiré.` + bouton `Générer un nouveau code`, sans alarmisme. Après succès : `Nouvel appareil appairé`, liste rafraîchie, highlight temporaire 2-3 s.
|
||||
|
||||
**Nom d'appareil** — saisi sur le **nouvel appareil** au moment de l'appairage, prérempli par dérivation lisible (`iPhone`, `Chrome sur Windows`), obligatoire, 1-40 caractères, renommable ensuite via le menu `⋯`.
|
||||
|
||||
**Révocation** — menu `⋯` → `Révoquer`. Confirmation : `Révoquer cet appareil ?` / `Il devra être appairé à nouveau pour accéder à IdeA.` Si c'est l'appareil courant : `Vous serez déconnecté immédiatement.` puis coupure et redirection vers l'écran d'appairage. `Révoquer tous les appareils` en bas, confirmation forte, n'exige pas de lire la liste, inclut l'appareil courant.
|
||||
|
||||
**Erreurs** — `Code invalide ou expiré.` (incorrect/expiré/utilisé, message unique) ; `Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.`
|
||||
|
||||
---
|
||||
|
||||
## Cadrage Architecture
|
||||
|
||||
### Persistance — sous-domaine d'accès mono-utilisateur
|
||||
|
||||
- **domain** : `PairedDevice`, `DeviceId`, `SessionTokenHash`, `DeviceName`, `PairingCode`.
|
||||
- **application** : `PairDevice`, `ListDevices`, `RenameDevice`, `RevokeDevice`, `RevokeAllDevices`, `AuthenticateSession`, `TouchDevice`.
|
||||
- **port** : `DeviceSessionStore` — **adapter** : `FsDeviceSessionStore`.
|
||||
|
||||
Store dans `{app_data_dir}/security/devices.json`, **pas dans `.ideai/` projet** : ce sont des accès à l'instance, pas des données du repo.
|
||||
|
||||
```json
|
||||
{ "version": 1, "devices": [ { "deviceId": "uuid", "name": "…", "pairedAt": "…", "lastSeenAt": "…", "sessionTokenHash": "sha256:…" } ] }
|
||||
```
|
||||
|
||||
**Token** : 32 octets CSPRNG. Hash disque `SHA-256("idea-session-v1\0" || token_bytes)`, hex préfixé par l'algo, **comparaison constant-time**.
|
||||
|
||||
**Pas d'Argon2id** — décision Architect, et elle est juste : ce n'est pas un mot de passe faible mais un secret aléatoire 256-bit. Un KDF lent n'ajoute rien contre la préimage et coûte du CPU serveur à chaque requête. Le point dur reste : jamais de token en clair sur disque.
|
||||
|
||||
**DTO UI** : `{ deviceId, name, pairedAt, lastSeenAt, isCurrentDevice }`. Pas d'IP, pas de User-Agent.
|
||||
|
||||
### Code éphémère
|
||||
|
||||
**Ne va pas dans le store persistant** — reste en mémoire process, mais sort du `pairing_code: String` statique de `ServerState`. État : `{ code_hash, expires_at, generation_id, used }`. Consommation atomique `consume(code, now) -> Valid | InvalidOrExpired`. Générer invalide toujours le précédent. Sans `--new-code`, **aucun code n'existe au boot**.
|
||||
|
||||
Desktop Tauri : commande appelant le **même use case via le composition root, pas via HTTP**.
|
||||
|
||||
À supprimer : `EmbeddedServerHandle.pairing_code` / `EmbeddedServerStatusDto.pairing_code` comme source permanente.
|
||||
|
||||
### Révocation → WebSockets (durcissement n°4)
|
||||
|
||||
Le domaine publie `DeviceRevoked { device_id }` / `AllDevicesRevoked`. Le web-server (adapter transport) tient un `ActiveConnectionRegistry: device_id -> Vec<ConnectionHandle>`. `AuthenticateSession` retourne `AuthenticatedDevice { device_id, token_hash }` — **pas un booléen** — pour que `run_ws_connection` s'enregistre sous le bon `device_id`.
|
||||
|
||||
Séquence : le use case supprime du store → publie l'événement → l'adapter observe → ferme les WS concernés via un canal `shutdown` que la boucle sélectionne en parallèle de `read_ws_frame`, puis unregister `ws_pty_bridge` comme aujourd'hui. `OutputBridge` reste un outil de flux PTY, **jamais le mécanisme d'autorisation**.
|
||||
|
||||
### Rate-limit
|
||||
|
||||
Port `PairAttemptLimiter` dans l'application, appelé par `PairDevice` **avant** validation du code. Adapter prod `InMemoryPairAttemptLimiter`, adapter test à horloge fixe. Clé `RateLimitKey { origin, route }` — **pas de headers HTTP dans le port**, la résolution proxy reste dans l'adapter. Repères : 5 échecs/min par origine, 30 échecs/min global.
|
||||
|
||||
### Cookie
|
||||
|
||||
`HttpOnly`, `SameSite=Strict`, `Secure` selon config existante, `Path=/`, `Max-Age≈34560000` (400 j). **Renouvellement glissant** sur toute requête authentifiée réussie. Seule la révocation invalide. `lastSeenAt` **throttlé** (≤ 1 écriture / 5 min / appareil).
|
||||
|
||||
### `--new-code`
|
||||
|
||||
Bool dans `ServerArgs`. Après `ServerState` créé et listener bindé : génération + impression **uniquement dans ce cas**. Ne touche pas le store, ne crée pas d'appareil, ne change aucune config.
|
||||
|
||||
---
|
||||
|
||||
## Lots
|
||||
|
||||
| Lot | Contenu | Dépend de |
|
||||
|---|---|---|
|
||||
| **B1** | `DeviceSessionStore`, hash tokens, `PairDevice {code, name}`, cookie Max-Age + renouvellement, normalisation serveur (#76) | — (**bloquant**) |
|
||||
| **B2** | Code éphémère, `POST /api/pairing-code`, `--new-code`, suppression impression au boot, TTL/usage unique/invalidation, `invalid_or_expired` | B1 |
|
||||
| **B3** | Endpoints list/rename/revoke/revoke-all/logout, event `DeviceRevoked`, registre WS par `device_id`, fermeture immédiate + cleanup `OutputBridge` | B1 |
|
||||
| **B4** | Port `PairAttemptLimiter`, adapter mémoire + tests horloge, réponse `rate_limited` | parallèle à B2 |
|
||||
| **F1** | `POST /api/pair {code, name}`, normalisation frontend espaces **et tirets**, erreurs | contrat B1 |
|
||||
| **F2** | Écran Appareils partagé web/desktop | DTO figés |
|
||||
|
||||
Écart UX ↔ Architecture : aucun sur la forme des DTO.
|
||||
|
||||
---
|
||||
|
||||
## État d'avancement (2026-07-17)
|
||||
|
||||
- **B1 — vert, vérifié par Main** (le rapport de DevBackend a été perdu par un timeout de rendez-vous ; les tests ont été rejoués indépendamment). `cargo test -p domain -p application -p infrastructure -p web-server` intégralement vert. Couverture réelle constatée : store (round-trip, fichier absent, JSON corrompu), `session_token_hash_is_prefixed_sha256_and_verifies_constant_time`, `authenticated_invoke_renews_session_cookie_max_age`, `pairing_code_normalization_removes_spaces_hyphens_and_uppercases`, `touch_device_is_throttled_to_five_minutes`, validation du nom.
|
||||
- **F1 + F2 — verts sur mock**, 91 fichiers / 841 tests. `tsc --noEmit` à 0. Garde-fous `no-direct-invoke` et `desktop-only` verts. `test:bundle-transport` confirme `dist` = tauri, `dist-web` = http.
|
||||
- **B2 + B4 — en cours.**
|
||||
- **B3 — à lancer.**
|
||||
|
||||
---
|
||||
|
||||
## Contrats figés par Main après F1/F2
|
||||
|
||||
F1/F2 ont été livrés avant B3. DevFrontend a dû déduire des contrats que le cadrage ne nommait pas ; **je les fige tels quels**, ils sont cohérents avec les routes déjà nommées par Architect. **B3 s'y conforme** — si divergence, c'est le backend qui s'aligne, pas le frontend.
|
||||
|
||||
### Routes REST
|
||||
|
||||
| Route | Usage |
|
||||
|---|---|
|
||||
| `GET /api/devices` | liste |
|
||||
| `POST /api/devices/{id}/rename` | renommage |
|
||||
| `POST /api/devices/{id}/revoke` | révocation d'un appareil |
|
||||
| `POST /api/devices/revoke-all` | révocation totale |
|
||||
| `POST /api/pairing-code` | génération d'un code |
|
||||
| `POST /api/pair` | `{code, name}` |
|
||||
|
||||
Raisonnement retenu (DevFrontend, validé Main) : la surface d'authentification ne peut pas vivre sur le RPC générique `/api/invoke`, qui est lui-même auth-gated. Elle vit donc sur des routes dédiées, comme `/api/pair` et `/api/logout` aujourd'hui.
|
||||
|
||||
### Commandes Tauri
|
||||
|
||||
`list_devices`, `create_pairing_code`, `rename_device`, `revoke_device`, `revoke_all_devices`.
|
||||
|
||||
### Notification d'appairage — polling, pas d'event
|
||||
|
||||
UX demande « nouvel appareil appairé → liste rafraîchie + highlight ». **Décision Main : pas d'event `DevicePaired` côté B3.** L'UI poll `listDevices()` toutes les 3 s **uniquement pendant que le panneau de code est ouvert** (≤ 10 min, jamais en régime permanent), pour un acte rare. Un event domaine routé jusqu'au WS live serait plus propre en théorie, mais ajoute une surface et du code client pour un gain invisible. Option notée, non retenue.
|
||||
|
||||
### Choix de sécurité frontend à préserver
|
||||
|
||||
**Le message d'erreur du serveur n'est jamais réaffiché** : l'UI mappe sur le code (`invalid_or_expired`, `rate_limited`) vers les libellés figés. Si un backend écrivait un jour « code déjà utilisé » dans `message`, un pont qui forwarde réintroduirait l'oracle que le ticket interdit. Le mapping rend la fuite structurellement impossible plutôt que d'en faire une affaire de discipline. **Un test verrouille ce point — ne pas le contourner.**
|
||||
|
||||
### Écart B1 → corrigé en B2
|
||||
|
||||
`new_session_token()` concaténait deux UUID v4 au lieu d'un CSPRNG (aucune crate `rand` dans `web-server`). Pas une faille — UUID v4 tire de `getrandom`, 244 bits effectifs — mais écart au cadrage sur un chemin de sécurité, et construction que le prochain lecteur devrait re-vérifier pour se rassurer. Correction demandée dans B2.
|
||||
|
||||
### Hors périmètre, consigné ailleurs
|
||||
|
||||
- Langue mélangée des sections Settings desktop (« Appareils » vs « AI Profiles », « Deployment ») → **#78**, décision UX de fond (règle de langue de l'UI, éventuel i18n).
|
||||
- `ConfirmDialog` laissée locale à la feature plutôt qu'ajoutée au design system : décision UX/Architect, pas un effet de bord de ce ticket.
|
||||
|
||||
### Dette de topologie à traiter par Git
|
||||
|
||||
**B1, F1 et F2 ont été implémentés directement sur `develop`**, sans branche de feature — erreur de cadrage de Main, qui a lancé les devs sans passer par Git. Le travail est sain et non commité. **Git doit ranger ça avant tout commit**, sachant que `develop` porte déjà 37 commits non poussés.
|
||||
77
.ideai/tickets/77/issue.md
Normal file
77
.ideai/tickets/77/issue.md
Normal file
@ -0,0 +1,77 @@
|
||||
---
|
||||
id: "eecf77ee-dfcb-436c-aa5f-0c7033c7bdfa"
|
||||
number: 77
|
||||
title: "Appairage : appareils enregistrés persistants, révocables, et code éphémère à usage unique"
|
||||
status: "qa"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: [{"target":"#76","kind":"relatesTo"},{"target":"#75","kind":"relatesTo"},{"target":"#68","kind":"relatesTo"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784279214815
|
||||
updatedAt: 1784287453378
|
||||
version: 4
|
||||
---
|
||||
## Besoin utilisateur
|
||||
|
||||
Deux constats live (téléphone, instance exposée derrière reverse proxy) :
|
||||
|
||||
1. Le code d'appairage est redemandé **à chaque ouverture de la page**. Impraticable.
|
||||
2. Le code étant affiché sur la machine, un appairage est impossible quand l'utilisateur n'est pas chez lui.
|
||||
|
||||
## Cause racine du #1 — trois couches, confirmées
|
||||
|
||||
- **Le cookie de session n'a ni `Max-Age` ni `Expires`** (`crates/web-server/src/lib.rs:1629`). C'est un cookie de session au sens navigateur : détruit à la fermeture. Sur mobile, purge agressive en arrière-plan → réappairage quasi systématique. **C'est la cause directe.**
|
||||
- **Les sessions vivent en mémoire** (`sessions: Mutex<HashSet<String>>`, ligne 422). Un redémarrage désappaire tous les appareils, même avec un cookie persistant.
|
||||
- **Le code d'appairage est régénéré à chaque démarrage** (`new_pairing_code()` appelé dans `with_core`, ligne 437). Le code noté hier ne vaut plus rien.
|
||||
|
||||
## Design validé (discussion utilisateur ↔ Main, 2026-07-17)
|
||||
|
||||
### Appareils
|
||||
|
||||
- Un **appareil** appairé est persistant, nommé, listable, révocable. Session valide **indéfiniment jusqu'à révocation**.
|
||||
- Le **serveur est la source de vérité** de la durée de vie. Le cookie n'est qu'un porteur, renouvelé à chaque visite (le plafond navigateur réel est ~400 jours côté Chrome — « indéfini » se tient côté serveur, pas côté cookie).
|
||||
- Vocabulaire figé : **« appareils »**, jamais « utilisateurs ». Ce design est **mono-utilisateur assumé** : tous les appareils ont les pleins pouvoirs, pas de comptes, pas de mots de passe, pas de permissions différenciées. Le jour où plusieurs personnes seront nécessaires, ce sera un autre chantier — le vocabulaire ne doit pas avoir menti entre-temps.
|
||||
|
||||
### Code d'appairage
|
||||
|
||||
- **Éphémère, à usage unique, généré à la demande.** TTL court (5-10 min à arbitrer). Générer un nouveau code invalide le précédent.
|
||||
- **Suppression du code statique au démarrage**, y compris l'`eprintln!("IdeA pairing code: …")` (ligne 619) : un secret permanent qui part dans stderr/journald/toute capture de sortie. Après ce lot, aucun secret au repos — un code n'existe que quand il est demandé.
|
||||
- Génération : depuis l'**app desktop** (serveur embarqué, accès direct à l'état) et depuis l'**UI web déjà appairée**.
|
||||
- **Pas de commande CLI de génération, pas de socket de contrôle, pas de store partagé entre processus.** Décision explicite : supprime l'IPC, la concurrence d'écriture CLI↔serveur et la classe de bugs associée.
|
||||
- **Flag `--new-code`** au lancement du serveur headless : affiche un code au démarrage. C'est le **bootstrap du premier lancement** et la **trappe de secours** quand plus aucune UI n'est accessible. Le flag ne doit **rien rendre persistant** (laissé par mégarde dans une unit systemd, il ne doit pas transformer chaque redémarrage en distribution de code).
|
||||
- Le code réapparaît dans stderr avec `--new-code` : risque résiduel accepté, car TTL court + usage unique rendent sans valeur un log qui fuite plus tard.
|
||||
|
||||
### Condition de validité du design
|
||||
|
||||
**L'écran de gestion des appareils doit vivre dans l'UI web, pas seulement dans l'app desktop.** Sinon, sur une installation headless, générer un code impose un redémarrage — donc couper PTY, agents en cours et WebSockets — à chaque nouvel appareil. Avec l'écran dans l'UI web, la boucle se ferme : `--new-code` donne le premier appareil, tout le reste se gère depuis cet appareil, et `--new-code` redevient une trappe de secours.
|
||||
|
||||
### Accès distant — trou assumé
|
||||
|
||||
Le design ne couvre **pas** l'appairage d'un appareil neuf à distance : générer un code suppose une UI appairée ou un accès à la machine. Le filet est **SSH** (se connecter, relancer avec `--new-code`). Assumé consciemment, acceptable tant que les appareils persistent réellement — le cas devient rare. **Passkey/WebAuthn gardée en réserve, hors périmètre de ce lot.**
|
||||
|
||||
## Durcissements non négociables
|
||||
|
||||
1. **TTL sur le code** — pas seulement l'usage unique. Un code généré et jamais consommé qui reste valide pour toujours recrée le problème d'aujourd'hui.
|
||||
2. **Limite de tentatives sur `POST /api/pair`** — 8 caractères hex = 4,3e9 combinaisons, brute-forçable en quelques semaines à débit soutenu contre un code permanent. TTL **et** rate-limit, pas l'un ou l'autre.
|
||||
3. **Tokens de session hachés sur disque, jamais en clair.** Le store `.ideai/` part dans les backups et les synchros ; en clair il donne un accès shell. À traiter comme un fichier de mots de passe.
|
||||
4. **La révocation doit couper les WebSockets déjà ouverts.** Un WS établi ne revalide jamais le cookie : révoquer un appareil qui a un PTY ouvert le laisserait piloter la machine. Une révocation qui ne coupe pas la connexion vivante n'est pas une révocation.
|
||||
|
||||
## Surface de gestion
|
||||
|
||||
Lister les appareils (nom, date d'appairage, dernière activité), les révoquer un par un, et **révoquer tout** — pour le jour où un téléphone est perdu et où lister avant d'agir n'est pas souhaitable.
|
||||
|
||||
## Points d'entrée code
|
||||
|
||||
- `crates/web-server/src/lib.rs` : `ServerState` (418-482), `create_session`/`has_session`/`revoke_session` (469-484), `eprintln!` du code (619), comparaison du code (1612), pose du cookie (1629-1640), `logout_response` (1643), `new_pairing_code` (2220), `new_session_token` (2230).
|
||||
- `crates/app-tauri/src/embedded_server.rs` : exposition `pairing_code` (97, 334).
|
||||
- `frontend/src/features/web/PairingScreen.tsx`, `frontend/src/adapters/http/webSession.ts`.
|
||||
|
||||
## DoD
|
||||
|
||||
- Un appareil appairé le reste après fermeture du navigateur **et** après redémarrage du serveur.
|
||||
- Aucun code d'appairage n'existe au repos ; un code demandé expire et ne sert qu'une fois.
|
||||
- Révocation effective immédiatement, WebSockets vivants inclus.
|
||||
- Écran de gestion accessible depuis l'UI web **et** l'app desktop.
|
||||
- Validation live : téléphone appairé une fois, toujours connecté le lendemain après redémarrage de l'AppImage.
|
||||
Reference in New Issue
Block a user