diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index fac99ad..4e9a81b 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -31,8 +31,31 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + codeql: + name: CodeQL JavaScript/TypeScript + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + actions: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 + with: + languages: javascript-typescript + build-mode: none + queries: security-extended + - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 + with: + category: drive-test-javascript + verify: name: Vérification complète + needs: codeql runs-on: ubuntu-24.04 timeout-minutes: 30 # Même version que package-lock.json : navigateurs et bibliothèques déjà présents. @@ -43,10 +66,10 @@ jobs: env: BUILD_SHA: ${{ github.sha }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 — SHA vérifié le 23/10/2026 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 — SHA vérifié le 23/10/2026 with: node-version: 22 cache: npm @@ -90,14 +113,14 @@ jobs: CI: '1' - name: Captures d'écran des parcours (données fictives uniquement) if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 — SHA vérifié le 23/10/2026 with: name: captures-parcours path: screenshots/ retention-days: 30 - name: Rapport Playwright if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 — SHA vérifié le 23/10/2026 with: name: rapport-playwright path: playwright-report/ @@ -105,17 +128,25 @@ jobs: # L'artefact publié est celui-ci : construit puis testé ci-dessus, pour ce commit. - name: Artefact vérifié (Cloudflare) if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 — SHA vérifié le 23/10/2026 with: name: site-verifie-${{ github.sha }} path: dist-cloudflare/ include-hidden-files: true retention-days: 7 + # Même archive Linux que upload-pages-artifact v3, sans son appel + # transitif mobile à upload-artifact@v4. Les octets de dist/ restent ceux testés. + - name: Archive vérifiée (GitHub Pages) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: tar --dereference --hard-dereference -C dist --exclude=.git --exclude=.github -cvf "$RUNNER_TEMP/artifact.tar" . - name: Artefact vérifié (GitHub Pages) if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 — SHA vérifié le 23/10/2026 with: - path: dist/ + name: github-pages + path: ${{ runner.temp }}/artifact.tar + retention-days: 1 + if-no-files-found: error gate: name: Porte de publication @@ -144,7 +175,7 @@ jobs: cancel-in-progress: false steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 — SHA vérifié le 23/10/2026 - name: Le site sert bien ce commit run: | url="${{ steps.deployment.outputs.page_url }}" @@ -170,13 +201,13 @@ jobs: cancel-in-progress: false steps: - name: Récupération de l'artefact vérifié de ce run - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 — SHA vérifié le 23/10/2026 with: name: site-verifie-${{ github.sha }} path: dist - name: L'artefact correspond au commit run: grep -q "\"sha\":\"${{ github.sha }}\"" dist/version.json - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 — SHA vérifié le 23/10/2026 with: node-version: 22 - name: Projet Cloudflare en envoi direct (créé une seule fois, sans connexion Git) diff --git a/docs/DRIVE_ACCOUNT_BOUNDARY_20261023.md b/docs/DRIVE_ACCOUNT_BOUNDARY_20261023.md new file mode 100644 index 0000000..8f81b4f --- /dev/null +++ b/docs/DRIVE_ACCOUNT_BOUNDARY_20261023.md @@ -0,0 +1,47 @@ +# Frontière compte et destination — NOESIS, 23 octobre 2026 + +Statut : préparation sensible, revue humaine obligatoire, aucune fusion ni activation cloud autorisée par cette PR. Cette note ne modifie pas les contrats normatifs V1/A1/A2. + +## Défauts reproduits avant correction + +Deux contre-tests du fournisseur, exécutés sur la version antérieure, échouent : un dossier personnel simplement accessible en écriture est accepté ; un fichier déplacé hors du dossier sélectionné peut encore être téléchargé et annoncé vérifié si ses empreintes concordent. + +## Correction limitée au périmètre + +Le choix du compte reste dans l'interface officielle Google, avec `prompt: select_account`, le seul scope `drive.file` et aucun consentement automatique. Après le retour Google, l'application lit seulement l'identité du compte courant (`about.get`, permissionId/emailAddress), l'affiche en mémoire et exige une confirmation explicite avant Picker, réservation d'ID ou accès aux fichiers. Ni compte, ni adresse, ni jeton ne sont persistés ou consignés. L'application ne peut pas déterminer seule si le compte est personnel : l'utilisateur doit choisir un compte de test dédié. La configuration de production reste absente/désactivée. + +Le dossier est neuf, créé par l'application avec un ID réservé et la marque privée `rc-folder-v1`, puis relu. Sa sélection reste explicite dans Google Picker. Le fournisseur exige son identité, sa propriété, son type, l'absence de corbeille et le droit d'ajout. Aucune modification, suppression, exploration générale ou labellisation d'un dossier existant. Un dossier ancien non marqué est refusé : aucune migration implicite. Le même dossier marqué peut être choisi sur l'autre appareil avec le même projet/client OAuth autorisé. + +Listes et relectures sont limitées au dossier choisi, aux marques `rc-v1`/`rc-key-a1` et aux métadonnées attendues. Le parent du fichier est vérifié avant sa lecture. Une empreinte SHA-256 reste une preuve d'octets, pas une authentification GCM : celle-ci appartient uniquement à Race Control. Un ajout extérieur au dossier ou une suppression par un utilisateur autorisé n'est pas empêché par cette marque. + +Le jeton est exclusivement en mémoire. Expiration, déconnexion et révocation connue invalident la sélection. Les callbacks d'un ancien compte ou d'un ancien Picker ne réactivent pas la nouvelle session. Un changement de destination invalide le bail réseau précédent. Une opération persistée conserve sa liaison opaque compte/client/dossier et ne peut être reprise ailleurs. Le marquage du dossier ne remplace ni l'autorisation Google ni la comparaison de cette liaison. + +La création du dossier réutilise l'ID réservé pendant la session si une réponse est perdue. Après fermeture complète avant confirmation du dossier, il faut retrouver explicitement le dossier existant dans Picker ; ne pas annoncer une reprise persistante de cette préparation de dossier. Les files d'archives conservent déjà durablement leurs octets et identifiants, séparément. + +Race Control suspend son moteur avant un changement de destination, attend la fin de l'ancienne exécution, et ne valide pas son résultat tardif. La file reste conservée ; un paquet éventuellement créé est relu avec son ID initial lors d'une reprise autorisée dans sa destination initiale. Les données métier restent exclusivement dans Race Control. + +Control Vault affiche le compte et le dossier sélectionnés, la date d'opération, la file connue et les états distincts attente/envoi/envoyé/octets vérifiés/blocage/erreur. Les têtes A2 listées restent déclaratives avant authentification dans Race Control. Aucun déchiffrement ou arbitrage métier dans Vault. + +## Reproduction et vérifications + +- Vault : `npm ci`, `npm run typecheck`, `npm test`, `npm run build`, `npm run security`, `CI=1 npm run test:e2e` ; les deux moteurs et les quatre projets de viewport restent requis. +- Contre-tests : `tests/unit/drive-boundary.test.ts` (dossier non dédié, parent divergent, confirmation, changement de compte, callbacks tardifs, création idempotente et révocation). +- Parcours navigateur : `tests/e2e/connected-drive.spec.ts` (Google entièrement intercepté, données fictives, confirmation avant création/Picker, relecture et téléchargement, rejet du clair, oubli du jeton au rechargement). +- Race Control : `node tests/run-ci.js --lane unit`, `BROWSER=chromium node tests/e2e85-drive-runtime.js`, puis `BROWSER=webkit node tests/e2e85-drive-runtime.js` ; suite CI complète avant toute décision de fusion. `tests/test_drive_sync.js` vérifie aussi le changement de destination pendant l'envoi. +- Reproduction statique du fournisseur dans Race Control : `node tools/vendor-vault-drive.js ../Control-Vault`. Les SHA-256 des quatre sources sont inscrits dans l'en-tête généré. Le module GIS est identique entre dépôts, hormis l'export ES de Vault. + +Ces preuves sont unitaires et d'intégration avec réseau Google simulé. Aucun appel Google réel, aucun compte personnel, aucune validation sur un iPhone physique. VALIDATION PHYSIQUE EN ATTENTE. + +## Environnement réel à autoriser séparément + +Deux origines HTTPS de test indépendantes, un client OAuth Web de test commun à ces seules origines, une clé Picker restreinte, un compte Google de test confirmé par l'utilisateur et un dossier neuf sont nécessaires. La création de ressources administratives et leur publication nécessitent un GO ciblé distinct. Le schéma public reste exactement `approved`, `clientId`, `apiKey`, `appId`, `raceOrigin`, `vaultOrigin`. Aucun compte/dossier/jeton/secret ajouté au schéma. Ne jamais configurer un secret client OAuth dans une application statique. + +L'autorisation `approved: true` est réservée au futur environnement de test isolé après validation de ses ressources. Aucun changement des variables ou secrets de production. Aucun accès réel depuis `ipoower.github.io`. Les essais réels doivent démontrer les droits effectifs sur les fichiers des deux applications : Picker seul ne prouve pas ces droits. + +## Risques, dépendances et récupération + +Modification sensible commune aux deux clients : relire les deux PR et la compatibilité du fournisseur reproduit. La PR de sécurité CI/CD Control Vault #16 reste séparée et non fusionnée. Aucun changement de workflow, permission, secret, données existantes, contrat normatif, service worker ou animation. + +Le retour arrière concerne le code uniquement, par PR et après accord. Ne jamais effacer la file, les bases locales, le dossier ou les archives pour faciliter un retour arrière. Une installation ancienne non marquée reste disponible ; son export chiffré et sa restauration isolée doivent être validés avant toute migration personnelle explicite. + +Aucune promesse de synchronisation permanente d'une PWA fermée sur iOS. Reprise seulement application ouverte, autorisée, connectée et coffre déverrouillé ; expiration Google implique une action humaine. Un succès HTTP ne constitue pas une sauvegarde vérifiée, et les octets vérifiés par Vault ne constituent pas une restauration réussie. diff --git a/docs/DRIVE_REAL_TEST_20261025.md b/docs/DRIVE_REAL_TEST_20261025.md new file mode 100644 index 0000000..ffd9e37 --- /dev/null +++ b/docs/DRIVE_REAL_TEST_20261025.md @@ -0,0 +1,21 @@ +# Candidat Google Drive TEST — 25 octobre 2026 + +Cette branche est réservée aux projets Cloudflare existants `rc-drive-test-ipoower` et `cv-drive-test-ipoower`, branche de publication `noesis-test`. Aucun main, environnement de production, projet Reconversion ou travail Garage/Climatisation n'est modifié. Les PR temporaires #239/#20 restent des preuves et ne sont ni fusionnées ni publiées. + +Le candidat réunit les PR sources pertinentes sans intégrer les commits des branches temporaires. Les moteurs métier/chiffrement restent ceux des sources, avec les correctifs de sécurité/IDB et de récupération déjà testés. RC accepte désormais une base HTTPS à la racine, auparavant refusée par le validateur de build ; les URL avec identifiants, requête ou fragment restent refusées. + +Les scripts `tools/build-drive-test.cjs` (RC) et `scripts/build-drive-test.mjs` (Vault) exigent `BUILD_SHA` à 40 caractères. Avec `GOOGLE_DRIVE_CONFIG` absent, ils produisent un profil fermé, `googleEnabled:false`, configuration `null`. Ce n'est pas une activation Google. Un client réel ne sera accepté qu'avec les six champs existants et les deux origines TEST exactes ; aucune clé secrète, phrase, dossier, email ou jeton dans cette configuration publique. + +RC construit exclusivement depuis `tests/fixtures/preset.fake.json` et un relais fictif sans notification, avec les clés publiques de test du harnais. Il déchiffre et compare le préréglage produit. Les sorties sont limitées aux assets publics et données synthétiques ; aucun fichier chiffré du dossier personnel `encrypted/`, préréglage clair, artifact privé ou code du relais n'est publié. Géolocalisation interdite. Les deux PWA portent TEST, un bandeau visible et un lien réciproque TEST ; les empreintes de tous les fichiers figurent dans `fixture-manifest.json`. + +Le numéro RC `prod-999` est réservé à la fixture de recette ; ses copies ne doivent jamais être utilisées comme progression personnelle ou importées dans la production. Chaque construction utilise les sels et IV aléatoires normaux : les octets chiffrés changent légitimement entre deux builds. Vérifier le manifeste du ZIP effectivement publié, sans prétendre à un chiffré déterministe. + +Les nouvelles preuves Node couvrent 128 révisions A2 valides, 129 refusées avant HTTP et le budget 128 Mio. La borne mémoire utilise des métadonnées HTTP simulées et n'annonce aucun transfert Google de 128 Mio. Aucun historique n'est supprimé. Les copies sont indépendantes ; la limite demeure une restriction de disponibilité à traiter avant une promesse de récupération durable pour de gros historiques. + +Après vérification des SHA/CI, dépôt uniquement dans le projet TEST correspondant, jamais dans `race-control-ipoower` / `control-vault-ipoower`. Le libellé Cloudflare « production » de ces deux projets isolés désigne leurs seules URL TEST. Contrôler version, octets, CSP et origine effective avant OAuth. Une connexion Cloudflare sécurisée est actuellement nécessaire car le connecteur refuse le téléversement des assets avec ses permissions présentes. Ne pas extraire ou élargir les secrets de production pour contourner ce refus. + +Google Cloud doit utiliser un projet TEST dédié, Drive API + Picker, client Web commun, `drive.file`, clé Picker limitée aux référents TEST et à Picker API, aucun billing. L'administrateur et l'identité Drive de test sont deux rôles distincts ; ne jamais choisir automatiquement un compte personnel pour les essais. L'absence d'accès console ne constitue pas une configuration exécutée. + +La recette réelle requiert : envoi de fixture, relecture exacte des octets, reçu, édition pendant envoi, perte de réponse après écriture, reprise du même identifiant, hors ligne, deux contextes indépendants, conflit sans écrasement, installation vierge, fermeture/réouverture et égalité exhaustive des valeurs. Distinguer fournisseur Google simulé, Google hébergé et iPhone physique. Aucun mot de passe, code, jeton ou HAR brut dans les preuves. + +Rollback TEST : sélectionner le déploiement TEST précédent si nécessaire et autorisé, sans supprimer de déploiement, fichier Drive, cache, coffre, outbox ou historique. Un rollback logiciel ne restaure pas des données. Ne pas réintroduire une version ignorant un journal de récupération en cours. diff --git a/docs/DRIVE_RECOVERY_HANDOFF_20261025.md b/docs/DRIVE_RECOVERY_HANDOFF_20261025.md new file mode 100644 index 0000000..bbfb033 --- /dev/null +++ b/docs/DRIVE_RECOVERY_HANDOFF_20261025.md @@ -0,0 +1,56 @@ +# Reçus et récupération RC — 25 octobre 2026 + +Auteur : Bryan Fiarda. Correction sensible, non fusionnée, non déployée. +Base dépendante : #19, qui inclut #17. #16 et #18 restent séparées ; #20 est +une preuve temporaire à ne pas fusionner. Aucune branche source modifiée. + +## Défaut reproduit et correction + +Lors d'un téléchargement, #19 relit le reçu durable et s'assure que le job +d'upload concurrent est intact. La comparaison par JSON.stringify transforme +son Uint8Array chiffré entier en objet JSON numérique, deux fois. Avec la limite +contractuelle de fichier, ces chaînes peuvent occuper bien plus de mémoire que +le fichier. Aucune panne physique ni perte personnelle n'est supposée. + +Le nouveau contre-test place un upload synthétique de 2 Mio en attente et refuse +sa sérialisation JSON. Il échoue sur #19 (null au lieu des octets téléchargés), +puis passe avec sameJob. La comparaison vérifie la forme connue, la liaison, +l'identifiant réservé, l'empreinte, la longueur et chaque octet sans conversion +JSON. Une seule différence d'octet interdit VERIFIED_BYTES. Les quatre tests +antérieurs restent intacts ; le journal avant correction est conservé. + +La copie Uint8Array reste gérée par les mêmes archives et transactions IDB. +Aucune réduction de taille du ciphertext, aucune suppression ou nouvelle +compression n'est revendiquée. Ce changement évite une allocation inutile ; il +ne chiffre pas un gain de RAM d'un iPhone réel non mesuré. + +## Frontière de confiance et lien de récupération + +L'écran Drive garde le parcours d'import dans Race Control et ajoute le lien +`/recovery.html`. Cette page appartient au candidat RC de récupération +A2 du 25 octobre. Publier les deux candidats compatibles après leurs GO séparés. +Ne pas présenter ce lien comme un parcours disponible dans la production actuelle. + +Control Vault ne demande jamais le code, ne déchiffre pas et ne choisit pas une +tête A2. Le code et la comparaison métier restent dans RC. Le lien utilise +uniquement l'origine validée de la configuration, aucun paquet, code, jeton ou +identifiant privé dans l'URL, avec noopener/noreferrer. Les tests de l'interface +vérifient le lien et l'absence de champ mot de passe. + +## Validation et limites + +Reproduire : `npm run typecheck`, `npm test`, `npm run build`, `npm run security`, +`CI=1 npm run test:e2e`. Le workflow Chromium/WebKit existant reste inchangé. +Vérifier la CI sur le SHA exact de cette PR ; les anciennes CI ne la valident pas. + +Les réponses Google des tests sont fictives. Les comptes, restrictions de clé +Picker, appProperties et visibilité entre les origines doivent être vérifiés +sur un compte de test autorisé, puis sur iPhone physique. Aucun essai réel, +configuration Google, déploiement ou transmission personnelle dans ce lot. + +## Impact et retour arrière + +Changement local au contrôle de reçu et à un lien explicite ; aucun format, +scope, secret, schéma IDB, cache, contrat V1/A1/A2 ou CI modifié. Ne jamais fusionner +la PR temporaire #20. Revert logiciel par PR après GO, sans purger les jobs, +archives, reçus, fichiers Drive ou stockages d'autres applications. diff --git a/docs/NOESIS31_CI_PROVENANCE_20261023.md b/docs/NOESIS31_CI_PROVENANCE_20261023.md new file mode 100644 index 0000000..5fe785c --- /dev/null +++ b/docs/NOESIS31_CI_PROVENANCE_20261023.md @@ -0,0 +1,49 @@ +# NOESIS 3.1 — références CI immuables — 23 octobre 2026 + +## Problème et périmètre + +Le pipeline utilisait des tags mobiles d'actions, y compris lors des publications. +Un déplacement amont peut ainsi changer le code exécuté sans modification du dépôt. +Ce correctif sensible est préparé pour revue humaine ; il ne doit pas être fusionné +automatiquement. Aucun réglage GitHub/Cloudflare, secret, OAuth ou fichier utilisateur +n'est modifié. Les conditions, permissions, environnements et gates de publication +restent inchangés. + +Les SHA ci-dessous sont ceux résolus dans les logs du [pipeline réussi 39928831251](https://github.com/iPoower/Control-Vault/actions/runs/39928831251), +comparés le 23/10/2026 aux références des dépôts officiels `actions/*`. +Aucune montée de version d'action n'est effectuée. + +| Action | Référence figée | +|---|---| +| actions/checkout v4 | `11d5960a326750d5838078e36cf38b85af677262` | +| actions/setup-node v4 | `49933ea5288caeca8642d1e84afbd3f7d6820020` | +| actions/upload-artifact v4 | `ea165f8d65b6e75b540449e92b4886f43607fa02` | +| actions/download-artifact v4 | `d3f86a106a0bac45b974a628896c90dbdf5c8093` | +| actions/deploy-pages v4 | `d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e` | + +Épingler seulement `upload-pages-artifact` ne suffit pas : son [action composite v3](https://github.com/actions/upload-pages-artifact/blob/56afc609e74202658d3ffba0e8f6dda462b719fa/action.yml) +appelle `upload-artifact@v4`. L'archive Linux est donc préparée directement avec GNU tar, +puis envoyée par l'uploader figé : même nom `github-pages`, fichier `artifact.tar`, +liens déréférencés, fichiers cachés inclus hors métadonnées Git, rétention d'artefact CI +d'un jour. Cette rétention concerne uniquement l'artefact de publication GitHub Actions, +pas des sauvegardes personnelles ; elle conserve la valeur par défaut précédente. + +## Vérification et limites + +Les tests `workflow-provenance.test.ts` refusent les références mobiles et la réintroduction +du composite. Ils exécutent le vrai conditionnement sur des fichiers fictifs, comparent +les octets extraits (HTML, version/configuration, assets et fichier caché) et contrôlent +le déréférencement d'un lien ainsi que l'exclusion de `.git`/`.github`. +Ils échouent avant le correctif. Les invariants existants du pipeline restent requis. + +La PR ne publie rien : les jobs de déploiement exigent toujours un push sur main après +la vérification complète. Une CI de PR verte ne démontre pas à elle seule le déploiement +du nouvel assemblage Pages. La recette de publication restera à effectuer après GO humain. + +Le runner, l'image Playwright et le lancement npx de Wrangler restent des dépendances de +confiance à examiner séparément. Ce correctif ne prétend pas verrouiller toute la chaîne +transitive npm ni empêcher une compromission du compte administrateur. + +Retour arrière : PR relue et CI complète, vers un état connu ; ne pas réintroduire +silencieusement des tags mobiles. Un retour de code ne restaure aucune donnée. +Référence : [GitHub, usage sécurisé des actions](https://docs.github.com/en/actions/reference/security/secure-use), consultée le 23/10/2026. diff --git a/docs/NOESIS_FINAL_MILE_INTEGRATION_20261024.md b/docs/NOESIS_FINAL_MILE_INTEGRATION_20261024.md new file mode 100644 index 0000000..b04ea91 --- /dev/null +++ b/docs/NOESIS_FINAL_MILE_INTEGRATION_20261024.md @@ -0,0 +1,33 @@ +> Archive de validation historique : ce document décrit les PR temporaires #239/#20, qui restent exclues de fusion et de publication. Il ne définit pas la branche TEST actuelle ; voir DRIVE_REAL_TEST_20261025.md. Aucune archive n’est supprimée. + +# NOESIS Final Mile — integration fixture only + +This disposable branch combines #16, #17, #18 and #19 without changing their source branches. +#19 includes #17 and retains that dependency. Merges were conflict-free. +The V1/A1/A2 contract files remain byte-identical to Race Control. + +Local combined validation: TypeScript, 21 Vitest suites / 334 tests, Vite build and +security scanner passed. Browser CI must be verified on the exact new head before +claiming combined browser success. The Google provider in browser tests is simulated. +Control Vault transports opaque bytes and dated integrity receipts; it cannot authenticate +AES-GCM, validate business data or establish successful Race Control restoration. + +This branch is for CI/review only and must not be merged or deployed. No administrative +change or personal activation is authorized. Rollback: leave the branch unused; all +source branches and main stay unchanged. Original PRs require explicit human approval. + +Additional browser regression imports the real DriveStore and exercises native IDB +transaction abort, connection close/reopen, its versionchange handler and WebCrypto +recovery byte comparison. This new head needs its own full CI. +## Complément de validation — 25 octobre 2026 + +La combinaison de test conserve #16–#19 et les épreuves IDB natives, puis ajoute +#21 (7a2b78b037711816a99b4c47c666223a0cdb8031) : comparaison des jobs chiffrés +sans expansion JSON et lien vers la récupération qui reste exécutée dans RC. +Le code de déchiffrement n'entre jamais dans Control Vault. + +NE PAS FUSIONNER NI DÉPLOYER cette PR temporaire. Sa tête historique +3d177ac58173944599150d8d18515417375b9eb6 reste dans ses ancêtres. Aucun main +ou branche source n'est modifié. Vérifier sa nouvelle CI au SHA exact ; les +réponses Google sont fictives et aucun iPhone physique n'est validé. + diff --git a/scripts/build-drive-test.mjs b/scripts/build-drive-test.mjs new file mode 100644 index 0000000..18736a5 --- /dev/null +++ b/scripts/build-drive-test.mjs @@ -0,0 +1,34 @@ +// Profil TEST uniquement ; aucune connexion ni donnée personnelle. +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { GoogleDriveSession } from '../src/services/google-drive-session.js'; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const RACE = 'https://rc-drive-test-ipoower.pages.dev', VAULT = 'https://cv-drive-test-ipoower.pages.dev'; +const sha = process.env.BUILD_SHA || ''; +if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error('BUILD_SHA exact obligatoire.'); +const text = process.env.GOOGLE_DRIVE_CONFIG || ''; +if (Buffer.byteLength(text) > 2048) throw new Error('Configuration trop volumineuse.'); +let cfg = null; +try { if (text) cfg = GoogleDriveSession.config(JSON.parse(text), VAULT); } catch { throw new Error('Configuration publique TEST invalide.'); } +if (cfg && (cfg.raceOrigin !== RACE || cfg.vaultOrigin !== VAULT)) throw new Error('Origines TEST exactes requises.'); +const base = fs.mkdtempSync(path.join(ROOT, '.drive-test-profile-')), site = path.join(base, 'site'); +try { execFileSync(process.execPath, ['node_modules/vite/bin/vite.js', 'build'], { cwd: ROOT, env: { ...process.env, VAULT_OUT: site, BUILD_SHA: sha, VAULT_DEDICATED_BUILD: '1', GOOGLE_DRIVE_CONFIG: text }, stdio: 'pipe', timeout: 120000 }); } +catch { throw new Error('Build TEST refusé ; aucune sortie brute affichée.'); } +const p = name => path.join(site, name); +let html = fs.readFileSync(p('index.html'), 'utf8'); +html = html.replace('', '').replace('', ''); +fs.writeFileSync(p('index.html'), html); +fs.writeFileSync(p('drive-test.css'), '#drive-test-notice{display:block;padding:12px;background:#49380b;color:#fff;font:14px/1.5 system-ui}#drive-test-notice a{color:#fff;text-decoration:underline}'); +const manifest = JSON.parse(fs.readFileSync(p('manifest.webmanifest'), 'utf8')); +manifest.name = 'Control Vault — Drive TEST'; manifest.short_name = 'CV TEST'; +fs.writeFileSync(p('manifest.webmanifest'), JSON.stringify(manifest)); +const version = JSON.parse(fs.readFileSync(p('version.json'), 'utf8')); +fs.writeFileSync(p('version.json'), JSON.stringify({ ...version, environment: 'isolated-test', fixtureOnly: true, googleEnabled: !!cfg })); +const digest = b => crypto.createHash('sha256').update(b).digest('hex'); +function files(dir, prefix = '') { return fs.readdirSync(path.join(dir, prefix), { withFileTypes: true }).sort((a,b)=>a.name.localeCompare(b.name)).flatMap(e => { const n = prefix + e.name; if (e.isDirectory()) return files(dir, n + '/'); if (!e.isFile()) throw new Error('Fichier non régulier.'); return [n]; }); } +const contents = Object.fromEntries(files(site).map(n => [n, digest(fs.readFileSync(p(n)))])); +fs.writeFileSync(p('fixture-manifest.json'), JSON.stringify({ environment: 'isolated-test', fixtureOnly: true, sourceSha: sha, raceOrigin: RACE, vaultOrigin: VAULT, googleEnabled: !!cfg, files: contents }, null, 2)); +console.log(JSON.stringify({ site, sourceSha: sha, googleEnabled: !!cfg, files: Object.keys(contents).length })); diff --git a/src/services/connected-drive.ts b/src/services/connected-drive.ts index 16833e6..4b98812 100644 --- a/src/services/connected-drive.ts +++ b/src/services/connected-drive.ts @@ -8,8 +8,18 @@ import { OpaqueDrive, type RemotePacket, type ByteProof } from './opaque-drive'; interface Job { binding: string; bytes: Uint8Array; fileId: string | null; sha: string } interface Durable { v: 1; job: Job | null; receipts: ByteProof[] } +// Ne jamais sérialiser un Uint8Array pouvant atteindre 68 Mio en deux chaînes +// JSON géantes pour confirmer qu'un téléchargement n'a pas changé l'envoi. +function sameJob(a: Job | null, b: Job | null): boolean { + if (a === null || b === null) return a === b; + return !!a && !!b && Object.keys(a).length === 4 && Object.keys(b).length === 4 && + a.binding === b.binding && a.fileId === b.fileId && a.sha === b.sha && + a.bytes instanceof Uint8Array && b.bytes instanceof Uint8Array && a.bytes.length === b.bytes.length && + a.bytes.every((value, i) => value === b.bytes[i]); +} export interface ConnectedState { configured: boolean; prepared: boolean; connected: boolean; selected: boolean; busy: boolean; message: string; - versions: RemotePacket[]; receipts: ByteProof[]; recoveryOrigin: string | null } + versions: RemotePacket[]; receipts: ByteProof[]; recoveryOrigin: string | null; account: string | null; accountConfirmed: boolean; folder: string | null; + phase: 'WAITING' | 'SENDING' | 'UPLOADED' | 'VERIFIED_BYTES' | 'CONFLICT' | 'SYNC_BLOCKED' | 'ERROR'; pending: boolean; lastOperationAt: number | null } export class ConnectedDrive { private provider: OpaqueDrive | null = null; private session: DriveSession | null = null; @@ -20,7 +30,7 @@ export class ConnectedDrive { private listeners = new Set<() => void>(); private epoch = 0; state: ConnectedState = { configured: false, prepared: false, connected: false, selected: false, busy: false, - message: 'Connexion réelle non activée sur cette version.', versions: [], receipts: [], recoveryOrigin: null }; + message: 'Connexion réelle non activée sur cette version.', versions: [], receipts: [], recoveryOrigin: null, account: null, accountConfirmed: false, folder: null, phase: 'WAITING', pending: false, lastOperationAt: null }; subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); } private change(patch: Partial) { this.state = { ...this.state, ...patch }; this.listeners.forEach(fn => fn()); } async init() { @@ -29,6 +39,8 @@ export class ConnectedDrive { this.config = await GoogleDriveSession.load(); if (location.origin !== this.config.vaultOrigin) return; this.provider = new OpaqueDrive([this.config.vaultOrigin]); this.session = GoogleDriveSession.create(this.config, this.provider); + const checkSession = () => { if (this.state.connected && !this.session?.connected) { this.disconnect(); this.change({ phase: 'SYNC_BLOCKED', message: 'Autorisation Google expirée ou révoquée : reconnecte et confirme le compte dédié.' }); } }; + setInterval(checkSession, 30000); window.addEventListener('focus', checkSession); document.addEventListener('visibilitychange', checkSession); this.change({ configured: true, recoveryOrigin: this.config.raceOrigin, message: 'Prêt à connecter Google Drive. Aucun code Race Control ne sera demandé ici.' }); } catch { /* absence de configuration = aucun OAuth ni transfert */ } } @@ -38,26 +50,39 @@ export class ConnectedDrive { try { await work(epoch); } catch (e) { const code = (e as { code?: string }).code; - this.change({ connected: this.session?.connected === true, - message: code === 'auth' ? 'Session Google expirée : reconnecte-toi pour reprendre.' : code === 'binding' ? 'Cet envoi attend le compte et le dossier utilisés à sa préparation.' : code === 'quota' ? 'Espace Google Drive insuffisant. Copie locale conservée.' : 'Opération non confirmée. Aucune copie distante n’a été supprimée ni remplacée.' }); - } finally { this.change({ busy: false }); } + if (epoch !== this.epoch) return; + this.change({ connected: this.session?.connected === true, phase: code === 'conflict' ? 'CONFLICT' : ['auth','binding','integrity','conflict','confirmation','quota','missing','incomplete','unavailable','origin'].includes(code || '') ? 'SYNC_BLOCKED' : 'ERROR', lastOperationAt: Date.now(), + message: code === 'conflict' ? 'Conflit de destination ou de métadonnées : opération bloquée, copies conservées. Vérifie les révisions dans Race Control.' : code === 'integrity' ? 'Intégrité non confirmée : synchronisation bloquée, copie locale conservée.' : code === 'auth' ? 'Session Google expirée : reconnecte-toi pour reprendre.' : code === 'binding' ? 'Cet envoi attend le compte et le dossier utilisés à sa préparation.' : code === 'quota' ? 'Espace Google Drive insuffisant. Copie locale conservée.' : 'Opération non confirmée. Aucune copie distante n’a été supprimée ni remplacée.' }); + } finally { if (epoch === this.epoch) this.change({ busy: false }); } } - async prepare() { return this.action(async () => { await this.session?.prepare(); this.change({ prepared: true, message: 'Connexion prête. Appuie sur Se connecter à Google.' }); }); } + async prepare() { return this.action(async epoch => { await this.session?.prepare(); if (epoch !== this.epoch) return; this.change({ prepared: true, message: 'Connexion prête. Appuie sur Se connecter à Google.' }); }); } // Conserver le geste utilisateur : ouvrir Google avant tout await. connect() { if (!this.session || this.state.busy) return Promise.resolve(); - this.epoch++; this.selection = null; this.change({ selected: false, versions: [], receipts: [] }); + this.epoch++; this.selection = null; this.change({ selected: false, account: null, accountConfirmed: false, folder: null, versions: [], receipts: [], phase: 'WAITING' }); const promise = this.session.connect(); - return this.action(async epoch => { await promise; this.check(epoch); this.change({ connected: true, message: 'Connecté. Choisis le dossier de Race Control dans Google Drive.' }); }); + return this.action(async epoch => { await promise; this.check(epoch); this.change({ connected: true, account: this.session?.identity?.label || null, accountConfirmed: false, message: 'Compte affiché : confirme explicitement ce compte dédié avant tout accès aux fichiers.' }); }); + } + confirmAccount() { + if (!this.session?.identity || this.state.busy) return; + try { this.session.confirmAccount(this.session.identity.id); this.change({ accountConfirmed: true, message: 'Compte confirmé. Crée un dossier neuf dédié ou retrouve celui créé par cette application dans Picker.' }); } + catch { this.change({ phase: 'SYNC_BLOCKED', message: 'Confirmation du compte refusée. Reconnecte le compte dédié.' }); } + } + newFolder() { + if (!this.session || this.state.busy || !this.state.accountConfirmed) return Promise.resolve(); + this.epoch++; this.selection = null; this.change({ selected: false, folder: null, versions: [], receipts: [] }); + return this.action(async epoch => { await this.session!.newFolder(); this.check(epoch); this.change({ phase: 'WAITING', lastOperationAt: Date.now(), message: 'Dossier neuf dédié créé et relu. Choisis-le maintenant dans Google Picker.' }); }); } chooseFolder() { if (!this.session || this.state.busy) return Promise.resolve(); + if (!this.state.accountConfirmed) return Promise.resolve(); + this.epoch++; this.selection = null; this.change({ selected: false, folder: null, versions: [], receipts: [], phase: 'WAITING' }); const promise = this.session.chooseFolder(); - return this.action(async epoch => { const selection = await promise; this.check(epoch); this.selection = selection; this.change({ selected: true, versions: [], receipts: [], message: 'Dossier sélectionné. Actualise les versions pour les lire.' }); }); + return this.action(async epoch => { const selection = await promise; this.check(epoch); this.selection = selection; this.change({ selected: true, folder: selection.folder, versions: [], receipts: [], lastOperationAt: Date.now(), message: 'Dossier sélectionné. Actualise les versions pour les lire.' }); }); } disconnect() { this.epoch++; this.session?.disconnect(); this.selection = null; - this.change({ connected: false, selected: false, versions: [], receipts: [], message: 'Déconnecté. Les fichiers et les envois en attente sont conservés.' }); + this.change({ busy: false, connected: false, selected: false, account: null, accountConfirmed: false, folder: null, phase: 'WAITING', versions: [], receipts: [], message: 'Déconnecté. Les fichiers et les envois en attente sont conservés.' }); } private check(epoch: number) { if (this.epoch !== epoch || !this.session?.connected) throw Object.assign(new Error('auth'), { code: 'auth' }); } private selected() { if (!this.provider || !this.selection) throw new Error('Dossier requis'); return { provider: this.provider, ...this.selection }; } @@ -71,7 +96,7 @@ export class ConnectedDrive { const saved = await this.durable.read(); this.check(epoch); // Les anciens reçus restent des preuves d'octets datées, pas une preuve // que la copie existe encore ni qu'elle est authentique/lue par RC. - this.change({ versions, receipts: saved?.receipts || [], message: `${versions.length} copies distantes listées. Leur contenu est encore à vérifier.` }); + this.change({ versions, pending: !!saved?.job, receipts: (saved?.receipts || []).filter(p => versions.some(v => v.fileId === p.fileId && v.fileSha256 === p.fileSha256)), lastOperationAt: Date.now(), message: `${versions.length} copies distantes listées. Leur contenu est encore à vérifier.` }); }); } private async resumeInside(epoch: number, packet?: RacePacket) { @@ -85,13 +110,14 @@ export class ConnectedDrive { await this.archive.save(packet); this.check(epoch); saved = { ...saved, job: { binding, bytes: packet.copyBytes(), fileId: null, sha: packet.info.fileSha256 } }; await this.durable.write(saved); } - const job = saved.job; if (!job) { this.change({ message: 'Aucun envoi en attente.' }); return; } + const job = saved.job; this.change({ pending: !!job }); if (!job) { this.change({ message: 'Aucun envoi en attente.' }); return; } const checked = await readRacePacket(job.bytes, job.sha); this.check(epoch); if (!job.fileId) { job.fileId = await provider.reserveId(); this.check(epoch); await this.durable.write(saved); } - const back = await provider.upload(job.fileId, folder, checked); this.check(epoch); + this.change({ phase: 'SENDING' }); + const back = await provider.upload(job.fileId, folder, checked, undefined, (sent, total) => { if (epoch === this.epoch) this.change({ phase: sent === total ? 'UPLOADED' : 'SENDING' }); }); this.check(epoch); saved.receipts = [...saved.receipts.filter(p => p.fileId !== back.proof.fileId), back.proof]; saved.job = null; await this.durable.write(saved); this.check(epoch); - this.change({ receipts: saved.receipts, message: 'Copie envoyée et octets distants relus à l’identique. Authentification et restauration à effectuer dans Race Control.' }); + this.change({ receipts: saved.receipts, pending: false, phase: 'VERIFIED_BYTES', lastOperationAt: Date.now(), message: 'Copie envoyée et octets distants relus à l’identique. Authentification et restauration à effectuer dans Race Control.' }); } async upload(file: File) { return this.action(async epoch => { @@ -104,10 +130,31 @@ export class ConnectedDrive { async download(ref: RemotePacket): Promise { let bytes: Uint8Array | null = null; await this.action(async epoch => { - const { provider } = this.selected(); const back = await provider.download(ref); this.check(epoch); + const { provider, folder } = this.selected(); const back = await provider.download(ref, undefined, folder); this.check(epoch); await this.archive.save(back.packet); this.check(epoch); + // The archive and its transport receipt are separate guarantees. Serialize the + // receipt read/modify/write with uploads under the existing outbox lock. + let receipts: ByteProof[] = []; + await this.locked(async () => { + const previous = await this.durable.read() || { v: 1 as const, job: null, receipts: [] }; + this.check(epoch); + if (previous.v !== 1 || !Array.isArray(previous.receipts)) throw new Error('stockage'); + const updated: Durable = { + ...previous, + receipts: [...previous.receipts.filter(p => p.fileId !== back.proof.fileId), back.proof] + }; + await this.durable.write(updated); this.check(epoch); + const confirmed = await this.durable.read(); this.check(epoch); + const sameProof = (p: ByteProof) => p.fileId === back.proof.fileId && + p.fileSha256 === back.proof.fileSha256 && p.cipherSha256 === back.proof.cipherSha256 && + p.size === back.proof.size && p.verifiedAt === back.proof.verifiedAt; + if (!confirmed || confirmed.v !== 1 || !Array.isArray(confirmed.receipts) || + !confirmed.receipts.some(sameProof) || !sameJob(confirmed.job, previous.job)) + throw new Error('stockage'); + receipts = confirmed.receipts; + }); bytes = back.packet.copyBytes(); - this.change({ receipts: [...this.state.receipts.filter(p => p.fileId !== ref.fileId), back.proof], message: 'Octets vérifiés et copie chiffrée gardée sur cet appareil. Ouvre ce fichier dans Race Control pour le déchiffrer.' }); + this.change({ phase: 'VERIFIED_BYTES', lastOperationAt: Date.now(), receipts, message: 'Octets vérifiés et copie chiffrée gardée sur cet appareil. Ouvre ce fichier dans Race Control pour le déchiffrer.' }); }); return bytes; } diff --git a/src/services/drive-store.js b/src/services/drive-store.js index b1e4332..c2cb85a 100644 --- a/src/services/drive-store.js +++ b/src/services/drive-store.js @@ -4,21 +4,37 @@ */ const DriveStore = (() => { function create(name, indexedDB = globalThis.indexedDB) { - let opening; + let opening, active; function open() { if (!opening) opening = new Promise((resolve, reject) => { if (!indexedDB) return reject(new Error('stockage')); const r = indexedDB.open(name, 1); + let invalid = false; + const failure = () => { invalid = true; reject(new Error('stockage')); }; r.onupgradeneeded = () => { r.result.createObjectStore('state'); r.result.createObjectStore('recovery'); }; - r.onerror = () => reject(new Error('stockage')); r.onblocked = () => reject(new Error('stockage')); - r.onsuccess = () => { r.result.onversionchange = () => r.result.close(); resolve(r.result); }; - }); + r.onerror = failure; + // Blocked opens may eventually complete: close a late connection from a rejected attempt. + r.onblocked = failure; + r.onsuccess = () => { + const db = r.result; + if (invalid) { db.close(); return; } + active = db; + const invalidate = () => { if (active === db) { active = null; opening = null; } }; + db.onversionchange = () => { db.close(); invalidate(); }; + db.onclose = invalidate; + resolve(db); + }; + }).catch(error => { opening = null; throw error; }); return opening; } + function transaction(db, store, mode) { + try { return db.transaction(store, mode); } + catch { opening = null; if (active === db) active = null; throw new Error('stockage'); } + } async function read(store, key) { const db = await open(); return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readonly'), request = tx.objectStore(store).get(key); let value; + const tx = transaction(db, store, 'readonly'), request = tx.objectStore(store).get(key); let value; request.onsuccess = () => { value = request.result; }; tx.oncomplete = () => resolve(value ?? null); tx.onabort = tx.onerror = () => reject(new Error('stockage')); }); @@ -27,7 +43,7 @@ const DriveStore = (() => { const expected = structuredClone(value); const db = await open(); await new Promise((resolve, reject) => { - const tx = db.transaction('state', 'readwrite'); tx.objectStore('state').put(expected, 'current'); + const tx = transaction(db, 'state', 'readwrite'); tx.objectStore('state').put(expected, 'current'); tx.oncomplete = resolve; tx.onerror = tx.onabort = () => reject(new Error('stockage')); }); // Comparer les octets sans les convertir en une immense liste JSON. @@ -46,14 +62,14 @@ const DriveStore = (() => { const copy = bytes.slice(), hash = [...new Uint8Array(await crypto.subtle.digest('SHA-256', copy))].map(x => x.toString(16).padStart(2, '0')).join(''); const db = await open(); await new Promise((resolve, reject) => { - const tx = db.transaction('recovery', 'readwrite'), store = tx.objectStore('recovery'), r = store.get(hash); + const tx = transaction(db, 'recovery', 'readwrite'), store = tx.objectStore('recovery'), r = store.get(hash); r.onsuccess = () => { if (r.result == null) store.add(copy, hash); }; tx.oncomplete = resolve; tx.onerror = tx.onabort = () => reject(new Error('stockage')); }); const back = await read('recovery', hash); if (!(back instanceof Uint8Array) || back.length !== copy.length || back.some((v, i) => v !== copy[i])) throw new Error('stockage'); await new Promise((resolve, reject) => { - const tx = db.transaction('state', 'readwrite'); tx.objectStore('state').put(hash, 'recovery-latest'); + const tx = transaction(db, 'state', 'readwrite'); tx.objectStore('state').put(hash, 'recovery-latest'); tx.oncomplete = resolve; tx.onerror = tx.onabort = () => reject(new Error('stockage')); }); return hash; diff --git a/src/services/google-drive-session.d.ts b/src/services/google-drive-session.d.ts index 4e7653e..0496b70 100644 --- a/src/services/google-drive-session.d.ts +++ b/src/services/google-drive-session.d.ts @@ -1,6 +1,6 @@ import type { OpaqueDrive } from './opaque-drive'; export interface DriveConfig { approved: true; clientId: string; apiKey: string; appId: string; raceOrigin: string; vaultOrigin: string } -export interface DriveSession { prepare(): Promise; connect(): Promise; chooseFolder(): Promise<{ folder: string; binding: string }>; disconnect(): void; readonly connected: boolean } +export interface DriveSession { prepare(): Promise; connect(): Promise; confirmAccount(id: string): void; newFolder(): Promise; chooseFolder(): Promise<{ folder: string; binding: string; account: string }>; readonly identity: { id: string; label: string } | null; readonly confirmed: boolean; disconnect(): void; readonly connected: boolean } export const GoogleDriveSession: { config(value: unknown, origin?: string): DriveConfig; load(fetcher?: typeof fetch): Promise; diff --git a/src/services/google-drive-session.js b/src/services/google-drive-session.js index eb2cead..be611d6 100644 --- a/src/services/google-drive-session.js +++ b/src/services/google-drive-session.js @@ -30,7 +30,7 @@ const GoogleDriveSession = (() => { } function create(input, provider, host = globalThis) { const cfg = config(input, host.location.origin); - let token = null, until = 0, epoch = 0, prepared = false, pending = null, picker = null, pickReject = null; + let token = null, until = 0, epoch = 0, prepared = false, pending = null, picker = null, pickReject = null, pickerBusy = false, identity = null, approvedAccount = null, pendingFolder = null; const script = src => new Promise((resolve, reject) => { const el = host.document.createElement('script'); el.src = src; el.async = true; el.referrerPolicy = 'strict-origin-when-cross-origin'; const timer = setTimeout(() => { el.remove(); reject(Object.assign(new Error('network'), { code: 'network' })); }, 20000); @@ -38,7 +38,7 @@ const GoogleDriveSession = (() => { host.document.head.appendChild(el); }); function disconnect() { - token = null; until = 0; epoch++; provider.disconnect(); picker?.setVisible(false); picker = null; + token = null; until = 0; identity = approvedAccount = pendingFolder = null; pickerBusy = false; epoch++; provider.disconnect(); picker?.setVisible(false); picker = null; if (pickReject) { pickReject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); pickReject = null; } if (pending) { pending.reject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); pending = null; } } @@ -57,25 +57,45 @@ const GoogleDriveSession = (() => { return new Promise((resolve, reject) => { const request = { resolve, reject, epoch }; pending = request; const client = host.google.accounts.oauth2.initTokenClient({ client_id: cfg.clientId, scope: SCOPE, include_granted_scopes: false, - callback: response => { + callback: async response => { if (request !== pending || request.epoch !== epoch) return; - pending = null; try { if (response.error || typeof response.access_token !== 'string' || !host.google.accounts.oauth2.hasGrantedAllScopes(response, SCOPE) || response.scope?.split(/\s+/).filter(Boolean).join(' ') !== SCOPE) return fail('auth'); const seconds = Number(response.expires_in); provider.connect(response.access_token, seconds, [SCOPE]); token = response.access_token; until = Date.now() + (seconds - 30) * 1000; - request.resolve(); - } catch { disconnect(); request.reject(Object.assign(new Error('auth'), { code: 'auth' })); } + const selected = await provider.identity(); + if (request !== pending || request.epoch !== epoch) return; + identity = Object.freeze({ ...selected }); pending = null; request.resolve(); + } catch { if (request !== pending || request.epoch !== epoch) return; pending = null; disconnect(); request.reject(Object.assign(new Error('auth'), { code: 'auth' })); } }, error_callback: () => { if (request !== pending || request.epoch !== epoch) return; pending = null; disconnect(); request.reject(Object.assign(new Error('auth'), { code: 'auth' })); } }); try { client.requestAccessToken({ prompt: 'select_account' }); } catch { pending = null; disconnect(); reject(Object.assign(new Error('auth'), { code: 'auth' })); } }); } + function confirmAccount(accountId) { + if (!token || Date.now() >= until || !identity || identity.id !== accountId) return fail('confirmation'); + approvedAccount = accountId; + } + function fileAccess() { + if (!token || Date.now() >= until || provider.connected === false) { disconnect(); return fail('auth'); } + if (!identity || approvedAccount !== identity.id) return fail('confirmation'); + // Invalider toutes les réponses de l'ancienne destination, sans partager le jeton. + provider.connect(token, Math.floor((until - Date.now()) / 1000) + 30, [SCOPE]); + } + async function newFolder() { + fileAccess(); const lease = epoch, account = await provider.account(); + if (lease !== epoch || account !== approvedAccount) return fail('auth'); + if (!pendingFolder) { const reserved = await provider.reserveId(); if (lease !== epoch) return fail('auth'); pendingFolder = reserved; } + const folder = await provider.createFolder(pendingFolder); + if (lease !== epoch || Date.now() >= until) return fail('auth'); + return folder; + } function chooseFolder() { if (pickReject) return Promise.reject(Object.assign(new Error('busy'), { code: 'busy' })); if (!token || Date.now() >= until) { disconnect(); return Promise.reject(Object.assign(new Error('auth'), { code: 'auth' })); } + try { fileAccess(); } catch (e) { return Promise.reject(e); } const lease = epoch; return new Promise((resolve, reject) => { pickReject = reject; @@ -83,23 +103,27 @@ const GoogleDriveSession = (() => { const view = new gp.DocsView(gp.ViewId.FOLDERS).setIncludeFolders(true).setSelectFolderEnabled(true); picker = new gp.PickerBuilder().addView(view).setOAuthToken(token).setDeveloperKey(cfg.apiKey).setAppId(cfg.appId) .setOrigin(host.location.origin).setCallback(async data => { + if (lease !== epoch || pickReject !== reject || pickerBusy) return; if (data.action === gp.Action.CANCEL) { picker = null; pickReject = null; reject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); } if (data.action !== gp.Action.PICKED) return; - picker = null; pickReject = null; + picker = null; pickerBusy = true; try { if (lease !== epoch || Date.now() >= until || data.docs?.length !== 1) return fail('auth'); - const folder = data.docs[0].id; await provider.folder(folder); const account = await provider.account(); + if (lease !== epoch || account !== approvedAccount) return fail('auth'); + const folder = data.docs[0].id; await provider.folder(folder); if (lease !== epoch || Date.now() >= until) return fail('auth'); const binding = [...new Uint8Array(await host.crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([cfg.clientId, account, folder]))))].map(x => x.toString(16).padStart(2, '0')).join(''); if (lease !== epoch) return fail('auth'); - resolve({ folder, binding }); // identifiants jamais consignés ni placés dans une URL de l'application - } catch (e) { reject(e); } + pickReject = null; pickerBusy = false; resolve({ folder, binding, account: identity.label }); // identifiants jamais consignés ni placés dans une URL de l'application + } catch (e) { if (pickReject === reject) { pickReject = null; pickerBusy = false; } reject(e); } }).build(); picker.setVisible(true); }); } - return Object.freeze({ prepare, connect, chooseFolder, disconnect, get connected() { return !!token && Date.now() < until; } }); + return Object.freeze({ prepare, connect, confirmAccount, newFolder, chooseFolder, disconnect, + get identity() { return identity && { ...identity }; }, get confirmed() { return !!approvedAccount; }, + get connected() { if (token && (Date.now() >= until || provider.connected === false)) disconnect(); return !!token; } }); } return Object.freeze({ SCOPE, config, load, create }); })(); diff --git a/src/services/opaque-drive.ts b/src/services/opaque-drive.ts index 482c01f..74809f0 100644 --- a/src/services/opaque-drive.ts +++ b/src/services/opaque-drive.ts @@ -43,6 +43,7 @@ export class OpaqueDrive { this.#token = token; this.#until = this.now() + expiresIn * 1000 - 30000; } disconnect(): void { this.#token = null; this.#until = 0; this.#epoch++; } + get connected(): boolean { if (!this.#token || this.now() >= this.#until) { this.disconnect(); return false; } return true; } private lease(): number { this.guard(); if (!this.#token || this.now() >= this.#until) { this.disconnect(); return fail('auth'); } return this.#epoch; } private check(epoch: number, signal?: AbortSignal | null): void { if (signal?.aborted) return fail('cancelled'); @@ -126,11 +127,31 @@ export class OpaqueDrive { if (!object(value.user) || typeof value.user.permissionId !== 'string') return fail('incomplete'); return id(value.user.permissionId); } + async identity(signal?: AbortSignal): Promise<{ id: string; label: string }> { + const epoch = this.lease(); + const value = await this.json(await this.request(API + '/about?fields=user(permissionId,emailAddress)', { signal }, epoch), signal); + this.check(epoch, signal); + if (!object(value.user) || typeof value.user.permissionId !== 'string' || typeof value.user.emailAddress !== 'string' || + value.user.emailAddress.length > 254 || !/^[^\s@]+@[^\s@]+$/.test(value.user.emailAddress)) return fail('incomplete'); + return Object.freeze({ id: id(value.user.permissionId), label: value.user.emailAddress }); // mémoire/UI seulement + } async folder(folder: string, signal?: AbortSignal): Promise { id(folder); const epoch = this.lease(); - const p = await this.json(await this.request(API + '/files/' + folder + '?fields=id,mimeType,trashed,capabilities(canAddChildren)', { signal }, epoch), signal); + const p = await this.json(await this.request(API + '/files/' + folder + '?fields=id,mimeType,trashed,ownedByMe,appProperties,capabilities(canAddChildren)', { signal }, epoch), signal); this.check(epoch, signal); - if (p.id !== folder || p.trashed === true || p.mimeType !== 'application/vnd.google-apps.folder' || !object(p.capabilities) || p.capabilities.canAddChildren !== true) return fail('conflict'); + if (p.id !== folder || p.trashed !== false || p.ownedByMe !== true || p.mimeType !== 'application/vnd.google-apps.folder' || + !object(p.appProperties) || p.appProperties.cv_contract !== 'rc-folder-v1' || + !object(p.capabilities) || p.capabilities.canAddChildren !== true) return fail('conflict'); + } + async createFolder(fileId: string, signal?: AbortSignal): Promise { + id(fileId); const epoch = this.lease(); + // Réserver l'ID avant l'appel ; une réponse perdue est relue, jamais remplacée. + try { await this.folder(fileId, signal); this.check(epoch, signal); return fileId; } + catch (e) { if (!(e instanceof OpaqueDriveError) || e.code !== 'missing') throw e; } + const metadata = { id: fileId, name: 'Control Vault — sauvegardes chiffrées', mimeType: 'application/vnd.google-apps.folder', + appProperties: { cv_contract: 'rc-folder-v1' } }; + await this.request(API + '/files?fields=id', { method: 'POST', signal, headers: { 'Content-Type': 'application/json; charset=UTF-8' }, body: JSON.stringify(metadata) }, epoch, [409]); + await this.folder(fileId, signal); this.check(epoch, signal); return fileId; } // Descripteur A1 public : jamais un code, une clé ou un profil métier. Son // contrôle GCM et sa dérivation restent exclusivement dans Race Control. @@ -149,15 +170,17 @@ export class OpaqueDrive { Object.keys(p.check).sort().join() !== 'c,i' || !base64(p.check.i, 12) || !base64(p.check.c, 46)) return fail('integrity'); } async profiles(folder: string, signal?: AbortSignal): Promise { - id(folder); const epoch = this.lease(), out: SyncProfile[] = [], seen = new Set(); let next = ''; + id(folder); const epoch = this.lease(); await this.folder(folder, signal); this.check(epoch, signal); + const out: SyncProfile[] = [], seen = new Set(); let next = ''; do { const params = new URLSearchParams({ q: `'${folder}' in parents and trashed = false and appProperties has { key='cv_contract' and value='rc-key-a1' }`, - pageSize: '1000', fields: 'nextPageToken,incompleteSearch,files(id,size,appProperties)' }); + pageSize: '1000', fields: 'nextPageToken,incompleteSearch,files(id,size,trashed,parents,appProperties)' }); if (next) params.set('pageToken', next); const page = await this.json(await this.request(API + '/files?' + params, { signal }, epoch), signal); if (page.incompleteSearch !== false || !Array.isArray(page.files)) return fail('incomplete'); for (const f of page.files) { - if (!object(f) || typeof f.id !== 'string' || !object(f.appProperties) || !hash(f.appProperties.cv_file) || + if (!object(f) || typeof f.id !== 'string' || f.trashed === true || !Array.isArray(f.parents) || !f.parents.includes(folder) || + !object(f.appProperties) || f.appProperties.cv_contract !== 'rc-key-a1' || !hash(f.appProperties.cv_file) || typeof f.size !== 'string' || !/^[1-9][0-9]*$/.test(f.size) || Number(f.size) > 2048) return fail('incomplete'); const fileId = id(f.id), bytes = await this.bytes(await this.request(API + '/files/' + fileId + '?alt=media', { signal }, epoch), 2048, signal); this.profileBytes(bytes); @@ -176,6 +199,7 @@ export class OpaqueDrive { async uploadProfile(fileId: string, folder: string, input: Uint8Array, signal?: AbortSignal): Promise { id(fileId); id(folder); const bytes = input.slice(); this.profileBytes(bytes); const epoch = this.lease(), sha256 = await this.digest(bytes); + await this.folder(folder, signal); this.check(epoch, signal); const verify = async () => { const meta = await this.json(await this.request(API + '/files/' + fileId + '?fields=id,size,trashed,parents,appProperties', { signal }, epoch), signal); if (meta.id !== fileId || meta.trashed === true || !Array.isArray(meta.parents) || !meta.parents.includes(folder) || @@ -185,7 +209,6 @@ export class OpaqueDrive { this.check(epoch, signal); return { fileId, sha256, bytes: back }; }; try { return await verify(); } catch (e) { if (!(e instanceof OpaqueDriveError) || e.code !== 'missing') throw e; } - await this.folder(folder, signal); this.check(epoch, signal); const boundary = 'rc_key_' + sha256; const metadata = { id: fileId, name: 'rc-sync-key-' + sha256 + '.json', mimeType: 'application/json', parents: [folder], appProperties: { cv_contract: 'rc-key-a1', cv_file: sha256 } }; const body = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n--${boundary}\r\nContent-Type: application/json\r\n\r\n${new TextDecoder().decode(bytes)}\r\n--${boundary}--`; @@ -203,14 +226,16 @@ export class OpaqueDrive { ...(revision ? { revision } : {}) }); } async list(folder: string, signal?: AbortSignal): Promise { - id(folder); const epoch = this.lease(), versions = new Map(), tokens = new Set(); let next = ''; + id(folder); const epoch = this.lease(); await this.folder(folder, signal); this.check(epoch, signal); + const versions = new Map(), tokens = new Set(); let next = ''; do { const p = new URLSearchParams({ q: `'${folder}' in parents and trashed = false and appProperties has { key='cv_contract' and value='rc-v1' }`, - pageSize: '1000', fields: 'nextPageToken,incompleteSearch,files(id,size,trashed,appProperties)' }); + pageSize: '1000', fields: 'nextPageToken,incompleteSearch,files(id,size,trashed,parents,appProperties)' }); if (next) p.set('pageToken', next); const page = await this.json(await this.request(API + '/files?' + p, { signal }, epoch), signal); if (page.incompleteSearch !== false || !Array.isArray(page.files)) return fail('incomplete'); for (const raw of page.files) { + if (!object(raw) || !Array.isArray(raw.parents) || !raw.parents.includes(folder)) return fail('conflict'); const v = this.version(raw), old = versions.get(v.fileId); if (old && JSON.stringify(old) !== JSON.stringify(v)) return fail('conflict'); versions.set(v.fileId, v); @@ -239,7 +264,13 @@ export class OpaqueDrive { this.check(epoch, signal); return { packet, proof: Object.freeze({ ...remote, verifiedAt: this.now(), state: 'verified-bytes' }) }; } - async download(expected: RemotePacket, signal?: AbortSignal) { return this.verified(expected, this.lease(), undefined, signal); } + async download(expected: RemotePacket, signal?: AbortSignal, folder?: string) { + // Le contrôle du dossier est lui-même asynchrone : figer aussi AVANT lui. + try { expected = Object.freeze({ ...expected, ...(expected.revision !== undefined ? { revision: readSyncRevision(expected.revision) } : {}) }); } + catch { return fail('integrity'); } + const epoch = this.lease(); if (folder !== undefined) { await this.folder(folder, signal); this.check(epoch, signal); } + return this.verified(expected, epoch, folder, signal); + } async upload(fileId: string, folder: string, input: RacePacket, signal?: AbortSignal, progress: (sent: number, total: number) => void = () => {}) { return this.uploadPacket(fileId, folder, input, undefined, signal, progress); } @@ -253,13 +284,12 @@ export class OpaqueDrive { progress: (sent: number, total: number) => void) { id(fileId); id(folder); const packet = await readRacePacket(input.copyBytes(), input.info.fileSha256), epoch = this.lease(); + await this.folder(folder, signal); this.check(epoch, signal); const expected: RemotePacket = { fileId, fileSha256: packet.info.fileSha256, cipherSha256: packet.info.cipherSha256, size: packet.info.size, state: 'unverified', ...(revision ? { revision } : {}) }; // Réessai après perte de la dernière réponse : relire la copie existante, jamais l'écraser. try { return await this.verified(expected, epoch, folder, signal); } catch (e) { if (!(e instanceof OpaqueDriveError) || e.code !== 'missing') throw e; } - const parent = await this.json(await this.request(API + '/files/' + folder + '?fields=id,mimeType,capabilities(canAddChildren)', { signal }, epoch), signal); - if (parent.id !== folder || parent.mimeType !== 'application/vnd.google-apps.folder' || !object(parent.capabilities) || parent.capabilities.canAddChildren !== true) return fail('conflict'); const meta = { id: fileId, name: 'rc-' + expected.fileSha256 + '.json', mimeType: 'application/json', parents: [folder], appProperties: { cv_contract: 'rc-v1', cv_file: expected.fileSha256, cv_cipher: expected.cipherSha256, ...(revision ? syncRevisionProperties(revision) : {}) } }; diff --git a/src/views/drive.ts b/src/views/drive.ts index ef94a41..f97a86c 100644 --- a/src/views/drive.ts +++ b/src/views/drive.ts @@ -16,9 +16,16 @@ export function driveView(): View { h('p', { role: 'status', 'aria-live': 'polite' }, s.message), h('p', { class: 'muted' }, 'Race Control chiffre et déchiffre sur chaque appareil. Control Vault conserve les fichiers opaques : il ne demande jamais ton code et ne peut pas authentifier leur contenu.'), h('p', { class: 'muted' }, 'Choisis le même compte et le même dossier sur le PC et l’iPhone. La synchronisation de Race Control fonctionne aussi lorsque Control Vault est fermé. Les autres écrans restent des démonstrations.'), + s.account ? h('p', null, 'Compte Google sélectionné : ' + s.account) : null, + s.folder ? h('p', { class: 'wrap' }, 'Dossier dédié sélectionné : ' + s.folder) : null, + h('p', null, ({ WAITING: 'En attente', SENDING: 'Envoi en cours', UPLOADED: 'Envoyé — relecture requise', VERIFIED_BYTES: 'Octets distants vérifiés — restauration à valider dans Race Control', CONFLICT: 'Conflit', SYNC_BLOCKED: 'Synchronisation bloquée', ERROR: 'Erreur — action nécessaire' })[s.phase]), + s.lastOperationAt ? h('p', { class: 'muted' }, 'Dernière opération : ' + new Date(s.lastOperationAt).toLocaleString('fr-FR')) : null, + s.pending ? h('p', null, 'Un envoi chiffré est conservé en attente sur cet appareil.') : null, s.configured ? h('div', { class: 'btn-row' }, !s.prepared ? button('Préparer la connexion Google', () => connectedDrive.prepare()) : button(s.connected ? 'Changer de compte Google' : 'Se connecter à Google', () => connectedDrive.connect()), - s.connected ? button('Choisir le dossier Google Drive', () => connectedDrive.chooseFolder()) : null, + s.connected && !s.accountConfirmed ? button('Confirmer ce compte dédié', () => connectedDrive.confirmAccount()) : null, + s.connected && s.accountConfirmed ? button('Créer un dossier neuf dédié', () => connectedDrive.newFolder()) : null, + s.connected && s.accountConfirmed ? button('Choisir le dossier Google Drive', () => connectedDrive.chooseFolder()) : null, s.connected ? h('button', { class: 'btn', type: 'button', onclick: () => connectedDrive.disconnect() }, 'Déconnecter') : null, ) : null, s.selected ? h('div', { class: 'btn-row' }, button('Actualiser les versions', () => connectedDrive.refresh()), @@ -26,6 +33,7 @@ export function driveView(): View { button('Reprendre l’envoi en attente', () => connectedDrive.resume())) : null, )), h('section', { class: 'panel' }, h('div', { class: 'panel-head' }, h('h2', null, 'Versions distantes')), + (() => { const parents = new Set(s.versions.flatMap(v => v.revision?.parents || [])); const heads = s.versions.filter(v => v.revision && !parents.has(v.revision.id)); return heads.length > 1 ? h('p', { role: 'status' }, 'Plusieurs têtes de révision déclarées : conserver les branches et vérifier le conflit dans Race Control. Control Vault ne les authentifie ni ne les fusionne.') : null; })(), h('div', { class: 'panel-body' }, s.versions.length ? s.versions.map(ref => { const proof = s.receipts.find(p => p.fileId === ref.fileId && p.fileSha256 === ref.fileSha256); return h('div', { class: 'row' }, h('div', { class: 'row-main' }, h('p', { class: 'row-title wrap' }, ref.revision ? `Révision ${ref.revision.seq} · ${ref.revision.id.slice(0, 8)}` : 'Copie manuelle'), @@ -38,6 +46,8 @@ export function driveView(): View { h('section', { class: 'panel' }, h('div', { class: 'panel-body' }, h('h2', null, 'Récupération'), h('p', null, 'Vérifie et télécharge une version, puis importe ce fichier dans Réglages → Sauvegarde de Race Control. Une empreinte identique prouve les octets reçus ; seul Race Control vérifie le chiffrement et prépare la fusion avec tes données actuelles.'), s.recoveryOrigin ? h('a', { class: 'btn', href: s.recoveryOrigin, target: '_blank', rel: 'noopener noreferrer' }, 'Ouvrir Race Control') : null, + s.recoveryOrigin ? h('a', { class: 'btn', href: new URL('/recovery.html', s.recoveryOrigin).href, target: '_blank', rel: 'noopener noreferrer' }, 'Récupérer sur un appareil neuf dans Race Control') : null, + h('p', { class: 'muted small' }, 'Sur appareil neuf, la récupération A2 se fait uniquement dans Race Control : compte et dossier confirmés, code local, copie indépendante puis vérification après réouverture. Tout stockage existant bloque le parcours vierge.'), h('p', { class: 'muted small' }, 'Aucune purge automatique. Aucun envoi de JSON personnel en clair. Une session Google expirée exige une reconnexion volontaire.'))), ); }; diff --git a/tests/e2e/connected-drive.spec.ts b/tests/e2e/connected-drive.spec.ts index 95beeaf..0680787 100644 --- a/tests/e2e/connected-drive.spec.ts +++ b/tests/e2e/connected-drive.spec.ts @@ -1,14 +1,16 @@ import { test, expect } from '@playwright/test'; import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { fixtureCode } from '../fixtures/race-packet'; import { resolve } from 'node:path'; import { packetFixture } from '../fixtures/race-packet'; const origin = 'https://vault.example.com'; const scope = 'https://www.googleapis.com/auth/drive.file'; const config = { approved: true, clientId: ['123456', 'fake.apps.googleusercontent.com'].join('-'), apiKey: 'AIza' + 'a'.repeat(35), appId: '123456', raceOrigin: 'https://race.example.com', vaultOrigin: origin }; -test('parcours réel : connexion explicite, dossier Picker, upload opaque, relecture et jeton oublié au rechargement', async ({ context, page }) => { +test('application complète, Google simulé : confirmation compte, dossier dédié, upload opaque, relecture et jeton oublié', async ({ context, page, browser }) => { await context.route('**/*', r => r.abort()); - const fixture = await packetFixture(61), files = new Map; bytes: Buffer }>(), pending = new Map>(); let next = 0, posts = 0; + const fixture = await packetFixture(61), files = new Map; bytes: Buffer }>(), pending = new Map>(); let next = 0, posts = 0, folderPosts = 0, folderReady = false; await context.route(origin + '/**', async r => { const path = new URL(r.request().url()).pathname; if (path === '/drive-config.json') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(config) }); @@ -31,9 +33,11 @@ test('parcours réel : connexion explicite, dossier Picker, upload opaque, relec const json = (v: unknown, status = 200) => r.fulfill({ status, headers, contentType: 'application/json', body: JSON.stringify(v) }); if (request.method() === 'OPTIONS') return r.fulfill({ status: 204, headers }); expect(request.headers().authorization).toBe('Bearer fixture-token'); expect(u.searchParams.has('access_token')).toBe(false); - if (key === 'about') return json({ user: { permissionId: 'synthetic_account' } }); - if (key === 'generateIds') return json({ ids: ['version-' + ++next] }); - if (key === 'folder-A') return json({ id: key, mimeType: 'application/vnd.google-apps.folder', capabilities: { canAddChildren: true } }); + if (key === 'about') return json({ user: { permissionId: 'synthetic_account', emailAddress: 'fixture-backup@example.invalid' } }); + if (key === 'generateIds') return json({ ids: [++next === 1 ? 'folder-A' : 'version-' + next] }); + if (key === 'folder-A' && !folderReady) return json({},404); + if (key === 'folder-A') return json({ id: key, mimeType: 'application/vnd.google-apps.folder', trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); + if (request.method() === 'POST' && u.pathname === '/drive/v3/files') { const m=request.postDataJSON(); expect(m.mimeType).toBe('application/vnd.google-apps.folder');expect(m.appProperties).toEqual({cv_contract:'rc-folder-v1'});expect(m.id).toBe('folder-A');folderReady=true;folderPosts++;return json({id:m.id}); } if (request.method() === 'POST') { posts++; const m = request.postDataJSON() as Record; pending.set(String(m.id), m); return r.fulfill({ headers: { ...headers, Location: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&upload_id=' + m.id } }); } if (request.method() === 'PUT') { const id = u.searchParams.get('upload_id')!, bytes = request.postDataBuffer()!; expect(bytes.equals(Buffer.from(fixture.bytes))).toBe(true); files.set(id, { meta: { ...pending.get(id), size: String(bytes.length) }, bytes }); return json({ id }); } if (u.pathname === '/drive/v3/files') return json({ incompleteSearch: false, files: [...files.values()].map(f => f.meta) }); @@ -43,14 +47,77 @@ test('parcours réel : connexion explicite, dossier Picker, upload opaque, relec await page.goto(origin + '/#/reglages'); await page.getByRole('link', { name: /Copies chiffrées Race Control/ }).click(); await expect(page.getByRole('heading', { name: 'Google Drive', exact: true })).toBeVisible(); + const recoveryLink=page.getByRole('link',{name:'Récupérer sur un appareil neuf dans Race Control'}); + await expect(recoveryLink).toHaveAttribute('href','https://race.example.com/recovery.html'); + await expect(recoveryLink).toHaveAttribute('rel','noopener noreferrer'); + await expect(page.locator('input[type=password]')).toHaveCount(0); await page.getByRole('button', { name: 'Préparer la connexion Google' }).click(); await page.getByRole('button', { name: 'Se connecter à Google', exact: true }).click(); + await expect(page.getByText('Compte Google sélectionné : fixture-backup@example.invalid')).toBeVisible(); + await expect(page.getByRole('button', {name:'Choisir le dossier Google Drive'})).toHaveCount(0); + expect(posts).toBe(0);expect(folderPosts).toBe(0); + await page.getByRole('button', {name:'Confirmer ce compte dédié'}).click(); + await page.getByRole('button', {name:'Créer un dossier neuf dédié'}).click(); + await expect(page.getByRole('status').filter({hasText:'Dossier neuf dédié créé'})).toBeVisible(); + expect(folderPosts).toBe(1); + await page.getByRole('button', {name:'Créer un dossier neuf dédié'}).click(); + await expect(page.getByRole('status').filter({hasText:'Dossier neuf dédié créé'})).toBeVisible();expect(folderPosts).toBe(1); await page.getByRole('button', { name: 'Choisir le dossier Google Drive' }).click(); await page.getByLabel('Copie chiffrée Race Control').setInputFiles({ name: 'opaque.json', mimeType: 'application/json', buffer: Buffer.from(fixture.bytes) }); await expect(page.getByRole('status').filter({ hasText: 'Copie envoyée' })).toBeVisible(); expect(files.size).toBe(1); expect(posts).toBe(1); await page.getByRole('button', { name: 'Actualiser les versions' }).click(); - const download = page.waitForEvent('download'); await page.getByRole('button', { name: 'Vérifier et télécharger' }).click(); await download; + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Vérifier et télécharger' }).click(); + const archive = await download; + const downloaded = readFileSync(await archive.path()); + expect(createHash('sha256').update(downloaded).digest('hex')).toBe(fixture.hash); + // Full business payload recovered with the ACTUAL pinned Race Control owner + // codec, not with a second crypto implementation in Control Vault. + // A virgin isolated browser has no producer storage, key or sessions. + const virgin = await browser.newContext({ serviceWorkers: 'block' }); + try { + const ownerSource = readFileSync('tests/fixtures/race-owner/control-vault-packet.js', 'utf8'); + await virgin.route('https://race.example.com/**', route => { + const p = new URL(route.request().url()).pathname; + return p === '/owner.js' + ? route.fulfill({contentType: 'text/javascript', body: ownerSource + '\nwindow.rcOwner=ControlVaultPacket;'}) + : route.fulfill({contentType: 'text/html', body: 'Virgin synthetic recovery'}); + }); + const newDevice = await virgin.newPage(); + await newDevice.goto('https://race.example.com/'); + const result = await newDevice.evaluate(async ({ bytes, code, hash }) => { + const nativeCodec = (window as unknown as { rcOwner: {create: (o: unknown) => {open: (...args: unknown[])=>Promise}}}).rcOwner; + if (!nativeCodec) throw Error('Race Control owner codec not loaded'); + const codec = nativeCodec.create({validateBusiness: (x: unknown) => x !== null && typeof x === 'object'}); + const localBefore = localStorage.length, sessionBefore = sessionStorage.length; + const databasesBefore = typeof indexedDB.databases === 'function' ? (await indexedDB.databases()).length : null; + const decoded = await codec.open(Uint8Array.from(bytes), code, 'prod-156', hash); + let wrongCode = ''; + try { await codec.open(Uint8Array.from(bytes), 'incorrect-synthetic-code', 'prod-156', hash); } + catch (e) { wrongCode = String((e as {code?:string}).code || (e as Error).message); } + const modified = Uint8Array.from(bytes); modified[modified.length - 12] ^= 1; + let tampered = ''; + try { await codec.open(modified, code, 'prod-156', hash); } + catch (e) { tampered = String((e as {code?:string}).code || (e as Error).message); } + return {decoded, localBefore, sessionBefore, databasesBefore, wrongCode, tampered, + localAfter:localStorage.length, sessionAfter:sessionStorage.length}; + }, { bytes: Array.from(downloaded), code: fixtureCode, hash: fixture.hash }); + expect(result.localBefore).toBe(0); + expect(result.sessionBefore).toBe(0); + if (result.databasesBefore !== null) expect(result.databasesBefore).toBe(0); + expect(result.decoded).toEqual(fixture.snapshot.data); + expect(result.wrongCode).toBe('AUTHENTICATION_FAILED'); + expect(result.tampered).not.toBe(''); + expect(result.localAfter).toBe(0); + expect(result.sessionAfter).toBe(0); + const clear = result.decoded as typeof fixture.snapshot.data; + expect(clear.settings.cars[0].odo[0].km).toBe(44000); + expect(clear.settings.cars[0].tire.treads[0].mm).toBe(6.2); + expect(clear.durable.context.debrief.archive.records).toHaveLength(63); + } finally { + await virgin.close(); + } await expect(page.locator('input[type=password]')).toHaveCount(0); await page.getByLabel('Copie chiffrée Race Control').setInputFiles({ name: 'clear.json', mimeType: 'application/json', buffer: Buffer.from('{"settings":{"private":true}}') }); await expect(page.getByRole('status').filter({ hasText: 'Opération non confirmée' })).toBeVisible(); expect(posts).toBe(1); diff --git a/tests/e2e/opaque-drive.spec.ts b/tests/e2e/opaque-drive.spec.ts index a2847a3..d1b2099 100644 --- a/tests/e2e/opaque-drive.spec.ts +++ b/tests/e2e/opaque-drive.spec.ts @@ -51,7 +51,7 @@ function remoteFixture() { files.set(id, { meta: { ...meta, size: String(bytes.length), trashed: false }, bytes }); pending.delete(id); await json({ id }); return; } - if (key === folder) { await json({ id: folder, mimeType: 'application/vnd.google-apps.folder', capabilities: { canAddChildren: true } }); return; } + if (key === folder) { await json({ id: folder, mimeType: 'application/vnd.google-apps.folder', trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); return; } if (u.pathname === '/drive/v3/files') { await json({ incompleteSearch: false, files: [...files.values()].map(x => x.meta) }); return; } const entry = files.get(key); if (!entry) { await json({}, 404); return; } diff --git a/tests/e2e/opaque-durability.spec.ts b/tests/e2e/opaque-durability.spec.ts index d9bde3d..0624dbe 100644 --- a/tests/e2e/opaque-durability.spec.ts +++ b/tests/e2e/opaque-durability.spec.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { test, expect, chromium, webkit, type BrowserContext } from '@playwright/test'; import { build } from 'vite'; import { mkdtemp, rm } from 'node:fs/promises'; @@ -96,3 +97,15 @@ test('quota et corruption : aucune version précédente supprimée, aucun catalo expect(result).toEqual({ quota: true, ids: [fixture.hash], refused: true, count: 1 }); } finally { await context.close(); } }); + + +test('DriveStore natif : abort, connexion close, versionchange et récupération préservée', async ({ browser }) => { + const context=await browser.newContext({serviceWorkers:'block'});await route(context); + try { + const page=await context.newPage();await page.goto(origin); + const result=await page.evaluate(readFileSync('tests/fixtures/native-drive-store.js','utf8')) as any; + expect(result.aborted).toBe(true);expect(result.closedRejected).toBe(true); + for(const key of ['afterAbort','afterReopen','afterVersionChange'])expect(result[key]).toEqual(result.expected); + expect(result.recoveryHash).toMatch(/^[0-9a-f]{64}$/);expect(result.recovery).toEqual([9,8,7,0,255]); + } finally {await context.close();} +}); diff --git a/tests/fixtures/native-drive-store.js b/tests/fixtures/native-drive-store.js new file mode 100644 index 0000000..fb0f111 --- /dev/null +++ b/tests/fixtures/native-drive-store.js @@ -0,0 +1,38 @@ +(async () => { + const createStore = window.CVPacketTest.DriveStore.create; + const name = 'noesis-native-drive-store-fixture-v1'; + const original = IDBDatabase.prototype.transaction; + let captured, abortNext = false; + IDBDatabase.prototype.transaction = function (...args) { + const tx = original.apply(this, args); + if (this.name === name) { + captured = this; + if (args[1] === 'readwrite' && abortNext) { + abortNext = false; + queueMicrotask(() => tx.abort()); + } + } + return tx; + }; + const seed = { v: 1, job: { fileId: 'synthetic-reserved', bytes: new Uint8Array([0, 1, 254, 255]) }, receipts: [{ id: 'fixture-receipt' }] }; + const store = createStore(name); + let aborted = false, closedRejected = false; + try { + await store.write(seed); + abortNext = true; + try { await store.write({ v: 1, job: null, receipts: [] }); } catch { aborted = true; } + const afterAbort = await store.read(); + captured.close(); + try { await store.read(); } catch { closedRejected = true; } + const afterReopen = await store.read(); + captured.dispatchEvent(new Event('versionchange')); + const afterVersionChange = await store.read(); + const recovery = new Uint8Array([9, 8, 7, 0, 255]); + const hash = await store.recovery(recovery); + const copy = await store.latestRecovery(); + const value = x => ({ ...x, job: { ...x.job, bytes: Array.from(x.job.bytes) } }); + return { aborted, closedRejected, expected: value(seed), afterAbort: value(afterAbort), + afterReopen: value(afterReopen), afterVersionChange: value(afterVersionChange), + recoveryHash: hash, recovery: Array.from(copy) }; + } finally { IDBDatabase.prototype.transaction = original; } +})() diff --git a/tests/fixtures/opaque-runtime.ts b/tests/fixtures/opaque-runtime.ts index cd29503..d83604f 100644 --- a/tests/fixtures/opaque-runtime.ts +++ b/tests/fixtures/opaque-runtime.ts @@ -1,3 +1,5 @@ // Entrée compilée uniquement par les tests navigateur, jamais par le build produit. export { OpaqueArchive, OPAQUE_DB } from '../../src/services/opaque-archive'; export { readRacePacket } from '../../src/core/race-packet'; + +export { DriveStore } from "../../src/services/drive-store.js"; diff --git a/tests/unit/connected-drive-receipts.test.ts b/tests/unit/connected-drive-receipts.test.ts new file mode 100644 index 0000000..9e37ad3 --- /dev/null +++ b/tests/unit/connected-drive-receipts.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +let ConnectedDrive: typeof import('../../src/services/connected-drive')['ConnectedDrive']; +import type { ByteProof, RemotePacket } from '../../src/services/opaque-drive'; + +const sha = (c: string) => c.repeat(64); +const proof: ByteProof = { fileId:'fixture-packet', fileSha256:sha('a'), cipherSha256:sha('b'), size:3, verifiedAt:1730000000000, state:'verified-bytes' }; +const ref: RemotePacket = { fileId:proof.fileId, fileSha256:proof.fileSha256, cipherSha256:proof.cipherSha256, size:proof.size, state:'unverified' }; +const bytes = new Uint8Array([42,43,44]); +function storage(seed: { v:1; job:any; receipts:ByteProof[] } = {v:1,job:null,receipts:[]}) { + let saved = structuredClone(seed); + let failReadback = false; + const read = vi.fn(async () => { + if (failReadback) return structuredClone(seed); + return structuredClone(saved); + }); + const write = vi.fn(async (value:typeof saved) => { saved=structuredClone(value); }); + return { read,write, get saved(){return saved;},set failReadback(x:boolean){failReadback=x;} }; +} +function setup(store: ReturnType) { + const app = new ConnectedDrive(); + const p = { + download: vi.fn(async () => ({packet:{copyBytes:()=>bytes.slice()},proof})), + list: vi.fn(async () => [ref]) + }; + const archive={save:vi.fn(async () => {})}; + const internal=app as any; + internal.provider=p; + internal.session={connected:true}; + internal.selection={folder:'synthetic-dedicated-folder',binding:'test-binding'}; + internal.archive=archive; + internal.durable=store; + return {app,p,archive}; +} +beforeEach(async()=>{ + vi.stubGlobal('indexedDB',{open:vi.fn()}); + vi.stubGlobal('navigator',{locks:{request:async (_name:string,cb:()=>Promise)=>cb()}}); + vi.resetModules(); + ({ConnectedDrive}=await import('../../src/services/connected-drive')); +}); +afterEach(()=>vi.unstubAllGlobals()); + +describe('NOESIS-CV-001 — proof of opaque download survives reload',()=>{ + it('archives packet, commits receipt once, and reloads verified receipt without losing a concurrent upload',async()=>{ + const job={binding:'test-binding',bytes:new Uint8Array([1,2]),fileId:'reserved',sha:sha('c')}; + const disk=storage({v:1,job,receipts:[]}); + const {app,p,archive}=setup(disk); + const ret=await app.download(ref); + expect(ret).toEqual(bytes); + expect(archive.save).toHaveBeenCalledOnce(); + expect(disk.saved.job).toEqual(job); + expect(disk.saved.receipts).toHaveLength(1); + expect(disk.saved.receipts[0]).toEqual(proof); + expect(p.download).toHaveBeenCalledTimes(1); + const fresh=setup(disk).app; + await fresh.refresh(); + expect(fresh.state.receipts).toEqual([proof]); + }); + it('redownloading is idempotent and does not create duplicate receipts',async()=>{ + const disk=storage(),{app}=setup(disk); + await app.download(ref); await app.download(ref); + expect(disk.saved.receipts).toEqual([proof]); + expect(disk.write).toHaveBeenCalledTimes(2); + }); + it('a failed receipt readback cannot return bytes or claim VERIFIED_BYTES',async()=>{ + const disk=storage(); + disk.failReadback=true; + const {app}=setup(disk); + const result=await app.download(ref); + expect(result).toBeNull(); + expect(app.state.phase).toBe('ERROR'); + expect(app.state.message).toMatch(/Opération non confirmée/); + expect(app.state.receipts).toEqual([]); + expect(disk.write).toHaveBeenCalledOnce(); + }); + it('provider error retains the original pending upload and its receipts',async()=>{ + const disk=storage({v:1,job:{binding:'test-binding',bytes:new Uint8Array([7]),fileId:null,sha:sha('d')},receipts:[]}); + const {app,p}=setup(disk); + p.download.mockRejectedValueOnce(new Error('synthetic network fault')); + expect(await app.download(ref)).toBeNull(); + expect(disk.saved.job).not.toBeNull(); + expect(disk.saved.receipts).toEqual([]); + }); + it('compares an encrypted pending upload without expanding its bytes into JSON strings',async()=>{ + const job={binding:'test-binding',bytes:new Uint8Array(2*1024*1024).fill(73),fileId:'reserved',sha:sha('c')}; + const disk=storage({v:1,job,receipts:[]}),{app}=setup(disk); + const stringify=JSON.stringify; + const guard=vi.spyOn(JSON,'stringify').mockImplementation(((value:any,...args:any[])=>{ + if(value?.bytes instanceof Uint8Array)throw new Error('encrypted upload must not be expanded into JSON'); + return stringify(value,...args); + }) as typeof JSON.stringify); + try { expect(await app.download(ref)).toEqual(bytes); expect(app.state.phase).toBe('VERIFIED_BYTES'); } + finally { guard.mockRestore(); } + const after=disk.saved.job; + expect(after?.binding).toBe(job.binding);expect(after?.fileId).toBe(job.fileId);expect(after?.sha).toBe(job.sha); + expect(after?.bytes.length).toBe(job.bytes.length); + expect(after&&Buffer.from(after.bytes).equals(Buffer.from(job.bytes))).toBe(true); + }); + it('rejects a one-byte change to a pending upload during receipt readback',async()=>{ + const job={binding:'test-binding',bytes:new Uint8Array([7,8,9]),fileId:'reserved',sha:sha('c')}; + const disk=storage({v:1,job,receipts:[]}),{app}=setup(disk); + const read=disk.read; + disk.read.mockImplementation(async()=>{ + const value=structuredClone(disk.saved); + if(value.receipts.length&&value.job)value.job.bytes[1]^=1; + return value; + }); + expect(await app.download(ref)).toBeNull();expect(app.state.phase).toBe('ERROR'); + expect(disk.saved.job).toEqual(job);expect(read).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/drive-boundary.test.ts b/tests/unit/drive-boundary.test.ts new file mode 100644 index 0000000..361c92c --- /dev/null +++ b/tests/unit/drive-boundary.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { OpaqueDrive, DRIVE_FILE_SCOPE } from '../../src/services/opaque-drive'; +import { GoogleDriveSession } from '../../src/services/google-drive-session.js'; +import { webcrypto } from 'node:crypto'; +import { packetFixture } from '../fixtures/race-packet'; + +const origin = 'https://vault.example.com'; +const json = (v: unknown, status = 200) => new Response(JSON.stringify(v), { status, headers: { 'Content-Type': 'application/json' } }); +beforeEach(() => vi.stubGlobal('location', { origin })); +afterEach(() => vi.unstubAllGlobals()); +const client = (fetcher: typeof fetch) => { const p = new OpaqueDrive([origin], fetcher); p.connect('fixture-token', 3600, [DRIVE_FILE_SCOPE]); return p; }; + +describe('compte et destination : frontière réelle du fournisseur, réseau fictif', () => { + it('refuse un dossier personnel accessible en écriture mais non créé comme dossier dédié par cette application', async () => { + const fetcher = vi.fn(async () => json({ id: 'personal-folder', mimeType: 'application/vnd.google-apps.folder', + trashed: false, ownedByMe: true, capabilities: { canAddChildren: true } })); + await expect(client(fetcher).folder('personal-folder')).rejects.toMatchObject({ code: 'conflict' }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(String(fetcher.mock.calls[0][0])).not.toContain('alt=media'); + }); + + it('refuse un fichier déplacé hors de la destination avant de lire ses octets, même si son hash est correct', async () => { + const fixture = await packetFixture(); let media = 0; + const meta = { id: 'archive-file', parents: ['other-folder'], trashed: false, size: String(fixture.bytes.length), + appProperties: { cv_contract: 'rc-v1', cv_file: fixture.hash, cv_cipher: fixture.envelope.sha256 } }; + const fetcher = vi.fn(async input => { + const u = new URL(String(input)); + if (u.pathname.endsWith('/allowed-folder')) return json({ id: 'allowed-folder', mimeType: 'application/vnd.google-apps.folder', + trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); + if (u.searchParams.get('alt') === 'media') { media++; return new Response(fixture.bytes as BodyInit); } + return json(meta); + }); + const ref = { fileId: meta.id, fileSha256: fixture.hash, cipherSha256: fixture.envelope.sha256, size: fixture.bytes.length, state: 'unverified' as const }; + await expect((client(fetcher).download as any)(ref, undefined, 'allowed-folder')).rejects.toMatchObject({ code: 'integrity' }); + expect(media).toBe(0); + }); +}); + + +const scope = DRIVE_FILE_SCOPE; +const config = { approved: true as const, clientId: ['123456','fake.apps.googleusercontent.com'].join('-'), apiKey: 'AIza'+'a'.repeat(35), appId: '123456', raceOrigin: 'https://race.example.com', vaultOrigin: origin }; +function sessionFixture() { + const clients: any[] = [], callbacks: any[] = []; + class View { setIncludeFolders(){return this;} setSelectFolderEnabled(){return this;} } + class Picker { + addView(){return this;} setOAuthToken(){return this;} setDeveloperKey(){return this;} setAppId(){return this;} setOrigin(){return this;} + setCallback(cb: any){callbacks.push(cb);return this;} build(){return this;} setVisible(){} + } + const provider = { connect: vi.fn(), disconnect: vi.fn(), identity: vi.fn(async()=>({id:'fixture-account',label:'backup-fixture@example.invalid'})), + account: vi.fn(async()=>'fixture-account'), folder: vi.fn(async()=>{}), reserveId: vi.fn(async()=>'reserved-folder'), createFolder: vi.fn(async(id: string)=>id) }; + const host = { location:{origin}, crypto:webcrypto, gapi:{load:(_: string,o: any)=>o.callback()}, google:{accounts:{oauth2:{ + initTokenClient:(o: any)=>{clients.push(o);return {requestAccessToken:(arg: any)=>expect(arg.prompt).toBe('select_account')};},hasGrantedAllScopes:()=>true}}, + picker:{DocsView:View,PickerBuilder:Picker,ViewId:{FOLDERS:'folders'},Action:{PICKED:'picked',CANCEL:'cancel'}}}}; + const session = (GoogleDriveSession.create as any)(config,provider,host); + const connect = async()=>{await session.prepare();const done=session.connect();await clients.at(-1).callback({access_token:'fixture-token',scope,expires_in:3600});await done;}; + return {session,provider,clients,callbacks,connect}; +} + +describe('consentement et réponses tardives GIS/Picker, aucun vrai compte',()=>{ + it('impose la confirmation du compte affiché avant toute sélection ou création de dossier',async()=>{ + const f=sessionFixture();await f.connect();expect(f.session.identity.label).toBe('backup-fixture@example.invalid'); + await expect(f.session.chooseFolder()).rejects.toMatchObject({code:'confirmation'}); + await expect(f.session.newFolder()).rejects.toMatchObject({code:'confirmation'}); + expect(()=>f.session.confirmAccount('other-account')).toThrow('confirmation'); + expect(f.provider.folder).not.toHaveBeenCalled();expect(f.provider.reserveId).not.toHaveBeenCalled(); + f.session.confirmAccount('fixture-account');expect(f.session.confirmed).toBe(true); + const p=f.session.chooseFolder();await f.callbacks.at(-1)({action:'picked',docs:[{id:'dedicated-folder'}]}); + expect((await p).folder).toBe('dedicated-folder');expect(f.provider.folder).toHaveBeenCalledWith('dedicated-folder'); + f.session.disconnect();expect(f.session.identity).toBeNull(); + }); + it('bloque un compte différent avant toute lecture de dossier ou réservation',async()=>{ + const f=sessionFixture();await f.connect();f.session.confirmAccount('fixture-account');f.provider.account.mockResolvedValue('other-account'); + const p=f.session.chooseFolder();const rejected=expect(p).rejects.toMatchObject({code:'auth'}); + await f.callbacks.at(-1)({action:'picked',docs:[{id:'dedicated-folder'}]});await rejected; + await expect(f.session.newFolder()).rejects.toMatchObject({code:'auth'}); + expect(f.provider.folder).not.toHaveBeenCalled();expect(f.provider.reserveId).not.toHaveBeenCalled(); + }); + it('ignore le callback Picker de la session antérieure sans annuler le nouveau choix',async()=>{ + const f=sessionFixture();await f.connect();f.session.confirmAccount('fixture-account'); + const old=f.session.chooseFolder().catch((e: any)=>e.code), stale=f.callbacks.at(-1);f.session.disconnect();expect(await old).toBe('cancelled'); + await f.connect();f.session.confirmAccount('fixture-account');const current=f.session.chooseFolder(); + await stale({action:'picked',docs:[{id:'old-folder'}]});expect(f.provider.folder).not.toHaveBeenCalled(); + await f.callbacks.at(-1)({action:'picked',docs:[{id:'new-folder'}]});expect((await current).folder).toBe('new-folder'); + }); + it('réutilise le même identifiant de dossier après une réponse perdue',async()=>{ + const f=sessionFixture();await f.connect();f.session.confirmAccount('fixture-account'); + f.provider.createFolder.mockRejectedValueOnce(Object.assign(Error('unavailable'),{code:'unavailable'})); + await expect(f.session.newFolder()).rejects.toMatchObject({code:'unavailable'});expect(await f.session.newFolder()).toBe('reserved-folder'); + expect(f.provider.reserveId).toHaveBeenCalledTimes(1);expect(f.provider.createFolder.mock.calls.map(c=>c[0])).toEqual(['reserved-folder','reserved-folder']); + }); +}); + +describe('relecture du dossier réservé et révocation',()=>{ + it('retrouve le dossier créé malgré une réponse POST perdue, sans nouvelle création',async()=>{ + let exists=false, posts=0; + const fetcher=vi.fn(async(_url,init)=>{ + if(init?.method==='POST'){posts++;exists=true;throw Error('response lost');} + return exists?json({id:'reserved-folder',mimeType:'application/vnd.google-apps.folder',trashed:false,ownedByMe:true, + appProperties:{cv_contract:'rc-folder-v1'},capabilities:{canAddChildren:true}}):json({},404); + }); + const provider=client(fetcher); + await expect(provider.createFolder('reserved-folder')).rejects.toMatchObject({code:'unavailable'}); + expect(await provider.createFolder('reserved-folder')).toBe('reserved-folder');expect(posts).toBe(1); + }); + it('ne réactive pas un jeton révoqué pour changer de destination',async()=>{ + const f=sessionFixture();await f.connect();f.session.confirmAccount('fixture-account'); + Object.defineProperty(f.provider,'connected',{get:()=>false}); + await expect(f.session.newFolder()).rejects.toMatchObject({code:'auth'}); + expect(f.session.connected).toBe(false);expect(f.session.identity).toBeNull();expect(f.provider.reserveId).not.toHaveBeenCalled(); + }); +}); + +describe('référence du téléchargement figée avant le contrôle du dossier',()=>{ + it('ne suit pas une référence modifiée pendant la relecture asynchrone du dossier',async()=>{ + const fixture=await packetFixture();let release!:()=>void;const held=new Promise(r=>release=r); + const fetcher=vi.fn(async input=>{ + const u=new URL(String(input)),fileId=u.pathname.split('/').at(-1)!; + if(fileId==='dedicated-folder'){await held;return json({id:fileId,mimeType:'application/vnd.google-apps.folder',trashed:false,ownedByMe:true,appProperties:{cv_contract:'rc-folder-v1'},capabilities:{canAddChildren:true}});} + if(u.searchParams.get('alt')==='media')return new Response(fixture.bytes as BodyInit); + return json({id:fileId,parents:['dedicated-folder'],size:String(fixture.bytes.length),appProperties:{cv_contract:'rc-v1',cv_file:fixture.hash,cv_cipher:fixture.envelope.sha256}}); + }); + const ref={fileId:'expected-archive',fileSha256:fixture.hash,cipherSha256:fixture.envelope.sha256,size:fixture.bytes.length,state:'unverified' as const}; + const reading=client(fetcher).download(ref,undefined,'dedicated-folder');ref.fileId='other-archive';release(); + expect((await reading).proof.fileId).toBe('expected-archive'); + expect(fetcher.mock.calls.some(c=>String(c[0]).includes('/other-archive'))).toBe(false); + }); +}); diff --git a/tests/unit/drive-connection.test.ts b/tests/unit/drive-connection.test.ts index 87e4648..c0a62f0 100644 --- a/tests/unit/drive-connection.test.ts +++ b/tests/unit/drive-connection.test.ts @@ -31,13 +31,13 @@ describe('connexion réelle et métadonnées publiques A1', () => { if (u.pathname.endsWith('/about')) return json({ user: { permissionId: 'synthetic_account' } }); if (u.pathname.endsWith('/files/generateIds')) return json({ ids: ['reserved_key'] }); if (u.pathname === '/upload/drive/v3/files') { expect(init.method).toBe('POST'); expect(String(init.body)).not.toContain('synthetic-owner-code'); expect(String(init.body)).toContain('reserved_key'); stored = true; return json({ id: 'reserved_key' }); } - if (u.pathname.endsWith('/files/folder_A')) return json({ id: 'folder_A', mimeType: 'application/vnd.google-apps.folder', capabilities: { canAddChildren: true } }); + if (u.pathname.endsWith('/files/folder_A')) return json({ id: 'folder_A', mimeType: 'application/vnd.google-apps.folder', trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); if (u.pathname.endsWith('/files/reserved_key')) { if (!stored) return json({}, 404); if (u.searchParams.get('alt') === 'media') return new Response(bytes as BodyInit); return json({ id: 'reserved_key', size: String(bytes.length), parents: ['folder_A'], appProperties: { cv_contract: 'rc-key-a1', cv_file: hash } }); } - if (u.pathname.endsWith('/files')) return json({ incompleteSearch: false, files: stored ? [{ id: 'reserved_key', size: String(bytes.length), appProperties: { cv_contract: 'rc-key-a1', cv_file: hash } }] : [] }); + if (u.pathname.endsWith('/files')) return json({ incompleteSearch: false, files: stored ? [{ id: 'reserved_key', parents: ['folder_A'], size: String(bytes.length), appProperties: { cv_contract: 'rc-key-a1', cv_file: hash } }] : [] }); throw new Error('Unexpected request'); }) as typeof fetch; const drive = new OpaqueDrive(['https://vault.example.com'], fetcher); drive.connect('synthetic_token', 3600, [DRIVE_FILE_SCOPE]); diff --git a/tests/unit/drive-store-reopen.test.ts b/tests/unit/drive-store-reopen.test.ts new file mode 100644 index 0000000..32375e7 --- /dev/null +++ b/tests/unit/drive-store-reopen.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { DriveStore } from '../../src/services/drive-store.js'; + +// NOESIS-CV-002: injected provider only; the production DriveStore is imported. +function backend() { + let mode: 'ok' | 'error' | 'blocked' = 'ok'; + let opens = 0; + let deletes = 0; + const connections: any[] = []; + const late: Array<() => void> = []; + const rows: Record> = { state: new Map(), recovery: new Map() }; + function connection() { + let closed = false; + const db: any = { + close() { closed = true; }, createObjectStore() {}, + get closed() { return closed; }, + transaction(store: string) { + if (closed) throw Error('InvalidStateError: closed database'); + const tx: any = { objectStore() { return { + get(key: string) { + const req: any = {}; + queueMicrotask(() => { req.result = rows[store].get(key); req.onsuccess?.(); queueMicrotask(() => tx.oncomplete?.()); }); + return req; + }, + put(value: unknown, key: string) { + rows[store].set(key, structuredClone(value)); + queueMicrotask(() => tx.oncomplete?.()); + } + }; } }; + return tx; + } + }; + connections.push(db); + return db; + } + const indexedDB = { + open() { + opens++; + const req: any = { result: connection() }; + const behavior = mode; mode = 'ok'; + queueMicrotask(() => { + if (behavior === 'error') { req.onerror?.(); return; } + if (behavior === 'blocked') { req.onblocked?.(); late.push(() => req.onsuccess?.()); return; } + req.onupgradeneeded?.(); req.onsuccess?.(); + }); + return req; + }, + deleteDatabase() { deletes++; throw Error('Forbidden database deletion'); } + } as unknown as IDBFactory; + return { indexedDB, connections, late, rows, set mode(next: 'ok' | 'error' | 'blocked') { mode = next; }, get opens() { return opens; }, get deletes() { return deletes; } }; +} + +describe('NOESIS-CV-002 — durable IndexedDB open retry without any purge', () => { + it('retries a rejected first open rather than caching a rejected promise', async () => { + const f = backend(), store = DriveStore.create('fixture-reopen', f.indexedDB); + f.mode = 'error'; + await expect(store.read()).rejects.toThrow('stockage'); + await expect(store.read()).resolves.toBeNull(); + expect(f.opens).toBe(2); + }); + it('rejects a blocked open, closes any late success, and can reopen', async () => { + const f = backend(), store = DriveStore.create('fixture-reopen', f.indexedDB); + f.mode = 'blocked'; + await expect(store.read()).rejects.toThrow('stockage'); + await expect(store.read()).resolves.toBeNull(); + f.late.forEach(release => release()); + await Promise.resolve(); + expect(f.connections[0].closed).toBe(true); + expect(f.deletes).toBe(0); + }); + it('closes and invalidates on versionchange and can read existing bytes', async () => { + const f = backend(), store = DriveStore.create('fixture-reopen', f.indexedDB); + await store.write({v: 1, job: null, receipts: [{fileId: 'synthetic'}]}); + f.connections[0].onversionchange(); + expect(f.connections[0].closed).toBe(true); + expect((await store.read() as { receipts: Array<{fileId:string}> }).receipts[0].fileId).toBe('synthetic'); + expect(f.opens).toBe(2); + }); + it('retries after a manually closed transaction and never deletes data', async () => { + const f = backend(), store = DriveStore.create('fixture-reopen', f.indexedDB); + await store.write({v: 1, job: null, receipts: []}); + f.connections[0].close(); + await expect(store.read()).rejects.toThrow('stockage'); + await expect(store.read()).resolves.toMatchObject({v:1}); + expect(f.opens).toBe(2); + expect(f.deletes).toBe(0); + }); + it('coalesces simultaneous opens', async () => { + const f = backend(), store = DriveStore.create('fixture-reopen', f.indexedDB); + const reads = await Promise.all([store.read(), store.read(), store.read()]); + expect(reads).toEqual([null,null,null]); + expect(f.opens).toBe(1); + }); +}); diff --git a/tests/unit/drive-test-profile.test.ts b/tests/unit/drive-test-profile.test.ts new file mode 100644 index 0000000..b10d27e --- /dev/null +++ b/tests/unit/drive-test-profile.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { parse } from 'yaml'; +import { run } from '../../scripts/security-check'; +it('publie uniquement un profil TEST identifié, vérifiable et fermé sans client Google', () => { + const result = spawnSync(process.execPath, ['scripts/build-drive-test.mjs'], { encoding: 'utf8', env: { ...process.env, BUILD_SHA: 'a'.repeat(40), GOOGLE_DRIVE_CONFIG: '' }, timeout: 120000 }); + expect(result.status).toBe(0); + const data = JSON.parse(result.stdout.trim()); + const manifest = JSON.parse(readFileSync(join(data.site, 'fixture-manifest.json'), 'utf8')); + expect(manifest.fixtureOnly).toBe(true); expect(manifest.sourceSha).toBe('a'.repeat(40)); expect(manifest.googleEnabled).toBe(false); + expect(readFileSync(join(data.site, 'index.html'), 'utf8')).toContain('TEST — DONNÉES FICTIVES UNIQUEMENT'); + expect(readFileSync(join(data.site, 'drive-config.json'), 'utf8')).toBe('null'); + for (const [name, hash] of Object.entries(manifest.files)) expect(createHash('sha256').update(readFileSync(join(data.site, name))).digest('hex')).toBe(hash); + expect(run(data.site)).toEqual([]); + const other = { approved: true, clientId: ['123456', 'a'.repeat(24) + '.apps.googleusercontent.com'].join('-'), apiKey: 'AIza' + 'a'.repeat(35), appId: '123456', raceOrigin: 'https://race.example.com', vaultOrigin: 'https://vault.example.com' }; + expect(spawnSync(process.execPath, ['scripts/build-drive-test.mjs'], { encoding: 'utf8', env: { ...process.env, BUILD_SHA: 'a'.repeat(40), GOOGLE_DRIVE_CONFIG: JSON.stringify(other) } }).status).not.toBe(0); +}, 30000); +it('bloque aussi les portes de publication lorsque CodeQL échoue, sans secret dans ce job', () => { + const pipeline = parse(readFileSync('.github/workflows/pipeline.yml', 'utf8')); + expect(pipeline.jobs.verify.needs).toBe('codeql'); + expect(pipeline.jobs.codeql.permissions).toEqual({ contents: 'read', actions: 'read', 'security-events': 'write' }); + expect(JSON.stringify(pipeline.jobs.codeql)).not.toMatch(/secrets\.|deploy-pages|pages deploy|always\(\)/); + expect(pipeline.jobs.codeql.steps.filter((s: { uses: string }) => s.uses.startsWith('github/codeql-action/')).every((s: { uses: string }) => /@[0-9a-f]{40}$/.test(s.uses))).toBe(true); +}); diff --git a/tests/unit/opaque-drive.test.ts b/tests/unit/opaque-drive.test.ts index 4ae651c..cdcf318 100644 --- a/tests/unit/opaque-drive.test.ts +++ b/tests/unit/opaque-drive.test.ts @@ -35,7 +35,7 @@ function backend() { if (loseReply) throw new TypeError('Panne fictive après écriture'); return json({ id }); } - if (key === folder) return json({ id: folder, mimeType: 'application/vnd.google-apps.folder', capabilities: { canAddChildren: true } }); + if (key === folder) return json({ id: folder, mimeType: 'application/vnd.google-apps.folder', trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); if (u.pathname === '/drive/v3/files') return json({ incompleteSearch: false, files: [...files.values()].map(f => f.meta) }); const file = files.get(key); if (!file) return json({}, 404); if (u.searchParams.get('alt') === 'media') { @@ -290,15 +290,15 @@ describe('fournisseur opaque, réseau entièrement fictif', () => { await expect(drive.reserveId(aborted.signal)).rejects.toMatchObject({ code: 'cancelled' }); }); it('énumère toutes les pages, en gardant les versions non vérifiées', async () => { - const meta = (n: number) => ({ id: 'fixture-' + n, size: String(fixture.bytes.length), appProperties: { cv_contract: 'rc-v1', cv_file: fixture.hash, cv_cipher: fixture.envelope.sha256 } }); + const meta = (n: number) => ({ id: 'fixture-' + n, parents: [folder], size: String(fixture.bytes.length), appProperties: { cv_contract: 'rc-v1', cv_file: fixture.hash, cv_cipher: fixture.envelope.sha256 } }); const calls: string[] = []; - const drive = client(async input => { calls.push(String(input)); return json({ incompleteSearch: false, files: Array.from({ length: 100 }, (_, i) => meta(i + (calls.length - 1) * 100)), ...(calls.length === 1 ? { nextPageToken: 'next-fixture' } : {}) }); }); + const drive = client(async input => { if(new URL(String(input)).pathname.endsWith('/'+folder)) return json({id:folder,mimeType:'application/vnd.google-apps.folder',trashed:false,ownedByMe:true,appProperties:{cv_contract:'rc-folder-v1'},capabilities:{canAddChildren:true}}); calls.push(String(input)); return json({ incompleteSearch: false, files: Array.from({ length: 100 }, (_, i) => meta(i + (calls.length - 1) * 100)), ...(calls.length === 1 ? { nextPageToken: 'next-fixture' } : {}) }); }); const list = await drive.list(folder); expect(list).toHaveLength(200); expect(list.every(v => v.state === 'unverified')).toBe(true); expect(new URL(calls[1]).searchParams.get('pageToken')).toBe('next-fixture'); }); it('recherche incomplète et boucle de pagination ne donnent jamais un catalogue complet', async () => { for (const page of [{ incompleteSearch: true, files: [] }, { incompleteSearch: false, files: [], nextPageToken: 'again' }]) { - const drive = client(async () => json(page)); await expect(drive.list(folder)).rejects.toMatchObject({ code: 'incomplete' }); + const drive = client(async input => new URL(String(input)).pathname.endsWith('/'+folder) ? json({id:folder,mimeType:'application/vnd.google-apps.folder',trashed:false,ownedByMe:true,appProperties:{cv_contract:'rc-folder-v1'},capabilities:{canAddChildren:true}}) : json(page)); await expect(drive.list(folder)).rejects.toMatchObject({ code: 'incomplete' }); } }); }); diff --git a/tests/unit/workflow-provenance.test.ts b/tests/unit/workflow-provenance.test.ts new file mode 100644 index 0000000..e38ba47 --- /dev/null +++ b/tests/unit/workflow-provenance.test.ts @@ -0,0 +1,66 @@ +// Regression guards for action provenance and the Pages artifact contract. +// The archive test runs only on disposable files, never app/backup data. +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; + +interface Step { name?: string; uses?: string; if?: string; run?: string; env?: Record; with?: Record } +const workflow = parse(readFileSync('.github/workflows/pipeline.yml', 'utf8')) as { jobs: Record }; +const steps = Object.values(workflow.jobs).flatMap(job => job.steps); +const verify = workflow.jobs.verify.steps; + +describe('provenance immuable du pipeline', () => { + it('toutes les actions tierces sont liées à un commit complet, jamais un tag ou une branche', () => { + const references = steps.flatMap(step => step.uses ? [step.uses] : []); + expect(references.length).toBeGreaterThan(0); + for (const reference of references) { + expect(reference).toMatch(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_./-]+)?@[0-9a-f]{40}$/); + } + }); + + it('Pages utilise directement un uploader figé, sans composite à référence transitive mobile', () => { + expect(steps.some(step => (step.uses ?? '').startsWith('actions/upload-pages-artifact@'))).toBe(false); + const archive = verify.find(step => step.name === 'Archive vérifiée (GitHub Pages)'); + const upload = verify.find(step => step.with?.name === 'github-pages'); + expect(archive?.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/main'"); + expect(upload?.if).toBe(archive?.if); + expect(upload?.uses).toMatch(/^actions\/upload-artifact@[0-9a-f]{40}$/); + expect(upload?.with?.path).toBe('${{ runner.temp }}/artifact.tar'); + expect(upload?.with?.['retention-days']).toBe(1); + expect(upload?.with?.['if-no-files-found']).toBe('error'); + }); + + it('le vrai conditionnement garde les octets, fichiers cachés et liens déréférencés, sans métadonnées Git', () => { + const archive = verify.find(step => step.name === 'Archive vérifiée (GitHub Pages)'); + expect(archive?.run).toBeTruthy(); + const root = mkdtempSync(join(tmpdir(), 'cv-pages-fixture-')); + try { + for (const dir of ['dist/assets', 'dist/.git', 'dist/.github', 'runner']) mkdirSync(join(root, dir), { recursive: true }); + const fixture: Record = { + 'index.html': '

fictional-build

', + 'version.json': '{"sha":"fictional-build"}', + 'drive-config.json': 'null', + '.hidden-fixture': 'included', + 'assets/app.js': 'console.log("fixture");', + }; + for (const [file, content] of Object.entries(fixture)) writeFileSync(join(root, 'dist', file), content); + writeFileSync(join(root, 'dist/.git/private'), 'must-not-be-packed'); + writeFileSync(join(root, 'dist/.github/private'), 'must-not-be-packed'); + symlinkSync('assets/app.js', join(root, 'dist/alias.js')); + const packed = spawnSync('sh', ['-ec', archive!.run!], { cwd: root, env: { ...process.env, RUNNER_TEMP: join(root, 'runner') }, encoding: 'utf8' }); + expect(packed.status, packed.stderr).toBe(0); + const tar = join(root, 'runner/artifact.tar'); + const names = execFileSync('tar', ['-tf', tar], { encoding: 'utf8' }).trim().split('\n'); + expect(names.some(name => /\/(?:\.git|\.github)(?:\/|$)/.test(name))).toBe(false); + for (const [file, content] of Object.entries(fixture)) { + expect(execFileSync('tar', ['-xOf', tar, './' + file], { encoding: 'utf8' })).toBe(content); + } + expect(execFileSync('tar', ['-xOf', tar, './alias.js'], { encoding: 'utf8' })).toBe(fixture['assets/app.js']); + const listing = execFileSync('tar', ['-tvf', tar], { encoding: 'utf8' }).split('\n'); + expect(listing.filter(line => line.includes('./alias.js')).every(line => line.startsWith('-'))).toBe(true); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +});