diff --git a/.dockerignore b/.dockerignore index a5adbcc..9025ce6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,8 @@ dist Dockerfile docker-compose.yml README.md + +sync-data +pardis-secours*.json +test-results +playwright-report diff --git a/.env.example b/.env.example index 8480039..226c914 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,7 @@ LISTEN_IP=0.0.0.0 # Adresse/CIDR du proxy qui transmet le protocole PROXY à Caddy. # Remplacer cette adresse d’exemple par celle du proxy réellement utilisé. TRUST_PROXY=192.0.2.10/32 + +# Encrypted revision storage quota per vault (50 MiB), and total vault limit. +SYNC_MAX_BYTES=52428800 +SYNC_MAX_VAULTS=10000 diff --git a/.gitignore b/.gitignore index 76672d6..d12cdd7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ dist-ssr # Browser test artifacts playwright-report/ test-results/ + +# Local synchronization service data and recovery files +sync-data/ +pardis-secours*.json diff --git a/PRODUCTION.md b/PRODUCTION.md new file mode 100644 index 0000000..2962911 --- /dev/null +++ b/PRODUCTION.md @@ -0,0 +1,128 @@ +# Validation avant production — Pardis + +## Vérifications exécutées dans cet environnement + +- Dernière validation locale : 10 octobre 2026, avec `npm run validate` et la + suite Playwright utilisant le Chromium Headless Shell installé via + `PARDIS_CHROME_PATH`. Ces résultats ne remplacent pas une recette sur téléphones + réels ni le résultat du workflow CI du commit publié. +- 272 tests unitaires et IndexedDB simulée passent (15 fichiers). +- `npm run check` : aucune erreur ni avertissement. +- Build PWA complet : `sw.js`, Workbox, manifeste et précache générés. +- L’importeur de la version précédente, extrait du commit avant l’évolution du format, refuse réellement + un export v2 avec son message de mise à jour (sans importer les pauses comme travail). +- Cas couverts : 8h30 de travail / 30 minutes de pause, récapitulatif/copier et + suggestions sans pause, reprise continue et discontinue à 18h, reprise avec + dernier repas/pause, deux appels concurrents, aucune pause vide, refus de reprise + passée, annulation transactionnelle sur erreur, repas automatique pendant une + pause, clôture pendant une pause, ajout/correction/suppression sur journée close, + import v1 et aller-retour v2, préservation des intervalles vides, écriture de + réglages contre leur dernière valeur persistée et échec d’écriture. + +Commande reproductible : `npm ci` puis `npm run validate`. +La CI Gitea exécute déjà tests, vérification Svelte et build avant déploiement. + +## Parcours navigateur automatisés + +Seize tests Playwright exécutent le build PWA réel dans Chrome, avec des profils +éphémères et des fixtures synthétiques à 320 px. Ils couvrent consultation et +correction, copie, statistiques, date et Retour après rechargement, focus et +Échap (y compris les panneaux imbriqués), annulation et double appui, deux onglets, +les reprises après repas/pause, export téléchargé puis vérifié, purge/restauration, +erreur IndexedDB puis Réessayer, erreur de réglage avec champ restauré, ajout +d’une pause dans un intervalle vide et rechargement hors ligne sous service worker. +L’écran **Données et sauvegardes** est vérifié depuis les réglages et le rappel, +avec Retour, rechargement, durée persistée et absence de débordement à 320 px. +Le bandeau de sauvegarde est aussi testé avec une police agrandie. + +Les trois parcours d’erreur ajoutés couvrent : + +- Échec d’écriture de la conservation : erreur visible, champ restauré, + base inchangée, valeur conservée après rechargement et nouvelle tentative réussie. +- Export JSON valide mais modifié : refus de vérification sans changement des + journées, postes, réglages ou statut de sauvegarde ; le fichier original peut + ensuite être vérifié. +- Échec de suppression des journées après écriture de la sauvegarde et suppression + des postes : annulation de toute la transaction, y compris restauration de la + sauvegarde précédente ; le dialogue reste ouvert et permet de réessayer. + +Les captures MCP avaient été refusées par le contrôle d’autorisation. Les tests +Playwright ont été autorisés comme vérification automatisée isolée : aucune trace, +vidéo ou capture n’est produite, aucun contenu de page n’est transmis à MCP. + +Commandes : `npx playwright install chromium` puis `npm run validate:all`. +Un navigateur installé peut être choisi avec `PARDIS_CHROME_PATH`. +La CI installe Chromium avec ses dépendances et lance les mêmes parcours. +`docker compose config --quiet` a été validé avec des variables d’exemple. +Le build et le lancement de l’image Docker n’ont pas été exécutés ici (accès au +socket Docker indisponible dans le bac à sable). + +## Vérifications sur appareils réels à terminer avant publication + +- [ ] Sur petit téléphone (320 px), parcourir Récap → Voir les détails → Corriger, + retourner au récapitulatif et vérifier date, copie et statistiques. +- [ ] Au clavier, vérifier les pages Détails, Automatismes, Jours non travaillés + et Données et sauvegardes, le focus initial, Tab et Maj+Tab dans chaque dialogue, + Échap et restitution au bouton d’origine. Inclure remplacement et purge. +- [ ] Vérifier Retour du navigateur et Retour de la PWA depuis les pages filles + des réglages et du récapitulatif. +- [ ] Annuler la reprise depuis chacun des deux boutons : aucun changement. + Double appui et deux onglets réels : un seul poste de reprise. +- [ ] Installer la PWA puis démarrer hors ligne, suspendre et rouvrir. + Tester une mise à jour sans interrompre une journée active. +- [ ] Exporter sur iOS/Safari et Android/Chrome, vérifier le fichier téléchargé, + restaurer par fusion puis remplacer sur un profil de test. +- [ ] Simuler IndexedDB inaccessible : erreur visible et Réessayer. +- [ ] Vérifier les libellés d’ajustement de l’import sur un historique endommagé. +- [ ] Vérifier les dates affichées avant purge et récupérer sa sauvegarde. +- [ ] Depuis les réglages puis le rappel, ouvrir Données et sauvegardes, modifier + la durée, recharger et vérifier sa persistance sans suppression de journées. + +### Consigner la recette manuelle + +Utiliser des profils et des données de test. Pour Android/Chrome et iOS/Safari, +prendre une version stable actuelle et consigner les versions exactes. Aucun de +ces contrôles n’a été exécuté sur appareil réel dans cet environnement. + +| Environnement | Appareil et versions OS/navigateur | Date | Résultat | +| --- | --- | --- | --- | +| Android / Chrome, PWA installée | À renseigner | — | À effectuer | +| iOS / Safari, ajout à l’écran d’accueil | À renseigner | — | À effectuer | +| Chromium sur ordinateur, navigation au clavier | À renseigner | — | Recette manuelle à effectuer | + +Pour chaque environnement, relever les résultats d’export/vérification/import +(fusion et remplacement), démarrage hors ligne, suspension/réouverture, Retour +et affichage étroit. Pour les dialogues de remplacement et de purge, relever +aussi le focus initial, Tab/Maj+Tab, Échap et la restitution du focus. Une mise +à jour doit être proposée sans interrompre une journée active. Ne cocher les +vérifications qu’après leur exécution ; consigner les anomalies avec le parcours +et les versions utilisées. + +## Suivi de fiabilité et limites connues + +- La purge au lancement est désactivée pour protéger les imports anciens. + La purge manuelle conserve une sauvegarde dans IndexedDB, récupérable depuis + **Données et sauvegardes**. Modifier la durée de conservation ne supprime rien. + Cette copie n’est pas une sauvegarde externe et disparaît si les + données du site sont effacées. Une seule sauvegarde avant purge est conservée. +- La date du dernier export **préparé** n’est pas une preuve que le + téléchargement a été conservé : le navigateur ne confirme pas cette étape + automatiquement. Une date distincte est marquée vérifiée après relecture du fichier + téléchargé et comparaison de son empreinte complète. Le rappel se déclenche + après sept jours sans export vérifié. La restauration par import et la + récupération après purge sont testées, mais conserver une copie externe reste + nécessaire. +- Les journées dont tous les postes sont automatiques sont signalées comme + déduites et à vérifier. Leur statut métier reste « terminée » ; une étape formelle + de validation et son intégration aux statistiques restent à définir. Cette + fonctionnalité reste au backlog ; la finalisation actuelle conserve les + règles de calcul existantes. +- Tester les navigateurs et téléphones réellement ciblés avec une matrice de + versions, notamment changement d’heure, minuit et suspension prolongée. +- Le nom IndexedDB `TimeLogDB` et l’identifiant `timelog-export` restent stables + pour préserver les données. Les noms visibles et les icônes utilisent Pardis. +- Avant changement de domaine : exporter sur l’ancien domaine, vérifier le fichier, + ouvrir le nouveau domaine, importer et comparer journées, postes et réglages. + Les données locales et le service worker ne migrent pas entre origines. +- La configuration Caddy conserve les règles de revalidation HTML/service worker + et de cache des fichiers hachés ; vérifier les en-têtes sur le domaine publié. diff --git a/README.md b/README.md index 5d14d07..5f8b7f0 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Offline-first PWA for tracking hours on site. While the app is running, it can s the day automatically and prompt for its end. Each task begins where the previous one ended. Closed days have concise summaries ready to copy into a paper logbook. -Built for use outdoors: high contrast, large targets, usable with gloves on. All data -stays on the device (IndexedDB); there is no backend and nothing is uploaded. +Built for use outdoors: high contrast, large targets, usable with gloves on. Data +is saved locally in IndexedDB. Optional end-to-end encrypted synchronization shares +it between authorized devices without e-mail or password registration. ## Development @@ -27,7 +28,8 @@ Dans **Réglages → Gérer les données et sauvegardes**, l’écran **Données sauvegardes** regroupe l’export, l’import, la vérification des sauvegardes et la durée de conservation. Le rappel **Sauvegarder** ouvre aussi cet écran. -Les données restent dans le navigateur sur cet appareil, sans synchronisation. +Les données restent disponibles dans le navigateur sur cet appareil. La synchronisation +chiffrée peut être activée séparément pour les partager avec vos autres appareils. Effacer les données du site ou perdre l’appareil peut faire perdre l’historique : gardez une copie vérifiée du fichier exporté sur un autre support. @@ -213,7 +215,8 @@ modification sont refusés sans écriture partielle. Ouvrez **Réglages → Consulter les statistiques**. Le bouton **Stats** de la barre du bas n'apparaît que sur cette page ; les autres écrans gardent leurs trois -boutons habituels. La page fonctionne hors ligne et ne transmet aucune donnée. +boutons habituels. La page fonctionne hors ligne. Les calculs de statistiques restent sur l’appareil ; +la synchronisation facultative peut partager leur historique source chiffré. Les filtres couvrent tout l'historique, le mois, l'année, les 30 derniers jours ou une plage de dates inclusive. Les journées en cours sont exclues par défaut. @@ -246,8 +249,10 @@ In dev the console exposes `timelog.seed()` to generate 20 days of realistic his ## Deployment -The stack is a single container: a multi-stage build compiles the app and Caddy serves -the static output, obtaining and renewing a Let's Encrypt certificate on its own. +The stack contains the static app served by Caddy and an optional-use synchronization +API backed by SQLite in a persistent volume. Caddy obtains and renews the HTTPS +certificate. Synchronization stays disabled in each client until explicitly activated. +See [SYNC.md](SYNC.md) for pairing, encryption, recovery, quotas and server backups. ```sh cp .env.example .env @@ -349,8 +354,8 @@ dans [PRODUCTION.md](PRODUCTION.md). **Vérifier le fichier exporté…** relit le fichier téléchargé, contrôle qu’il est importable et compare son contenu à l’export. La date de la dernière sauvegarde vérifiée est distincte de celle du dernier export préparé. Après sept jours sans -export vérifié, Pardis affiche un rappel pendant son utilisation. Aucune donnée -n’est envoyée à un serveur ; conservez le fichier hors de cet appareil. +export vérifié, Pardis affiche un rappel pendant son utilisation. L’export reste local ; conservez le fichier hors de cet appareil. La synchronisation +facultative utilise un coffre distinct et ne remplace pas cette sauvegarde. La purge affiche une liste figée de journées. Sauvegarde et suppression partagent une transaction ; une correction concurrente bloque la purge et demande une @@ -369,3 +374,21 @@ tests. Elle télécharge uniquement la variante utilisée par les tests sans int et accorde 180 secondes à chaque connexion de téléchargement, avec les nouvelles tentatives intégrées à Playwright. L’installation dispose de 20 minutes et le job de validation de 30 minutes. Une erreur d’installation bloque le déploiement. + + +## Synchronisation facultative entre appareils + +**Données et sauvegardes → Synchronisation et appareils** permet de créer un coffre +chiffré sans e-mail ni mot de passe, puis de lier des appareils avec un code temporaire +ou un QR code et une confirmation sur l’écran dédié. La saisie reste locale et +fonctionne hors ligne. Les journées concurrentes sont conservées pour résolution. +Un document de secours permet de retrouver l’accès après perte des appareils. + +Les règles métier sont partagées ; le thème et le suivi des sauvegardes restent +locaux. Un appareil est responsable des automatismes, avec proposition de transfert +à l’ouverture sur un appareil secondaire. Les demandes d’association ne produisent +aucune alerte ailleurs dans l’application. + +Le protocole, les limites de sécurité, le fonctionnement des suppressions et les +commandes de sauvegarde/restauration du serveur sont documentés dans [SYNC.md](SYNC.md). +Pour le développement, lancer aussi `npm run sync:server` avec Node.js 24. diff --git a/SYNC.md b/SYNC.md new file mode 100644 index 0000000..06953cf --- /dev/null +++ b/SYNC.md @@ -0,0 +1,219 @@ +# Synchronisation chiffrée + +La synchronisation est facultative, désactivée après la migration de la base locale. +Sans activation, aucune requête n’est envoyée à l’API de synchronisation. +Une écriture de journée reste locale et transactionnelle, même sans réseau. + +## Parcours + +Dans **Données et sauvegardes → Synchronisation et appareils** : + +- Préparer le coffre, télécharger `pardis-secours.json`, le conserver à part, + puis confirmer l’activation. Le document donne accès au coffre : il doit être + protégé comme un mot de passe de gestionnaire de secrets. +- Ajouter un appareil depuis ce même écran. L’invitation expire après cinq minutes, + n’accepte qu’un candidat et doit être renouvelée par l’écran visible toutes les + quelques secondes. Quitter ou masquer l’écran l’annule ; en cas de fermeture + brutale, le bail serveur expire sous 25 secondes. +- Saisir les huit caractères sur le nouvel appareil ou scanner le QR avec l’appareil + photo du téléphone. Le QR ouvre l’application web ; le navigateur/profil qui + reçoit ce lien doit être celui dans lequel on veut utiliser Pardis. Les copies + installées et les navigateurs peuvent avoir des stockages distincts, notamment + sur iOS ; le code manuel permet d’associer directement la bonne copie. +- Comparer **toute** l’empreinte de huit groupes hexadécimaux sur les deux écrans, + cocher leur égalité et confirmer sur l’appareil existant. Les demandes ne sont + ni surveillées ni affichées en dehors de l’écran dédié. +- Les données déjà présentes sur le nouvel appareil sont conservées. Les journées + distinctes sont réunies et les dates communes divergentes deviennent des conflits. + Les réglages déjà enregistrés peuvent eux aussi nécessiter un choix. + +Les automatismes ont un responsable. Un autre appareil propose discrètement de +prendre le rôle ; ce transfert exige le réseau et un numéro de rôle encore actuel. +L’ancien responsable hors ligne ne peut pas apprendre immédiatement le transfert : +ses écritures restent conservées et peuvent produire un conflit à la reconnexion. +La saisie manuelle fonctionne sur chaque appareil. Les conflits suspendent les +transitions automatiques locales jusqu’à leur résolution. + +La récupération avec le document de secours retire les anciens appareils, renouvelle +la clé de données et désigne l’appareil récupéré comme responsable. Elle fonctionne +aussi quand la limite d’appareils est atteinte. Les anciens appareils conservés +peuvent ensuite être associés à nouveau. + +Mettre la synchronisation en pause conserve les clés et les changements à envoyer. +Déconnecter un appareil retire ses clés locales et propose séparément d’effacer ses +données. Pour couper aussi son autorisation serveur, le retirer depuis un autre +appareil. Supprimer le coffre supprime les données et les autorisations serveur ; +aucun mécanisme ne peut effacer les copies déjà téléchargées à distance. + +## Modèle et garanties + +La base Dexie passe de v2 à v3 sans conversion des journées. Les tables de +synchronisation sont distinctes de `settings`, donc un import en remplacement ne +peut pas effacer les clés. Un middleware écrit un marqueur durable dans la même +transaction IndexedDB que toute modification métier. Les données locales et leur +dernier instantané capturé permettent ensuite de matérialiser les révisions dans +la file d’envoi, sans fenêtre où une saisie validée serait perdue après un crash. + +Chaque journée avec ses postes forme un objet indivisible. Chaque réglage métier +forme un objet ; thème et suivi des sauvegardes restent locaux. Les ID numériques +Dexie des postes ne traversent pas le réseau. Un identifiant HMAC masque la date ou +le nom du réglage au serveur. Les révisions forment un graphe de parents, indépendant +des horloges des appareils. Plusieurs têtes différentes déclenchent un conflit ; +la résolution crée une révision qui référence toutes les têtes. Des têtes au contenu +identique sont réunies sans demander un choix. Aucun tri par heure ne choisit un +« gagnant ». Les changements faits pendant une requête sont capturés avant +l’installation de sa réponse. Valeurs reçues, parents, signatures et cohérence des +journées actives sont vérifiés côté client ; l’API ne peut pas valider le texte chiffré. + +La purge produit des tombstones : un ancien appareil ne peut pas ressusciter une +journée en renvoyant une ancienne révision. Un conflit suppression/modification +conserve les deux versions. **La purge retire les journées de l’historique courant, +mais les anciennes révisions chiffrées sont conservées jusqu’à la suppression du +coffre.** Cette première version ne compacte pas les révisions ; elles comptent +dans le quota de 50 Mio. Les sauvegardes de l’hébergeur ont une rétention distincte. +La sauvegarde locale avant purge et les exports indépendants restent disponibles. +Les exports JSON classiques sont en clair et ne contiennent pas les clés de sync. + +La synchronisation fonctionne à l’ouverture, au retour au premier plan, au retour +du réseau, et toutes les 15 secondes tant que l’app est visible. La PWA fermée ou +suspendue n’offre aucune garantie d’exécution ni de synchronisation en arrière-plan. +Une requête échouée laisse les révisions en attente et n’empêche pas la saisie. +Web Locks sérialise les synchronisations entre onglets ; IDs idempotents et +transactions protègent aussi les nouvelles tentatives. Le curseur n’avance qu’avec +la validation et l’installation transactionnelle de la page reçue. + +## Cryptographie et limites de confiance + +- Données : AES-256-GCM via WebCrypto, IV aléatoire de 96 bits à chaque chiffrement, + en-tête de révision (coffre, objet, parents, époque et auteur) authentifié en AAD. +- Requêtes et révisions : ECDSA P-256/SHA-256. Chaque requête signe méthode, chemin + avec query, empreinte du corps, heure et nonce. Tolérance d’horloge de deux minutes, + nonces conservés quatre minutes en SQLite pour refuser les rejeux après redémarrage. +- Enveloppes de clés : HPKE RFC 9180 (`@hpke/core`), DHKEM P-256/HKDF-SHA-256 et + AES-256-GCM. Contexte lié au coffre et au destinataire. Le mode de base HPKE assure + la confidentialité ; l’authentification de l’association vient de la comparaison + de l’empreinte liant invitation et clés des deux participants. Le QR épingle en + plus l’identité de l’appareil parrain. Le code court ne dérive aucune clé. +- L’empreinte humaine a 128 bits. Elle n’est pas remplacée par un petit code décimal + qui, sans protocole d’engagement adapté, serait vulnérable à une recherche hors ligne. +- Chaque appareil a sa clé de signature et sa clé de réception. Le document de + secours possède une identité distincte et une clé privée de déchiffrement HPKE. + **Les appareils liés ne reçoivent que ses clés publiques.** La copie temporaire + du document préparé est supprimée de la base lors de la confirmation d’activation. +- La révocation renouvelle l’époque et la clé de données, enveloppe le trousseau + pour les appareils restants et le secours, et refuse les écritures d’une ancienne + époque. Un appareil encore autorisé peut reprendre ses changements hors ligne en + les chiffrant sous la nouvelle clé. Un appareil retiré garde ses données déjà lues. + S’il détenait aussi le document de secours externe, il faut renouveler ce document. +- Le renouvellement du secours n’affiche pas l’ancien secret : il produit un nouveau + document, à conserver avant confirmation, puis remplace atomiquement l’identité + de secours et les enveloppes serveur. L’ancien document ne permet plus de récupérer + le trousseau courant. Les anciennes copies chiffrées restent déchiffrables par qui + possédait déjà leurs clés. + +Les noms des appareils sont aussi chiffrés. Le serveur connaît les IP, identifiants, +clés publiques, relations entre appareils/révisions, tailles et dates de connexion. +Le chiffrement ne masque pas ces métadonnées et ne garantit pas la disponibilité +contre un serveur qui supprime ou retient des messages. Le client détecte une +substitution de contenu et un retour à un trousseau déjà dépassé ; il ne garantit +pas la détection de toute omission ou divergence présentée à un nouveau client. + +L’ouverture locale reste immédiate : données et clés sont accessibles au code de +l’origine et à un utilisateur du navigateur déverrouillé. Un navigateur compromis +ou du JavaScript malveillant servi par l’hébergeur peuvent lire les clés. HTTPS, +CSP et dépendances empaquetées limitent l’exposition sans supprimer cette limite +d’une application web. Pas de tiers chargé à l’exécution, pas d’analytique. + +## API et exploitation + +Node.js 24 minimum, SQLite embarquée, WAL et transactions. En développement : + +```sh +npm run sync:server +# dans un autre terminal +npm run dev +``` + +Vite et son serveur de prévisualisation transmettent `/api/sync` au port local 3001. +`SYNC_DB` définit le chemin SQLite ; par défaut `./sync-data/pardis.sqlite` (ignoré +par Git et les images Docker). `SYNC_HOST` vaut `127.0.0.1` hors conteneur. + +Le compose contient Caddy et le service `sync`, dont le port reste privé. Le volume +nommé `sync-data` est persistant. Caddy écrase `X-Pardis-Client-IP` avec l’adresse +client validée ; ne pas exposer directement l’API avec `SYNC_TRUST_PROXY=1`. +`SYNC_ORIGIN` protège l’origine des requêtes navigateur. HTTPS est nécessaire. + +```sh +docker compose --env-file .env.example config --quiet +# Après configuration du domaine et des variables de déploiement : +docker compose up -d --build +``` + +API JSON versionnée dans les enveloppes (`v: 1`), authentifiée par +`X-Pardis-Proof` (JSON encodé base64url). Aucun cookie ou mot de passe utilisateur. + +| Routes sous `/api/sync` | Usage | +| --- | --- | +| `POST /vaults` | Créer un coffre et son premier appareil | +| `GET /state` | Appareils, trousseau enveloppé et responsable | +| `POST /invitations`, `POST /join` | Invitation et candidature | +| `POST /invitations/:code/heartbeat`, `/approve` | Présence de l’écran et autorisation | +| `GET /invitations/:code/result`, `DELETE /invitations/:code` | Remise des clés ou annulation | +| `GET /revisions?after=…`, `POST /revisions` | Pages de 100 révisions et envoi idempotent | +| `POST /role`, `POST /revoke` | Transfert du rôle et révocation avec rotation | +| `GET /recovery`, `POST /recover`, `POST /recovery-reset` | Récupération et remplacement du secours | +| `DELETE /vault`, `GET /health` | Suppression du coffre et disponibilité | + +Limites : 10 appareils actifs/coffre, 100 000 révisions/coffre, 50 Mio de révisions +par défaut (`SYNC_MAX_BYTES`), 10 000 coffres (`SYNC_MAX_VAULTS`), requêtes de 2 Mio +maximum et contenu chiffré d’une révision inférieur à 200 000 caractères. Un quota +atteint bloque les envois, conserve la saisie locale et expose une erreur explicite. +Les limites par minute sont 12 candidatures/IP, 5 créations/IP, 10 invitations/appareil, +600 requêtes/IP. Elles sont en mémoire et repartent de zéro au redémarrage ; les +nonces anti-rejeu sont persistants. Le service n’écrit pas de contenu métier ni de +secrets dans les logs. Le healthcheck contrôle l’HTTP ; superviser aussi l’espace +volume, la disponibilité et le succès des sauvegardes. + +### Sauvegarde et restauration serveur + +Sauvegarder avec l’API SQLite, qui inclut les transactions WAL ; ne pas copier +uniquement le fichier principal pendant que le serveur tourne. + +```sh +docker compose exec sync node server/backup.js /data/backup.sqlite +docker compose cp sync:/data/backup.sqlite ./pardis-server-backup.sqlite +``` + +Déplacer la copie hors du serveur, sous contrôle d’accès. Définir explicitement la +rétention des sauvegardes chez l’hébergeur et expliquer que les suppressions logiques +peuvent y subsister pendant cette période. Un backup contient aussi les anciennes +autorisations et identités de secours ; le chiffrement n’annule pas ce fait. + +Pour restaurer : arrêter `sync`, conserver une copie du volume courant, remplacer +la base avec une sauvegarde cohérente et retirer les anciens fichiers `-wal`/`-shm` +associés à la base remplacée, puis redémarrer. **Ne pas remettre en production une +sauvegarde ancienne sans contrôler les révocations/rotations intervenues depuis.** +Une restauration ancienne peut réactiver des autorisations retirées. Les clients +ayant vu de nouvelles clés refusent un trousseau plus ancien. Un retour arrière +nécessite une procédure de récupération coordonnée ; ce n’est pas une fusion +transparente. Tester régulièrement la restauration sur une instance isolée. + +## Validation + +`npm run validate` exécute les tests métier, cryptographiques et API, la vérification +Svelte et le build. `npm run test:sync` cible la synchronisation. L’API de test utilise +une base temporaire, jamais le volume du service. `npm run test:e2e` démarre aussi +un serveur de sync en mémoire pour les contextes navigateur isolés. + +Les tests couvrent migration v2, journal et rollback, conflits, tombstones, +suppression concurrente, journées actives, données modifiées pendant une requête, +chiffrement falsifié, mauvais destinataire HPKE, anti-rejeu, séparation des coffres, +expiration/annulation, approbation exacte, reprise idempotente, récupération, +révocation, rotation et redémarrage SQLite. Les parcours navigateur couvrent les +empreintes sur deux appareils, l’absence d’alerte hors écran, les modifications +hors ligne, la résolution, le rôle, la révocation et la récupération. + +La validation automatisée navigateur utilise Chromium. Vérifier aussi les parcours +sur de vrais appareils Safari/iOS et Android avant ouverture large du service ; +une revue cryptographique indépendante reste souhaitable avant de promettre une +protection contre un hébergeur activement malveillant. diff --git a/docker-compose.yml b/docker-compose.yml index 53deaf2..bb684df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,3 +19,25 @@ services: volumes: - ./caddy/data:/data - ./caddy/config:/config + + sync: + build: + context: . + dockerfile: docker/Sync.Dockerfile + restart: unless-stopped + environment: + SYNC_ORIGIN: https://${SITE_ADDRESS} + SYNC_TRUST_PROXY: "1" + SYNC_MAX_BYTES: ${SYNC_MAX_BYTES:-52428800} + SYNC_MAX_VAULTS: ${SYNC_MAX_VAULTS:-10000} + volumes: + - sync-data:/data + expose: + - "3001" + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +volumes: + sync-data: diff --git a/docker/Caddyfile b/docker/Caddyfile index eaabffc..cac7bdc 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -31,6 +31,12 @@ http://{$SITE_ADDRESS} { } {$SITE_ADDRESS} { + handle /api/sync/* { + reverse_proxy sync:3001 { + header_up X-Pardis-Client-IP {client_ip} + } + } + root * /srv encode zstd gzip @@ -55,6 +61,7 @@ http://{$SITE_ADDRESS} { header @manifest Content-Type "application/manifest+json" header { + Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" X-Content-Type-Options nosniff X-Frame-Options DENY Referrer-Policy strict-origin-when-cross-origin @@ -62,6 +69,8 @@ http://{$SITE_ADDRESS} { } # The app routes on the hash, so this only catches someone typing a stray path. - try_files {path} /index.html - file_server + handle { + try_files {path} /index.html + file_server + } } diff --git a/docker/Sync.Dockerfile b/docker/Sync.Dockerfile new file mode 100644 index 0000000..5aedd93 --- /dev/null +++ b/docker/Sync.Dockerfile @@ -0,0 +1,13 @@ +FROM node:24-slim +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev +COPY server ./server +COPY src/lib/sync/crypto.js src/lib/sync/model.js ./src/lib/sync/ +COPY src/lib/backup.js src/lib/time.js src/lib/automation-options.js ./src/lib/ +RUN mkdir /data && chown node:node /data +USER node +ENV PORT=3001 SYNC_HOST=0.0.0.0 SYNC_DB=/data/pardis.sqlite +EXPOSE 3001 +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD node -e "fetch('http://127.0.0.1:3001/api/sync/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +CMD ["node", "server/index.js"] diff --git a/package-lock.json b/package-lock.json index fec420c..30c34be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,14 @@ "name": "pardis", "version": "0.8.0", "dependencies": { - "dexie": "^4.4.4" + "@hpke/core": "^1.9.0", + "dexie": "^4.4.4", + "qrcode": "^1.5.4" }, "devDependencies": { "@playwright/test": "^1.64.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/qrcode": "^1.5.6", "@vite-pwa/assets-generator": "^1.0.2", "fake-indexeddb": "^6.2.5", "svelte": "^5.56.4", @@ -1649,6 +1652,27 @@ "tslib": "^2.4.0" } }, + "node_modules/@hpke/common": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@hpke/common/-/common-1.10.1.tgz", + "integrity": "sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@hpke/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@hpke/core/-/core-1.9.0.tgz", + "integrity": "sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==", + "license": "MIT", + "dependencies": { + "@hpke/common": "^1.10.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -2996,6 +3020,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -3102,6 +3146,30 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -3403,6 +3471,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -3450,6 +3527,17 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3478,7 +3566,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3491,7 +3578,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-string": { @@ -3660,6 +3746,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-bmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/decode-bmp/-/decode-bmp-0.2.1.tgz", @@ -3765,6 +3860,12 @@ "integrity": "sha512-wWCHdihT3dmlUSuNhn5mMZDWSpG0suxjAni7YjjiZdzabZcKmy3uNZGZ7AeYYXBoLbpSXX35fikPLkPC44Osiw==", "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3803,6 +3904,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -4145,6 +4252,19 @@ "node": ">=10" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4273,6 +4393,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4686,6 +4815,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -5343,6 +5481,18 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -5529,6 +5679,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -5536,6 +5722,15 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5629,6 +5824,15 @@ "node": ">=20" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5681,6 +5885,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/quansync": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", @@ -5814,6 +6035,15 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5824,6 +6054,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -6017,6 +6253,12 @@ "node": ">=20.0.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6326,6 +6568,20 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", @@ -6429,6 +6685,18 @@ "node": ">=4" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -6770,6 +7038,13 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -7174,6 +7449,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", @@ -7433,6 +7714,26 @@ "workbox-core": "7.4.1" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -7440,6 +7741,41 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zimmerframe": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", diff --git a/package.json b/package.json index f8364d8..e9cb47d 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,14 @@ "test:watch": "vitest", "check": "svelte-check --tsconfig ./jsconfig.json", "test:e2e": "playwright test", - "validate:all": "npm run validate && npm run test:e2e" + "validate:all": "npm run validate && npm run test:e2e", + "sync:server": "node server/index.js", + "test:sync": "vitest run src/lib/sync server" }, "devDependencies": { "@playwright/test": "^1.64.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/qrcode": "^1.5.6", "@vite-pwa/assets-generator": "^1.0.2", "fake-indexeddb": "^6.2.5", "svelte": "^5.56.4", @@ -26,6 +29,8 @@ "vitest": "^5.0.0" }, "dependencies": { - "dexie": "^4.4.4" + "@hpke/core": "^1.9.0", + "dexie": "^4.4.4", + "qrcode": "^1.5.4" } } diff --git a/playwright.config.js b/playwright.config.js index 8366e98..5c07780 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -14,9 +14,13 @@ export default defineConfig({ trace: 'off', screenshot: 'off', video: 'off', launchOptions: process.env.PARDIS_CHROME_PATH ? { executablePath: process.env.PARDIS_CHROME_PATH } : {}, }, - webServer: { + webServer: [{ command: 'npm run preview -- --host 127.0.0.1 --port 4183 --strictPort', url: 'http://127.0.0.1:4183', reuseExistingServer: false, - }, + }, { + command: 'node tests/sync-server.js', + url: 'http://127.0.0.1:3001/api/sync/health', + reuseExistingServer: false, + }], }); diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..5c28a2f --- /dev/null +++ b/server/app.js @@ -0,0 +1,256 @@ +import { createServer } from 'node:http'; +import { DatabaseSync } from 'node:sqlite'; +import { randomBytes } from 'node:crypto'; +import { canonical, digest, verify } from '../src/lib/sync/crypto.js'; +import { revisionHeader } from '../src/lib/sync/model.js'; + +const PREFIX = '/api/sync'; +const parse = value => JSON.parse(value); +const json = value => JSON.stringify(value); +const fail = (status, message, code = undefined) => { throw Object.assign(new Error(message), { status, code }); }; +const uuid = value => typeof value === 'string' && /^[a-f\d-]{36}$/.test(value); +const envelope = value => value && typeof value.ciphertext === 'string' && value.ciphertext.length < 200_000 && (typeof value.iv === 'string' || typeof value.enc === 'string'); +const publicDevice = d => d && uuid(d.id) && d.publicKey?.kty === 'EC' && d.publicKey.crv === 'P-256' && !d.publicKey.d && typeof d.wrapPublic === 'string' && d.wrapPublic.length < 200; + +export function createSyncServer({ filename = ':memory:', origin = '', maxBytes = 50 * 1024 * 1024, maxVaults = 10000, trustProxy = false, now = Date.now } = {}) { + const db = new DatabaseSync(filename); + db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; + CREATE TABLE IF NOT EXISTS vaults (id TEXT PRIMARY KEY, recovery_pub TEXT NOT NULL, recovery_envelope TEXT NOT NULL, epoch TEXT NOT NULL, owner TEXT NOT NULL, role_version INTEGER NOT NULL DEFAULT 1); + CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, vault TEXT NOT NULL REFERENCES vaults(id) ON DELETE CASCADE, pub TEXT NOT NULL, wrap TEXT NOT NULL, label TEXT NOT NULL, envelope TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, seen INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS revisions (seq INTEGER PRIMARY KEY AUTOINCREMENT, vault TEXT NOT NULL REFERENCES vaults(id) ON DELETE CASCADE, id TEXT NOT NULL, object TEXT NOT NULL, data TEXT NOT NULL, size INTEGER NOT NULL, UNIQUE(vault,id)); + CREATE INDEX IF NOT EXISTS revisions_vault ON revisions(vault,seq); + CREATE TABLE IF NOT EXISTS invitations (code TEXT PRIMARY KEY, vault TEXT NOT NULL REFERENCES vaults(id) ON DELETE CASCADE, sponsor TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, expires INTEGER NOT NULL, lease INTEGER NOT NULL, applicant TEXT, approved INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS nonces (id TEXT PRIMARY KEY, expires INTEGER NOT NULL); + CREATE INDEX IF NOT EXISTS nonces_expiration ON nonces(expires); + `); + const get = (sql, ...args) => db.prepare(sql).get(...args); + const all = (sql, ...args) => db.prepare(sql).all(...args); + const run = (sql, ...args) => db.prepare(sql).run(...args); + const atomic = action => { db.exec('BEGIN IMMEDIATE'); try { const result = action(); db.exec('COMMIT'); return result; } catch (error) { db.exec('ROLLBACK'); throw error; } }; + const limits = new Map(); + function limit(key, count) { + const time = now(); + if (limits.size > 10000) for (const [k, v] of limits) if (v.end < time) limits.delete(k); + const state = limits.get(key) ?? { count: 0, end: time + 60_000 }; + if (time > state.end) { state.count = 0; state.end = time + 60_000; } + state.count++; limits.set(key, state); + if (state.count > count || limits.size > 20000) fail(429, 'Trop de tentatives. Réessayez dans une minute.'); + } + function liveInvite(code, approved = false) { + const invite = get('SELECT * FROM invitations WHERE code=?', code); + if (!invite || invite.expires < now() || (!invite.approved && invite.lease < now()) || (!approved && invite.approved)) fail(410, 'Invitation expirée ou fermée.'); + return invite; + } + function addDevice(vault, device, label, wrapped) { + if (!publicDevice(device) || !envelope(label) || !envelope(wrapped)) fail(400, 'Appareil invalide.'); + const existing = get('SELECT * FROM devices WHERE id=?', device.id); + if (existing) { + if (existing.vault === vault && !existing.revoked && canonical(parse(existing.pub)) === canonical(device.publicKey) && existing.wrap === device.wrapPublic) return; + fail(409, 'Identifiant déjà utilisé.'); + } + if (get('SELECT count(*) n FROM devices WHERE vault=? AND revoked=0', vault).n >= 10) fail(409, 'Dix appareils maximum par coffre.'); + run('INSERT INTO devices(id,vault,pub,wrap,label,envelope,seen) VALUES(?,?,?,?,?,?,?)', device.id, vault, json(device.publicKey), device.wrapPublic, json(label), json(wrapped), now()); + } + async function authenticate(req, path, raw, body) { + let auth; + try { auth = parse(Buffer.from(String(req.headers['x-pardis-proof'] ?? ''), 'base64url').toString()); } catch { fail(401, 'Authentification requise.'); } + const { proof, signature } = auth ?? {}; + if (!proof || typeof proof.who !== 'string' || !uuid(proof.nonce) || !Number.isSafeInteger(proof.time) || Math.abs(now() - proof.time) > 120_000 || proof.method !== req.method || proof.path !== path || proof.hash !== await digest(raw)) fail(401, 'Requête expirée ou invalide. Vérifiez l’horloge de cet appareil.'); + let pub, device, vault; + if (path === `${PREFIX}/vaults` && req.method === 'POST') { + if (!publicDevice(body.device) || proof.who !== body.device.id) fail(400, 'Identité invalide.'); + pub = body.device.publicKey; + } else if (proof.who.startsWith('recovery:')) { + vault = get('SELECT * FROM vaults WHERE id=?', proof.who.slice(9)); + if (!['/recovery', '/recover'].some(p => path === PREFIX + p) || !vault) fail(401, 'Secours invalide.'); + pub = parse(vault.recovery_pub); + } else if (proof.who.startsWith('pair:')) { + const code = proof.who.slice(5); + const invite = liveInvite(code, true); + if (path !== `${PREFIX}/invitations/${code}/result` || !invite.applicant) fail(401, 'Association invalide.'); + pub = parse(invite.applicant).publicKey; + } else { + device = get('SELECT * FROM devices WHERE id=? AND revoked=0', proof.who); + if (!device) fail(403, 'Cet appareil n’est plus autorisé.', 'DEVICE_REVOKED'); + vault = get('SELECT * FROM vaults WHERE id=?', device.vault); + pub = parse(device.pub); + } + if (!await verify(pub, proof, signature)) fail(401, 'Signature invalide.'); + run('DELETE FROM nonces WHERE expires= maxVaults) fail(507, 'Capacité du service atteinte.'); + atomic(() => { + run('INSERT INTO vaults(id,recovery_pub,recovery_envelope,epoch,owner) VALUES(?,?,?,?,?)', body.id, json(body.recoveryPublic), json(body.recoveryEnvelope), body.epoch, body.device.id); + addDevice(body.id, body.device, body.label, body.envelope); + }); + return { id: body.id }; + } + if (path === `${PREFIX}/recovery` && req.method === 'GET') return { id: vault.id, epoch: vault.epoch, envelope: parse(vault.recovery_envelope) }; + if (path === `${PREFIX}/recover` && req.method === 'POST') { + const existing = publicDevice(body.device) && get('SELECT * FROM devices WHERE id=? AND vault=? AND revoked=0', body.device.id, vault.id); + if (existing && canonical(parse(existing.pub)) === canonical(body.device.publicKey)) return { ok: true }; + if (body.expectedEpoch !== vault.epoch || !uuid(body.epoch) || body.epoch === vault.epoch || !envelope(body.recoveryEnvelope)) fail(409, 'Les clés ont changé. Recommencez la récupération.'); + // Possession of the recovery document restores access even at the device + // limit. Lost devices lose authorization and cannot read future changes. + atomic(() => { + run('UPDATE devices SET revoked=1 WHERE vault=?', vault.id); + run('DELETE FROM invitations WHERE vault=?', vault.id); + addDevice(vault.id, body.device, body.label, body.envelope); + run('UPDATE vaults SET epoch=?, recovery_envelope=?, owner=?, role_version=role_version+1 WHERE id=?', body.epoch, json(body.recoveryEnvelope), body.device.id, vault.id); + }); + return { ok: true }; + } + if (path.endsWith('/result') && req.method === 'GET') { + const invite = liveInvite(path.split('/').at(-2), true); + if (!invite.approved) return { waiting: true }; + const applicant = parse(invite.applicant); + const added = get('SELECT * FROM devices WHERE id=? AND revoked=0', applicant.id); + if (!added) fail(410, 'Appareil retiré.'); + return { vault: invite.vault, envelope: parse(added.envelope) }; + } + if (!device || !vault) fail(403, 'Action non autorisée.'); + if (path === `${PREFIX}/state` && req.method === 'GET') return { + vault: vault.id, epoch: vault.epoch, owner: vault.owner, roleVersion: vault.role_version, + envelope: parse(device.envelope), devices: all('SELECT * FROM devices WHERE vault=?', vault.id).map(d => ({ id: d.id, publicKey: parse(d.pub), wrapPublic: d.wrap, label: parse(d.label), revoked: !!d.revoked, seen: d.seen })), + }; + if (path === `${PREFIX}/role` && req.method === 'POST') { + if (body.expected !== vault.role_version) fail(409, 'Le responsable a changé. Actualisez avant de réessayer.'); + run('UPDATE vaults SET owner=?, role_version=role_version+1 WHERE id=?', device.id, vault.id); + return { owner: device.id, roleVersion: vault.role_version + 1 }; + } + if (path === `${PREFIX}/revisions` && req.method === 'GET') { + const after = Number(url.searchParams.get('after') ?? 0); + if (!Number.isSafeInteger(after) || after < 0) fail(400, 'Curseur invalide.'); + const records = all('SELECT seq,data FROM revisions WHERE vault=? AND seq>? ORDER BY seq LIMIT 100', vault.id, after); + return { records: records.map(r => ({ seq: r.seq, ...parse(r.data) })), more: records.length === 100 }; + } + if (path === `${PREFIX}/revisions` && req.method === 'POST') { + const v = body; + if (v.v !== 1 || v.vault !== vault.id || v.device !== device.id || !uuid(v.id) || typeof v.object !== 'string' || !/^[\w-]{43}$/.test(v.object) || !Array.isArray(v.parents) || v.parents.length > 100 || !v.parents.every(uuid) || new Set(v.parents).size !== v.parents.length || !envelope(v)) fail(400, 'Révision invalide.'); + const signed = { ...revisionHeader(v), iv: v.iv, ciphertext: v.ciphertext }; + if (!await verify(parse(device.pub), signed, v.signature)) fail(401, 'Révision non signée.'); + const existing = get('SELECT * FROM revisions WHERE vault=? AND id=?', vault.id, v.id); + if (existing) { + const old = parse(existing.data); + if (old.device !== device.id || old.object !== v.object || canonical(old.parents) !== canonical(v.parents)) fail(409, 'Identifiant déjà utilisé.'); + return { seq: existing.seq }; + } + if (!get('SELECT id FROM devices WHERE id=? AND revoked=0', device.id)) fail(403, 'Appareil révoqué.', 'DEVICE_REVOKED'); + if (v.epoch !== get('SELECT epoch FROM vaults WHERE id=?', vault.id)?.epoch) fail(409, 'Les clés ont changé. Relancez la synchronisation.'); + for (const parent of v.parents) if (!get('SELECT id FROM revisions WHERE vault=? AND id=? AND object=?', vault.id, parent, v.object)) fail(409, 'Révision parente manquante.'); + const total = get('SELECT coalesce(sum(size),0) bytes, count(*) n FROM revisions WHERE vault=?', vault.id); + if (total.bytes + raw.length > maxBytes || total.n >= 100000) fail(507, 'Quota du coffre atteint. Exportez vos données et contactez l’hébergeur.'); + const stored = { ...signed, signature: v.signature }; + const result = run('INSERT INTO revisions(vault,id,object,data,size) VALUES(?,?,?,?,?)', vault.id, v.id, v.object, json(stored), Buffer.byteLength(raw)); + return { seq: Number(result.lastInsertRowid) }; + } + if (path === `${PREFIX}/invitations` && req.method === 'POST') { + limit(`invite:${device.id}`, 10); + run('DELETE FROM invitations WHERE expires alphabet[n % alphabet.length]).join(''); } while (get('SELECT code FROM invitations WHERE code=?', code)); + run('INSERT INTO invitations(code,vault,sponsor,expires,lease) VALUES(?,?,?,?,?)', code, vault.id, device.id, now() + 300000, now() + 25000); + return { code, expires: now() + 300000, sponsor: { id: device.id, publicKey: parse(device.pub), wrapPublic: device.wrap } }; + } + const match = path.match(/^\/api\/sync\/invitations\/([A-Z2-9]{8})(?:\/(approve|heartbeat))?$/); + if (match) { + const invite = liveInvite(match[1], true); + if (invite.sponsor !== device.id) fail(403, 'Invitation d’un autre appareil.'); + if (req.method === 'DELETE') { run('DELETE FROM invitations WHERE code=?', invite.code); return { ok: true }; } + if (match[2] === 'heartbeat' && req.method === 'POST') { + if (!invite.approved) run('UPDATE invitations SET lease=? WHERE code=?', now() + 25000, invite.code); + return { applicant: invite.applicant ? parse(invite.applicant) : null, approved: !!invite.approved }; + } + if (match[2] === 'approve' && req.method === 'POST') { + if (invite.approved) return { ok: true }; + if (!invite.applicant || body.epoch !== vault.epoch || canonical(body.device) !== canonical(parse(invite.applicant))) fail(409, 'Demande modifiée. Recommencez l’association.'); + atomic(() => { addDevice(vault.id, body.device, body.label, body.envelope); run('UPDATE invitations SET approved=1 WHERE code=?', invite.code); }); + return { ok: true }; + } + } + if ([`${PREFIX}/revoke`, `${PREFIX}/recovery-reset`].includes(path) && req.method === 'POST') { + if (body.target === device.id) fail(400, 'Utilisez un autre appareil pour révoquer celui-ci.'); + const reset = path.endsWith('/recovery-reset'); + if (reset && (body.target !== null || !body.recoveryPublic || body.recoveryPublic.d || body.recoveryPublic.kty !== 'EC')) fail(400, 'Secours invalide.'); + const target = reset || get('SELECT * FROM devices WHERE id=? AND vault=? AND revoked=0', body.target, vault.id); + if (!target || body.expectedEpoch !== vault.epoch || !uuid(body.epoch) || body.epoch === vault.epoch || !envelope(body.recoveryEnvelope)) fail(409, 'Appareils ou clés modifiés. Actualisez la liste.'); + const remaining = all('SELECT id FROM devices WHERE vault=? AND revoked=0 AND id<>?', vault.id, body.target ?? ''); + if (!body.envelopes || Object.keys(body.envelopes).length !== remaining.length || remaining.some(d => !envelope(body.envelopes[d.id]))) fail(400, 'Clés manquantes pour un appareil.'); + atomic(() => { + if (!reset) run('UPDATE devices SET revoked=1 WHERE id=?', body.target); + else run('UPDATE vaults SET recovery_pub=? WHERE id=?', json(body.recoveryPublic), vault.id); + run('DELETE FROM invitations WHERE vault=?', vault.id); + for (const d of remaining) run('UPDATE devices SET envelope=? WHERE id=?', json(body.envelopes[d.id]), d.id); + run('UPDATE vaults SET epoch=?,recovery_envelope=?,owner=?,role_version=role_version+1 WHERE id=?', body.epoch, json(body.recoveryEnvelope), vault.owner === body.target ? device.id : vault.owner, vault.id); + }); + return { ok: true }; + } + if (path === `${PREFIX}/vault` && req.method === 'DELETE') { run('DELETE FROM vaults WHERE id=?', vault.id); return { ok: true }; } + fail(404, 'Route inconnue.'); + } + const server = createServer(async (req, res) => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + try { + if (origin && req.headers.origin && req.headers.origin !== origin) fail(403, 'Origine refusée.'); + if (req.headers['sec-fetch-site'] === 'cross-site') fail(403, 'Origine refusée.'); + const ip = trustProxy ? String(req.headers['x-pardis-client-ip'] ?? req.socket.remoteAddress) : req.socket.remoteAddress; + limit(`all:${ip}`, 600); + if (req.method !== 'GET' && req.headers['content-type'] !== 'application/json') fail(415, 'JSON requis.'); + const chunks = []; let size = 0; + for await (const chunk of req) { size += chunk.length; if (size > 2 * 1024 * 1024) fail(413, 'Requête trop volumineuse.'); chunks.push(chunk); } + const raw = Buffer.concat(chunks).toString('utf8'); + let body = {}; + try { if (raw) body = parse(raw); } catch { fail(400, 'JSON invalide.'); } + if (!body || typeof body !== 'object' || Array.isArray(body)) fail(400, 'Objet JSON requis.'); + const url = new URL(req.url, 'http://localhost'); + if (!url.pathname.startsWith(PREFIX)) fail(404, 'Route inconnue.'); + const result = await route(req, url, raw, body, ip); + res.end(json(result)); + } catch (error) { + res.statusCode = error.status ?? (String(error.code).startsWith('ERR_SQLITE') ? 409 : 500); + res.end(json({ error: error.status ? error.message : 'Opération impossible. Réessayez.', ...(error.code === 'DEVICE_REVOKED' ? { code: error.code } : {}) })); + } + }); + server.requestTimeout = 15000; server.headersTimeout = 10000; + server.on('close', () => db.close()); + return server; +} diff --git a/server/app.test.js b/server/app.test.js new file mode 100644 index 0000000..2a967ed --- /dev/null +++ b/server/app.test.js @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { once } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { createSyncServer } from './app.js'; +import { b64, bytes, canonical, decrypt, encrypt, identity, open, publicIdentity, randomSecret, requestProof, seal, sign } from '../src/lib/sync/crypto.js'; +import { revisionHeader } from '../src/lib/sync/model.js'; + +let server, base, directory, filename, offset; +async function start() { server = createSyncServer({ filename, now: () => Date.now() + offset, origin: 'https://pardis.example' }); server.listen(0, '127.0.0.1'); await once(server, 'listening'); base = `http://127.0.0.1:${server.address().port}/api/sync`; } +beforeEach(async () => { directory = mkdtempSync(join(tmpdir(), 'pardis-sync-test-')); filename = join(directory, 'test.sqlite'); offset = 0; await start(); }); +afterEach(async () => { await new Promise(resolve => server.close(resolve)); rmSync(directory, { recursive: true, force: true }); }); +async function request(path, device = null, body, method = body === undefined ? 'GET' : 'POST', who = device?.id, extraHeaders = {}) { + const raw = body === undefined ? '' : canonical(body); + const headers = { 'Content-Type': 'application/json', ...extraHeaders }; + if (device) headers['X-Pardis-Proof'] = b64(bytes(canonical(await requestProof(device.privateKey, method, `/api/sync${path}`, raw, who)))); + const response = await fetch(base + path, { method, headers, ...(raw ? { body: raw } : {}) }); + return { status: response.status, data: await response.json(), headers, raw }; +} +async function vault() { + const device = await identity('Phone'), recovery = await identity('Recovery'); + const id = crypto.randomUUID(), epoch = crypto.randomUUID(); + const ring = { v: 1, epoch, keys: { [epoch]: randomSecret() }, routingKey: randomSecret(), recovery: publicIdentity(recovery) }; + const body = { id, epoch, device: publicIdentity(device), recoveryPublic: recovery.publicKey, + envelope: await seal(device.wrapPublic, ring, `pardis-device-v1:${id}:${device.id}`), + label: { epoch, ...await encrypt(ring.keys[epoch], device.name, `pardis-label-v1:${id}:${device.id}`) }, + recoveryEnvelope: await seal(recovery.wrapPublic, ring, `pardis-recovery-v1:${id}`) }; + expect((await request('/vaults', device, body)).status).toBe(200); + return { device, recovery, id, epoch, ring, body }; +} +async function linked(v) { + const b = await identity('Computer'); + const invite = (await request('/invitations', v.device, {})).data; + expect((await request('/join', null, { code: invite.code, device: publicIdentity(b) })).status).toBe(200); + const body = { device: publicIdentity(b), epoch: v.epoch, + label: { epoch: v.epoch, ...await encrypt(v.ring.keys[v.epoch], b.name, `pardis-label-v1:${v.id}:${b.id}`) }, + envelope: await seal(b.wrapPublic, v.ring, `pardis-device-v1:${v.id}:${b.id}`) }; + expect((await request(`/invitations/${invite.code}/approve`, v.device, body)).status).toBe(200); + return { b, invite, body }; +} +async function revision(v, device = v.device, parents = [], object = b64(new Uint8Array(32))) { + const header = revisionHeader({ vault: v.id, id: crypto.randomUUID(), object, epoch: v.epoch, device: device.id, parents }); + const encrypted = await encrypt(v.ring.keys[v.epoch], { key: 'day:2026-10-10', value: 'Private chantier' }, canonical(header)); + const signed = { ...header, ...encrypted }; + return { ...signed, signature: await sign(device.privateKey, signed) }; +} + +describe('synchronization API', () => { + it('stores only encrypted data and scopes every read to the authenticated vault', async () => { + const a = await vault(), b = await vault(); + const v = await revision(a); + expect((await request('/revisions', a.device, v)).status).toBe(200); + expect((await request('/revisions?after=0', b.device)).data.records).toEqual([]); + const result = await request('/revisions?after=0', a.device); + expect(result.data.records).toHaveLength(1); + const database = new DatabaseSync(filename, { readOnly: true }); + const data = database.prepare('SELECT data FROM revisions').get().data; + expect(data).not.toContain('Private chantier'); expect(data).not.toContain('2026-10-10'); + const storedVault = database.prepare('SELECT * FROM vaults').get(); + expect(JSON.stringify(storedVault)).not.toContain(a.ring.keys[a.epoch]); + database.close(); + }); + it('requires signed, fresh requests and refuses replay or changed payloads', async () => { + const a = await vault(); + expect((await request('/state')).status).toBe(401); + const response = await request('/state', a.device); + const replay = await fetch(base + '/state', { headers: response.headers }); expect(replay.status).toBe(409); + const spoofed = await identity('Spoof'); spoofed.id = a.device.id; + expect((await request('/state', spoofed)).status).toBe(401); + expect((await request('/state', a.device, undefined, 'GET', a.device.id, { Origin: 'https://evil.example' })).status).toBe(403); + offset = 180000; + expect((await request('/state', a.device)).status).toBe(401); + }); + it('is idempotent on vault creation, revision upload and recovery retries', async () => { + const a = await vault(); + expect((await request('/vaults', a.device, a.body)).status).toBe(200); + const v = await revision(a); + const first = await request('/revisions', a.device, v); + expect((await request('/revisions', a.device, v)).data.seq).toBe(first.data.seq); + expect((await request('/revisions?after=0', a.device)).data.records).toHaveLength(1); + const { b, body } = await linked(a); + expect((await request('/recover', a.recovery, body, 'POST', `recovery:${a.id}`)).status).toBe(200); + expect((await request('/state', b)).data.devices).toHaveLength(2); + }); + it('does not disclose keys before explicit approval, and encrypts the result only to the applicant', async () => { + const a = await vault(); const b = await identity('New phone'); + const invite = (await request('/invitations', a.device, {})).data; + expect((await request('/join', null, { code: invite.code, device: publicIdentity(b) })).status).toBe(200); + const waiting = await request(`/invitations/${invite.code}/result`, b, undefined, 'GET', `pair:${invite.code}`); + expect(waiting.data).toEqual({ waiting: true }); + expect((await request('/state', b)).status).toBe(403); + const heartbeat = await request(`/invitations/${invite.code}/heartbeat`, a.device, {}); + expect(heartbeat.data.applicant).toEqual(publicIdentity(b)); + const label = await encrypt(a.ring.keys[a.epoch], 'New', 'label'); + const wrapped = await seal(b.wrapPublic, a.ring, `pardis-device-v1:${a.id}:${b.id}`); + expect((await request(`/invitations/${invite.code}/approve`, a.device, { epoch: a.epoch, device: publicIdentity(b), label, envelope: wrapped })).status).toBe(200); + const result = (await request(`/invitations/${invite.code}/result`, b, undefined, 'GET', `pair:${invite.code}`)).data; + expect(await open(b.wrapPrivate, result.envelope, `pardis-device-v1:${a.id}:${b.id}`)).toEqual(a.ring); + await expect(open(a.device.wrapPrivate, result.envelope, `pardis-device-v1:${a.id}:${b.id}`)).rejects.toThrow(); + }); + it('expires pairing when the dedicated screen stops renewing its lease', async () => { + const a = await vault(); const b = await identity('B'); + const invite = (await request('/invitations', a.device, {})).data; + offset = 26000; + expect((await request('/join', null, { code: invite.code, device: publicIdentity(b) })).status).toBe(410); + offset = 0; + const fresh = (await request('/invitations', a.device, {})).data; + await request(`/invitations/${fresh.code}`, a.device, {}, 'DELETE'); + expect((await request('/join', null, { code: fresh.code, device: publicIdentity(b) })).status).toBe(410); + }); + it('binds invitation approval to its sponsor and the exact applicant keys', async () => { + const a = await vault(), unrelated = await vault(); + const b = await identity('B'), c = await identity('C'); + const invite = (await request('/invitations', a.device, {})).data; + await request('/join', null, { code: invite.code, device: publicIdentity(b) }); + expect((await request('/join', null, { code: invite.code, device: publicIdentity(c) })).status).toBe(409); + expect((await request(`/invitations/${invite.code}/heartbeat`, unrelated.device, {})).status).toBe(403); + expect((await request(`/invitations/${invite.code}/approve`, a.device, { epoch: a.epoch, device: publicIdentity(c) })).status).toBe(409); + }); + it('recovers the current keyring only with the private recovery document', async () => { + const a = await vault(); + const bad = await identity('Wrong secret'); + expect((await request('/recovery', bad, undefined, 'GET', `recovery:${a.id}`)).status).toBe(401); + const result = await request('/recovery', a.recovery, undefined, 'GET', `recovery:${a.id}`); + expect(await open(a.recovery.wrapPrivate, result.data.envelope, `pardis-recovery-v1:${a.id}`)).toEqual(a.ring); + expect(a.ring.recovery).not.toHaveProperty('privateKey'); + expect(a.ring.recovery).not.toHaveProperty('wrapPrivate'); + }); + it('atomically revokes access and rotates keys without giving the removed device the new key', async () => { + const a = await vault(); const { b } = await linked(a); + const epoch = crypto.randomUUID(); + const ring = { ...a.ring, epoch, keys: { ...a.ring.keys, [epoch]: randomSecret() } }; + const result = await request('/revoke', a.device, { target: b.id, expectedEpoch: a.epoch, epoch, + envelopes: { [a.device.id]: await seal(a.device.wrapPublic, ring, `pardis-device-v1:${a.id}:${a.device.id}`) }, + recoveryEnvelope: await seal(a.recovery.wrapPublic, ring, `pardis-recovery-v1:${a.id}`) }); + expect(result.status).toBe(200); + expect((await request('/state', b)).status).toBe(403); + expect((await request('/revisions', b, await revision(a, b))).status).toBe(403); + expect((await request('/revisions', a.device, await revision(a))).status).toBe(409); + const state = (await request('/state', a.device)).data; + expect(state.epoch).toBe(epoch); + const newRing = await open(a.device.wrapPrivate, state.envelope, `pardis-device-v1:${a.id}:${a.device.id}`); + expect(newRing.keys[epoch]).toBe(ring.keys[epoch]); + const protectedValue = await encrypt(ring.keys[epoch], 'Future data', 'data'); + await expect(decrypt(a.ring.keys[a.epoch], protectedValue, 'data')).rejects.toThrow(); + }); + it('recovers at the device limit, revoking lost devices and rotating the epoch atomically', async () => { + const a = await vault(); + for (let i = 0; i < 9; i++) await linked(a); + const replacement = await identity('Recovered'); + const epoch = crypto.randomUUID(); + const ring = { ...a.ring, epoch, keys: { ...a.ring.keys, [epoch]: randomSecret() } }; + const body = { expectedEpoch: a.epoch, epoch, device: publicIdentity(replacement), + label: { epoch, ...await encrypt(ring.keys[epoch], replacement.name, 'label') }, + envelope: await seal(replacement.wrapPublic, ring, `pardis-device-v1:${a.id}:${replacement.id}`), + recoveryEnvelope: await seal(a.recovery.wrapPublic, ring, `pardis-recovery-v1:${a.id}`) }; + expect((await request('/recover', a.recovery, body, 'POST', `recovery:${a.id}`)).status).toBe(200); + expect((await request('/recover', a.recovery, body, 'POST', `recovery:${a.id}`)).status).toBe(200); + expect((await request('/state', a.device)).status).toBe(403); + const state = (await request('/state', replacement)).data; + expect(state.devices.filter(d => !d.revoked)).toHaveLength(1); + expect(state.owner).toBe(replacement.id); expect(state.epoch).toBe(epoch); + expect(await open(replacement.wrapPrivate, state.envelope, `pardis-device-v1:${a.id}:${replacement.id}`)).toEqual(ring); + }); + it('replaces the recovery identity and rejects the old secret', async () => { + const a = await vault(); const next = await identity('New recovery'); const epoch = crypto.randomUUID(); + const ring = { ...a.ring, epoch, keys: { ...a.ring.keys, [epoch]: randomSecret() }, recovery: publicIdentity(next) }; + expect((await request('/recovery-reset', a.device, { target: null, expectedEpoch: a.epoch, epoch, recoveryPublic: next.publicKey, + envelopes: { [a.device.id]: await seal(a.device.wrapPublic, ring, `pardis-device-v1:${a.id}:${a.device.id}`) }, + recoveryEnvelope: await seal(next.wrapPublic, ring, `pardis-recovery-v1:${a.id}`) })).status).toBe(200); + expect((await request('/recovery', a.recovery, undefined, 'GET', `recovery:${a.id}`)).status).toBe(401); + const result = await request('/recovery', next, undefined, 'GET', `recovery:${a.id}`); + expect(await open(next.wrapPrivate, result.data.envelope, `pardis-recovery-v1:${a.id}`)).toEqual(ring); + }); + it('checks role revisions, ancestry and revision signatures', async () => { + const a = await vault(); const { b } = await linked(a); + expect((await request('/role', b, { expected: 1 })).status).toBe(200); + expect((await request('/role', a.device, { expected: 1 })).status).toBe(409); + expect((await request('/state', b)).data.owner).toBe(b.id); + const missing = await revision(a, a.device, [crypto.randomUUID()]); + expect((await request('/revisions', a.device, missing)).status).toBe(409); + const v = await revision(a); v.ciphertext = v.ciphertext.slice(1); + expect((await request('/revisions', a.device, v)).status).toBe(401); + }); + it('persists encrypted records and replay protection across service restarts', async () => { + const a = await vault(); const record = await revision(a); + await request('/revisions', a.device, record); + const state = await request('/state', a.device); + await new Promise(resolve => server.close(resolve)); await start(); + expect((await request('/revisions?after=0', a.device)).data.records).toHaveLength(1); + expect((await fetch(base + '/state', { headers: state.headers })).status).toBe(409); + }); + it('deletes the vault, its ciphertext and all device access', async () => { + const a = await vault(); await request('/revisions', a.device, await revision(a)); + expect((await request('/vault', a.device, {}, 'DELETE')).status).toBe(200); + expect((await request('/state', a.device)).status).toBe(403); + expect((await request('/recovery', a.recovery, undefined, 'GET', `recovery:${a.id}`)).status).toBe(401); + const database = new DatabaseSync(filename, { readOnly: true }); + expect(database.prepare('SELECT count(*) n FROM revisions').get().n).toBe(0); database.close(); + }); +}); diff --git a/server/backup.js b/server/backup.js new file mode 100644 index 0000000..2385189 --- /dev/null +++ b/server/backup.js @@ -0,0 +1,9 @@ +// Online SQLite backup includes committed WAL transactions. Run in the service +// container, then copy the resulting file to independent, protected storage. +import { DatabaseSync, backup } from 'node:sqlite'; +const source = process.env.SYNC_DB ?? './sync-data/pardis.sqlite'; +const destination = process.argv[2]; +if (!destination || destination === source) throw new Error('Usage: node server/backup.js /data/backup.sqlite (distinct destination)'); +const db = new DatabaseSync(source, { readOnly: true }); +try { await backup(db, destination); console.info('Encrypted database backup completed.'); } +finally { db.close(); } diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..3fa06bc --- /dev/null +++ b/server/index.js @@ -0,0 +1,8 @@ +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { createSyncServer } from './app.js'; +const filename = process.env.SYNC_DB ?? './sync-data/pardis.sqlite'; +mkdirSync(dirname(filename), { recursive: true }); +const server = createSyncServer({ filename, origin: process.env.SYNC_ORIGIN ?? '', trustProxy: process.env.SYNC_TRUST_PROXY === '1', maxBytes: Number(process.env.SYNC_MAX_BYTES ?? 52428800), maxVaults: Number(process.env.SYNC_MAX_VAULTS ?? 10000) }); +server.listen(Number(process.env.PORT ?? 3001), process.env.SYNC_HOST ?? '127.0.0.1', () => console.info('Pardis sync listening')); +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close()); diff --git a/src/App.svelte b/src/App.svelte index f32cdfa..b359b9b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,6 +1,6 @@ @@ -59,6 +90,13 @@ {#if !ready}
{#if bootError}

{bootError}

{:else}

Chargement…

{/if}
{:else} + {#if syncState.linked && !['sync', 'data'].includes(view)} +
+ {/if} + {#if syncState.enabled && syncState.owner && syncState.owner !== syncState.deviceId && !roleDismissed && view !== 'sync'} +
Les automatismes sont exécutés sur un autre appareil.
+ {#if roleError}

{roleError}

{/if} + {/if} {#if backupState.loaded && backupReminderDue(backupState.status, clockNow) && !['settings', 'data'].includes(view)}
Sauvegarde à vérifier
{/if} @@ -80,6 +118,8 @@ {:else if view === 'data'} + {:else if view === 'sync'} + {:else if ['settings', 'automations', 'non-working'].includes(view)} {:else if view === 'today'} @@ -107,9 +147,9 @@ > {#if view === 'stats'} diff --git a/src/components/BackupPanel.svelte b/src/components/BackupPanel.svelte index 96e437d..6408012 100644 --- a/src/components/BackupPanel.svelte +++ b/src/components/BackupPanel.svelte @@ -16,6 +16,7 @@ import { prepareVerifiedExport, verifyExportFile, backupReminderDue } from '../lib/backup-safety.js'; import { backupState, loadBackupStatus } from '../lib/backup-state.svelte.js'; import { db } from '../lib/db.js'; + import { syncState } from '../lib/sync/state.svelte.js'; import { fmtDayLabel, fmtTime, localDateKey } from '../lib/time.js'; /** @type {HTMLInputElement} */ @@ -170,6 +171,7 @@ > {/if}

+ {#if syncState.linked}

Les modifications issues de cet import seront partagées avec les autres appareils. Un remplacement transmettra aussi les corrections et suppressions.

{/if} {#if staged.dropped}

{staged.dropped} entrée(s) illisible(s) ignorée(s).

{/if} @@ -196,6 +198,7 @@ Toutes les journées et tous les réglages de cet appareil seront remplacés par ceux du fichier. {#if dayState.day}La journée en cours sera également remplacée.{/if}

+ {#if syncState.linked}

Journées supprimées ici puis dans le coffre : {[...dayState.closedDays, ...(dayState.day ? [dayState.day] : [])].filter(day => !staged.days.some(incoming => incoming.date === day.date)).map(day => day.date).join(', ') || 'aucune'}. Les dates présentes dans le fichier seront remplacées et synchronisées. Les clés d’accès au coffre restent conservées.

{/if}
+
@@ -87,6 +91,7 @@ { if (!purgeBusy) purgeOpen = false; }}> + {#if syncState.linked}

Ces journées seront aussi supprimées du coffre et des autres appareils à leur prochaine synchronisation. Les modifications concurrentes seront conservées comme conflits. L’historique des révisions et les sauvegardes serveur suivent la politique de conservation de l’hébergeur.

{/if}

Journées concernées : {reviewedPurge.length ? reviewedPurge.map(item => item.day.date).join(', ') : 'aucune'}.

Une sauvegarde récupérable sera conservée sur cet appareil avant la suppression.

{#if purgeError}

{purgeError}

{/if} diff --git a/src/routes/SyncSettings.svelte b/src/routes/SyncSettings.svelte new file mode 100644 index 0000000..9cd7939 --- /dev/null +++ b/src/routes/SyncSettings.svelte @@ -0,0 +1,260 @@ + + +
+

Synchronisation et appareils

+ +

Vos saisies sont toujours enregistrées sur cet appareil. La synchronisation facultative partage vos journées et règles avec vos appareils autorisés, sous forme chiffrée.

+ {#if error}{/if} + {#if message}

{message}

{/if} + {#if busy}

Opération en cours…

{/if} + + {#if !syncState.linked} + +
+

Créer mon coffre

+

Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.

+ +
+
+

Lier à un appareil existant

+

Sur l’appareil déjà lié, ouvrez cet écran puis « Ajouter un appareil ». Saisissez son code ici ou scannez son QR code avec l’appareil photo de votre téléphone.

+

Les journées déjà présentes ici seront conservées et fusionnées. Les dates communes et les règles différentes pourront nécessiter un choix.

+ + + {#if joining} +

Comparez toute cette empreinte avec celle affichée sur l’appareil d’origine, puis confirmez sur celui-ci :

+ {joining.fingerprint} +

En attente de confirmation sur l’autre appareil…

+ + {/if} +
+
+

Récupérer mon coffre

+

Utilisez votre document de secours. Il donne accès à tout votre coffre ; gardez-le à l’abri.

+ + +

Les données présentes ici seront conservées. La récupération retire les anciens appareils et renouvelle les clés ; vous pourrez les associer à nouveau.

+ +
+ {:else} +
+

État de la synchronisation

+

{syncState.revoked ? 'Cet appareil a été révoqué.' : syncState.enabled ? syncState.busy ? 'Synchronisation en cours…' : syncState.pending ? 'Enregistré sur cet appareil · en attente d’envoi' : 'Modifications envoyées' : 'Synchronisation en pause'}

+

Dernière synchronisation : {when(syncState.lastSync)}.

+ {#if syncState.error}

{syncState.error} Vos données restent enregistrées ici.

{/if} + {#if !syncState.revoked} + + + {/if} +
+
+

Appareils autorisés

+ {#each syncState.devices as device} +
+ {device.name}{device.id === syncState.deviceId ? ' (cet appareil)' : ''} + {device.revoked ? 'Révoqué' : device.id === syncState.owner ? 'Responsable des automatismes' : 'Saisie manuelle et consultation'} + Dernier contact : {when(device.seen)} + {#if !device.revoked && device.id !== syncState.deviceId}{/if} +
+ {/each} + {#if syncState.owner !== syncState.deviceId && !syncState.revoked}{/if} +

Les automatismes nécessitent que l’appareil responsable puisse exécuter l’application. Un ancien responsable hors ligne apprend le transfert à sa reconnexion ; ses changements éventuels sont conservés.

+ + {#if invite} +

Invitation valable jusqu’à {new Date(invite.expires).toLocaleTimeString('fr-FR')}. Gardez cet écran visible ; le quitter annule l’invitation.

+ {invite.code.slice(0, 4)} {invite.code.slice(4)} + {#if qr}QR code d’association à scanner sur le nouvel appareil{/if} + {#if applicant} +

Une demande est arrivée. Comparez l’empreinte complète sur les deux écrans :

+ {comparison} + + + + {:else}

En attente d’une demande sur cet écran…

{/if} + + {/if} +
+ +
+

Versions à départager ({syncState.conflicts.length})

+ {#if !syncState.conflicts.length}

Aucun conflit.

{/if} + {#each syncState.conflicts as conflict} +
+

{conflict.key.startsWith('day:') ? `Journée du ${conflict.key.slice(4)}` : settingLabels[conflict.key.slice(8)]}

+

{conflict.reason} Les automatismes sont suspendus sur cet appareil pendant la résolution.

+ {#each conflict.versions as version} +
+ {deviceName(version.device)} · {when(version.modifiedAt)} + {#if version.value === null}

Suppression

+ {:else if conflict.key.startsWith('day:')} +

{version.value.day.status === 'active' ? 'En cours' : 'Terminée'}

+
    {#each version.value.entries as entry}
  • {new Date(entry.startTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})}–{entry.endTs === null ? 'en cours' : new Date(entry.endTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})} · {entry.task} {entry.client ?? ''}
  • {/each}
+ {:else}

{settingValue(conflict.key, version.value)}

{/if} + +
+ {/each} + +

Les autres versions restent dans l’historique chiffré. Vous pouvez ensuite corriger la journée dans son écran habituel.

+
+ {/each} +
+
+

Secours et déconnexion

+

Le document de secours n’est pas conservé sur les appareils liés. Vous pouvez en préparer un nouveau ; après validation, le précédent ne permettra plus de récupérer le coffre.

+ + + +
+ {/if} + + {#if recovery} +
+

Conserver le document de secours

+

Enregistrez-le ailleurs que sur cet appareil, par exemple dans un gestionnaire de mots de passe. Toute personne qui le possède peut accéder au coffre.

+ +
Afficher le document pour le copier
+ + + +
+ {/if} + {#if action} +
+ {#if action === 'revoke'} +

Retirer {deviceName(target)} ?

+

Son accès sera coupé et les clés renouvelées. Ses copies déjà téléchargées ne peuvent pas être effacées à distance. S’il possède le document de secours, renouvelez-le aussi.

+ + {:else if action === 'disconnect'} +

Déconnecter cet appareil ?

+

Les changements non envoyés ne seront plus synchronisés. Les autres appareils et le coffre restent accessibles.

+ + + {:else} +

Supprimer le coffre en ligne ?

+

Les données en ligne et les accès des appareils seront supprimés. Les copies déjà téléchargées restent sur les appareils. Les sauvegardes de l’hébergeur suivent sa durée de conservation.

+ + + {/if} + +
+ {/if} +
+ + diff --git a/tests/e2e/sync.spec.js b/tests/e2e/sync.spec.js new file mode 100644 index 0000000..1a558ce --- /dev/null +++ b/tests/e2e/sync.spec.js @@ -0,0 +1,133 @@ +import { test, expect } from '@playwright/test'; + +// Helpers only touch synthetic data in fresh browser contexts. +async function store(page, name, operation, value = null) { + return page.evaluate(({ name, operation, value }) => new Promise((resolve, reject) => { + const request = indexedDB.open('TimeLogDB'); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const tx = database.transaction(name, operation === 'getAll' ? 'readonly' : 'readwrite'); + const table = tx.objectStore(name); + const query = operation === 'getAll' ? table.getAll() : operation === 'put' ? table.put(value) : table.clear(); + let result; + query.onsuccess = () => { result = query.result; }; + tx.oncomplete = () => { database.close(); resolve(result); }; + tx.onabort = () => { database.close(); reject(tx.error); }; + }; + }), { name, operation, value }); +} +async function ready(page) { + await page.goto('/#/sync'); + await expect(page.getByRole('heading', { name: 'Synchronisation et appareils' })).toBeVisible(); + await store(page, 'settings', 'put', { key: 'app', value: { autoStartDay: false } }); + // A real clock is necessary for signed HTTP requests. +} +async function create(page) { + await ready(page); + await page.getByLabel('Nom de cet appareil', { exact: true }).fill('Téléphone test'); + await page.getByRole('button', { name: 'Préparer la synchronisation', exact: true }).click(); + await page.getByText('Afficher le document pour le copier', { exact: true }).click(); + const recovery = await page.getByLabel('Contenu du document de secours').inputValue(); + await page.getByRole('checkbox', { name: 'J’ai conservé une copie du document à part' }).check(); + await page.getByRole('button', { name: 'Activer la synchronisation', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Synchroniser maintenant', exact: true })).toBeEnabled(); + await expect(page.locator('fieldset')).not.toContainText('Opération impossible'); + return recovery; +} +async function sync(page) { + await page.getByRole('button', { name: 'Synchroniser maintenant', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Synchroniser maintenant', exact: true })).toBeEnabled(); + await expect(page.getByText('Modifications envoyées', { exact: true })).toBeVisible(); +} +async function pair(a, b) { + await a.getByRole('button', { name: 'Ajouter un appareil', exact: true }).click(); + await expect(a.locator('.code')).toBeVisible(); + const code = await a.locator('.code').innerText(); + await ready(b); + await b.getByLabel('Code d’association', { exact: true }).fill(code); + await b.getByRole('button', { name: 'Demander la liaison', exact: true }).click(); + await expect(b.locator('.fingerprint')).toBeVisible(); + await expect(a.locator('.fingerprint')).toBeVisible({ timeout: 15000 }); + expect(await a.locator('.fingerprint').innerText()).toBe(await b.locator('.fingerprint').innerText()); + await a.getByLabel('Nom du nouvel appareil', { exact: true }).fill('Ordinateur test'); + await a.getByRole('checkbox', { name: 'Les deux empreintes sont identiques' }).check(); + await a.getByRole('button', { name: 'Autoriser cet appareil', exact: true }).click(); + await expect(b.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible({ timeout: 15000 }); + await sync(b); +} +async function addDay(page, task) { + const date = '2026-10-08', start = Date.parse(`${date}T08:00:00Z`), end = start + 3600000; + await store(page, 'days', 'put', { date, status: 'closed', startedTs: start, endedTs: end, plannedStartTs: start, plannedEndTs: end, lunchType: null, restaurant: null, closePromptedTs: null }); + await store(page, 'entries', 'put', { date, seq: 0, kind: 'work', task, client: 'Client synthétique', startTs: start, endTs: end }); +} +async function editTask(page, task) { + const entry = (await store(page, 'entries', 'getAll'))[0]; + await store(page, 'entries', 'put', { ...entry, task }); +} + +test('association, modification hors ligne, conflit, rôle et révocation entre deux appareils', async ({ page, browser }) => { + test.setTimeout(90000); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + const otherContext = await browser.newContext({ baseURL: 'http://127.0.0.1:4183', viewport: { width: 320, height: 640 } }); + const other = await otherContext.newPage(); other.on('pageerror', error => errors.push(error.message)); + try { + await create(page); await addDay(page, 'Version initiale'); await sync(page); + await pair(page, other); + expect((await store(other, 'entries', 'getAll'))[0].task).toBe('Version initiale'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + expect(await other.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await other.evaluate(() => navigator.serviceWorker.ready.then(() => true)); + await page.context().setOffline(true); await otherContext.setOffline(true); + await other.reload(); + await expect(other.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible(); + await editTask(page, 'Modification téléphone'); await editTask(other, 'Modification ordinateur'); + await page.context().setOffline(false); await sync(page); + await otherContext.setOffline(false); await sync(other); await sync(page); + await expect(page.getByRole('heading', { name: 'Versions à départager (1)', exact: true })).toBeVisible(); + await expect(page.locator('.conflict')).toContainText('Modification téléphone'); + await expect(page.locator('.conflict')).toContainText('Modification ordinateur'); + const remote = page.locator('.version').filter({ hasText: 'Modification ordinateur' }); + await remote.getByRole('button', { name: 'Conserver cette version', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Versions à départager (0)', exact: true })).toBeVisible(); + await sync(other); + expect((await store(page, 'entries', 'getAll'))[0].task).toBe('Modification ordinateur'); + expect((await store(other, 'entries', 'getAll'))[0].task).toBe('Modification ordinateur'); + await other.getByRole('button', { name: 'Utiliser cet appareil pour les automatismes', exact: true }).click(); + await expect(other.getByRole('button', { name: 'Utiliser cet appareil pour les automatismes', exact: true })).toHaveCount(0); + await sync(page); + await expect(page.getByRole('button', { name: 'Utiliser cet appareil pour les automatismes', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Retirer cet appareil…', exact: true }).click(); + await page.getByRole('button', { name: 'Confirmer le retrait', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Synchroniser maintenant', exact: true })).toBeEnabled(); + await other.getByRole('button', { name: 'Synchroniser maintenant', exact: true }).click(); + await expect(other.getByText('Cet appareil a été révoqué.', { exact: true })).toBeVisible(); + expect((await store(other, 'entries', 'getAll'))[0].task).toBe('Modification ordinateur'); + expect(errors).toEqual([]); + } finally { await otherContext.close(); } +}); + +test('secours après perte des appareils et annulation de l’invitation en quittant l’écran', async ({ page, browser }) => { + test.setTimeout(60000); + const recovery = await create(page); await addDay(page, 'Historique récupérable'); await sync(page); + await page.getByRole('button', { name: 'Ajouter un appareil', exact: true }).click(); + await expect(page.locator('.code')).toBeVisible(); const code = await page.locator('.code').innerText(); + await page.getByRole('button', { name: 'Retour aux données', exact: true }).click(); + const context = await browser.newContext({ baseURL: 'http://127.0.0.1:4183' }); const replacement = await context.newPage(); + try { + await ready(replacement); await replacement.getByLabel('Code d’association', { exact: true }).fill(code); + await replacement.getByRole('button', { name: 'Demander la liaison', exact: true }).click(); + await expect(replacement.getByRole('alert')).toContainText('expirée'); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.getByText('Une demande est arrivée.', { exact: false })).toHaveCount(0); + await replacement.getByLabel('Ou coller le document').fill(recovery); + await replacement.getByRole('button', { name: 'Récupérer l’accès', exact: true }).click(); + await expect(replacement.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible(); + await sync(replacement); + expect((await store(replacement, 'entries', 'getAll'))[0].task).toBe('Historique récupérable'); + const config = (await store(replacement, 'syncState', 'getAll')).find(r => r.key === 'config').value; + expect(config.ring.recovery.privateKey).toBeUndefined(); + expect(config.ring.recovery.wrapPrivate).toBeUndefined(); + } finally { await context.close(); } +}); diff --git a/tests/sync-server.js b/tests/sync-server.js new file mode 100644 index 0000000..d972bbd --- /dev/null +++ b/tests/sync-server.js @@ -0,0 +1,5 @@ +// Isolated, disposable API for browser tests. Never opens the production DB. +import { createSyncServer } from '../server/app.js'; +const server = createSyncServer({ maxVaults: 100 }); +server.listen(3001, '127.0.0.1'); +for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => server.close()); diff --git a/vite.config.js b/vite.config.js index f1097c5..fbaf612 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,7 +5,9 @@ import { VitePWA } from 'vite-plugin-pwa'; export default defineConfig({ server: { host: '0.0.0.0', + proxy: { '/api/sync': 'http://127.0.0.1:3001' }, }, + preview: { proxy: { '/api/sync': 'http://127.0.0.1:3001' } }, plugins: [ svelte(), VitePWA({ @@ -47,7 +49,7 @@ export default defineConfig({ }), ], test: { - include: ['src/**/*.test.js'], + include: ['src/**/*.test.js', 'server/**/*.test.js'], environment: 'node', }, });