From 1e254340739edf8afbec56a970d7037b6db58e66 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:26:47 +0000 Subject: [PATCH 1/6] test(cloud): cover resume triggers of the automatic backup chain Add offline controller cases for the 'online', 'visibilitychange' and 'pageshow' resume listeners and for session expiry during read/write. These triggers existed in cloud-ui.js but had no test; a mutation that removes the online/visibilitychange listeners now fails 2 cases. Synthetic data only, no network. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E5jwZntqAvJaRThsuEjgja --- tests/cloud-ui-races.cjs | 44 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/cloud-ui-races.cjs b/tests/cloud-ui-races.cjs index 6b94a48..8c022cf 100644 --- a/tests/cloud-ui-races.cjs +++ b/tests/cloud-ui-races.cjs @@ -9,7 +9,8 @@ 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, failHead = false, fullReads=0, headReads=0, clock=0; + let interval, timeout, failWrite = false, failRestore = false, failHead = false, fullReads=0, headReads=0, clock=0, expired=false; + const listeners = {}; const gates = new Map(), nodes = new Map(), writes = []; const node = selector => { if (!nodes.has(selector)) nodes.set(selector, {value: "", innerHTML: "", textContent: ""}); @@ -21,11 +22,12 @@ function fixture() { if (gate) { gates.delete(stage); gate.started.resolve(); await gate.release.promise; } } const client = { - sendCode: async () => {}, verifyCode: async () => "00000000-0000-4000-8000-000000000001", logout: () => {}, - async read() { fullReads++; const result = copy(row); await wait("read"); return result; }, + sendCode: async () => {}, verifyCode: async () => { expired = false; return "00000000-0000-4000-8000-000000000001"; }, logout: () => {}, + async read() { if (expired) throw Error("Session expirée ou révoquée : reconnecte-toi"); 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 (expired) throw Error("Session expirée ou révoquée : reconnecte-toi"); if (failWrite) { failWrite = false; throw Error("Fictitious network failure"); } assert.equal(revision, row ? row.revision : null, "conditional write must use the expected revision"); row = {revision: revision === null ? 1 : revision + 1, payload: copy(payload)}; @@ -36,7 +38,9 @@ function fixture() { const sandbox = { localStorage: {getItem:k=>store.get(k)||null,setItem:(k,v)=>store.set(k,v),removeItem:k=>store.delete(k)}, Date: class extends Date { static now(){return Date.now()+clock;} }, - navigator: {onLine: true}, document: {querySelector: node}, confirm: () => true, + navigator: {onLine: true}, confirm: () => true, + document: {querySelector: node, addEventListener: (type, fn) => { listeners["document:" + type] = fn; }}, + window: {addEventListener: (type, fn) => { listeners["window:" + type] = fn; }}, setInterval: callback => { interval = callback; return 1; }, clearInterval: () => { interval = null; }, setTimeout: callback => { timeout = callback; return 2; }, clearTimeout: () => { timeout = null; }, RC_CLOUD_TRANSPORT: { @@ -72,6 +76,12 @@ function fixture() { manual: () => click("now"), advance: ms => {clock+=ms;}, hidden: value => {sandbox.document.hidden=value;}, + online: value => {sandbox.navigator.onLine=value;}, + // Browser lifecycle events registered by attach(); handlers do not return their promise. + async fire(target, type) { const fn = listeners[target + ":" + type]; assert(fn, target + " " + type + " listener must be registered"); fn({type}); for (let i = 0; i < 20; i++) await new Promise(r => setImmediate(r)); }, + dropTimer: () => { timeout = null; }, + expireSession: () => { expired = true; }, + disconnect: () => click("disconnect"), corruptHead: value => {row.payload.snapshot.progress.dns=value;}, removeCloud: () => {row=null;}, failHead: () => {failHead=true;}, @@ -171,6 +181,32 @@ async function run() { 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/); }); + // Resume triggers (task: automatic backup chain). Each case starts from a durable local edit. + await check("reconnection ('online' event) uploads an edit made while offline", async () => { + const f = fixture(); await f.prepare("automatic"); f.online(false); f.edit("offline-edit"); + await f.pending(); assert.match(f.html, /Hors connexion/); assert.notEqual(f.remoteValue, "offline-edit"); + assert.doesNotMatch(f.html, /À jour sur cet appareil et dans le cloud/); + f.online(true); await f.fire("window", "online"); + assert.equal(f.remoteValue, "offline-edit"); assert.match(f.html, /sauvegardées et chiffrées|À jour/); + }); + for (const [target, type] of [["document", "visibilitychange"], ["window", "pageshow"]]) await check("foreground (" + type + ") resumes an upload whose debounce timer was suspended", async () => { + const f = fixture(); await f.prepare("automatic"); f.hidden(true); f.edit("background-edit"); f.dropTimer(); + assert.notEqual(f.remoteValue, "background-edit"); f.hidden(false); await f.fire(target, type); + assert.equal(f.remoteValue, "background-edit"); assert.equal(f.local, "background-edit"); + }); + for (const stage of ["read", "write"]) await check("session expiry during " + stage + " never acknowledges; local edit is uploaded after re-authentication", async () => { + const f = fixture(); await f.prepare("automatic"); f.edit("edit-before-expiry"); + const writes = f.writes.length; + if (stage === "read") { f.expireSession(); await f.pending(); } + else { const gate = f.pause("write"), sending = f.pending(); await gate.started; f.expireSession(); gate.release(); await sending; + assert.match(f.journal, /"pending":\{/, "an interrupted upload must stay in the durable encrypted outbox"); } + assert.equal(f.writes.length, writes); assert.equal(f.local, "edit-before-expiry"); + assert.match(f.html, /Session expirée/); assert.doesNotMatch(f.html, /À jour sur cet appareil et dans le cloud/); + await f.disconnect(); await f.prepare("relogin"); + if (stage === "read") await f.pending(); // no outbox yet: the dirty snapshot is re-sent by the normal debounce + assert.equal(f.remoteValue, "edit-before-expiry"); assert.equal(f.writes.length, writes + 1); + assert.doesNotMatch(f.journal, /"pending":\{/); + }); 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"); } From e8cd2a12ebfb168ef2d78127ac0cbe15d3222726 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:34:26 +0000 Subject: [PATCH 2/6] fix(cloud): bind progress ciphertext to account and revision (rollback resistance) A malicious or compromised server could serve an OLD progress ciphertext under a HIGHER revision; a non-dirty device decrypted and applied it silently because the AES-GCM AAD was a constant. - New progress envelopes keep the SQL-pinned v1 shape (no migration) but use AAD 'reconversion-control-progress-v2||'. - openProgress() verifies the row's own user/revision; one PBKDF2 derivation, then bound AAD, then (optionally) legacy constant AAD. - Legacy envelopes stay readable until this device acknowledges a bound envelope for the account; then refusal is sticky (meta.bound=true in the per-user journal). A refused legacy head becomes an explicit conflict: only 'send this device' proceeds, after preserving the legacy copy. - Preserve-only copies and explicit history recovery still read legacy. - Session envelopes unchanged. Red: tests/cloud-rollback.cjs 3/9 on 0edcf3f (replay, touch-replay, cross-account, sticky, push cases failed). Green: 9/9. Test helpers that decrypted uploaded rows with the legacy API now use the bound API. Mixed-version: old main client cannot open bound envelopes and never writes over them (conditional PATCH + mandatory preserve before push). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E5jwZntqAvJaRThsuEjgja --- .github/workflows/tests.yml | 1 + TESTING.md | 1 + cloud-transport.js | 27 ++++++- cloud-ui.js | 50 ++++++++---- docs/cloud-security-model.md | 2 + tests/cloud-final-mile-browser.cjs | 7 +- tests/cloud-final-mile.cjs | 12 +++ tests/cloud-oauth-browser.cjs | 2 +- tests/cloud-rollback.cjs | 124 +++++++++++++++++++++++++++++ tests/cloud-ui-browser.cjs | 6 +- tests/cloud-ui-races.cjs | 12 ++- 11 files changed, 216 insertions(+), 28 deletions(-) create mode 100644 tests/cloud-rollback.cjs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3794bce..120994d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -102,6 +102,7 @@ jobs: node tests/cloud-oauth.cjs BROWSER=${{ matrix.browser }} node tests/cloud-oauth-browser.cjs node tests/cloud-final-mile.cjs + node tests/cloud-rollback.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 diff --git a/TESTING.md b/TESTING.md index cc8e404..cb9b735 100644 --- a/TESTING.md +++ b/TESTING.md @@ -72,6 +72,7 @@ Le dossier est conservé lors d’un import ancien qui ne possède pas de champ ```sh node tests/cloud-sync.spec.cjs node tests/cloud-final-mile.cjs +node tests/cloud-rollback.cjs node tests/cloud-history.cjs node tests/cloud-storage.cjs BROWSER=chromium node tests/cloud-final-mile-browser.cjs diff --git a/cloud-transport.js b/cloud-transport.js index 0c63f9b..838cb5f 100644 --- a/cloud-transport.js +++ b/cloud-transport.js @@ -145,7 +145,8 @@ const RC_CLOUD_TRANSPORT = (() => { const cipher=await crypto.subtle.encrypt({name:"AES-GCM",iv,additionalData:te.encode(context)},key,te.encode(raw)); return {v:1,kdf:"PBKDF2-SHA256",iterations:250000,salt:b64(salt),iv:b64(iv),ciphertext:b64(new Uint8Array(cipher))}; } - async function decrypt(envelope,phrase,context){ + // contexts: AES-GCM additional data candidates, tried in order with ONE key derivation. + async function decryptAny(envelope,phrase,contexts){ if(!envelope||envelope.v!==1||envelope.kdf!=="PBKDF2-SHA256"||envelope.iterations!==250000||Object.keys(envelope).sort().join(",")!=="ciphertext,iterations,iv,kdf,salt,v")throw Error("Format de sauvegarde cloud inconnu"); try{ if(typeof phrase!=="string"||phrase.length<12||phrase.length>1024)throw Error("Phrase"); @@ -153,9 +154,27 @@ const RC_CLOUD_TRANSPORT = (() => { if(salt.length!==16||iv.length!==12||cipher.length<16)throw Error("Format"); const material=await crypto.subtle.importKey("raw",te.encode(phrase),"PBKDF2",false,["deriveKey"]); const key=await crypto.subtle.deriveKey({name:"PBKDF2",salt,iterations:250000,hash:"SHA-256"},material,{name:"AES-GCM",length:256},false,["decrypt"]); - const value=JSON.parse(td.decode(await crypto.subtle.decrypt({name:"AES-GCM",iv,additionalData:te.encode(context)},key,cipher)));bounded(value);return value; - }catch(e){throw Error("Déchiffrement impossible : mauvaise phrase ou données altérées");} + for(let index=0;indexencrypt(s,p,progress),open:(s,p)=>decrypt(s,p,progress),sealSession:(s,p)=>encrypt(s,p,auth),openSession:(s,p)=>decrypt(s,p,auth)}); + // Progress envelopes keep the SQL-pinned v1 shape; only the authenticated data changes. + // Bound AAD ties a ciphertext to one account and the exact revision it was written as, + // so a server cannot replay an older (or another account's) ciphertext as a newer head. + function boundContext(ctx){ + if(!ctx||typeof ctx.user!=="string"||!/^[0-9a-f-]{36}$/i.test(ctx.user)||!validRevision(ctx.revision))throw Error("Contexte de sauvegarde invalide"); + return "reconversion-control-progress-v2|"+ctx.user.toLowerCase()+"|"+ctx.revision; + } + async function sealProgress(snapshot,phrase,ctx){return encrypt(snapshot,phrase,boundContext(ctx));} + // legacy:true additionally accepts constant-AAD v1 progress envelopes (pre-binding clients). + async function openProgress(envelope,phrase,ctx){ + const contexts=[boundContext(ctx)].concat(ctx.legacy===true?[progress]:[]); + const opened=await decryptAny(envelope,phrase,contexts);return {data:opened.value,bound:opened.index===0}; + } + return Object.freeze({create,config,serialize,fingerprint,seal:(s,p)=>encrypt(s,p,progress),open:(s,p)=>decrypt(s,p,progress),sealProgress,openProgress,sealSession:(s,p)=>encrypt(s,p,auth),openSession:(s,p)=>decrypt(s,p,auth)}); })(); diff --git a/cloud-ui.js b/cloud-ui.js index d508cd9..97ef24a 100644 --- a/cloud-ui.js +++ b/cloud-ui.js @@ -3,7 +3,7 @@ Durable encrypted outbox; receipts scoped to provider + authenticated user. */ 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,googleIdentity=null; + let env,client,cfg,email="",phrase="",remember=false,user="",scope="",meta={v:1},remote=null,remoteData=null,remoteBound=false,googleIdentity=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,lastFullRead=0; const FULL_READ_MS=600000; const T=()=>RC_CLOUD_TRANSPORT,now=()=>new Date().toISOString(); @@ -16,6 +16,23 @@ const RC_CLOUD_UI = (() => { function guard(ticket){if(ticket!==epoch)throw Error("Opération annulée");} function persist(next){if(next.v!==1)throw Error("Journal cloud incompatible : écriture refusée");save(scope,next);meta=next;} function dirty(){return local()!==baseline;} + function target(expected){return expected===null?1:expected+1;} + // Progress ciphertexts are bound to (account, revision). Legacy constant-AAD envelopes are + // accepted only until this device has acknowledged a bound one for this account (sticky + // meta.bound), or for explicit, preserve-only copies made before an explicit replacement. + function openAt(payload,rev,preserveOnly){return T().openProgress(payload,phrase,{user,revision:rev,legacy:preserveOnly===true||meta.bound!==true});} + // A legacy head refused after a bound ack (older client still writing, or a replay) is never + // applied: it becomes an explicit conflict where only "send this device" can proceed. + async function openHead(latest,ticket){ + try{return await openAt(latest.payload,latest.revision);} + catch(e){ + if(meta.bound!==true)throw e; + try{await openAt(latest.payload,latest.revision,true);}catch(x){throw e;} + guard(ticket);remote=latest;remoteData=null;remoteBound=false;mode="conflict"; + status="Version cloud non liée à ce compte et à cette révision (ancien client ou rejeu) : refusée, aucune donnée locale remplacée. Mets à jour l’autre appareil ou envoie explicitement cet appareil."; + return null; + } + } function delayed(){if(dirty())schedule();} function uploadStatus(message){return message+(dirty()?" De nouvelles modifications locales restent à synchroniser.":"");} function receipt(){return meta.ack?'

Dernière sauvegarde cloud confirmée : '+safe(meta.ack.at)+' · révision '+safe(meta.ack.revision)+'.

':"";} @@ -95,22 +112,22 @@ const RC_CLOUD_UI = (() => { } function loadMeta(){ scope="reconversion-control.cloud.sync.v1:"+cfg.url+":"+user;meta=load(scope)||{v:1}; - if(meta.v!==1||meta.ack&&(!Number.isSafeInteger(meta.ack.revision)||!/^[a-f0-9]{64}$/.test(meta.ack.hash))||meta.pending&&(!/^[a-f0-9]{64}$/.test(meta.pending.hash)||!(meta.pending.expected===null||Number.isSafeInteger(meta.pending.expected))))throw Error("Journal cloud incompatible : récupération manuelle requise"); + if(meta.v!==1||meta.ack&&(!Number.isSafeInteger(meta.ack.revision)||!/^[a-f0-9]{64}$/.test(meta.ack.hash))||meta.pending&&(!/^[a-f0-9]{64}$/.test(meta.pending.hash)||!(meta.pending.expected===null||Number.isSafeInteger(meta.pending.expected)))||meta.bound!==undefined&&meta.bound!==true)throw Error("Journal cloud incompatible : récupération manuelle requise"); } - async function acknowledge(sent,next,ticket,payload){ + async function acknowledge(sent,next,ticket,payload,bound){ 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;lastFullRead=Date.now(); + persist({...meta,ack:{revision:next,hash,payloadHash,at:now()},pending:null,...(bound===true?{bound:true}:{})});revision=next;baseline=sent;retries=0;lastFullRead=Date.now(); } async function flush(ticket){ const job=meta.pending;if(!job)return; - const data=validate(await T().open(job.payload,phrase));guard(ticket); + const opened=await openAt(job.payload,target(job.expected)),data=validate(opened.data);guard(ticket); const sent=T().serialize(data);if(await T().fingerprint(sent)!==job.hash)throw Error("Journal d’envoi altéré");guard(ticket); local(); // A failed/unconfirmed local write must suspend network transmission. - const next=await client.write(job.payload,job.expected);guard(ticket);await acknowledge(sent,next,ticket,job.payload); + const next=await client.write(job.payload,job.expected);guard(ticket);await acknowledge(sent,next,ticket,job.payload,opened.bound); } async function upload(expected,ticket){ if(meta.pending)throw Error("Un envoi précédent doit être résolu avant un nouvel envoi"); - const sent=local(),snapshot=JSON.parse(sent),payload=await T().seal(snapshot,phrase);guard(ticket); + const sent=local(),snapshot=JSON.parse(sent),payload=await T().sealProgress(snapshot,phrase,{user,revision:target(expected)});guard(ticket); const hash=await T().fingerprint(sent);guard(ticket);local(); persist({...meta,pending:{expected,hash,payload}}); await flush(ticket);delayed(); @@ -123,17 +140,18 @@ const RC_CLOUD_UI = (() => { const matches=latest&&latest.revision===next&&T().serialize(latest.payload)===T().serialize(meta.pending.payload); const retry=(!latest&&meta.pending.expected===null)||(latest&&latest.revision===meta.pending.expected); if(matches||retry){await flush(ticket);latest=await client.read();guard(ticket);} - else{remote=latest;remoteData=latest?validate(await T().open(latest.payload,phrase)):null;guard(ticket);mode="conflict";status="Conflit détecté : envoi précédent conservé, décision nécessaire.";return;} + else{remote=latest;remoteBound=false;remoteData=null;if(latest){const opened=await openHead(latest,ticket);if(!opened)return;remoteData=validate(opened.data);remoteBound=opened.bound;}guard(ticket);mode="conflict";status="Conflit détecté : envoi précédent conservé, décision nécessaire.";return;} } remote=latest; if(!latest){mode=meta.ack?"conflict":"initial";remoteData=null;status=meta.ack?"La sauvegarde cloud a disparu. Aucun changement local effacé.":"Connecté et déverrouillé · aucune sauvegarde cloud. Confirmation initiale requise.";return;} - remoteData=validate(await T().open(latest.payload,phrase));guard(ticket); + const opened=await openHead(latest,ticket);if(!opened)return;remoteData=validate(opened.data);remoteBound=opened.bound;guard(ticket); const here=local(),hash=await T().fingerprint(here),there=T().serialize(remoteData);guard(ticket); // No automatic source selection without a durable receipt from this account. if(!meta.ack){mode="choose";status="Une progression cloud existe déjà. Choisis laquelle conserver.";return;} if(latest.revision { guard(ticket);if(local()!==expected)throw Error("Progression locale modifiée pendant la réception"); restoredHere=true;env.restore(valid); const restored=local();if(restored!==T().serialize(valid))throw Error("Relecture restaurée différente"); - await acknowledge(restored,next,ticket,remote&&remote.payload);guard(ticket);persist({...meta,restore:{hash:meta.ack.hash,revision:next}}); + await acknowledge(restored,next,ticket,remote&&remote.payload,remoteBound);guard(ticket);persist({...meta,restore:{hash:meta.ack.hash,revision:next}}); }finally{applying=false;} } async function tick(periodic){ @@ -203,7 +221,7 @@ const RC_CLOUD_UI = (() => { const latest=await client.read();guard(ticket); if(!latest){mode="conflict";remote=remoteData=null;status="La sauvegarde cloud a disparu. Aucun changement local effacé.";return;} if(latest.revision!==revision){ - const decrypted=validate(await T().open(latest.payload,phrase));guard(ticket);remote=latest;remoteData=decrypted; + const opened=await openHead(latest,ticket);if(!opened)return;const decrypted=validate(opened.data);guard(ticket);remote=latest;remoteData=decrypted;remoteBound=opened.bound; if(latest.revision { try{ const before=local(),latest=await client.read();guard(ticket); if(!latest||latest.revision!==remote.revision||T().serialize(latest.payload)!==T().serialize(remote.payload)||local()!==before)throw Error("Une version a changé : revérifie les deux sources"); - if(meta.pending&&env.preserve)env.preserve(validate(await T().open(meta.pending.payload,phrase)));guard(ticket); + if(meta.pending&&env.preserve)env.preserve(validate((await openAt(meta.pending.payload,target(meta.pending.expected),true)).data));guard(ticket); if(local()!==before)throw Error("Progression locale modifiée"); await apply(remoteData,latest.revision,ticket);mode="active";status="Progression cloud relue localement. Restauration après réouverture à vérifier.";activate(); }catch(e){if(ticket===epoch)status="Import cloud refusé : "+e.message;} @@ -245,8 +263,8 @@ const RC_CLOUD_UI = (() => { try{ const latest=await client.read();guard(ticket); if((!latest&&remote)||latest&&(!remote||latest.revision!==remote.revision))throw Error("La version cloud a encore changé. Revérifie et réessaie."); - if(latest&&env.preserve)env.preserve(validate(await T().open(latest.payload,phrase)));guard(ticket); - if(meta.pending&&env.preserve)env.preserve(validate(await T().open(meta.pending.payload,phrase)));guard(ticket); + if(latest&&env.preserve)env.preserve(validate((await openAt(latest.payload,latest.revision,true)).data));guard(ticket); + if(meta.pending&&env.preserve)env.preserve(validate((await openAt(meta.pending.payload,target(meta.pending.expected),true)).data));guard(ticket); persist({...meta,pending:null});await upload(latest?latest.revision:null,ticket); mode="active";status=uploadStatus("Progression de cet appareil envoyée. Synchronisation automatique active.");activate();delayed(); }catch(e){if(ticket===epoch)status="Envoi refusé : "+e.message;} @@ -262,7 +280,7 @@ const RC_CLOUD_UI = (() => { const r=historyRows[index];if(busy||!r||!confirm("Récupérer cette ancienne révision localement ? La synchronisation sera suspendue jusqu’à une nouvelle décision."))return; busy=true;const ticket=epoch; try{ - const before=local(),saved=await client.historyVersion(r.revision);guard(ticket);const data=validate(await T().open(saved.payload,phrase));guard(ticket);if(local()!==before)throw Error("Progression locale modifiée"); + const before=local(),saved=await client.historyVersion(r.revision);guard(ticket);const data=validate((await openAt(saved.payload,r.revision,true)).data);guard(ticket);if(local()!==before)throw Error("Progression locale modifiée"); if(env.preserve)env.preserve(JSON.parse(before));restoredHere=true;env.restore(data); if(local()!==T().serialize(data))throw Error("Relecture restaurée différente"); mode="conflict";baseline="";status="Ancienne révision relue localement. Exporter puis revérifier les sources avant toute synchronisation."; @@ -271,7 +289,7 @@ const RC_CLOUD_UI = (() => { } async function disconnect(){ const ticket=++epoch,previous=client;if(timer)clearInterval(timer);if(pending)clearTimeout(pending);timer=pending=null; - client=null;phrase="";email="";user="";googleIdentity=null;remote=remoteData=null;revision=null;baseline="";meta={v:1};historyRows=[];busy=false;mode="setup"; + client=null;phrase="";email="";user="";googleIdentity=null;remote=remoteData=null;remoteBound=false;revision=null;baseline="";meta={v:1};historyRows=[];busy=false;mode="setup"; try{remove(SESSION);status="Déconnecté localement. Données et journal de récupération conservés.";} catch(e){status="Session en mémoire fermée ; suppression durable NON confirmée. Efface la session mémorisée avant de quitter cet appareil.";} repaint();if(previous){const revoked=await previous.logout();if(ticket===epoch&&!revoked){status+=" Révocation serveur non confirmée : reconnecte-toi au fournisseur pour révoquer la session.";repaint();}} diff --git a/docs/cloud-security-model.md b/docs/cloud-security-model.md index c27db2c..fdc2f82 100644 --- a/docs/cloud-security-model.md +++ b/docs/cloud-security-model.md @@ -27,6 +27,8 @@ Le stockage canonique actuel est localStorage, pas IndexedDB. Il contient la pro - 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. +- Liaison au contexte (rejeu/rollback) : les nouvelles enveloppes de progression gardent la forme V1 imposée par la contrainte SQL, mais leur AAD AES-GCM vaut `reconversion-control-progress-v2||`. Une ancienne enveloppe resservie sous une révision supérieure, ou l'enveloppe d'un autre compte, ne s'ouvre plus et n'est jamais appliquée. Les enveloppes historiques à AAD constante restent lisibles tant que cet appareil n'a acquitté aucune enveloppe liée pour ce compte ; ensuite le refus est persistant (`bound:true` dans le journal par compte). Une tête au format ancien refusée devient un conflit explicite : seul « Envoyer cet appareil » est possible, après copie locale de l'ancienne version. Les copies de préservation et la récupération explicite d'une version d'historique acceptent encore l'ancien format. Les enveloppes de session ne changent pas. +- Limite assumée : sans racine de confiance externe, aucun client ne peut prouver la fraîcheur absolue. Un appareil neuf (sans reçu) ne détecte ni un gel du serveur ni une ancienne enveloppe au format historique ; il exige seulement un choix explicite. Un serveur peut toujours retenir ou supprimer des versions (disponibilité). Pendant la transition, un client antérieur ne sait pas lire une enveloppe liée : il affiche « Déchiffrement impossible », conserve sa progression locale et n'écrase rien (écritures conditionnelles, préservation obligatoire avant envoi explicite) ; il faut le mettre à jour. - 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. diff --git a/tests/cloud-final-mile-browser.cjs b/tests/cloud-final-mile-browser.cjs index 1882b12..0cdb3a4 100644 --- a/tests/cloud-final-mile-browser.cjs +++ b/tests/cloud-final-mile-browser.cjs @@ -53,7 +53,7 @@ async function run(){ 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); + const remote=await pc.evaluate(async({p,phrase,ctx})=>(await RC_CLOUD_TRANSPORT.openProgress(p,phrase,ctx)).data,{p:row.payload,phrase:PHRASE,ctx:{user:USER,revision:row.revision}});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); @@ -108,12 +108,13 @@ async function run(){ console.log("PASS browser cloud: durable journal failure blocks upload; saved local edit resumes after reload"); // Valid but empty higher revision still requires an explicit source decision. const beforeEmpty=await snapshot(pc); - row={revision:row.revision+1,payload:await pc.evaluate(async phrase=>RC_CLOUD_TRANSPORT.seal({version:2,progress:{},review:{},labs:{},proofs:{},labChecks:{},dailyFive:{days:{}},career:RC_PROJECT_PLAN.empty()},phrase),PHRASE)}; + // Another up-to-date client writes the next revision (bound to account + revision). + row={revision:row.revision+1,payload:await pc.evaluate(async({phrase,ctx})=>RC_CLOUD_TRANSPORT.sealProgress({version:2,progress:{},review:{},labs:{},proofs:{},labChecks:{},dailyFive:{days:{}},career:RC_PROJECT_PLAN.empty()},phrase,ctx),{phrase:PHRASE,ctx:{user:USER,revision:row.revision+1}})}; await click(pc,"now");assert.deepEqual(await snapshot(pc),beforeEmpty);assert.match(await pc.locator("#rc-sync-status").textContent(),/vide nécessite une sélection explicite/); await click(pc,"push"); console.log("PASS browser cloud: an empty higher revision cannot silently replace a complete local snapshot"); // Future remote format is rejected without writes, including a new client. - const stable=await snapshot(pc);row={revision:row.revision+1,payload:await pc.evaluate(async phrase=>RC_CLOUD_TRANSPORT.seal({version:99,progress:{}},phrase),PHRASE)}; + const stable=await snapshot(pc);row={revision:row.revision+1,payload:await pc.evaluate(async({phrase,ctx})=>RC_CLOUD_TRANSPORT.sealProgress({version:99,progress:{}},phrase,ctx),{phrase:PHRASE,ctx:{user:USER,revision:row.revision+1}})}; await click(pc,"now");assert.deepEqual(await snapshot(pc),stable);assert.match(await pc.locator("#rc-sync-status").textContent(),/incompatible/); // Recover an older valid cloud version through the existing transport, explicitly. await click(pc,"history");await pc.evaluate(()=>document.querySelector("[data-sync-history]").onclick());assert.equal((await read(pc)).main.progress.dns,stable.progress.dns);assert.match(await pc.locator("#rc-sync-status").textContent(),/Ancienne révision/); diff --git a/tests/cloud-final-mile.cjs b/tests/cloud-final-mile.cjs index eae2e3f..931937d 100644 --- a/tests/cloud-final-mile.cjs +++ b/tests/cloud-final-mile.cjs @@ -46,6 +46,18 @@ async function run(){ for(const field of ["iv","salt","ciphertext"]){const changed={...x,[field]:(x[field][0]==="A"?"B":"A")+x[field].slice(1)};await assert.rejects(T.open(changed,phrase));} await assert.rejects(T.open({...x,v:2},phrase));await assert.rejects(T.open({...x,unknown:true},phrase));await assert.rejects(T.open({...x,ciphertext:"A".repeat(3000000)},phrase)); }); + await check("bound progress envelope keeps the SQL-pinned v1 shape and opens only for its own account and revision",async()=>{ + const {T}=fixture(),data={version:2,progress:{dns:"bound-fixture"}},x=await T.sealProgress(data,phrase,{user:a,revision:7}); + assert.deepEqual(Object.keys(x).sort(),["ciphertext","iterations","iv","kdf","salt","v"]);assert.equal(x.v,1);assert.equal(x.iterations,250000);assert.match(x.salt,/^[A-Za-z0-9+/]{22}==$/);assert.match(x.iv,/^[A-Za-z0-9+/]{16}$/); + assert.deepEqual(clone(await T.openProgress(x,phrase,{user:a,revision:7})),{data,bound:true}); + assert.deepEqual(clone(await T.openProgress(x,phrase,{user:a.toUpperCase(),revision:7,legacy:true})),{data,bound:true}); + for(const ctx of [{user:a,revision:8},{user:a,revision:6},{user:b,revision:7},{user:a,revision:8,legacy:true}])await assert.rejects(T.openProgress(x,phrase,ctx),/rejouée/); + await assert.rejects(T.open(x,phrase),/Déchiffrement/,"a pre-binding client cannot read (and so never applies) a bound envelope"); + const legacy=await T.seal(data,phrase); + assert.deepEqual(clone(await T.openProgress(legacy,phrase,{user:a,revision:3,legacy:true})),{data,bound:false}); + await assert.rejects(T.openProgress(legacy,phrase,{user:a,revision:3}),/rejouée/); + for(const ctx of [null,{user:"x",revision:1},{user:a,revision:0},{user:a,revision:"1"}])await assert.rejects(T.sealProgress(data,phrase,ctx),/Contexte/); + }); await check("serialization freezes before KDF and supports large histories without spread overflow",async()=>{ const {T}=fixture(),data={version:2,progress:{dns:"progress"},history:"x".repeat(300000)}; const pending=T.seal(data,phrase);data.progress.dns="mastered";const sealed=await pending; diff --git a/tests/cloud-oauth-browser.cjs b/tests/cloud-oauth-browser.cjs index 82fb8f2..cfad991 100644 --- a/tests/cloud-oauth-browser.cjs +++ b/tests/cloud-oauth-browser.cjs @@ -66,7 +66,7 @@ async function run(){ assert.equal(pcContext.calls.filter(r=>r.path.startsWith("/rest/")).length,0,"OAuth alone cannot download progress");assert.equal(row,null); const preUnlock=await pc.evaluate(()=>JSON.stringify({...localStorage})+JSON.stringify({...sessionStorage}));assert(!/OAUTH_ACCESS_CANARY|OAUTH_REFRESH_CANARY|GOOGLE_TOKEN_CANARY/.test(preUnlock)); await unlockGoogle(pc);await pc.locator("#rc-sync-create").waitFor();assert.equal(row,null);await pc.locator("#rc-sync-create").click();await pc.waitForFunction(()=>document.querySelector("#rc-sync-status")?.textContent.includes("confirmée"));assert.equal(row.revision,1); - const decoded=await pc.evaluate(async({payload,phrase})=>RC_CLOUD_TRANSPORT.open(payload,phrase),{payload:row.payload,phrase:PHRASE});assert.equal(decoded.progress.dns,"mastered");assert(!JSON.stringify(row).includes("mastered")); + const decoded=await pc.evaluate(async({payload,phrase,ctx})=>(await RC_CLOUD_TRANSPORT.openProgress(payload,phrase,ctx)).data,{payload:row.payload,phrase:PHRASE,ctx:{user:A,revision:row.revision}});assert.equal(decoded.progress.dns,"mastered");assert(!JSON.stringify(row).includes("mastered")); console.log("PASS Google/browser: PKCE return, account confirmation, independent phrase and explicit first encrypted backup with readback"); const phoneContext=await context(A,true),phone=await page(phoneContext);await google(phone);await unlockGoogle(phone);await phone.locator("#rc-sync-take").waitFor();await phone.locator("#rc-sync-take").click();await phone.waitForFunction(()=>JSON.parse(localStorage.getItem("reconversion-control.snapshot.v3")).main.progress.dns==="mastered"); const restored=await snapshot(phone);assert.deepEqual(restored.main.progress,initial.main.progress); diff --git a/tests/cloud-rollback.cjs b/tests/cloud-rollback.cjs new file mode 100644 index 0000000..f1fce21 --- /dev/null +++ b/tests/cloud-rollback.cjs @@ -0,0 +1,124 @@ +"use strict"; +// Rollback/replay resistance of encrypted progress envelopes. Real cloud-transport.js +// crypto + real cloud-ui.js controller; the Supabase server is simulated in memory and +// may behave maliciously (replays an older or foreign ciphertext under a new revision). +// Every account, phrase and progression below is fictitious. No network. +const assert=require("node:assert/strict"),fs=require("node:fs"),path=require("node:path"),vm=require("node:vm"),{webcrypto}=require("node:crypto"); +const A="11111111-1111-4111-8111-111111111111",B="22222222-2222-4222-8222-222222222222",PHRASE="fictional-rollback-phrase-2026"; +const clone=v=>JSON.parse(JSON.stringify(v)); +const settle=async()=>{for(let i=0;i<40;i++)await new Promise(r=>setImmediate(r));}; + +function server(){ + const rows=new Map(),history=[]; + async function fetch(input,init){ + const url=new URL(input),body=init.body?JSON.parse(init.body):{},token=(init.headers.Authorization||"").slice(7); + const reply=(status,j)=>({ok:status>=200&&status<300,status,text:async()=>JSON.stringify(j)}); + if(url.pathname==="/auth/v1/otp")return reply(200,{}); + if(url.pathname==="/auth/v1/verify"){const id=body.email.startsWith("b")?B:A;return reply(200,{access_token:id,refresh_token:"fictional-refresh",expires_in:3600,user:{id}});} + if(url.pathname==="/auth/v1/user")return reply(200,{id:token}); + if(url.pathname==="/auth/v1/logout")return reply(204,null); + if(url.pathname==="/rest/v1/reconversion_progress_history"){const want=url.searchParams.get("revision");return reply(200,history.filter(r=>r.user===token&&(!want||want==="eq."+r.revision)).reverse().map(r=>({revision:r.revision,payload:r.payload})));} + if(url.pathname!=="/rest/v1/reconversion_progress")return reply(404,{}); + if(init.method==="GET"){const row=rows.get(token);return reply(200,row?[url.searchParams.get("select")==="revision"?{revision:row.revision}:clone(row)]:[]);} + let current=rows.get(token); + if(init.method==="POST"){if(current)return reply(409,{});current={revision:1,payload:body.payload};} + else{if(!current||url.searchParams.get("revision")!=="eq."+current.revision)return reply(200,[]);current={revision:current.revision+1,payload:body.payload};} + rows.set(token,current);history.push({user:token,...clone(current)});return reply(200,[clone(current)]); + } + return {rows,history,fetch, + // Malicious server: serve `payload` as the next revision of `user` (no client write). + replay(user,payload){const head=rows.get(user);rows.set(user,{revision:head.revision+1,payload:clone(payload)});}}; +} + +// sources: optional {transport, ui} source text (used to replay older client versions). +function device(srv,mail="a@example.invalid",store=new Map(),sources){ + let state={version:2,progress:{dns:"initial-fixture"}},timeout=null;const preserved=[];const nodes=new Map(),listeners={}; + const node=s=>{if(!nodes.has(s))nodes.set(s,{value:"",innerHTML:"",textContent:"",checked:false});return nodes.get(s);}; + for(const [id,value] of Object.entries({url:"https://fixture.supabase.co",key:"sb_publishable_fixture",email:mail,otp:"123456",phrase:PHRASE}))node("#rc-sync-"+id).value=value; + const sandbox={URL,crypto:webcrypto,TextEncoder,TextDecoder,fetch:srv.fetch,Date, + btoa:s=>Buffer.from(s,"binary").toString("base64"),atob:s=>Buffer.from(s,"base64").toString("binary"), + localStorage:{getItem:k=>store.has(k)?store.get(k):null,setItem:(k,v)=>store.set(k,String(v)),removeItem:k=>store.delete(k)}, + navigator:{onLine:true},confirm:()=>true, + document:{querySelector:node,addEventListener:(t,f)=>{listeners[t]=f;}},window:{addEventListener:(t,f)=>{listeners[t]=f;}}, + setInterval:()=>1,clearInterval:()=>{},setTimeout:fn=>{timeout=fn;return 2;},clearTimeout:()=>{timeout=null;}}; + vm.createContext(sandbox); + const root=path.join(__dirname,".."); + const src=sources||{transport:fs.readFileSync(path.join(root,"cloud-transport.js"),"utf8"),ui:fs.readFileSync(path.join(root,"cloud-ui.js"),"utf8")}; + vm.runInContext(src.transport+"\n"+src.ui+"\nglobalThis.T=RC_CLOUD_TRANSPORT;globalThis.UI=RC_CLOUD_UI;",sandbox); + const api=sandbox.UI.attach({escape:s=>String(s).replaceAll("&","&").replaceAll("<","<"),snapshot:()=>clone(state),restore:d=>{state=clone(d);},preserve:d=>{preserved.push(clone(d));}}); + api.bind(); + const click=async id=>{await node("#rc-sync-"+id).onclick();await settle();}; + return {T:sandbox.T,store,preserved, + async login(){await click("request");await click("verify");}, + create:()=>click("create"),take:()=>click("take"),push:()=>click("push"),now:()=>click("now"), + async edit(v){state.progress.dns=v;api.changed();assert(timeout,"a local edit must schedule an upload");const run=timeout;timeout=null;await run();await settle();}, + get local(){return state.progress.dns;},get html(){return node("#rc-sync").innerHTML;}, + get meta(){for(const [k,v] of store)if(k.startsWith("reconversion-control.cloud.sync.v1:"))return JSON.parse(v);return null;}}; +} +const open=(T,payload)=>T.open(payload,PHRASE); + +async function run(){ + let passed=0;const failures=[]; + async function check(name,fn){try{await fn();passed++;console.log("PASS rollback: "+name);}catch(e){failures.push(e);console.error("FAIL rollback: "+name+": "+e.message);}} + async function acknowledged(){ // device A: rev1 = "A1", rev2 = "A2"; both acknowledged here. + const srv=server(),a=device(srv);await a.login();await a.create();await a.edit("A2"); + assert.equal(srv.rows.get(A).revision,2);assert.equal(a.meta.ack.revision,2);return {srv,a}; + } + await check("an older ciphertext replayed under a higher revision is refused and never applied",async()=>{ + const {srv,a}=await acknowledged();srv.replay(A,srv.history.find(r=>r.revision===1).payload); + await a.now();assert.equal(a.local,"A2","a replayed older snapshot must not replace local progress"); + assert.equal(a.meta.ack.revision,2,"a replay must never advance the acknowledgement"); + assert.doesNotMatch(a.html,/À jour sur cet appareil et dans le cloud/);assert.match(a.html,/refus|impossible/i); + }); + await check("the current ciphertext replayed under the next revision is refused (no 'touch' rollback)",async()=>{ + const {srv,a}=await acknowledged();await a.edit("A3");srv.replay(A,srv.rows.get(A).payload); + await a.now();assert.equal(a.local,"A3");assert.equal(a.meta.ack.revision,3); + }); + await check("another account's ciphertext (same phrase) is refused for this account",async()=>{ + const {srv,a}=await acknowledged(),b=device(srv,"b@example.invalid");await b.login();await b.create(); + srv.replay(A,srv.rows.get(B).payload);await a.now();assert.equal(a.local,"A2");assert.equal(a.meta.ack.revision,2); + }); + await check("legitimate next revision from another up-to-date device is applied",async()=>{ + const {srv,a}=await acknowledged(),pc=device(srv);await pc.login();await pc.take();assert.equal(pc.local,"A2"); + await pc.edit("PC3");assert.equal(srv.rows.get(A).revision,3);await a.now();assert.equal(a.local,"PC3");assert.equal(a.meta.ack.revision,3); + }); + await check("legacy constant-AAD envelopes remain readable on a device that never acknowledged a bound one",async()=>{ + const srv=server(),seed=device(srv);const legacy=await seed.T.seal({version:2,progress:{dns:"legacy-cloud"}},PHRASE); + srv.rows.set(A,{revision:4,payload:legacy});const a=device(srv);await a.login();assert.match(a.html,/Choisis/); + await a.take();assert.equal(a.local,"legacy-cloud");assert.equal(a.meta.ack.revision,4);assert.notEqual(a.meta.bound,true); + srv.replay(A,await a.T.seal({version:2,progress:{dns:"legacy-old-client-5"}},PHRASE));await a.now(); + assert.equal(a.local,"legacy-old-client-5","an old client's legacy write is still accepted during the transition"); + }); + await check("sticky: after the first bound acknowledgement, legacy envelopes for this account are refused",async()=>{ + const {srv,a}=await acknowledged(); + srv.replay(A,await a.T.seal({version:2,progress:{dns:"legacy-after-bound"}},PHRASE)); + await a.now();assert.equal(a.local,"A2","legacy (unbound) envelope must be refused once a bound one was acknowledged"); + assert.equal(a.meta.ack.revision,2); + // The refusal survives a full restart: the flag lives in the durable per-user journal. + const again=device(srv,"a@example.invalid",a.store);await again.login();assert.equal(again.meta.ack.revision,2);assert.notEqual(again.local,"legacy-after-bound"); + }); + await check("explicit push never blindly overwrites a head it cannot open as this account/revision",async()=>{ + const {srv,a}=await acknowledged();srv.replay(A,srv.history.find(r=>r.revision===1).payload); + await a.now();const head=clone(srv.rows.get(A));await a.push(); + assert.deepEqual(srv.rows.get(A),head,"no write over an unverifiable head");assert.equal(a.local,"A2"); + }); + await check("sticky legacy refusal still lets the user explicitly keep this device, preserving the legacy copy first",async()=>{ + const {srv,a}=await acknowledged();srv.replay(A,await a.T.seal({version:2,progress:{dns:"legacy-old-client"}},PHRASE)); + await a.now();assert.equal(a.local,"A2");const before=srv.rows.get(A).revision;await a.push(); + assert.equal(srv.rows.get(A).revision,before+1);assert(a.preserved.some(d=>d.progress.dns==="legacy-old-client"),"replaced legacy copy preserved locally"); + assert.equal(a.meta.ack.revision,before+1);assert.equal(a.meta.bound,true); + }); + await check("fresh device limitation: no external root of trust, so a legacy envelope under any revision cannot be proven fresh",async()=>{ + // Documented, not a guarantee: a device with no acknowledgement has nothing to compare against. + // It never auto-applies (explicit choice is required), but it cannot detect a stale legacy copy. + const srv=server(),seed=device(srv);srv.rows.set(A,{revision:9,payload:await seed.T.seal({version:2,progress:{dns:"possibly-stale"}},PHRASE)}); + const fresh=device(srv);await fresh.login();assert.match(fresh.html,/Choisis/);assert.equal(fresh.local,"initial-fixture"); + }); + console.log("Rollback/replay resistance: "+passed+"/"+(passed+failures.length)+" passed; simulated server only, fictitious data."); + if(failures.length)throw new AggregateError(failures,failures.length+" rollback cases failed"); +} +module.exports={server,device,A,B,PHRASE}; +if(require.main===module){ + const watchdog=setTimeout(()=>{throw Error("Rollback suite stalled");},120000); + run().catch(e=>{console.error(e.message);process.exitCode=1;}).finally(()=>clearTimeout(watchdog)); +} diff --git a/tests/cloud-ui-browser.cjs b/tests/cloud-ui-browser.cjs index aa46929..472fc14 100644 --- a/tests/cloud-ui-browser.cjs +++ b/tests/cloud-ui-browser.cjs @@ -46,7 +46,8 @@ async function run(browser, server) { await page.locator('[data-id="dns"][data-prog="' + value + '"]').click(); await page.locator('#nav [data-view="privacy"]').click(); }; - const open = payload => page.evaluate(({payload, phrase}) => RC_CLOUD_TRANSPORT.open(payload, phrase), {payload, phrase: PHRASE}); + // Uploaded envelopes are bound to (account, revision); the seeded "choice" row is legacy. + const open = (payload, revision = row.revision) => page.evaluate(async ({payload, phrase, ctx}) => (await RC_CLOUD_TRANSPORT.openProgress(payload, phrase, ctx)).data, {payload, phrase: PHRASE, ctx: {user: "00000000-0000-4000-8000-000000000001", revision, legacy: true}}); if (kind === "choice") row = {revision: 4, payload: await page.evaluate(phrase => RC_CLOUD_TRANSPORT.seal({version: 2, progress: {dns: "learn"}, review: {}, labs: {}, proofs: {}, labChecks: {}, dailyFive: {days:{}}, career: RC_PROJECT_PLAN.empty()}, phrase), PHRASE)}; await page.evaluate(() => document.querySelector("#rc-sync-verify").onclick()); if(kind === "automatic") { page.once("dialog", dialog => dialog.accept()); await page.evaluate(() => document.querySelector("#rc-sync-create").onclick()); } @@ -63,7 +64,8 @@ async function run(browser, server) { assert.equal(await page.evaluate(key => JSON.parse(localStorage.getItem(key)).progress.dns, KEY), "mastered"); assert.match(await page.locator("#rc-sync-status").textContent(), /restent à synchroniser/); if (kind === "automatic") { - const incoming = await page.evaluate(async ({phrase, payload}) => { const data = await RC_CLOUD_TRANSPORT.open(payload, phrase); data.progress.dns = "learn"; return RC_CLOUD_TRANSPORT.seal(data, phrase); }, {phrase: PHRASE, payload: row.payload}); + const user = "00000000-0000-4000-8000-000000000001"; + const incoming = await page.evaluate(async ({phrase, payload, user, revision}) => { const data = (await RC_CLOUD_TRANSPORT.openProgress(payload, phrase, {user, revision})).data; data.progress.dns = "learn"; return RC_CLOUD_TRANSPORT.sealProgress(data, phrase, {user, revision: revision + 1}); }, {phrase: PHRASE, payload: row.payload, user, revision: row.revision}); row = {revision: row.revision + 1, payload: incoming}; await page.evaluate(() => document.querySelector("#rc-sync-now").onclick()); assert.match(await page.locator("#rc-sync-status").textContent(), /Conflit détecté/); diff --git a/tests/cloud-ui-races.cjs b/tests/cloud-ui-races.cjs index 8c022cf..4e275da 100644 --- a/tests/cloud-ui-races.cjs +++ b/tests/cloud-ui-races.cjs @@ -48,7 +48,14 @@ function fixture() { serialize: JSON.stringify, fingerprint: async s => require("node:crypto").createHash("sha256").update(s).digest("hex"), // Serialize AFTER the wait, just as real seal() does after its asynchronous KDF. async seal(snapshot) { await wait("seal"); return {snapshot: copy(snapshot)}; }, - async open(payload) { await wait("open"); return copy(payload.snapshot); } + async open(payload) { await wait("open"); return copy(payload.snapshot); }, + // Simulated (account, revision) binding; payloads without ctx model legacy envelopes. + async sealProgress(snapshot, phrase, ctx) { await wait("seal"); return {snapshot: copy(snapshot), ctx: ctx.user + "|" + ctx.revision}; }, + async openProgress(payload, phrase, ctx) { + await wait("open"); const bound = payload.ctx === ctx.user + "|" + ctx.revision; + if (!bound && !(ctx.legacy === true && payload.ctx === undefined)) throw Error("Déchiffrement impossible : sauvegarde rejouée"); + return {data: copy(payload.snapshot), bound}; + } } }; vm.createContext(sandbox); @@ -71,7 +78,8 @@ function fixture() { send: kind => kind === "first" ? click("create") : kind === "choice" ? click("push") : interval(), edit(value) { state.progress.dns = value; api.changed(); }, 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}}}}; }, + // Another up-to-date (bound) client writes the next revision. + remote(value) { const revision = (row ? row.revision : 0) + 1; row = {revision, payload: {snapshot: {version: 2, progress: {dns: value}}, ctx: "00000000-0000-4000-8000-000000000001|" + revision}}; }, tick: () => interval(), manual: () => click("now"), advance: ms => {clock+=ms;}, From 5fc120a9bfad6d1e9a8d90257356f5ff324948cf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:36:05 +0000 Subject: [PATCH 3/6] feat(history): prepare daily retention migration (last 20 + latest per day, 30 days) History eviction kept only the last 20 revisions per user, so a burst of 25 quick edits evicted every earlier day. Prepared (NOT applied) migration 20261028120000_history_daily_retention.sql replaces only the eviction rule of the private trigger function (CREATE OR REPLACE, idempotent, guarded). RLS policies, grants, CHECK constraints, +1 revision rule unchanged. Red: cloud-history.cjs without the migration -> retention assertion fails (revisions 3 and 4 from earlier days evicted). Green: 11/11 groups, including policies/grants snapshot equality, double application, and clients still unable to delete/update/insert history (42501). Client history listing raised from 20 to 50 rows (20 + 30 days) so the retained daily versions are reachable; red 20 != 50, green 14/14. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E5jwZntqAvJaRThsuEjgja --- cloud-transport.js | 4 +- docs/cloud-security-model.md | 2 +- docs/cloud-storage-measures.md | 2 + ...20261028120000_history_daily_retention.sql | 55 +++++++++++++++++++ tests/cloud-final-mile.cjs | 10 +++- tests/cloud-history.cjs | 33 +++++++++++ 6 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 supabase/migrations/20261028120000_history_daily_retention.sql diff --git a/cloud-transport.js b/cloud-transport.js index 838cb5f..037793c 100644 --- a/cloud-transport.js +++ b/cloud-transport.js @@ -124,8 +124,8 @@ const RC_CLOUD_TRANSPORT = (() => { } async function history(){ if(!session)throw Error("Non connecté"); - const rows=await request("/rest/v1/reconversion_progress_history?user_id=eq."+encodeURIComponent(session.user)+"&select=revision&order=revision.desc&limit=20",{secure:true}); - if(!Array.isArray(rows)||rows.length>20||rows.some(r=>!validRevision(r.revision)))throw Error("Historique invalide");return rows.map(r=>({revision:r.revision})); + const rows=await request("/rest/v1/reconversion_progress_history?user_id=eq."+encodeURIComponent(session.user)+"&select=revision&order=revision.desc&limit=50",{secure:true}); + if(!Array.isArray(rows)||rows.length>50||rows.some(r=>!validRevision(r.revision)))throw Error("Historique invalide");return rows.map(r=>({revision:r.revision})); } async function historyVersion(revision){ if(!session||!validRevision(revision))throw Error("Révision inaccessible"); diff --git a/docs/cloud-security-model.md b/docs/cloud-security-model.md index fdc2f82..543f629 100644 --- a/docs/cloud-security-model.md +++ b/docs/cloud-security-model.md @@ -17,7 +17,7 @@ Ce modèle reprend les mécanismes existants ; il ne certifie pas un service per | 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 | +| Historique / corruption, effacement | 20 révisions par propriétaire (préparé, non appliqué : + dernière révision de chacun des 30 derniers jours UTC, `20261028120000_history_daily_retention.sql`), 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. diff --git a/docs/cloud-storage-measures.md b/docs/cloud-storage-measures.md index 050ac64..0666c48 100644 --- a/docs/cloud-storage-measures.md +++ b/docs/cloud-storage-measures.md @@ -47,6 +47,8 @@ Ce sont des extrapolations des corps mesurés, pas un relevé réseau ni une fac 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. +Migration préparée (non appliquée) `20261028120000_history_daily_retention.sql` : la borne devient 20 révisions + au plus une révision par jour UTC sur 30 jours, soit 50 instantanés au maximum par compte (≈ 2,5× la borne actuelle dans le pire cas). Re-mesurer avec `tests/cloud-storage.cjs` avant toute application. + ## 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. diff --git a/supabase/migrations/20261028120000_history_daily_retention.sql b/supabase/migrations/20261028120000_history_daily_retention.sql new file mode 100644 index 0000000..61b1fd0 --- /dev/null +++ b/supabase/migrations/20261028120000_history_daily_retention.sql @@ -0,0 +1,55 @@ +-- PREPARED ONLY. Do NOT apply to a hosted project without explicit GO ADMIN. +-- Execute first in an isolated test database (see tests/cloud-history.cjs). +-- Requires 20261024112625_encrypted_cloud_history.sql. Reads/changes no personal rows itself. +-- +-- History retention: keep the last 20 revisions PLUS the latest revision of each of the +-- last 30 UTC calendar days (today included), per user. Previously only the last 20 were +-- kept, so 25 quick edits in one session evicted every earlier day. +-- +-- Only the eviction rule of the existing private trigger function changes. Unchanged: +-- table, RLS policies, grants (history stays SELECT-only for authenticated owners), envelope +-- CHECK, revision +1 rule, ownership checks. Idempotent: safe to run more than once. +begin; +do $$ begin + if to_regclass('public.reconversion_progress_history') is null + or to_regprocedure('rc_backup_private.capture_progress()') is null + or not exists(select 1 from pg_trigger where tgname='progress_capture' and tgrelid='public.reconversion_progress'::regclass and not tgisinternal) then + raise exception 'Base history migration missing: apply 20261024112625_encrypted_cloud_history.sql first'; + end if; +end $$; + +create or replace function rc_backup_private.capture_progress() returns trigger + language plpgsql security definer set search_path='' as $$ +begin + if auth.uid() is null or auth.uid()<>new.user_id or coalesce(auth.jwt()->>'is_anonymous','false')<>'false' then + raise exception 'Authenticated owner required' using errcode='42501'; + end if; + if tg_op='INSERT' then + if new.revision<>1 then raise exception 'Initial revision must be 1' using errcode='23514'; end if; + else + if new.user_id<>old.user_id or new.revision<>old.revision+1 then + raise exception 'Revision must advance exactly once' using errcode='23514'; + end if; + insert into public.reconversion_progress_history(user_id,revision,payload) values(old.user_id,old.revision,old.payload) + on conflict(user_id,revision) do nothing; + end if; + new.updated_at=now(); + insert into public.reconversion_progress_history(user_id,revision,payload) values(new.user_id,new.revision,new.payload); + delete from public.reconversion_progress_history h + where h.user_id=new.user_id + and h.revision not in + (select keep.revision from public.reconversion_progress_history keep + where keep.user_id=new.user_id order by keep.revision desc limit 20) + and h.revision not in + (select distinct on ((daily.saved_at at time zone 'UTC')::date) daily.revision + from public.reconversion_progress_history daily + where daily.user_id=new.user_id + and (daily.saved_at at time zone 'UTC')::date >= (now() at time zone 'UTC')::date - 29 + order by (daily.saved_at at time zone 'UTC')::date, daily.revision desc); + return new; +end $$; +-- Re-assert (idempotent): never callable by API roles; only the trigger runs it. +revoke all on function rc_backup_private.capture_progress() from public, anon, authenticated; +commit; +-- Rollback path: re-run the function body from 20261024112625_encrypted_cloud_history.sql +-- (last-20 rule) with CREATE OR REPLACE. Rows already kept stay until the next write evicts them. diff --git a/tests/cloud-final-mile.cjs b/tests/cloud-final-mile.cjs index 931937d..0911e54 100644 --- a/tests/cloud-final-mile.cjs +++ b/tests/cloud-final-mile.cjs @@ -6,7 +6,7 @@ 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,headOverride; + const rows=new Map(),calls=[];let lost=false,failRead=false,empty=false,expired=false,refreshes=0,revoke=false,anon=false,clock=0,headOverride,historyRows=[]; 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,select:url.searchParams.get("select")}); @@ -18,6 +18,7 @@ function fixture(){ } if(url.pathname==="/auth/v1/user")return response(200,{id:token.slice(7)}); if(url.pathname==="/auth/v1/logout")return response(204,null); + if(url.pathname==="/rest/v1/reconversion_progress_history"){const limit=Number(url.searchParams.get("limit"));return response(200,historyRows.slice(0,limit));} 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,{}); @@ -36,7 +37,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),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;}}; + return {T,cfg,rows,calls,setHistory:r=>{historyRows=r;},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()=>{ @@ -58,6 +59,11 @@ async function run(){ await assert.rejects(T.openProgress(legacy,phrase,{user:a,revision:3}),/rejouée/); for(const ctx of [null,{user:"x",revision:1},{user:a,revision:0},{user:a,revision:"1"}])await assert.rejects(T.sealProgress(data,phrase,ctx),/Contexte/); }); + await check("history listing exposes daily-retained versions beyond the last 20 (up to 20 + 30 days)",async()=>{ + const f=fixture(),c=f.client();await c.verifyCode("a@example.invalid","123456"); + f.setHistory(Array.from({length:60},(_,i)=>({revision:80-i})));const listed=await c.history(); + assert.equal(listed.length,50);assert.equal(listed[0].revision,80);assert.equal(listed[49].revision,31); + }); await check("serialization freezes before KDF and supports large histories without spread overflow",async()=>{ const {T}=fixture(),data={version:2,progress:{dns:"progress"},history:"x".repeat(300000)}; const pending=T.seal(data,phrase);data.progress.dns="mastered";const sealed=await pending; diff --git a/tests/cloud-history.cjs b/tests/cloud-history.cjs index 0622683..af4580b 100644 --- a/tests/cloud-history.cjs +++ b/tests/cloud-history.cjs @@ -80,6 +80,39 @@ async function run(){ assert.equal((await db.query("select max(revision) n from public.reconversion_progress_history")).rows[0].n,24); await owner(b);assert.equal((await db.query("select count(*)::int n from public.reconversion_progress_history")).rows[0].n,1); }); + // Daily retention migration (prepared only). Applied AFTER the base checks above. + const c="33333333-3333-4333-8333-333333333333"; + await db.exec("reset role");await db.query("insert into auth.users values ($1)",[c]); + const security=async()=>{await db.exec("reset role");return JSON.stringify([ + (await db.query("select tablename,policyname,permissive,roles::text,cmd,qual,with_check from pg_policies where schemaname='public' order by 1,2")).rows, + (await db.query("select relname,relrowsecurity,relforcerowsecurity from pg_class where relname in ('reconversion_progress','reconversion_progress_history') order by 1")).rows, + (await db.query("select grantee,table_name,privilege_type from information_schema.role_table_grants where table_schema='public' and table_name like 'reconversion_progress%' order by 1,2,3")).rows, + (await db.query("select r.rolname,has_function_privilege(r.rolname,'rc_backup_private.capture_progress()','EXECUTE') x from pg_roles r where r.rolname in ('anon','authenticated') order by 1")).rows]);}; + const before=await security(); + const retention=fs.readdirSync(path.join(root,"supabase/migrations")).filter(n=>n.endsWith("_history_daily_retention.sql")); + for(const name of retention){const sql=fs.readFileSync(path.join(root,"supabase/migrations",name),"utf8");await db.exec(sql);await db.exec(sql);} // idempotent re-run + await check("retention migration leaves RLS policies, grants and function privileges unchanged",async()=>{assert.equal(await security(),before);}); + await check("25 quick edits keep the last 20 revisions AND the latest revision of each earlier day (30 days)",async()=>{ + await owner(c);await db.query("insert into public.reconversion_progress(user_id,payload) values($1,$2)",[c,payload]); + for(let n=2;n<=5;n++)await db.query("update public.reconversion_progress set revision=$1 where user_id=$2 and revision=$3",[n,c,n-1]); + // Test-only clock: age the archived rows as superuser (clients can never write saved_at). + await db.exec("reset role"); + for(const [rev,days] of [[1,40],[2,3],[3,3],[4,1],[5,0]])await db.query("update public.reconversion_progress_history set saved_at=now()-make_interval(days=>$1) where user_id=$2 and revision=$3",[days,c,rev]); + await owner(c); + for(let n=6;n<=30;n++)await db.query("update public.reconversion_progress set revision=$1 where user_id=$2 and revision=$3",[n,c,n-1]); + await db.exec("reset role"); + const kept=(await db.query("select revision from public.reconversion_progress_history where user_id=$1 order by revision",[c])).rows.map(x=>x.revision); + assert.deepEqual(kept,[3,4,...Array.from({length:20},(_,i)=>i+11)],"day-3 latest (3) and day-1 latest (4) survive; 40-day-old revision 1 is evicted"); + assert.equal((await db.query("select count(*)::int n from public.reconversion_progress_history where user_id=$1",[b])).rows[0].n,1,"other users untouched"); + }); + await check("after retention migration, clients still cannot delete, update, insert or call the capture function",async()=>{ + await owner(c); + for(const query of ["delete from public.reconversion_progress_history","update public.reconversion_progress_history set saved_at=now()-interval '1 day'","insert into public.reconversion_progress_history(user_id,revision,payload) select user_id,99,payload from public.reconversion_progress"]) + await assert.rejects(db.query(query),e=>e.code==="42501"); + await assert.rejects(db.query("select rc_backup_private.capture_progress()"),e=>e.code==="42501"); + await owner(a);assert.equal((await db.query("select count(*)::int n from public.reconversion_progress_history where user_id=$1",[c])).rows[0].n,0,"history stays private to its owner"); + await db.exec("reset role");assert(retention.length===1,"exactly one prepared retention migration"); + }); console.log("Isolated PostgreSQL/RLS: "+count+" groups passed. Mock auth.uid()/JWT only; hosted Supabase integration remains separate."); }finally{await db.close();} } From ad435dad07394ddbf183727c7a9fcac9d9c451ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:37:27 +0000 Subject: [PATCH 4/6] fix(cloud-ui): honest backup status and manual 'Sauvegarder maintenant' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header label said 'LOCAL + CLOUD CHIFFRÉ' whenever sync mode was active, even with a pending/failed upload. It now says 'sauvegarde confirmée (révision N)' only when the current snapshot equals the acknowledged revision with no outbox, else 'LOCAL · sauvegarde cloud en attente'. - Active-mode button renamed 'Sauvegarder maintenant'; same #rc-sync-now id and same tick() pipeline (no second loop). - Session expiry: retries no longer degrade to 'Non connecté'; status keeps asking to reconnect and states unsent changes are not yet in the cloud. - Intro lists what is sent and what stays local (display preferences, recovery copies); locked mode says edits stay local until unlock. Red: 4/32 UI cases failed on previous commit. Green: 32/32. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E5jwZntqAvJaRThsuEjgja --- cloud-ui.js | 13 ++++++++----- tests/cloud-ui-races.cjs | 26 ++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/cloud-ui.js b/cloud-ui.js index 97ef24a..91f36ef 100644 --- a/cloud-ui.js +++ b/cloud-ui.js @@ -37,13 +37,14 @@ const RC_CLOUD_UI = (() => { function uploadStatus(message){return message+(dirty()?" De nouvelles modifications locales restent à synchroniser.":"");} function receipt(){return meta.ack?'

Dernière sauvegarde cloud confirmée : '+safe(meta.ack.at)+' · révision '+safe(meta.ack.revision)+'.

':"";} function view(){ - const intro='

☁️ Synchronisation chiffrée PC ↔ iPhone (optionnelle)

Local d’abord. Après activation et déverrouillage, la progression complète est chiffrée avant envoi. Phrase en mémoire seulement. Une app iPhone fermée reprend à la réouverture.

'; + const intro='

☁️ Synchronisation chiffrée PC ↔ iPhone (optionnelle)

Local d’abord. Après activation et déverrouillage, la progression (statuts, révisions, labs, preuves, Daily Five, dossier) est chiffrée avant envoi ; les préférences d’affichage et les copies de récupération restent sur cet appareil. Phrase en mémoire seulement. Une app iPhone fermée reprend à la réouverture.

'; const msg='

'+safe(status)+'

'+receipt(); const disconnect=''; if(mode==="setup")return intro+'

Connexion réservée à tes appareils personnels. Sur un PC professionnel, utilise les cours publics sans connexion.

Google identifie le compte. Ta phrase de chiffrement sera demandée ensuite ; elle ne sera pas transmise à Google ou Supabase.

Connexion email existante
'+msg; if(mode==="oauth")return intro+'

Compte Google confirmé : '+safe(googleIdentity&&googleIdentity.email)+'

'+disconnect+'
'+msg; if(mode==="otp"||mode==="locked")return intro+(mode==="otp"?'

Code email Supabase requis (modèle {{ .Token }}).

':'

Session chiffrée mémorisée. Déverrouille-la pour vérifier sa validité auprès de Supabase.

')+'
'+(mode==="otp"?'':'')+''+disconnect+'
'+msg; - let controls='
'+disconnect+'
'; + // Manual trigger of the SAME tick() pipeline (read, verify, encrypt, conditional write, read-back). + let controls='
'+disconnect+'
'; if(mode==="initial")controls='

Le cloud est vide. Ta progression locale sera la source initiale. Vérifie d’abord ton export indépendant.

'+disconnect+'
'; if(mode==="choose"||mode==="conflict")controls='

'+safe(mode==="choose"?'Choisis explicitement la source initiale.':'Conflit : aucune version remplacée automatiquement.')+'

'+disconnect+'

Chaque remplacement nécessite confirmation et copie locale de récupération. Exporte aussi une sauvegarde indépendante.

'; if(mode==="error")controls='
'+disconnect+'
'; @@ -52,9 +53,11 @@ const RC_CLOUD_UI = (() => { } function repaint(){ const root=typeof document!=="undefined"&&document.querySelector("#rc-sync");if(!root||!env)return; - const label=document.querySelector("#rc-storage-label");if(label)label.textContent=mode==="active"?"LOCAL + CLOUD CHIFFRÉ · voir l’état de sauvegarde":"LOCAL · cloud "+(cfg?"configuré":"non configuré"); + const label=document.querySelector("#rc-storage-label");if(label)label.textContent=mode==="active"?(confirmed()?"LOCAL + CLOUD CHIFFRÉ · sauvegarde confirmée (révision "+revision+")":"LOCAL · sauvegarde cloud en attente"):"LOCAL · cloud "+(cfg?"configuré":"non configuré"); root.innerHTML=view();bind(); } + // True only when this device's current snapshot is exactly the acknowledged cloud revision. + function confirmed(){try{return !!(meta.ack&&!meta.pending&&revision===meta.ack.revision&&baseline&&!dirty());}catch(e){return false;}} function warn(t){status=t;repaint();} function makeClient(ticket){ return T().create(cfg,{sessionChanged:async session=>{ @@ -232,7 +235,7 @@ const RC_CLOUD_UI = (() => { else status="À jour sur cet appareil et dans le cloud · sauvegarde cloud confirmée."; } 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)));}} + }catch(e){if(ticket===epoch){lastFullRead=0;status=/^(Non connecté|Session expirée)/.test(e.message)?"Sauvegarde cloud interrompue : session expirée ou révoquée, reconnecte-toi (Déconnecter puis se reconnecter). Progression locale conservée"+(confirmed()?".":" ; des modifications ne sont pas encore dans le cloud."):"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(){ @@ -301,7 +304,7 @@ const RC_CLOUD_UI = (() => { } function attach(options){ env=options; - try{const saved=load(CONFIG);if(saved){cfg=T().config(saved.url,saved.key);if(saved.owner!==undefined){if(!/^[0-9a-f-]{36}$/i.test(saved.owner))throw Error("Compte mémorisé invalide");cfg.owner=saved.owner;}}if(cfg&&load(SESSION)){mode="locked";status="Session mémorisée chiffrée · déverrouillage requis, aucun envoi automatique.";}} + try{const saved=load(CONFIG);if(saved){cfg=T().config(saved.url,saved.key);if(saved.owner!==undefined){if(!/^[0-9a-f-]{36}$/i.test(saved.owner))throw Error("Compte mémorisé invalide");cfg.owner=saved.owner;}}if(cfg&&load(SESSION)){mode="locked";status="Session mémorisée chiffrée · déverrouillage requis, aucun envoi automatique : les modifications restent sur cet appareil jusqu’au déverrouillage.";}} catch(e){status="Cloud suspendu : configuration locale illisible. Progression locale conservée.";} if(typeof window!=="undefined"&&window.addEventListener){ window.addEventListener("online",tick); diff --git a/tests/cloud-ui-races.cjs b/tests/cloud-ui-races.cjs index 4e275da..711f4b4 100644 --- a/tests/cloud-ui-races.cjs +++ b/tests/cloud-ui-races.cjs @@ -11,6 +11,7 @@ function fixture() { let state = {version: 2, progress: {dns: "initial-fixture"}}, row = null; let interval, timeout, failWrite = false, failRestore = false, failHead = false, fullReads=0, headReads=0, clock=0, expired=false; const listeners = {}; + const sessionError = () => { const first = expired === true; expired = "gone"; return Error(first ? "Session expirée ou révoquée : reconnecte-toi" : "Non connecté"); }; const gates = new Map(), nodes = new Map(), writes = []; const node = selector => { if (!nodes.has(selector)) nodes.set(selector, {value: "", innerHTML: "", textContent: ""}); @@ -23,11 +24,11 @@ function fixture() { } const client = { sendCode: async () => {}, verifyCode: async () => { expired = false; return "00000000-0000-4000-8000-000000000001"; }, logout: () => {}, - async read() { if (expired) throw Error("Session expirée ou révoquée : reconnecte-toi"); fullReads++; const result = copy(row); await wait("read"); return result; }, + async read() { if (expired) throw sessionError(); 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 (expired) throw Error("Session expirée ou révoquée : reconnecte-toi"); + if (expired) throw sessionError(); if (failWrite) { failWrite = false; throw Error("Fictitious network failure"); } assert.equal(revision, row ? row.revision : null, "conditional write must use the expected revision"); row = {revision: revision === null ? 1 : revision + 1, payload: copy(payload)}; @@ -98,7 +99,7 @@ function fixture() { failWrite: () => { failWrite = true; }, failRestore: () => { failRestore = true; }, get local() { return state.progress.dns; }, get remoteValue() { return row && row.payload.snapshot.progress.dns; }, corruptJournal() { for(const key of store.keys())if(key.startsWith("reconversion-control.cloud.sync.v1:"))store.set(key,JSON.stringify({v:99,retained:"future-journal"})); }, - get html() { return node("#rc-sync").innerHTML; }, get writes() { return writes; }, get journal() { return Array.from(store.values()).join("\n"); } + get html() { return node("#rc-sync").innerHTML; }, get label() { return node("#rc-storage-label").textContent; }, get writes() { return writes; }, get journal() { return Array.from(store.values()).join("\n"); } }; } @@ -209,12 +210,29 @@ async function run() { else { const gate = f.pause("write"), sending = f.pending(); await gate.started; f.expireSession(); gate.release(); await sending; assert.match(f.journal, /"pending":\{/, "an interrupted upload must stay in the durable encrypted outbox"); } assert.equal(f.writes.length, writes); assert.equal(f.local, "edit-before-expiry"); - assert.match(f.html, /Session expirée/); assert.doesNotMatch(f.html, /À jour sur cet appareil et dans le cloud/); + assert.match(f.html, /session expirée/i); assert.doesNotMatch(f.html, /À jour sur cet appareil et dans le cloud/); await f.disconnect(); await f.prepare("relogin"); if (stage === "read") await f.pending(); // no outbox yet: the dirty snapshot is re-sent by the normal debounce assert.equal(f.remoteValue, "edit-before-expiry"); assert.equal(f.writes.length, writes + 1); assert.doesNotMatch(f.journal, /"pending":\{/); }); + // Status wording: never claim a cloud backup while the state is local-only or pending. + await check("header label distinguishes confirmed cloud backup from pending/failed upload", async () => { + const f = fixture(); await f.prepare("automatic"); f.edit("pending-label"); f.failWrite(); await f.pending(); + assert.notEqual(f.remoteValue, "pending-label"); assert.doesNotMatch(f.label, /CLOUD CHIFFRÉ/); assert.match(f.label, /en attente/); + await f.pending(); assert.equal(f.remoteValue, "pending-label"); assert.match(f.label, /CLOUD CHIFFRÉ · sauvegarde confirmée/); + }); + await check("manual 'Sauvegarder maintenant' reuses the single pipeline (no second loop)", async () => { + const f = fixture(); await f.prepare("automatic"); assert.match(f.html, /id="rc-sync-now">Sauvegarder maintenant { + const f = fixture(); await f.prepare("automatic"); f.edit("after-expiry"); f.expireSession(); await f.pending(); await f.pending(); + assert.match(f.html, /reconnecte-toi/); assert.match(f.html, /pas encore dans le cloud/); assert.doesNotMatch(f.label, /CLOUD CHIFFRÉ/); + }); + await check("intro states which local data is not sent", async () => { + const f = fixture(); await f.prepare("automatic"); assert.match(f.html, /préférences d’affichage/); assert.doesNotMatch(f.html, /progression complète est chiffrée/); + }); 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"); } From 3ab0dd7ad9cc45798b580cff1fc4dbcbdfb038ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:39:00 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix(cloud):=20aucun=20contenu=20p=C3=A9rim?= =?UTF-8?q?=C3=A9=20appliqu=C3=A9=20;=20d=C3=A9ploiement=20en=20deux=20pha?= =?UTF-8?q?ses=20du=20format=20li=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - X1 (preuve exécutable NOESIS 13.6) : inspect() associait la nouvelle tête distante au contenu déchiffré de la précédente quand le déchiffrement échouait ; « Récupérer le cloud » l'appliquait sous le nouveau numéro, puis la saisie suivante écrasait la tête. remoteData est effacé dès que la tête change, et chooseCloud re-déchiffre la version actuelle avant d'appliquer. - C3b : l'ancien client de production (0edcf3f) a le même défaut et peut écraser une tête LIÉE qu'il ne sait pas lire. Phase A livrée (BOUND_WRITES=false) : lecture des formats lié et hérité, écriture du format hérité, lisible par les anciens appareils. Phase B (une ligne) seulement après mise à jour de tous les appareils. - Tests : cloud-rollback 12/12 (cas existants exécutés en phase B, + X1, + 2 cas phase A). --- cloud-transport.js | 7 ++++++- cloud-ui.js | 11 ++++++++--- tests/cloud-rollback.cjs | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/cloud-transport.js b/cloud-transport.js index 037793c..872ebfd 100644 --- a/cloud-transport.js +++ b/cloud-transport.js @@ -170,11 +170,16 @@ const RC_CLOUD_TRANSPORT = (() => { if(!ctx||typeof ctx.user!=="string"||!/^[0-9a-f-]{36}$/i.test(ctx.user)||!validRevision(ctx.revision))throw Error("Contexte de sauvegarde invalide"); return "reconversion-control-progress-v2|"+ctx.user.toLowerCase()+"|"+ctx.revision; } + // Déploiement en deux phases (NOESIS 13.6). Phase A (false) : lecture des formats lié ET hérité, + // écriture du format hérité, lisible par les clients encore en production (0edcf3f) : un ancien + // appareil ne rencontre jamais de tête illisible (preuve C3b). Phase B (true), après mise à jour de + // TOUS les appareils : écriture liée au compte et à la révision (anti-rejeu). + const BOUND_WRITES=false; async function sealProgress(snapshot,phrase,ctx){return encrypt(snapshot,phrase,boundContext(ctx));} // legacy:true additionally accepts constant-AAD v1 progress envelopes (pre-binding clients). async function openProgress(envelope,phrase,ctx){ const contexts=[boundContext(ctx)].concat(ctx.legacy===true?[progress]:[]); const opened=await decryptAny(envelope,phrase,contexts);return {data:opened.value,bound:opened.index===0}; } - return Object.freeze({create,config,serialize,fingerprint,seal:(s,p)=>encrypt(s,p,progress),open:(s,p)=>decrypt(s,p,progress),sealProgress,openProgress,sealSession:(s,p)=>encrypt(s,p,auth),openSession:(s,p)=>decrypt(s,p,auth)}); + return Object.freeze({BOUND_WRITES,create,config,serialize,fingerprint,seal:(s,p)=>encrypt(s,p,progress),open:(s,p)=>decrypt(s,p,progress),sealProgress,openProgress,sealSession:(s,p)=>encrypt(s,p,auth),openSession:(s,p)=>decrypt(s,p,auth)}); })(); diff --git a/cloud-ui.js b/cloud-ui.js index 91f36ef..bf5d67f 100644 --- a/cloud-ui.js +++ b/cloud-ui.js @@ -130,7 +130,8 @@ const RC_CLOUD_UI = (() => { } async function upload(expected,ticket){ if(meta.pending)throw Error("Un envoi précédent doit être résolu avant un nouvel envoi"); - const sent=local(),snapshot=JSON.parse(sent),payload=await T().sealProgress(snapshot,phrase,{user,revision:target(expected)});guard(ticket); + // Phase A : format hérité, lisible par les appareils pas encore mis à jour ; phase B : format lié. + const sent=local(),snapshot=JSON.parse(sent),payload=T().BOUND_WRITES===true?await T().sealProgress(snapshot,phrase,{user,revision:target(expected)}):await T().seal(snapshot,phrase);guard(ticket); const hash=await T().fingerprint(sent);guard(ticket);local(); persist({...meta,pending:{expected,hash,payload}}); await flush(ticket);delayed(); @@ -145,7 +146,8 @@ const RC_CLOUD_UI = (() => { if(matches||retry){await flush(ticket);latest=await client.read();guard(ticket);} else{remote=latest;remoteBound=false;remoteData=null;if(latest){const opened=await openHead(latest,ticket);if(!opened)return;remoteData=validate(opened.data);remoteBound=opened.bound;}guard(ticket);mode="conflict";status="Conflit détecté : envoi précédent conservé, décision nécessaire.";return;} } - remote=latest; + // Jamais de contenu déchiffré d'une autre révision associé à `remote` (preuve X1, NOESIS 13.6). + remote=latest;remoteData=null;remoteBound=false; if(!latest){mode=meta.ack?"conflict":"initial";remoteData=null;status=meta.ack?"La sauvegarde cloud a disparu. Aucun changement local effacé.":"Connecté et déverrouillé · aucune sauvegarde cloud. Confirmation initiale requise.";return;} const opened=await openHead(latest,ticket);if(!opened)return;remoteData=validate(opened.data);remoteBound=opened.bound;guard(ticket); const here=local(),hash=await T().fingerprint(here),there=T().serialize(remoteData);guard(ticket); @@ -256,7 +258,10 @@ const RC_CLOUD_UI = (() => { if(!latest||latest.revision!==remote.revision||T().serialize(latest.payload)!==T().serialize(remote.payload)||local()!==before)throw Error("Une version a changé : revérifie les deux sources"); if(meta.pending&&env.preserve)env.preserve(validate((await openAt(meta.pending.payload,target(meta.pending.expected),true)).data));guard(ticket); if(local()!==before)throw Error("Progression locale modifiée"); - await apply(remoteData,latest.revision,ticket);mode="active";status="Progression cloud relue localement. Restauration après réouverture à vérifier.";activate(); + // Re-déchiffrer la version distante ACTUELLE : jamais appliquer un contenu mis en cache (X1). + const opened=await openHead(latest,ticket);if(!opened)throw Error("version cloud illisible pour ce compte et cette révision"); + const fresh=validate(opened.data);guard(ticket);if(local()!==before)throw Error("Progression locale modifiée"); + await apply(fresh,latest.revision,ticket);mode="active";status="Progression cloud relue localement. Restauration après réouverture à vérifier.";activate(); }catch(e){if(ticket===epoch)status="Import cloud refusé : "+e.message;} finally{if(ticket===epoch){busy=false;repaint();}} } diff --git a/tests/cloud-rollback.cjs b/tests/cloud-rollback.cjs index f1fce21..d0d5f83 100644 --- a/tests/cloud-rollback.cjs +++ b/tests/cloud-rollback.cjs @@ -31,7 +31,8 @@ function server(){ } // sources: optional {transport, ui} source text (used to replay older client versions). -function device(srv,mail="a@example.invalid",store=new Map(),sources){ +// phase "B" (défaut de cette suite) : écriture liée activée ; "A" : source de production telle quelle. +function device(srv,mail="a@example.invalid",store=new Map(),sources,phase="B"){ let state={version:2,progress:{dns:"initial-fixture"}},timeout=null;const preserved=[];const nodes=new Map(),listeners={}; const node=s=>{if(!nodes.has(s))nodes.set(s,{value:"",innerHTML:"",textContent:"",checked:false});return nodes.get(s);}; for(const [id,value] of Object.entries({url:"https://fixture.supabase.co",key:"sb_publishable_fixture",email:mail,otp:"123456",phrase:PHRASE}))node("#rc-sync-"+id).value=value; @@ -43,7 +44,9 @@ function device(srv,mail="a@example.invalid",store=new Map(),sources){ setInterval:()=>1,clearInterval:()=>{},setTimeout:fn=>{timeout=fn;return 2;},clearTimeout:()=>{timeout=null;}}; vm.createContext(sandbox); const root=path.join(__dirname,".."); - const src=sources||{transport:fs.readFileSync(path.join(root,"cloud-transport.js"),"utf8"),ui:fs.readFileSync(path.join(root,"cloud-ui.js"),"utf8")}; + const raw=fs.readFileSync(path.join(root,"cloud-transport.js"),"utf8"); + if(!sources&&phase==="B")assert(raw.includes("const BOUND_WRITES=false;"),"bascule de phase introuvable"); + const src=sources||{transport:phase==="B"?raw.replace("const BOUND_WRITES=false;","const BOUND_WRITES=true;"):raw,ui:fs.readFileSync(path.join(root,"cloud-ui.js"),"utf8")}; vm.runInContext(src.transport+"\n"+src.ui+"\nglobalThis.T=RC_CLOUD_TRANSPORT;globalThis.UI=RC_CLOUD_UI;",sandbox); const api=sandbox.UI.attach({escape:s=>String(s).replaceAll("&","&").replaceAll("<","<"),snapshot:()=>clone(state),restore:d=>{state=clone(d);},preserve:d=>{preserved.push(clone(d));}}); api.bind(); @@ -114,6 +117,34 @@ async function run(){ const srv=server(),seed=device(srv);srv.rows.set(A,{revision:9,payload:await seed.T.seal({version:2,progress:{dns:"possibly-stale"}},PHRASE)}); const fresh=device(srv);await fresh.login();assert.match(fresh.html,/Choisis/);assert.equal(fresh.local,"initial-fixture"); }); + // NOESIS 13.6 (preuve exécutable X1) : sur l'écran de conflit, une tête distante illisible ne doit jamais + // laisser « Récupérer le cloud » appliquer le contenu DÉCHIFFRÉ D'UNE AUTRE RÉVISION sous son numéro. + await check("conflict screen: an unopenable head never lets 'take cloud' apply stale content under its revision",async()=>{ + const srv=server(),d=device(srv);await d.login();await d.create();await d.edit("D2"); + const e=device(srv);await e.login();await e.take(); + await e.edit("E3"); // E publie la révision 3 + await d.edit("D-unsaved-before-conflict");await d.now(); // D, en retard, modifie : conflit + assert.match(d.html,/Conflit|conflit/,"conflit attendu côté D"); + srv.replay(A,srv.history.find(r=>r.revision===1).payload); // ancien chiffré lié servi comme révision suivante + await d.now();const before=JSON.stringify(srv.rows.get(A)); + await d.take(); + assert.notEqual(d.meta.ack&&d.meta.ack.revision,srv.rows.get(A).revision,"jamais d'accusé d'une révision non déchiffrée"); + assert.equal(d.local,"D-unsaved-before-conflict","saisie locale conservée"); + await d.edit("D-after-take");await d.now(); + assert.equal(JSON.stringify(srv.rows.get(A)),before,"la tête distante n'est jamais écrasée sans décision sur une version lisible"); + }); + // Phase A (production telle que livrée) : les anciens clients restent compatibles. + await check("phase A: production writes the legacy format that pre-binding clients (0edcf3f API) can decrypt",async()=>{ + const srv=server(),a=device(srv,undefined,undefined,undefined,"A");assert.equal(a.T.BOUND_WRITES,false,"phase A livrée"); + await a.login();await a.create();await a.edit("PA-2"); + const head=srv.rows.get(A);assert.equal(head.revision,2); + assert.equal((await open(a.T,head.payload)).progress.dns,"PA-2","T.open (AAD constante de 0edcf3f) déchiffre la tête"); + assert.notEqual(a.meta.bound,true,"ses propres écritures ne rendent jamais l'appareil « lié »"); + }); + await check("phase A: a phase-B device's bound head is read by a phase-A device (forward compatible), never overwritten blindly",async()=>{ + const srv=server(),b=device(srv);await b.login();await b.create();await b.edit("PB-2"); + const a=device(srv,undefined,undefined,undefined,"A");await a.login();await a.take();assert.equal(a.local,"PB-2"); + }); console.log("Rollback/replay resistance: "+passed+"/"+(passed+failures.length)+" passed; simulated server only, fictitious data."); if(failures.length)throw new AggregateError(failures,failures.length+" rollback cases failed"); } From 5621f983496dcd9981f79d4c2f56a643d1f20e22 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:46:09 +0000 Subject: [PATCH 6/6] =?UTF-8?q?test(cloud):=20v=C3=A9rifications=20navigat?= =?UTF-8?q?eur=20compatibles=20avec=20la=20phase=20A=20;=20documentation?= =?UTF-8?q?=20du=20d=C3=A9ploiement=20en=20deux=20phases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les aides qui décodent les lignes envoyées acceptent les formats lié et hérité (legacy:true) ; les assertions sont inchangées. Les tests qui scellent explicitement au format lié restent liés. --- docs/cloud-security-model.md | 8 ++++++++ .../test_pages_provenance.cpython-313.pyc | Bin 0 -> 25854 bytes .../test_pages_public_reader.cpython-313.pyc | Bin 0 -> 4903 bytes .../test_pages_workflow.cpython-313.pyc | Bin 0 -> 3907 bytes tests/cloud-final-mile-browser.cjs | 2 +- tests/cloud-oauth-browser.cjs | 2 +- tests/cloud-ui-browser.cjs | 2 +- 7 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 tests/__pycache__/test_pages_provenance.cpython-313.pyc create mode 100644 tests/__pycache__/test_pages_public_reader.cpython-313.pyc create mode 100644 tests/__pycache__/test_pages_workflow.cpython-313.pyc diff --git a/docs/cloud-security-model.md b/docs/cloud-security-model.md index 543f629..48617c0 100644 --- a/docs/cloud-security-model.md +++ b/docs/cloud-security-model.md @@ -43,3 +43,11 @@ Le format V1 déployé ne transporte pas de négociation de compression. Une ado ## 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. + +## Déploiement en deux phases du format lié (NOESIS 13.6) + +Preuve exécutable : le client de production `0edcf3f` peut écraser une tête **liée** qu'il ne sait pas déchiffrer. Sur l'écran de conflit, « Récupérer le cloud » appliquait le contenu en cache d'une révision précédente. Ce code ne peut pas être corrigé à distance. + +- **Phase A (livrée, `BOUND_WRITES=false`)** : lecture des formats lié et hérité, écriture du format hérité. Les anciens appareils lisent toujours la tête. L'anti-rejeu par révision n'est pas encore actif ; la protection reste alors celle de la production actuelle. +- **Phase B (une ligne, sur GO séparé)** : `BOUND_WRITES=true`, seulement après mise à jour de **tous** les appareils (chacun rouvert sur la nouvelle version). +- Les deux phases corrigent X1 : la tête distante n'est jamais associée au contenu d'une autre révision, et « Récupérer le cloud » re-déchiffre la version actuelle. diff --git a/tests/__pycache__/test_pages_provenance.cpython-313.pyc b/tests/__pycache__/test_pages_provenance.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..41d3af0061525c1eb2db24cc75f08a925817766b GIT binary patch literal 25854 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 bt*0kuHQDo8>(|=;sO^sre1xnVwkG~B_TBW1 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_pages_public_reader.cpython-313.pyc b/tests/__pycache__/test_pages_public_reader.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2900a889928533843d7ce93f878bdb5764f96e78 GIT binary patch literal 4903 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 HX7A%ab_29L literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_pages_workflow.cpython-313.pyc b/tests/__pycache__/test_pages_workflow.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74b606479f89fa49d49ed7a4f00e0b3e1d4333b5 GIT binary patch literal 3907 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 literal 0 HcmV?d00001 diff --git a/tests/cloud-final-mile-browser.cjs b/tests/cloud-final-mile-browser.cjs index 0cdb3a4..4cb055c 100644 --- a/tests/cloud-final-mile-browser.cjs +++ b/tests/cloud-final-mile-browser.cjs @@ -53,7 +53,7 @@ async function run(){ 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,ctx})=>(await RC_CLOUD_TRANSPORT.openProgress(p,phrase,ctx)).data,{p:row.payload,phrase:PHRASE,ctx:{user:USER,revision:row.revision}});assert.deepEqual(remote,initial); + const remote=await pc.evaluate(async({p,phrase,ctx})=>(await RC_CLOUD_TRANSPORT.openProgress(p,phrase,ctx)).data,{p:row.payload,phrase:PHRASE,ctx:{user:USER,revision:row.revision,legacy:true}});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); diff --git a/tests/cloud-oauth-browser.cjs b/tests/cloud-oauth-browser.cjs index cfad991..293ce8b 100644 --- a/tests/cloud-oauth-browser.cjs +++ b/tests/cloud-oauth-browser.cjs @@ -66,7 +66,7 @@ async function run(){ assert.equal(pcContext.calls.filter(r=>r.path.startsWith("/rest/")).length,0,"OAuth alone cannot download progress");assert.equal(row,null); const preUnlock=await pc.evaluate(()=>JSON.stringify({...localStorage})+JSON.stringify({...sessionStorage}));assert(!/OAUTH_ACCESS_CANARY|OAUTH_REFRESH_CANARY|GOOGLE_TOKEN_CANARY/.test(preUnlock)); await unlockGoogle(pc);await pc.locator("#rc-sync-create").waitFor();assert.equal(row,null);await pc.locator("#rc-sync-create").click();await pc.waitForFunction(()=>document.querySelector("#rc-sync-status")?.textContent.includes("confirmée"));assert.equal(row.revision,1); - const decoded=await pc.evaluate(async({payload,phrase,ctx})=>(await RC_CLOUD_TRANSPORT.openProgress(payload,phrase,ctx)).data,{payload:row.payload,phrase:PHRASE,ctx:{user:A,revision:row.revision}});assert.equal(decoded.progress.dns,"mastered");assert(!JSON.stringify(row).includes("mastered")); + const decoded=await pc.evaluate(async({payload,phrase,ctx})=>(await RC_CLOUD_TRANSPORT.openProgress(payload,phrase,ctx)).data,{payload:row.payload,phrase:PHRASE,ctx:{user:A,legacy:true,revision:row.revision}});assert.equal(decoded.progress.dns,"mastered");assert(!JSON.stringify(row).includes("mastered")); console.log("PASS Google/browser: PKCE return, account confirmation, independent phrase and explicit first encrypted backup with readback"); const phoneContext=await context(A,true),phone=await page(phoneContext);await google(phone);await unlockGoogle(phone);await phone.locator("#rc-sync-take").waitFor();await phone.locator("#rc-sync-take").click();await phone.waitForFunction(()=>JSON.parse(localStorage.getItem("reconversion-control.snapshot.v3")).main.progress.dns==="mastered"); const restored=await snapshot(phone);assert.deepEqual(restored.main.progress,initial.main.progress); diff --git a/tests/cloud-ui-browser.cjs b/tests/cloud-ui-browser.cjs index 472fc14..cfcb8cc 100644 --- a/tests/cloud-ui-browser.cjs +++ b/tests/cloud-ui-browser.cjs @@ -65,7 +65,7 @@ async function run(browser, server) { assert.match(await page.locator("#rc-sync-status").textContent(), /restent à synchroniser/); if (kind === "automatic") { const user = "00000000-0000-4000-8000-000000000001"; - const incoming = await page.evaluate(async ({phrase, payload, user, revision}) => { const data = (await RC_CLOUD_TRANSPORT.openProgress(payload, phrase, {user, revision})).data; data.progress.dns = "learn"; return RC_CLOUD_TRANSPORT.sealProgress(data, phrase, {user, revision: revision + 1}); }, {phrase: PHRASE, payload: row.payload, user, revision: row.revision}); + const incoming = await page.evaluate(async ({phrase, payload, user, revision}) => { const data = (await RC_CLOUD_TRANSPORT.openProgress(payload, phrase, {user, revision, legacy: true})).data; data.progress.dns = "learn"; return RC_CLOUD_TRANSPORT.sealProgress(data, phrase, {user, revision: revision + 1}); }, {phrase: PHRASE, payload: row.payload, user, revision: row.revision}); row = {revision: row.revision + 1, payload: incoming}; await page.evaluate(() => document.querySelector("#rc-sync-now").onclick()); assert.match(await page.locator("#rc-sync-status").textContent(), /Conflit détecté/);