diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dfb5d4d..e6674a0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -100,6 +100,7 @@ jobs: node tests/cloud-final-mile.cjs node tests/cloud-history.cjs BROWSER=${{ matrix.browser }} node tests/cloud-final-mile-browser.cjs + BROWSER=${{ matrix.browser }} node tests/cloud-security-browser.cjs node tests/atomic-restoration.cjs BROWSER=${{ matrix.browser }} node tests/atomic-restoration-browser.cjs BROWSER=${{ matrix.browser }} node tests/storage-multitab.cjs @@ -108,6 +109,11 @@ jobs: BROWSER: ${{ matrix.browser }} RC_SCREENSHOT_DIR: artifacts/reconversion-${{ matrix.browser }} run: node tests/project.spec.cjs + - name: Mesures de stockage fictives, PostgreSQL isolé + if: matrix.browser == 'chromium' + env: + RC_STORAGE_REPORT: artifacts/reconversion-chromium/cloud-storage.json + run: node tests/cloud-storage.cjs - name: Navigation, recherche et affichage iPhone env: BROWSER: ${{ matrix.browser }} diff --git a/TESTING.md b/TESTING.md index 07c5fea..5d9feb4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -67,11 +67,18 @@ Le dossier est conservé lors d’un import ancien qui ne possède pas de champ node tests/cloud-sync.spec.cjs node tests/cloud-final-mile.cjs node tests/cloud-history.cjs +node tests/cloud-storage.cjs BROWSER=chromium node tests/cloud-final-mile-browser.cjs BROWSER=webkit node tests/cloud-final-mile-browser.cjs +BROWSER=chromium node tests/cloud-security-browser.cjs +BROWSER=webkit node tests/cloud-security-browser.cjs ``` -Le troisième test exécute le SQL proposé dans PostgreSQL WASM (PGlite 0.5.11 verrouillé), avec rôles distincts, RLS réelle et fonctions Auth simulées. Il vérifie propriétaire, utilisateur B, anon, anonymous-auth, immutabilité de l'historique, révisions, bornes et rollback transactionnel. Ce n'est pas un test du projet Supabase hébergé. +Le troisième test exécute le SQL proposé dans PostgreSQL WASM (PGlite 0.5.11 verrouillé), avec rôles distincts, RLS réelle et fonctions Auth simulées. Il vérifie propriétaire, utilisateur B, anon, anonymous-auth, immutabilité de l'historique, révisions, bornes et rollback transactionnel. Il reproduit aussi le helper privilégié d'auto-RLS, retire son droit d'appel client et prouve que son event trigger active toujours RLS sur une table de test annulée. Ce n'est pas un test du projet Supabase hébergé. + +Le banc de stockage utilise les validateurs métier et le chiffrement de production avec des progressions entièrement fictives (0, 365 et 1825 jours). Il mesure JSON, Gzip/Brotli, enveloppes AES-GCM et relations PostgreSQL/index/TOAST avant et après compactage isolé. Les objets compressés d'étude ne sont pas des sauvegardes applicatives compatibles ; ils ne sont jamais envoyés à un service. La limite de décompression et le round-trip intégral sont vérifiés. Les octets de corps HTTP et projections de lecture excluent headers, TLS, Auth et changements réels. Le rapport JSON est conservé dans l'artefact CI Chromium. + +Les tests de contrôleur ajoutent les lectures de révision sans acquittement, contrôles complets obligatoires, retour d'horloge, visibilité, erreurs de métadonnées et modifications pendant une lecture légère. La suite CSP injecte des scripts de test et une base étrangère dans un profil isolé ; les deux navigateurs doivent les bloquer et conserver le snapshot. Les logs du parcours cloud sont contrôlés avec des secrets canaris fictifs. Le test navigateur utilise deux profils indépendants, la vraie app, AES-GCM et localStorage. Toutes les requêtes fournisseur sont interceptées : choix initial, aller-retour, fermeture/réouverture, session chiffrée, réponse perdue après commit, reprise idempotente, conflit, stockage défaillant, format futur et récupération historique. Les quinze cas N03 restent exécutés ; leur amorçage est adapté à la nouvelle confirmation explicite de premier envoi et leur watchdog évite une fin Node trompeuse lorsqu'une promesse reste suspendue. diff --git a/cloud-transport.js b/cloud-transport.js index b223545..c2ba2fe 100644 --- a/cloud-transport.js +++ b/cloud-transport.js @@ -75,6 +75,12 @@ const RC_CLOUD_TRANSPORT = (() => { const rows=await request("/rest/v1/reconversion_progress?user_id=eq."+encodeURIComponent(session.user)+"&select=revision,payload",{secure:true}); if(!Array.isArray(rows)||rows.length>1)throw Error("Réponse cloud invalide");return rows.length?row(rows[0]):null; } + async function readRevision(){ + if(!session)throw Error("Non connecté"); + const rows=await request("/rest/v1/reconversion_progress?user_id=eq."+encodeURIComponent(session.user)+"&select=revision",{secure:true}); + if(!Array.isArray(rows)||rows.length>1||rows.length&&(!rows[0]||Object.keys(rows[0]).join(",")!=="revision"||!validRevision(rows[0].revision)))throw Error("Révision cloud invalide"); + return rows.length?rows[0].revision:null; + } async function write(payload,revision){ if(!session)throw Error("Non connecté");if(revision!==null&&!validRevision(revision))throw Error("Révision invalide");bounded(payload); const expected=revision===null?1:revision+1; @@ -102,7 +108,7 @@ const RC_CLOUD_TRANSPORT = (() => { if(!Array.isArray(rows)||rows.length!==1||rows[0].revision!==revision)throw Error("Version historique absente");return row(rows[0]); } async function logout(){const previous=session;generation++;session=null;refreshing=null;if(previous){try{await request("/auth/v1/logout?scope=local",{method:"POST",headers:{Authorization:"Bearer "+previous.access_token}});return true;}catch(e){return false;}}return true;} - return {sendCode,verifyCode,resume,read,write,history,historyVersion,logout}; + return {sendCode,verifyCode,resume,read,readRevision,write,history,historyVersion,logout}; } async function encrypt(snapshot,phrase,context){ if(typeof phrase!=="string"||phrase.length<12||phrase.length>1024)throw Error("Phrase de chiffrement : 12 à 1024 caractères"); diff --git a/cloud-ui.js b/cloud-ui.js index b8e5df5..a2677a4 100644 --- a/cloud-ui.js +++ b/cloud-ui.js @@ -4,7 +4,8 @@ const RC_CLOUD_UI = (() => { const CONFIG="reconversion-control.cloud.config.v1",SESSION="reconversion-control.cloud.session.v1"; let env,client,cfg,email="",phrase="",remember=false,user="",scope="",meta={v:1},remote=null,remoteData=null; - let revision=null,baseline="",busy=false,applying=false,mode="setup",status="Local uniquement · cloud non configuré.",timer=null,pending=null,epoch=0,retries=0,historyRows=[],restoredHere=false; + let revision=null,baseline="",busy=false,applying=false,mode="setup",status="Local uniquement · cloud non configuré.",timer=null,pending=null,epoch=0,retries=0,historyRows=[],restoredHere=false,lastFullRead=0; + const FULL_READ_MS=600000; const T=()=>RC_CLOUD_TRANSPORT,now=()=>new Date().toISOString(); function safe(s){return env.escape(String(s||""));} function load(key){const raw=localStorage.getItem(key);if(raw===null)return null;if(raw.length>4200000)throw Error("Métadonnées trop volumineuses");return JSON.parse(raw);} @@ -60,7 +61,7 @@ const RC_CLOUD_UI = (() => { } async function acknowledge(sent,next,ticket,payload){ const hash=await T().fingerprint(sent),payloadHash=payload?await T().fingerprint(T().serialize(payload)):null;guard(ticket); - persist({...meta,ack:{revision:next,hash,payloadHash,at:now()},pending:null});revision=next;baseline=sent;retries=0; + persist({...meta,ack:{revision:next,hash,payloadHash,at:now()},pending:null});revision=next;baseline=sent;retries=0;lastFullRead=Date.now(); } async function flush(ticket){ const job=meta.pending;if(!job)return; @@ -95,7 +96,7 @@ const RC_CLOUD_UI = (() => { if(latest.revision { }catch(e){if(ticket===epoch){status="Déverrouillage refusé : "+e.message;if(user)mode="error";}} finally{if(ticket===epoch){busy=false;repaint();}} } - function activate(){if(timer)clearInterval(timer);timer=setInterval(tick,30000);} + function activate(){if(timer)clearInterval(timer);timer=setInterval(()=>tick(true),30000);} function schedule(delay){if(pending)clearTimeout(pending);pending=setTimeout(()=>{pending=null;return tick();},delay||2000);} function changed(){ if(!env||applying||mode!=="active")return; @@ -141,14 +142,26 @@ const RC_CLOUD_UI = (() => { await acknowledge(restored,next,ticket,remote&&remote.payload);guard(ticket);persist({...meta,restore:{hash:meta.ack.hash,revision:next}}); }finally{applying=false;} } - async function tick(){ + async function tick(periodic){ + if(periodic!==true)lastFullRead=0; if(!client||busy||!["active","conflict","error"].includes(mode))return; + if(periodic===true&&document.hidden)return; if(!navigator.onLine){warn("Hors connexion · progression locale conservée, reprise à la reconnexion.");return;} busy=true;const ticket=epoch; try{ loadMeta(); if(mode!=="active"){await inspect(ticket);return;} if(meta.pending)await flush(ticket); + // An unchanged metadata probe never replaces a payload check or creates a receipt. + // Local edits, pending writes, manual actions and resumes always read the payload. + const stamp=Date.now(); + if(periodic===true&&!dirty()&&meta.ack&&meta.ack.payloadHash&&lastFullRead>0&&stamp>=lastFullRead&&stamp-lastFullRead0&&checked>=lastFullRead&&checked-lastFullRead { if(dirty()){await upload(revision,ticket);status=uploadStatus("Modifications locales sauvegardées et chiffrées dans le cloud.");} else status="À jour sur cet appareil et dans le cloud · sauvegarde cloud confirmée."; } - delayed(); - }catch(e){if(ticket===epoch){status="Synchronisation en attente : "+e.message+" · progression locale conservée.";schedule(Math.min(60000,2000*2**Math.min(retries++,5)));}} + lastFullRead=Date.now();delayed(); + }catch(e){if(ticket===epoch){lastFullRead=0;status="Synchronisation en attente : "+e.message+" · progression locale conservée.";schedule(Math.min(60000,2000*2**Math.min(retries++,5)));}} finally{if(ticket===epoch){busy=false;repaint();}} } async function createFirst(){ diff --git a/docs/cloud-security-model.md b/docs/cloud-security-model.md new file mode 100644 index 0000000..c27db2c --- /dev/null +++ b/docs/cloud-security-model.md @@ -0,0 +1,43 @@ +# Modèle de menaces — sauvegarde chiffrée + +Auteur : Bryan Fiarda. Révision : 24 octobre 2026. Périmètre : Reconversion Control uniquement. + +Ce modèle reprend les mécanismes existants ; il ne certifie pas un service personnel ni un iPhone physique. Les migrations, le code testé, le service hébergé et la recette personnelle ont des preuves distinctes. + +## Frontières de confiance et invariants + +| Surface / menace | Invariant et contrôle | Preuve attendue | +| --- | --- | --- | +| Navigateur, import, DOM / XSS | Validation métier stricte, refus des champs perdus/inconnus et clés dangereuses ; échappement HTML des données privées ; CSP script-src self sans unsafe-inline ni unsafe-eval | project-checks, project.spec, cloud-final-mile et tests CSP | +| PWA / cache obsolète | Assets limités au périmètre local, build identifié, aucun cache de réponse Supabase ; reprise à la réouverture | sw.spec, tests de provenance et réouverture navigateur | +| Persistance / quota, deux onglets | Snapshot V3 écrit et relu avant cloud ; Web Lock ; un état non confirmé bloque l'envoi ; restauration atomique et rollback vérifié | atomic-restoration, atomic-restoration-browser, storage-multitab | +| Phrase et session / vol au repos | Phrase en mémoire ; option de session persistante chiffrée et domaines progression/session séparés ; pas de jeton en clair dans export ou logs | cloud-final-mile et captures de logs fictifs dans les tests navigateur | +| HTTP / CSRF, fuite, réponse perdue | HTTPS Supabase uniquement ; Bearer explicite, credentials omit, redirects refusés, no-store ; outbox persistante et relecture exacte | cloud-sync, cloud-final-mile et scénario navigateur de réponse perdue | +| API / IDOR, BOLA | Identité Supabase vérifiée ; SELECT/INSERT/UPDATE limités au propriétaire ; anon et anonymous-auth refusés ; aucune clé serveur dans le client | tests SQL sous rôles distincts, catalogue et contrôles Data API du projet confirmé | +| PostgreSQL / concurrence, rejeu | Révision initiale 1 puis incrément exact ; PATCH conditionnel sur révision précédente ; un acquittement exige la même enveloppe relue | cloud-history, cloud-final-mile, cloud-ui-races | +| Enveloppe incomplète | CHECK explicitement IS TRUE ; kdf:null et autres formes malformées refusés ; refus sans modification de tête ou d'historique | groupe de régression SQL de la PR #33 | +| Fonction privilégiée | Archivage par trigger privé capture_progress, search_path vide, noms qualifiés, droits EXECUTE/USAGE retirés aux clients ; aucun appel client au helper Supabase d'auto-RLS préexistant, dont le trigger reste actif | catalogue pg_proc / pg_namespace, refus des appels directs, test de création de table annulé après contrôle de RLS | +| Historique / corruption, effacement | 20 révisions par propriétaire, archivage et élagage dans la transaction ; clients en lecture seule ; export indépendant avant remplacement | cloud-history, récupération navigateur et contrôles réversibles hébergés autorisés | +| CI/CD / chaîne logicielle | Dépendances verrouillées, actions et image SHA épinglées, permissions minimales, PR SHA exact ; Pages séparé et protégé | npm audit, CI et tests test_pages*.py | + +Le stockage canonique actuel est localStorage, pas IndexedDB. Il contient la progression locale lisible ; l'outbox et la session mémorisée sont chiffrées. Les préfixes de clés ne constituent pas une isolation entre sites partageant une même origine. + +## Limites explicites + +- Une compromission du JavaScript autorisé sur l'origine, une extension malveillante ou un appareil déverrouillé compromis peut lire les données disponibles dans la page. CSP et échappement réduisent les injections ; ils ne remplacent pas l'intégrité de l'hébergement, les mises à jour et le verrouillage de l'appareil. +- Les domaines AAD séparent progression et session. Une enveloppe authentifiée ne prouve pas, à elle seule, qu'elle est la dernière version : un opérateur serveur privilégié peut supprimer ou rejouer des données. Les révisions, reçus locaux, copies de récupération et exports indépendants sont complémentaires ; aucun serveur unique n'est une garantie absolue de disponibilité. +- Le client refuse une révision distante inférieure ou un contenu différent à la même révision. Un appareil neuf exige toujours un choix de source et une vérification humaine avant remplacement. +- La phrase de chiffrement perdue n'est pas récupérable depuis Supabase. Exporter toute copie locale encore lisible avant une réinitialisation. +- Une PWA iOS fermée/suspendue ne garantit pas de tâche réseau permanente. Chaque reprise vérifie la session et la source avant synchronisation. + +## Compression et KDF + +Les enveloppes V1 restent lisibles, avec AES-256-GCM, sels/IV aléatoires et PBKDF2-SHA256 250000 itérations. Une augmentation du KDF doit être versionnée et testée sur l'iPhone cible ; aucun changement aveugle des paramètres existants n'est acceptable. + +Gzip et Brotli sont mesurés uniquement dans un banc d'essai isolé, avant chiffrement. Les objets d'étude sont explicitement distincts du DTO métier et ne doivent jamais être envoyés comme une sauvegarde applicative. Leur encodage est chiffré/authentifié, la décompression est bornée et son résultat est comparé intégralement à la source. Les essais Node ne prouvent pas une implémentation de décompression dans WebKit. + +Le format V1 déployé ne transporte pas de négociation de compression. Une adoption requiert un nouveau contrat authentifié, compatibilité de lecture, limites de mémoire en flux et analyse de l'oracle de longueur lorsque des champs privés sont compressés avec du texte importé. L'étude n'active donc aucune compression en production. Les économies immédiatement compatibles viennent du DTO métier, de l'absence d'envoi identique, du regroupement des modifications et de lectures de révision légères sans nouvel acquittement. + +## Critères de mise en service + +Tout défaut critique ou élevé confirmé et non maîtrisé bloque l'activation. Des tests locaux ou CI verts ne remplacent ni les vérifications du bon projet, ni la délivrabilité OTP, ni la recette PC ↔ iPhone physique. Aucune fusion, publication, création d'utilisateur personnel ou progression réelle n'est autorisée par ce document. diff --git a/docs/cloud-storage-measures.md b/docs/cloud-storage-measures.md new file mode 100644 index 0000000..050ac64 --- /dev/null +++ b/docs/cloud-storage-measures.md @@ -0,0 +1,56 @@ +# Mesures de stockage et transferts — données fictives + +Auteur : Bryan Fiarda. Mesures locales du 24 octobre 2026, Node 24.22.1, PostgreSQL PGlite 0.5.11. Ce banc d'essai n'utilise aucun projet Supabase, compte ou progression personnels. + +## Décision compatible avec les sauvegardes existantes + +Les sauvegardes applicatives restent des enveloppes V1 AES-256-GCM non compressées. Le contrôle périodique d'un appareil à jour lit seulement la révision toutes les 30 secondes ; une lecture complète reste obligatoire au plus tard après dix minutes, à la reprise, sur action manuelle, en présence d'une modification locale ou d'un envoi en attente. Une lecture légère ne crée ni reçu ni date de sauvegarde confirmée. Toute erreur invalide ce raccourci. + +Gzip et Brotli ont été étudiés avant chiffrement. Ils peuvent augmenter la taille des très petits états. Leur adoption exigerait un format authentifié versionné, une décompression bornée en navigateur, la compatibilité des anciens clients et l'analyse des fuites de longueur. Les objets d'étude sont rejetés par le validateur métier actuel. Aucune sauvegarde compressée n'est activée par ces travaux. + +## Mesures reproductibles + +Commande : `RC_STORAGE_REPORT=artifacts/cloud-storage.json node tests/cloud-storage.cjs`. + +Le corpus conserve les catalogues réels et les validateurs V3, mais toutes les progressions, preuves et dates sont fictives. Les profils contiennent zéro, 365 et 1825 jours de Daily Five avec progression, révisions, labs et dossier. Ils illustrent trois états ; ils ne prédisent pas la taille d'un dossier personnel, notamment ses textes libres. + +Les objets compressés portent un en-tête d'étude et le corps compressé encodé en base64, à l'intérieur du chiffrement réel. Cet overhead est inclus. Chaque cas vérifie déchiffrement, décompression bornée, égalité complète et absence de réutilisation des sels/IV générés ; les tests refusent corruption, dépassement de limite et mauvais contexte. La limite Node `maxOutputLength` protège ces seuls essais, pas une future implémentation iOS. + +| Jours fictifs | Encodage | JSON initial (octets) | Corps avant chiffrement¹ | Ciphertext (octets) | Enveloppe JSON (octets) | PostgreSQL après 21 écritures² | PostgreSQL compacté² | +| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 0 | V1 sans compression | 277 | 277 | 293 | 515 | 98 304 | 73 728 | +| 0 | Gzip, étude | 277 | 190 | 352 | 595 | 98 304 | 73 728 | +| 0 | Brotli, étude | 277 | 178 | 338 | 575 | 98 304 | 73 728 | +| 365 | V1 sans compression | 72 548 | 72 548 | 72 564 | 96 875 | 4 407 296 | 2 220 032 | +| 365 | Gzip, étude | 72 548 | 3 287 | 4 482 | 6 099 | 475 136 | 253 952 | +| 365 | Brotli, étude | 72 548 | 2 814 | 3 852 | 5 259 | 376 832 | 212 992 | +| 1825 | V1 sans compression | 323 668 | 323 668 | 323 684 | 431 703 | 18 948 096 | 9 519 104 | +| 1825 | Gzip, étude | 323 668 | 10 618 | 14 259 | 19 135 | 999 424 | 516 096 | +| 1825 | Brotli, étude | 323 668 | 9 226 | 12 405 | 16 663 | 851 968 | 475 136 | + +¹ Pour les études : taille du corps compressé seul, avant son en-tête/base64 intérieur. Pour V1 : JSON brut. Le ciphertext inclut bien tous ces frais et le tag GCM. + +² Somme des deux relations applicatives, index, TOAST et overhead PostgreSQL inclus. Vingt versions distinctes sont conservées ; la tête est également présente dans l'historique, soit 21 lignes au total. Le compactage `VACUUM FULL` est exécuté uniquement dans la base jetable du test. Ce n'est ni une recommandation d'entretien du projet personnel ni une mesure de son disque réel. Les valeurs après écritures montrent l'espace mort avant entretien ; l'élagage logique ne réduit pas instantanément les fichiers physiques. + +Sur le profil 1825 jours, les études Gzip/Brotli réduisent l'enveloppe de 95,57 % / 96,14 % et les relations compactées de 94,58 % / 95,01 %. À zéro jour, elles augmentent l'enveloppe de 15,53 % / 11,65 %. Ces résultats dépendent du corpus répétitif et ne constituent pas une économie déjà déployée. + +## Transferts et projection prudente + +Le banc mesure les corps JSON REST exacts : pour le profil 1825 jours sans compression, une lecture complète vaut 431 731 octets et une lecture de révision 17 octets. Modèle à état inchangé, cinq heures cumulées d'appareils actifs par jour, trente jours : + +| Profil | Toutes les lectures complètes à 30 s | Révision à 30 s + lecture complète à 10 min | Réduction modélisée | +| --- | ---: | ---: | ---: | +| 365 jours | 1 744 254 000 octets/mois | 87 503 400 octets/mois | 94,98 % | +| 1825 jours | 7 771 158 000 octets/mois | 388 848 600 octets/mois | 95,00 % | + +Ce sont des extrapolations des corps mesurés, pas un relevé réseau ni une facture Supabase. Elles excluent HTTP/TLS, Auth, ouverture d'application, reprises, réessais et modifications effectives. Dix heures d'appareils actifs doublent ce poste. Deux appareils allumés simultanément comptent chacun ; un appareil suspendu ne réalise pas ce polling. + +La rétention ne croît pas avec le nombre d'envois après vingt versions, mais chaque instantané complet peut grossir. À taille comparable aux profils étudiés, les relations compactées passent de 2,12 Mio à un an à 9,08 Mio à cinq ans, sans compression ; le profil cinq ans atteint 18,07 Mio juste après 21 écritures. Le disque réel inclut aussi catalogues, Auth, autres tables et espace mort. Surveiller les métriques hébergées et l'autovacuum ; aucune projection de taille constante indéfinie n'est justifiée. + +## Offre Free et limites économiques + +La [tarification officielle Supabase](https://supabase.com/pricing), consultée le 24 octobre 2026, indique Free à 0 $/mois, 500 Mo de base par projet, 5 Go de trafic sortant, et la mise en pause après une semaine d'inactivité. Cela ne garantit ni coût total nul, ni disponibilité continue, ni sauvegarde physique automatique/PITR. Le SMTP peut aussi entraîner un coût séparé. Aucun forfait payant n'est activé ici. + +Les mesures applicatives seules sont inférieures au quota affiché, mais le disque et l'egress réellement consommés restent à vérifier sur le bon service. Garder les exports indépendants et ne jamais supprimer une copie exploitable pour respecter un quota. + +Sources techniques : [limite de sortie zlib Node](https://nodejs.org/api/zlib.html#class-options), [disque Supabase](https://supabase.com/docs/guides/platform/manage-your-usage/disk-size), [egress Supabase](https://supabase.com/docs/guides/platform/manage-your-usage/egress). diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index 03601b6..ef44f6f 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -6,7 +6,7 @@ Base vérifiée : `2ab4c0f74c74b5ad430e10032ae810708113b3f0` (PR #31). ## Fonctionnement quotidien prévu 1. Les saisies sont d’abord enregistrées dans `reconversion-control.snapshot.v3` et relues. Un seul onglet a le Web Lock d’écriture. Un onglet obsolète, un quota ou un stockage illisible suspendent le cloud. -2. Après activation volontaire et déverrouillage, les modifications persistantes déclenchent un envoi différé d’environ 2 secondes. Vérification périodique toutes les 30 secondes au premier plan ; reprise aux événements réseau, visibilité et réouverture. Échecs : reprise progressive jusqu’à 60 secondes. +2. Après activation volontaire et déverrouillage, les modifications persistantes déclenchent un envoi différé d’environ 2 secondes. Vérification de révision toutes les 30 secondes au premier plan lorsque la copie locale est inchangée ; lecture complète au moins toutes les 10 minutes, ainsi qu'aux modifications locales, reprises réseau, retours au premier plan et actions manuelles. Une lecture de révision ne crée aucun reçu et ne change pas l'heure de la dernière confirmation. Révision différente, disparition, erreur ou horloge reculant imposent une lecture complète. Échecs : reprise progressive jusqu’à 60 secondes. 3. Le paquet cloud existant V2 contient la projection métier complète de l’instantané local V3 : progression, révisions SRS, labs, preuves, validations, Daily Five et dossier. La navigation propre à l’appareil, les secrets et journaux cloud sont exclus. Pas de second format concurrent ni de migration des enveloppes existantes. 4. Le JSON est figé avant toute attente cryptographique. L’enveloppe chiffrée et l’empreinte sont écrites/reluées dans une outbox locale avant la requête. Une modification survenue ensuite reste en attente. 5. Une révision est acquittée seulement après relecture du **même** paquet sur le serveur à la révision attendue. Une réponse perdue peut signifier que l’écriture a réussi. La reprise réutilise exactement le même ciphertext ; elle ne génère pas une nouvelle sauvegarde au hasard. @@ -31,7 +31,9 @@ La mémorisation chiffrée réduit le risque d’extraction au repos ; elle ne p ## Historique et récupération -Migration proposée dans `supabase/migrations/*_encrypted_cloud_history.sql`, **non appliquée**. Elle conserve les 20 dernières révisions chiffrées par utilisateur, dont la tête, dans une table en lecture seule pour le client. Un trigger privé contrôle le propriétaire, l’incrément exact des révisions et l’archivage transactionnel ; les champs métier en clair sont refusés. Son `SECURITY DEFINER` est limité à cette fonction de trigger dans un schéma non exposé, `search_path` vide, droits d’exécution retirés aux rôles publics. La RLS reste active ; les utilisateurs anonymes et les connexions anonymes authentifiées sont exclus. +Migration de référence dans `supabase/migrations/*_encrypted_cloud_history.sql`. L'installation est une opération administrative distincte, réservée au projet confirmé après autorisation ; l'état appliqué se vérifie dans son catalogue et son journal d'intervention, jamais à partir de la seule présence du fichier. Elle conserve les 20 dernières révisions chiffrées par utilisateur, dont la tête, dans une table en lecture seule pour le client. Un trigger privé contrôle le propriétaire, l’incrément exact des révisions et l’archivage transactionnel ; les champs métier en clair sont refusés. Son `SECURITY DEFINER` est limité à cette fonction de trigger dans un schéma non exposé, `search_path` vide, droits d’exécution retirés aux rôles publics. La RLS reste active ; les utilisateurs anonymes et les connexions anonymes authentifiées sont exclus. + +La migration suivante, `20261024173600_restrict_rls_event_trigger_execute.sql`, retire uniquement le droit client d'exécuter l'éventuel helper Supabase `public.rls_auto_enable()`. Elle vérifie sa nature d'event trigger privilégié appartenant à postgres, ne crée aucune fonction parallèle et ne modifie ni son code ni le trigger `ensure_rls`. L'activation automatique de RLS reste testée après restriction. Sur un projet sans ce helper, cette migration ne fait rien. Inspecter tout schéma existant et les migrations déjà appliquées avant exécution ; un SQL Editor manuel ne renseigne pas automatiquement le registre des migrations CLI. L’archivage démarre au prochain envoi : il ne recrée aucune version passée. Vingt versions rapprochées ne remplacent pas un export durable hors appareil. La liste ne télécharge que les numéros ; un seul paquet est chargé pour une récupération. Une version historique restaurée suspend la synchronisation et nécessite une nouvelle décision. @@ -56,6 +58,8 @@ Commandes reproductibles dans `TESTING.md`. Tests HTTP simulés + vraie cryptogr Une PWA suspendue ou fermée sur iOS n’exécute pas continuellement le réseau : reprise après réouverture et déverrouillage. La relecture après fermeture de page prouve la persistance du profil navigateur de test ; elle ne prouve pas un iPhone physique ni un redémarrage matériel. Si un navigateur ne propose pas Web Locks ou perd le verrou après suspension, exporter toute saisie en mémoire puis fermer les autres onglets et recharger. Références officielles consultées le 24/10/2026 : +- Modèle de menaces et limites : `cloud-security-model.md`. +- Mesures reproductibles sans compte ni réseau Supabase : `node tests/cloud-storage.cjs`, résultats et projections dans `cloud-storage-measures.md`. Gzip/Brotli y restent des objets d'étude, refusés comme DTO par l'application actuelle ; aucun protocole compressé n'est activé. - https://supabase.com/docs/guides/auth/sessions - https://supabase.com/docs/guides/auth/signout - https://supabase.com/docs/guides/database/postgres/row-level-security diff --git a/supabase/migrations/20261024112625_encrypted_cloud_history.sql b/supabase/migrations/20261024112625_encrypted_cloud_history.sql index 437a3e1..41c51be 100644 --- a/supabase/migrations/20261024112625_encrypted_cloud_history.sql +++ b/supabase/migrations/20261024112625_encrypted_cloud_history.sql @@ -24,7 +24,8 @@ create policy "progress_update_self" on public.reconversion_progress for update -- New writes must be bounded opaque envelopes; existing rows need explicit validation. alter table public.reconversion_progress add constraint progress_safe_revision check (revision between 1 and 9007199254740990) not valid; -alter table public.reconversion_progress add constraint progress_encrypted_envelope check ( +-- SQL CHECK accepts UNKNOWN; require a definite TRUE so JSON null cannot pass. +alter table public.reconversion_progress add constraint progress_encrypted_envelope check (( jsonb_typeof(payload)='object' and payload ?& array['v','kdf','iterations','salt','iv','ciphertext'] and (payload - array['v','kdf','iterations','salt','iv','ciphertext'])='{}'::jsonb @@ -34,7 +35,7 @@ alter table public.reconversion_progress add constraint progress_encrypted_envel and jsonb_typeof(payload->'ciphertext')='string' and length(payload->>'ciphertext') between 24 and 2796204 and (payload->>'ciphertext') ~ '^[A-Za-z0-9+/]+={0,2}$' and octet_length(payload::text)<=2800000 -) not valid; +) is true) not valid; create schema if not exists rc_backup_private; revoke all on schema rc_backup_private from public, anon, authenticated; diff --git a/supabase/migrations/20261024173600_restrict_rls_event_trigger_execute.sql b/supabase/migrations/20261024173600_restrict_rls_event_trigger_execute.sql new file mode 100644 index 0000000..f0b3712 --- /dev/null +++ b/supabase/migrations/20261024173600_restrict_rls_event_trigger_execute.sql @@ -0,0 +1,17 @@ +-- Schema hardening only. Keep Supabase's automatic RLS event trigger intact. +-- Some projects have no such helper; do not install a parallel implementation. +begin; +do $restrict$ declare target regprocedure:=to_regprocedure('public.rls_auto_enable()'); +begin + if target is null then return; end if; + if not exists(select 1 from pg_proc p where p.oid=target + and p.prorettype='pg_catalog.event_trigger'::regtype and p.prosecdef + and pg_get_userbyid(p.proowner)='postgres') then + raise exception 'Unexpected rls_auto_enable function: inspect before changing privileges'; + end if; + revoke execute on function public.rls_auto_enable() from public, anon, authenticated; + if has_function_privilege('anon',target,'EXECUTE') or has_function_privilege('authenticated',target,'EXECUTE') then + raise exception 'Inherited client EXECUTE remains: review role membership'; + end if; +end $restrict$; +commit; diff --git a/tests/cloud-final-mile-browser.cjs b/tests/cloud-final-mile-browser.cjs index da46de1..a45125d 100644 --- a/tests/cloud-final-mile-browser.cjs +++ b/tests/cloud-final-mile-browser.cjs @@ -5,10 +5,11 @@ const B={chromium,webkit}[process.env.BROWSER||"chromium"],CANON="reconversion-c const pause=()=>{let resolve;const promise=new Promise(r=>{resolve=r;});return{promise,resolve};}; async function run(){ const server=await startServer(),browser=await B.launch({headless:true}),contexts=[],errors=[],unexpected=[]; - let row=null,history=[],lose=false,failReads=false,writeGate=null,writes=0; - const methods=[]; + let row=null,history=[],lose=false,failReads=false,writeGate=null,writes=0,revisionReads=0,fullReads=0; + const methods=[],browserLogs=[]; async function context(mobile=false){ const c=await browser.newContext({...(mobile?devices["iPhone 11 Pro Max"]:{}),serviceWorkers:"block",acceptDownloads:true});contexts.push(c); + await c.addInitScript(()=>{const original=window.setInterval;window.setInterval=(fn,delay,...args)=>{if(delay===30000)window.__cloudPeriodic=fn;return original(fn,delay,...args);};}); await c.route("**/*",async route=>{ const req=route.request(),url=new URL(req.url());if(url.origin===server.origin)return route.continue(); if(url.origin!=="https://fixture.supabase.co"){unexpected.push(req.url());return route.abort();} @@ -20,7 +21,7 @@ async function run(){ if(url.pathname==="/auth/v1/logout")return reply(200,{}); if(url.pathname==="/rest/v1/reconversion_progress_history"){const versions=url.searchParams.has("revision")?history.filter(r=>"eq."+r.revision===url.searchParams.get("revision")):[...history].reverse().slice(0,20);return reply(200,url.searchParams.get("select")==="revision"?versions.map(r=>({revision:r.revision})):versions);} if(url.pathname!=="/rest/v1/reconversion_progress"){unexpected.push(req.url());return route.abort();} - if(req.method()==="GET"){if(failReads)return route.abort("failed");return reply(200,row?[row]:[]);} + if(req.method()==="GET"){if(failReads)return route.abort("failed");const small=url.searchParams.get("select")==="revision";if(small)revisionReads++;else fullReads++;return reply(200,row?[small?{revision:row.revision}:row]:[]);} const data=req.postDataJSON(),first=req.method()==="POST"; if(first&&row||!first&&(!row||url.searchParams.get("revision")!=="eq."+row.revision))return reply(409,{}); if(writeGate){const held=writeGate;writeGate=null;held.started.resolve();await held.release.promise;} @@ -29,7 +30,7 @@ async function run(){ }); return c; } - async function page(c){const p=await c.newPage();p.on("pageerror",e=>errors.push(e.message));p.on("dialog",d=>d.accept());await p.goto(server.url+"/#privacy");await p.waitForFunction(k=>!!localStorage.getItem(k),CANON);return p;} + async function page(c){const p=await c.newPage();p.on("pageerror",e=>errors.push(e.message));p.on("console",m=>browserLogs.push(m.text()));p.on("dialog",d=>d.accept());await p.goto(server.url+"/#privacy");await p.waitForFunction(k=>!!localStorage.getItem(k),CANON);return p;} async function click(p,id){await p.evaluate(id=>document.querySelector("#rc-sync-"+id).onclick(),id);} async function connect(p,remember=true){await p.locator("#rc-sync-url").fill("https://fixture.supabase.co");await p.locator("#rc-sync-key").fill("sb_publishable_fixture");await p.locator("#rc-sync-email").fill("test@example.invalid");if(remember)await p.locator("#rc-sync-remember").check();await click(p,"request");await p.locator("#rc-sync-otp").fill("123456");await p.locator("#rc-sync-phrase").fill(PHRASE);await click(p,"verify");} async function unlock(p){await p.locator("#rc-sync-phrase").fill(PHRASE);await click(p,"unlock");} @@ -51,8 +52,13 @@ async function run(){ const initial=await snapshot(pc);assert.equal(initial.progress.dns,"mastered"); await connect(pc);assert.equal(row,null,"OTP/unlock alone must not upload a first real snapshot"); await click(pc,"create");assert.equal(row.revision,1);assert(!JSON.stringify(row).includes("mastered")); + const firstWrites=writes;await click(pc,"now");await click(pc,"now");assert.equal(writes,firstWrites);assert.equal(row.revision,1); const remote=await pc.evaluate(async({p,phrase})=>RC_CLOUD_TRANSPORT.open(p,phrase),{p:row.payload,phrase:PHRASE});assert.deepEqual(remote,initial); console.log("PASS browser cloud: explicit first source; complete atomic V3 business snapshot encrypted before send"); + const beforeProbe={revisionReads,fullReads,writes},receiptBefore=await pc.evaluate(()=>Object.keys(localStorage).filter(k=>k.startsWith("reconversion-control.cloud.sync.v1:")).map(k=>localStorage.getItem(k)).join("\n")); + await pc.evaluate(()=>window.__cloudPeriodic());assert.equal(revisionReads,beforeProbe.revisionReads+1);assert.equal(fullReads,beforeProbe.fullReads);assert.equal(writes,beforeProbe.writes); + assert.equal(await pc.evaluate(()=>Object.keys(localStorage).filter(k=>k.startsWith("reconversion-control.cloud.sync.v1:")).map(k=>localStorage.getItem(k)).join("\n")),receiptBefore); + console.log("PASS browser cloud: idle periodic check transfers only a revision and preserves the confirmed receipt"); const phone=await page(await context(true));await connect(phone);assert(await phone.locator("#rc-sync-take").isVisible());assert.notEqual((await read(phone)).main.progress.dns,"mastered"); await click(phone,"take");assert.deepEqual(await snapshot(phone),initial); // Actual page close/reopen discards memory/keys and retains browser Storage. @@ -117,6 +123,7 @@ async function run(){ console.log("PASS browser cloud: independent recovery export downloads every preserved business field"); await click(pc,"disconnect");assert.equal(await pc.evaluate(()=>localStorage.getItem("reconversion-control.cloud.session.v1")),null);assert.deepEqual(await snapshot(pc),stable); assert.deepEqual(errors,[]);assert.deepEqual(unexpected,[]); + for(const secret of [PHRASE,"test-access","test-refresh"])assert(!browserLogs.some(line=>line.includes(secret)),"browser logs must not contain authentication or encryption secrets"); console.log("PASS browser cloud: explicit logout deletes remembered secrets and preserves local progress; no unmocked provider requests"); }finally{if(writeGate)writeGate.release.resolve();for(const c of contexts)await c.close();await browser.close();await server.close();} } diff --git a/tests/cloud-final-mile.cjs b/tests/cloud-final-mile.cjs index 410c3b0..eae2e3f 100644 --- a/tests/cloud-final-mile.cjs +++ b/tests/cloud-final-mile.cjs @@ -6,10 +6,10 @@ const clone=v=>JSON.parse(JSON.stringify(v)),response=(status,j)=>({ok:status>=2 let passed=0; async function check(name,fn){await fn();passed++;console.log("PASS cloud final mile: "+name);} function fixture(){ - const rows=new Map(),calls=[];let lost=false,failRead=false,empty=false,expired=false,refreshes=0,revoke=false,anon=false,clock=0; + const rows=new Map(),calls=[];let lost=false,failRead=false,empty=false,expired=false,refreshes=0,revoke=false,anon=false,clock=0,headOverride; async function fetch(input,init){ const url=new URL(input),body=init.body?JSON.parse(init.body):{},token=init.headers.Authorization; - assert.equal(url.origin,"https://fixture.supabase.co");calls.push({path:url.pathname,method:init.method}); + assert.equal(url.origin,"https://fixture.supabase.co");calls.push({path:url.pathname,method:init.method,select:url.searchParams.get("select")}); if(url.pathname==="/auth/v1/verify")return response(200,{access_token:body.email.startsWith("b")?b:a,refresh_token:"refresh-fixture",expires_in:expired?1:3600,user:{id:body.email.startsWith("b")?b:a,is_anonymous:anon}}); if(url.pathname==="/auth/v1/token"){ refreshes++;await Promise.resolve(); @@ -21,7 +21,7 @@ function fixture(){ if(url.pathname!=="/rest/v1/reconversion_progress")return response(404,{}); const uid=token&&token.slice(7),target=url.searchParams.get("user_id"); if(![a,b].includes(uid)||target&&target!=="eq."+uid)return response(403,{}); - if(init.method==="GET"){if(failRead)throw Error("synthetic offline read");return response(200,rows.has(uid)?[rows.get(uid)]:[]);} + if(init.method==="GET"){if(failRead)throw Error("synthetic offline read");return response(200,url.searchParams.get("select")==="revision"?(headOverride!==undefined?headOverride:rows.has(uid)?[{revision:rows.get(uid).revision}]:[]):rows.has(uid)?[rows.get(uid)]:[]);} let current=rows.get(uid); if(init.method==="POST"){ if(body.user_id!==uid)return response(403,{});if(current)return response(409,{});current={revision:1,payload:body.payload}; @@ -36,7 +36,7 @@ function fixture(){ const sandbox={URL,crypto:webcrypto,TextEncoder,TextDecoder,fetch,Date:class extends Date{static now(){return Date.now()+clock;}},btoa:s=>Buffer.from(s,"binary").toString("base64"),atob:s=>Buffer.from(s,"base64").toString("binary")}; vm.createContext(sandbox);vm.runInContext(fs.readFileSync(require("node:path").join(__dirname,"../cloud-transport.js"),"utf8")+"\nglobalThis.T=RC_CLOUD_TRANSPORT",sandbox); const T=sandbox.T,cfg={url:"https://fixture.supabase.co",key:"sb_publishable_fixture"}; - return {T,cfg,rows,calls,client:options=>T.create(cfg,options),lose:()=>{lost=true;},offline:v=>{failRead=v;},empty:()=>{empty=true;},expire:()=>{expired=true;},advance:()=>{clock+=3600001;},revoke:()=>{revoke=true;},anonymous:()=>{anon=true;},get refreshes(){return refreshes;}}; + return {T,cfg,rows,calls,client:options=>T.create(cfg,options),headShape:v=>{headOverride=v;},lose:()=>{lost=true;},offline:v=>{failRead=v;},empty:()=>{empty=true;},expire:()=>{expired=true;},advance:()=>{clock+=3600001;},revoke:()=>{revoke=true;},anonymous:()=>{anon=true;},get refreshes(){return refreshes;}}; } async function run(){ await check("legacy AES-256-GCM envelope, independent IV/salt and tamper refusal",async()=>{ @@ -52,6 +52,14 @@ async function run(){ assert.equal((await T.open(sealed,phrase)).progress.dns,"progress");await assert.rejects(T.seal({large:"x".repeat(2100000)},phrase)); await assert.rejects(T.seal(JSON.parse('{"__proto__":{"polluted":true}}'),phrase)); }); + await check("revision-only reads are scoped, small and reject malformed metadata",async()=>{ + const f=fixture(),c=f.client();await c.verifyCode("a@example.invalid","123456"); + assert.equal(await c.readRevision(),null);const p=await f.T.seal({marker:"metadata-fixture"},phrase);await c.write(p,null); + assert.equal(await c.readRevision(),1);assert.equal(f.calls.at(-1).select,"revision"); + for(const malformed of [{},null,[{revision:null}],[{revision:1,payload:p}],[{revision:1},{revision:2}],[{revision:Number.MAX_SAFE_INTEGER}]]){ + f.headShape(malformed);await assert.rejects(c.readRevision(),/Révision cloud invalide/); + } + }); await check("lost upload response is reconciled by exact ciphertext and revision",async()=>{ const f=fixture(),c=f.client();await c.verifyCode("a@example.invalid","123456");const payload=await f.T.seal({marker:"A"},phrase); f.lose();assert.equal(await c.write(payload,null),1);assert.equal(f.rows.get(a).revision,1); diff --git a/tests/cloud-history.cjs b/tests/cloud-history.cjs index a2dfe65..0622683 100644 --- a/tests/cloud-history.cjs +++ b/tests/cloud-history.cjs @@ -9,11 +9,46 @@ async function run(){ async function owner(id,anonymous=false){await db.exec("reset role");await db.query("select set_config('request.jwt.claims',$1,false)",[JSON.stringify({sub:id,is_anonymous:anonymous})]);await db.exec("set role authenticated");} try{ await db.exec("create role anon nologin; create role authenticated nologin; create schema auth; create table auth.users(id uuid primary key); grant usage on schema public,auth to anon,authenticated; create function auth.uid() returns uuid language sql stable as $$ select (nullif(current_setting('request.jwt.claims',true),'')::jsonb->>'sub')::uuid $$; create function auth.jwt() returns jsonb language sql stable as $$ select coalesce(nullif(current_setting('request.jwt.claims',true),'')::jsonb,'{}'::jsonb) $$;"); + // Reproduce the hosted auto-RLS helper, including PostgreSQL's PUBLIC EXECUTE default. + await db.exec("create function public.rls_auto_enable() returns event_trigger language plpgsql security definer set search_path=pg_catalog as $$ declare cmd record; begin for cmd in select * from pg_event_trigger_ddl_commands() where command_tag='CREATE TABLE' and object_type='table' and schema_name='public' loop execute format('alter table %s enable row level security',cmd.object_identity); end loop; end $$; create event trigger ensure_rls on ddl_command_end when tag in ('CREATE TABLE') execute function public.rls_auto_enable();"); + assert.equal((await db.query("select has_function_privilege('anon','public.rls_auto_enable()','EXECUTE') allowed")).rows[0].allowed,true); await db.query("insert into auth.users values ($1),($2)",[a,b]); await db.exec(fs.readFileSync(path.join(root,"sql/reconversion_progress.sql"),"utf8")); const migrations=fs.readdirSync(path.join(root,"supabase/migrations")).filter(n=>n.endsWith("_encrypted_cloud_history.sql"));assert.equal(migrations.length,1); await db.exec(fs.readFileSync(path.join(root,"supabase/migrations",migrations[0]),"utf8")); + const hardening=fs.readFileSync(path.join(root,"supabase/migrations/20261024173600_restrict_rls_event_trigger_execute.sql"),"utf8"); + await db.exec(hardening);await db.exec(hardening); // Restriction is safely repeatable. + await check("auto-RLS remains active while its privileged helper is inaccessible to clients",async()=>{ + for(const role of ["anon","authenticated"]){ + assert.equal((await db.query("select has_function_privilege($1,'public.rls_auto_enable()','EXECUTE') allowed",[role])).rows[0].allowed,false); + await db.exec("set role "+role);await assert.rejects(db.query("select public.rls_auto_enable()"),e=>e.code==="42501");await db.exec("reset role"); + } + await db.exec("begin; create table public.rc_rls_auto_probe(id int);"); + assert.equal((await db.query("select relrowsecurity from pg_class where oid='public.rc_rls_auto_probe'::regclass")).rows[0].relrowsecurity,true); + await db.exec("rollback");assert.equal((await db.query("select to_regclass('public.rc_rls_auto_probe') n")).rows[0].n,null); + }); await check("authenticated owner can create and read encrypted progress",async()=>{await owner(a);await db.query("insert into public.reconversion_progress(user_id,payload) values($1,$2)",[a,payload]);assert.equal((await db.query("select * from public.reconversion_progress")).rows.length,1);}); + await check("malformed encrypted envelopes (including kdf:null) fail closed",async()=>{ + await owner(a); + const failures=[ + ...["kdf","v","iterations","salt","iv","ciphertext"].map(k=>({...payload,[k]:null})), + {...payload,kdf:"PBKDF2-SHA1"}, + {...payload,ciphertext:""}, + {...payload,unexpected:true}, + ]; + for(const malformed of failures){ + await assert.rejects( + db.query("update public.reconversion_progress set revision=2,payload=$1 where user_id=$2",[malformed,a]), + e=>e.code==="23514", + "An invalid envelope must never pass the SQL constraint" + ); + } + const head=await db.query("select revision,payload from public.reconversion_progress where user_id=$1",[a]); + assert.equal(head.rows[0].revision,1); + assert.deepEqual(head.rows[0].payload,payload); + const history=await db.query("select revision from public.reconversion_progress_history where user_id=$1",[a]); + assert.deepEqual(history.rows.map(x=>x.revision),[1]); + }); await check("second user cannot see or change first user's progress/history",async()=>{ await owner(b);assert.equal((await db.query("select * from public.reconversion_progress")).rows.length,0);assert.equal((await db.query("select * from public.reconversion_progress_history")).rows.length,0); assert.equal((await db.query("update public.reconversion_progress set revision=2 returning *")).rows.length,0); diff --git a/tests/cloud-security-browser.cjs b/tests/cloud-security-browser.cjs new file mode 100644 index 0000000..cfb5ea5 --- /dev/null +++ b/tests/cloud-security-browser.cjs @@ -0,0 +1,38 @@ +"use strict"; +// Actual CSP and crypto in an isolated browser; no provider connection. +const assert=require("node:assert/strict"),{chromium,webkit}=require("playwright"),{startServer}=require("./server.cjs"); +const B={chromium,webkit}[process.env.BROWSER||"chromium"],CANON="reconversion-control.snapshot.v3"; +async function run(){ + const server=await startServer(),browser=await B.launch({headless:true}),context=await browser.newContext({serviceWorkers:"block"}); + const unexpected=[],errors=[]; + try{ + await context.route("**/*",route=>{if(new URL(route.request().url()).origin===server.origin)return route.continue();unexpected.push(route.request().url());return route.abort();}); + const page=await context.newPage();page.on("pageerror",e=>errors.push(e.message));await page.goto(server.url+"/#privacy"); + await page.waitForFunction(k=>!!localStorage.getItem(k),CANON); + const before=await page.evaluate(k=>localStorage.getItem(k),CANON); + const policy=await page.locator('meta[http-equiv="Content-Security-Policy"]').getAttribute("content"); + assert.equal(policy.split(";").map(s=>s.trim()).find(s=>s.startsWith("script-src ")),"script-src 'self'"); + const refused=await page.evaluate(async()=>{ + const phrase="FICTIVE-security-browser-phrase-2026",T=RC_CLOUD_TRANSPORT,p=await T.seal({fixture:"security-only"},phrase); + let n=0;for(const field of ["v","kdf","iterations","salt","iv","ciphertext"]){try{await T.open({...p,[field]:null},phrase);}catch{n++;}} + try{await T.openSession(p,phrase);}catch{n++;} + return n; + }); + assert.equal(refused,7);assert.equal(await page.evaluate(k=>localStorage.getItem(k),CANON),before); + const originalBase=await page.evaluate(()=>document.baseURI); + await page.evaluate(()=>{ + window.__noesisCspEvents=[];document.addEventListener("securitypolicyviolation",e=>window.__noesisCspEvents.push(e.effectiveDirective)); + const inline=document.createElement("script");inline.textContent="window.__noesisInlineExecuted=true;";document.body.append(inline); + const external=document.createElement("script");external.src="https://untrusted.example.invalid/blocked.js";document.body.append(external); + const base=document.createElement("base");base.href="https://untrusted.example.invalid/";document.head.append(base); + }); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + assert.equal(await page.evaluate(()=>window.__noesisInlineExecuted===true),false); + assert.equal(await page.evaluate(()=>document.baseURI),originalBase); + assert.deepEqual(unexpected,[]);assert.deepEqual(errors,[]); + assert.equal(await page.evaluate(k=>localStorage.getItem(k),CANON),before); + console.log("PASS browser security: inline/external script injection and foreign base blocked; null crypto fields and cross-domain decryption refused; local snapshot unchanged."); + }finally{await context.close();await browser.close();await server.close();} +} +const watchdog=setTimeout(()=>{throw Error("Security browser suite stalled");},60000); +run().catch(e=>{console.error(e);process.exitCode=1;}).finally(()=>clearTimeout(watchdog)); diff --git a/tests/cloud-storage.cjs b/tests/cloud-storage.cjs new file mode 100644 index 0000000..91cdc52 --- /dev/null +++ b/tests/cloud-storage.cjs @@ -0,0 +1,100 @@ +"use strict"; +// Offline measurements only. No Supabase URL, key, account or personal fixture. +const assert=require("node:assert/strict"),fs=require("node:fs"),path=require("node:path"),vm=require("node:vm"); +const z=require("node:zlib"),{webcrypto}=require("node:crypto"),{PGlite}=require("@electric-sql/pglite"); +const root=path.resolve(__dirname,".."),read=n=>fs.readFileSync(path.join(root,n),"utf8"); +const UID="11111111-1111-4111-8111-111111111111",PHRASE="FICTIVE-storage-benchmark-only-2026",MAX=2097136; +const clone=x=>JSON.parse(JSON.stringify(x)),bytes=x=>Buffer.byteLength(x),sandbox={URL,TextEncoder,TextDecoder,crypto:webcrypto,btoa:s=>Buffer.from(s,"binary").toString("base64"),atob:s=>Buffer.from(s,"base64").toString("binary")}; +vm.createContext(sandbox); +for(const name of ["data.js","course-foundations.js","course-race.js","course-operations.js","learning.js","english.js","drive-learning.js","deep-dives.js","evidence-checks.js","daily-five.js","cloud-transport.js","project-plan.js"])vm.runInContext(read(name),sandbox,{filename:name}); +const app=read("app.js"); +function sourceFunction(name){ + const start=app.indexOf("function "+name+"(");assert(start>=0,name); + const next=app.indexOf("\nfunction ",start+10);assert(next>start,name); + return app.slice(start,next); +} +const proofStart=app.indexOf("var RC_PORTFOLIO_PROOFS="),proofEnd=app.indexOf("\nvar RC_CAREER_STAGES=",proofStart); +assert(proofStart>=0&&proofEnd>proofStart); +vm.runInContext(app.slice(proofStart,proofEnd),sandbox); +vm.runInContext("var topic=id=>RC_TOPICS.find(t=>t.id===id),statuses=['learn','progress','mastered'],reviewRatings=['again','hard','good','easy'],dailyReasons=['Révision due','Erreur récente','Domaine faible','Rotation'];",sandbox); +for(const name of ["plainObject","validDate","progression","labStateSanitize","proofStateSanitize","dailyById","legacyDailySession","dailySanitize","labChecksSanitize","assertSnapshotFields","cloudValidate"])vm.runInContext(sourceFunction(name),sandbox); +vm.runInContext("globalThis.B={T:RC_CLOUD_TRANSPORT,P:RC_PROJECT_PLAN,topics:RC_TOPICS,labs:RC_LABS,daily:RC_DAILY_FIVE,proofs:RC_PORTFOLIO_PROOFS,validate:cloudValidate};",sandbox); +const {T,P}=sandbox.B,catalog=clone({topics:sandbox.B.topics,labs:sandbox.B.labs,daily:sandbox.B.daily,proofs:sandbox.B.proofs}); +function fixture(days){ + const p={version:2,progress:{},review:{},labs:{},proofs:{},labChecks:{},dailyFive:{days:{}},career:clone(P.empty())}; + if(days){ + for(const t of catalog.topics){p.progress[t.id]="mastered";p.review[t.id]={step:2,due:"2026-10-25",lapses:1,lastRating:"good"};} + for(const lab of catalog.labs)p.labs[lab.id]="progress"; + for(const proof of catalog.proofs)p.proofs[proof.lesson]={status:"progress",evidence:"Preuve entièrement fictive pour mesure isolée, sans donnée personnelle."}; + p.labChecks["lab-02"]={sha256:"a".repeat(64),at:"2026-10-24T06:00:00Z"}; + p.career.profile={diploma:"Bac fictif",history:"Dossier synthétique de mesure uniquement."};p.career.schools.jedha.costs={tuition:12000}; + } + for(let i=0;i0&&w.jsonBytes<=MAX); + assert(typeof w.body==="string"&&w.body.length<=4*Math.ceil(MAX/3)); + const compressed=Buffer.from(w.body,"base64");assert.equal(compressed.toString("base64"),w.body); + const raw=w.encoding==="gzip"?z.gunzipSync(compressed,{maxOutputLength:w.jsonBytes}):z.brotliDecompressSync(compressed,{maxOutputLength:w.jsonBytes}); + assert.equal(raw.length,w.jsonBytes);return raw; +} +async function database(){ + const db=new PGlite(); + await db.exec("create role anon nologin; create role authenticated nologin; create schema auth; create table auth.users(id uuid primary key); grant usage on schema public,auth to anon,authenticated; create function auth.uid() returns uuid language sql stable as $$ select (nullif(current_setting('request.jwt.claims',true),'')::jsonb->>'sub')::uuid $$; create function auth.jwt() returns jsonb language sql stable as $$ select coalesce(nullif(current_setting('request.jwt.claims',true),'')::jsonb,'{}'::jsonb) $$;"); + await db.exec(read("sql/reconversion_progress.sql")); + for(const n of fs.readdirSync(path.join(root,"supabase/migrations")).filter(n=>n.endsWith(".sql")).sort())await db.exec(read("supabase/migrations/"+n)); + await db.exec("alter table public.reconversion_progress validate constraint progress_safe_revision; alter table public.reconversion_progress validate constraint progress_encrypted_envelope;"); + await db.query("insert into auth.users(id) values($1)",[UID]); + await db.query("select set_config('request.jwt.claims',$1,false)",[JSON.stringify({sub:UID,is_anonymous:false})]);await db.exec("set role authenticated"); + return db; +} +async function sizes(db){ + return (await db.query("select c.relname,pg_relation_size(c.oid)::int heap_bytes,pg_indexes_size(c.oid)::int index_bytes,pg_total_relation_size(c.oid)::int total_bytes from pg_class c join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and c.relname in ('reconversion_progress','reconversion_progress_history') order by c.relname")).rows; +} +async function measure(base,encoding){ + const db=await database(),ivs=new Set(),salts=new Set();let last,raw,wrapped; + try{ + for(let revision=1;revision<=21;revision++){ + const p=clone(base);p.career.profile.history="Mesure FICTIVE hors ligne, révision "+revision+".";sandbox.B.validate(p); + raw=Buffer.from(T.serialize(p));assert(raw.length<=MAX); + wrapped=encoding==="none"?p:studyWrapper(raw,encoding); + if(encoding!=="none"){assert.throws(()=>sandbox.B.validate(wrapped));assert.equal(unpack(wrapped).toString(),raw.toString());} + last=await T.seal(wrapped,PHRASE);assert(!ivs.has(last.iv)&&!salts.has(last.salt));ivs.add(last.iv);salts.add(last.salt); + const opened=clone(await T.open(last,PHRASE)),restored=encoding==="none"?opened:JSON.parse(unpack(opened)); + assert.equal(T.serialize(sandbox.B.validate(restored)),raw.toString()); + if(revision===1)await db.query("insert into public.reconversion_progress(user_id,revision,payload) values($1,1,$2)",[UID,last]); + else{const changed=await db.query("update public.reconversion_progress set revision=$1,payload=$2 where user_id=$3 and revision=$4 returning revision",[revision,last,UID,revision-1]);assert.equal(changed.rows.length,1);} + } + assert.equal((await db.query("select count(*)::int n from public.reconversion_progress_history")).rows[0].n,20); + const recovered=(await db.query("select payload from public.reconversion_progress_history where revision=2")).rows[0].payload; + const old=clone(await T.open(recovered,PHRASE));sandbox.B.validate(encoding==="none"?old:JSON.parse(unpack(old))); + await db.exec("reset role"); + const afterWrites=await sizes(db); + await db.exec("vacuum full analyze public.reconversion_progress"); + await db.exec("vacuum full analyze public.reconversion_progress_history"); + const compacted=await sizes(db),readBody=JSON.stringify([{revision:21,payload:last}]),headBody=JSON.stringify([{revision:21}]); + const fullPolls=30*5*3600/30,integrityReads=30*5*3600/600; + return {encoding,studyOnly:encoding!=="none",dailyDays:Object.keys(base.dailyFive.days).length,jsonBytes:raw.length,compressedBytes:encoding==="none"?raw.length:Buffer.from(wrapped.body,"base64").length,ciphertextBytes:Buffer.from(last.ciphertext,"base64").length,envelopeJsonBytes:bytes(JSON.stringify(last)),uploadBodyBytes:bytes(JSON.stringify({revision:21,payload:last})),readBodyBytes:bytes(readBody),revisionBodyBytes:bytes(headBody),historyRows:20,mainRows:1,distinctVersions:20,currentCiphertextStoredTwice:true,postgresAfter21Writes:afterWrites,postgresCompacted:compacted,postgresTotalBytes:compacted.reduce((s,x)=>s+x.total_bytes,0),monthlyReadBodyBytesExample:{deviceHoursPerDay:5,days:30,unchangedFullEvery30s:fullPolls*bytes(readBody),revisionEvery30sAndFullEvery10min:(fullPolls-integrityReads)*bytes(headBody)+integrityReads*bytes(readBody)},headersTlsAuthAndChangesExcluded:true}; + }finally{await db.close();} +} +async function run(){ + const large=Buffer.alloc(65536,65),bomb=studyWrapper(large,"gzip");bomb.jsonBytes=1024;assert.throws(()=>unpack(bomb)); + const brBomb=studyWrapper(large,"brotli");brBomb.jsonBytes=1024;assert.throws(()=>unpack(brBomb)); + const bad=studyWrapper(Buffer.from("fictitious"),"gzip"),corrupt=Buffer.from(bad.body,"base64");corrupt[0]^=255;bad.body=corrupt.toString("base64");assert.throws(()=>unpack(bad)); + const results=[]; + for(const days of [0,365,1825]){const p=fixture(days);for(const encoding of ["none","gzip","brotli"])results.push(await measure(p,encoding));} + for(const r of results){const baseline=results.find(x=>x.dailyDays===r.dailyDays&&x.encoding==="none");r.envelopeSavingsPct=100*(1-r.envelopeJsonBytes/baseline.envelopeJsonBytes);r.postgresSavingsPct=100*(1-r.postgresTotalBytes/baseline.postgresTotalBytes);} + const report={fictitious:true,offline:true,engine:"PGlite 0.5.11, real PostgreSQL WASM; mock Auth",node:process.version,productionCompressionEnabled:false,studyFormatNotAcceptedByCurrentApp:true,boundedDecompressionAndRoundtripPassed:true,results}; + const json=JSON.stringify(report,null,2); + if(process.env.RC_STORAGE_REPORT){fs.mkdirSync(path.dirname(process.env.RC_STORAGE_REPORT),{recursive:true});fs.writeFileSync(process.env.RC_STORAGE_REPORT,json+"\n");} + console.log(json); +} +run().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/tests/cloud-ui-browser.cjs b/tests/cloud-ui-browser.cjs index bd857e7..5515309 100644 --- a/tests/cloud-ui-browser.cjs +++ b/tests/cloud-ui-browser.cjs @@ -22,7 +22,7 @@ async function run(browser, server) { if (url.pathname === "/auth/v1/otp") return reply(200, {}); if (url.pathname === "/auth/v1/verify") return reply(200, {access_token: "fixture-token", refresh_token: "fixture-refresh", expires_in: 3600, user: {id: "00000000-0000-4000-8000-000000000001"}}); if (url.pathname !== "/rest/v1/reconversion_progress") { unexpected.push(req.url()); return route.abort(); } - if (req.method() === "GET") return reply(200, row ? [row] : []); + if (req.method() === "GET") return reply(200, row ? [url.searchParams.get("select")==="revision"?{revision:row.revision}:row] : []); if (req.method() === "POST" || req.method() === "PATCH") { const body = req.postDataJSON(), initial = req.method() === "POST"; if ((initial && row) || (!initial && (!row || url.searchParams.get("revision") !== "eq." + row.revision))) return reply(409, {}); diff --git a/tests/cloud-ui-races.cjs b/tests/cloud-ui-races.cjs index 9e1706a..6b94a48 100644 --- a/tests/cloud-ui-races.cjs +++ b/tests/cloud-ui-races.cjs @@ -9,7 +9,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve function fixture() { let state = {version: 2, progress: {dns: "initial-fixture"}}, row = null; - let interval, timeout, failWrite = false, failRestore = false; + let interval, timeout, failWrite = false, failRestore = false, failHead = false, fullReads=0, headReads=0, clock=0; const gates = new Map(), nodes = new Map(), writes = []; const node = selector => { if (!nodes.has(selector)) nodes.set(selector, {value: "", innerHTML: "", textContent: ""}); @@ -22,7 +22,8 @@ function fixture() { } const client = { sendCode: async () => {}, verifyCode: async () => "00000000-0000-4000-8000-000000000001", logout: () => {}, - async read() { const result = copy(row); await wait("read"); return result; }, + async read() { fullReads++; const result = copy(row); await wait("read"); return result; }, + async readRevision() { headReads++; const result=row?row.revision:null; await wait("head"); if(failHead){failHead=false;throw Error("Fictitious metadata failure");}return result; }, async write(payload, revision) { await wait("write"); if (failWrite) { failWrite = false; throw Error("Fictitious network failure"); } @@ -34,7 +35,7 @@ function fixture() { const store = new Map(); const sandbox = { localStorage: {getItem:k=>store.get(k)||null,setItem:(k,v)=>store.set(k,v),removeItem:k=>store.delete(k)}, - Date, + Date: class extends Date { static now(){return Date.now()+clock;} }, navigator: {onLine: true}, document: {querySelector: node}, confirm: () => true, setInterval: callback => { interval = callback; return 1; }, clearInterval: () => { interval = null; }, setTimeout: callback => { timeout = callback; return 2; }, clearTimeout: () => { timeout = null; }, @@ -68,6 +69,13 @@ function fixture() { pause(stage) { const gate = {started: deferred(), release: deferred()}; gates.set(stage, gate); return {started: gate.started.promise, release: gate.release.resolve}; }, remote(value) { row = {revision: (row ? row.revision : 0) + 1, payload: {snapshot: {version: 2, progress: {dns: value}}}}; }, tick: () => interval(), + manual: () => click("now"), + advance: ms => {clock+=ms;}, + hidden: value => {sandbox.document.hidden=value;}, + corruptHead: value => {row.payload.snapshot.progress.dns=value;}, + removeCloud: () => {row=null;}, + failHead: () => {failHead=true;}, + get reads(){return {full:fullReads,head:headReads};}, async pending() { assert(timeout, "concurrent edits must schedule another attempt, including first backup"); const run = timeout; timeout = null; await run(); }, failWrite: () => { failWrite = true; }, failRestore: () => { failRestore = true; }, get local() { return state.progress.dns; }, get remoteValue() { return row && row.payload.snapshot.progress.dns; }, @@ -126,6 +134,43 @@ async function run() { const f=fixture();await f.prepare("automatic");f.corruptJournal();const before=f.journal,writes=f.writes.length;f.edit("pending-after-future-journal");await f.tick(); assert.equal(f.journal,before);assert.equal(f.writes.length,writes);assert.match(f.html,/incompatible/);assert.doesNotMatch(f.html,/À jour sur cet appareil et dans le cloud/); }); + await check("idle metadata reads do not upload or advance a receipt; full audits remain mandatory",async()=>{ + const f=fixture();await f.prepare("automatic");const before=f.reads,journal=f.journal,writes=f.writes.length; + await f.tick();assert.equal(f.reads.full,before.full);assert.equal(f.reads.head,before.head+1);assert.equal(f.writes.length,writes);assert.equal(f.journal,journal); + await f.manual();assert.equal(f.reads.full,before.full+1); + f.advance(600001);await f.tick();assert.equal(f.reads.full,before.full+2); + f.advance(-700000);await f.tick();assert.equal(f.reads.full,before.full+3,"clock rollback must force a full audit"); + f.hidden(true);const hidden=f.reads;await f.tick();assert.deepEqual(f.reads,hidden); + }); + await check("changed or disappeared revision always falls back to a full read",async()=>{ + const f=fixture();await f.prepare("automatic");const before=f.reads.full;f.remote("other-device");await f.tick(); + assert.equal(f.local,"other-device");assert.equal(f.reads.full,before+1); + f.removeCloud();await f.tick();assert.equal(f.local,"other-device");assert.match(f.html,/disparu/); + }); + for(const concurrentRemote of [false,true])await check("edit during metadata probe is preserved"+(concurrentRemote?" with a remote conflict":""),async()=>{ + const f=fixture();await f.prepare("automatic");const gate=f.pause("head"),poll=f.tick();await gate.started; + f.edit("new-local");if(concurrentRemote)f.remote("new-remote");gate.release();await poll; + assert.equal(f.local,"new-local"); + if(concurrentRemote){assert.equal(f.remoteValue,"new-remote");assert.match(f.html,/Conflit détecté/);} + else assert.equal(f.remoteValue,"new-local"); + }); + await check("metadata failure cannot manufacture a success and forces the next full read",async()=>{ + const f=fixture();await f.prepare("automatic");const before=f.reads.full,journal=f.journal;f.failHead();await f.tick(); + assert.equal(f.journal,journal);assert.match(f.html,/metadata failure/);assert.doesNotMatch(f.html,/À jour sur cet appareil et dans le cloud/); + await f.tick();assert.equal(f.reads.full,before+1); + }); + await check("manual request or audit expiry during a metadata probe forces a full read",async()=>{ + for(const action of ["manual","expiry"]){ + const f=fixture();await f.prepare("automatic");const before=f.reads.full,gate=f.pause("head"),poll=f.tick();await gate.started; + if(action==="manual")await f.manual();else f.advance(600001); + gate.release();await poll;assert.equal(f.reads.full,before+1); + } + }); + await check("same-revision payload tampering remains rejected by manual and repeated full checks",async()=>{ + const f=fixture();await f.prepare("automatic");const journal=f.journal;f.corruptHead("tampered-fixture");await f.manual(); + assert.equal(f.local,"initial-fixture");assert.equal(f.journal,journal);assert.match(f.html,/modifié sans nouvelle révision/); + const before=f.reads.full;await f.tick();assert.equal(f.reads.full,before+1);assert.match(f.html,/modifié sans nouvelle révision/); + }); console.log("N03 offline regression cases: " + count + "/" + (count + failures.length) + " passed; no network or real data."); if (failures.length) throw new AggregateError(failures, failures.length + " N03 regression cases failed"); }