Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ jobs:
node tests/cloud-final-mile.cjs
node tests/cloud-history.cjs
BROWSER=${{ matrix.browser }} node tests/cloud-final-mile-browser.cjs
BROWSER=${{ matrix.browser }} node tests/cloud-security-browser.cjs
node tests/atomic-restoration.cjs
BROWSER=${{ matrix.browser }} node tests/atomic-restoration-browser.cjs
BROWSER=${{ matrix.browser }} node tests/storage-multitab.cjs
Expand All @@ -108,6 +109,11 @@ jobs:
BROWSER: ${{ matrix.browser }}
RC_SCREENSHOT_DIR: artifacts/reconversion-${{ matrix.browser }}
run: node tests/project.spec.cjs
- name: Mesures de stockage fictives, PostgreSQL isolé
if: matrix.browser == 'chromium'
env:
RC_STORAGE_REPORT: artifacts/reconversion-chromium/cloud-storage.json
run: node tests/cloud-storage.cjs
- name: Navigation, recherche et affichage iPhone
env:
BROWSER: ${{ matrix.browser }}
Expand Down
9 changes: 8 additions & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,18 @@ Le dossier est conservé lors d’un import ancien qui ne possède pas de champ
node tests/cloud-sync.spec.cjs
node tests/cloud-final-mile.cjs
node tests/cloud-history.cjs
node tests/cloud-storage.cjs
BROWSER=chromium node tests/cloud-final-mile-browser.cjs
BROWSER=webkit node tests/cloud-final-mile-browser.cjs
BROWSER=chromium node tests/cloud-security-browser.cjs
BROWSER=webkit node tests/cloud-security-browser.cjs
```

Le troisième test exécute le SQL proposé dans PostgreSQL WASM (PGlite 0.5.11 verrouillé), avec rôles distincts, RLS réelle et fonctions Auth simulées. Il vérifie propriétaire, utilisateur B, anon, anonymous-auth, immutabilité de l'historique, révisions, bornes et rollback transactionnel. Ce n'est pas un test du projet Supabase hébergé.
Le troisième test exécute le SQL proposé dans PostgreSQL WASM (PGlite 0.5.11 verrouillé), avec rôles distincts, RLS réelle et fonctions Auth simulées. Il vérifie propriétaire, utilisateur B, anon, anonymous-auth, immutabilité de l'historique, révisions, bornes et rollback transactionnel. Il reproduit aussi le helper privilégié d'auto-RLS, retire son droit d'appel client et prouve que son event trigger active toujours RLS sur une table de test annulée. Ce n'est pas un test du projet Supabase hébergé.

Le banc de stockage utilise les validateurs métier et le chiffrement de production avec des progressions entièrement fictives (0, 365 et 1825 jours). Il mesure JSON, Gzip/Brotli, enveloppes AES-GCM et relations PostgreSQL/index/TOAST avant et après compactage isolé. Les objets compressés d'étude ne sont pas des sauvegardes applicatives compatibles ; ils ne sont jamais envoyés à un service. La limite de décompression et le round-trip intégral sont vérifiés. Les octets de corps HTTP et projections de lecture excluent headers, TLS, Auth et changements réels. Le rapport JSON est conservé dans l'artefact CI Chromium.

Les tests de contrôleur ajoutent les lectures de révision sans acquittement, contrôles complets obligatoires, retour d'horloge, visibilité, erreurs de métadonnées et modifications pendant une lecture légère. La suite CSP injecte des scripts de test et une base étrangère dans un profil isolé ; les deux navigateurs doivent les bloquer et conserver le snapshot. Les logs du parcours cloud sont contrôlés avec des secrets canaris fictifs.

Le test navigateur utilise deux profils indépendants, la vraie app, AES-GCM et localStorage. Toutes les requêtes fournisseur sont interceptées : choix initial, aller-retour, fermeture/réouverture, session chiffrée, réponse perdue après commit, reprise idempotente, conflit, stockage défaillant, format futur et récupération historique. Les quinze cas N03 restent exécutés ; leur amorçage est adapté à la nouvelle confirmation explicite de premier envoi et leur watchdog évite une fin Node trompeuse lorsqu'une promesse reste suspendue.

Expand Down
8 changes: 7 additions & 1 deletion cloud-transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ const RC_CLOUD_TRANSPORT = (() => {
const rows=await request("/rest/v1/reconversion_progress?user_id=eq."+encodeURIComponent(session.user)+"&select=revision,payload",{secure:true});
if(!Array.isArray(rows)||rows.length>1)throw Error("Réponse cloud invalide");return rows.length?row(rows[0]):null;
}
async function readRevision(){
if(!session)throw Error("Non connecté");
const rows=await request("/rest/v1/reconversion_progress?user_id=eq."+encodeURIComponent(session.user)+"&select=revision",{secure:true});
if(!Array.isArray(rows)||rows.length>1||rows.length&&(!rows[0]||Object.keys(rows[0]).join(",")!=="revision"||!validRevision(rows[0].revision)))throw Error("Révision cloud invalide");
return rows.length?rows[0].revision:null;
}
async function write(payload,revision){
if(!session)throw Error("Non connecté");if(revision!==null&&!validRevision(revision))throw Error("Révision invalide");bounded(payload);
const expected=revision===null?1:revision+1;
Expand Down Expand Up @@ -102,7 +108,7 @@ const RC_CLOUD_TRANSPORT = (() => {
if(!Array.isArray(rows)||rows.length!==1||rows[0].revision!==revision)throw Error("Version historique absente");return row(rows[0]);
}
async function logout(){const previous=session;generation++;session=null;refreshing=null;if(previous){try{await request("/auth/v1/logout?scope=local",{method:"POST",headers:{Authorization:"Bearer "+previous.access_token}});return true;}catch(e){return false;}}return true;}
return {sendCode,verifyCode,resume,read,write,history,historyVersion,logout};
return {sendCode,verifyCode,resume,read,readRevision,write,history,historyVersion,logout};
}
async function encrypt(snapshot,phrase,context){
if(typeof phrase!=="string"||phrase.length<12||phrase.length>1024)throw Error("Phrase de chiffrement : 12 à 1024 caractères");
Expand Down
27 changes: 20 additions & 7 deletions cloud-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
const RC_CLOUD_UI = (() => {
const CONFIG="reconversion-control.cloud.config.v1",SESSION="reconversion-control.cloud.session.v1";
let env,client,cfg,email="",phrase="",remember=false,user="",scope="",meta={v:1},remote=null,remoteData=null;
let revision=null,baseline="",busy=false,applying=false,mode="setup",status="Local uniquement · cloud non configuré.",timer=null,pending=null,epoch=0,retries=0,historyRows=[],restoredHere=false;
let revision=null,baseline="",busy=false,applying=false,mode="setup",status="Local uniquement · cloud non configuré.",timer=null,pending=null,epoch=0,retries=0,historyRows=[],restoredHere=false,lastFullRead=0;
const FULL_READ_MS=600000;
const T=()=>RC_CLOUD_TRANSPORT,now=()=>new Date().toISOString();
function safe(s){return env.escape(String(s||""));}
function load(key){const raw=localStorage.getItem(key);if(raw===null)return null;if(raw.length>4200000)throw Error("Métadonnées trop volumineuses");return JSON.parse(raw);}
Expand Down Expand Up @@ -60,7 +61,7 @@ const RC_CLOUD_UI = (() => {
}
async function acknowledge(sent,next,ticket,payload){
const hash=await T().fingerprint(sent),payloadHash=payload?await T().fingerprint(T().serialize(payload)):null;guard(ticket);
persist({...meta,ack:{revision:next,hash,payloadHash,at:now()},pending:null});revision=next;baseline=sent;retries=0;
persist({...meta,ack:{revision:next,hash,payloadHash,at:now()},pending:null});revision=next;baseline=sent;retries=0;lastFullRead=Date.now();
}
async function flush(ticket){
const job=meta.pending;if(!job)return;
Expand Down Expand Up @@ -95,7 +96,7 @@ const RC_CLOUD_UI = (() => {
if(latest.revision<meta.ack.revision)throw Error("Révision distante obsolète : restauration automatique refusée");
if(latest.revision===meta.ack.revision){
if(await T().fingerprint(there)!==meta.ack.hash)throw Error("Même révision avec contenu différent : restauration refusée");guard(ticket);
baseline=there;revision=latest.revision;mode="active";
baseline=there;revision=latest.revision;mode="active";lastFullRead=Date.now();
status=uploadStatus(meta.restore&&hash===meta.restore.hash&&!restoredHere?"Restauration vérifiée après réouverture · connecté et déverrouillé.":"Connecté et déverrouillé. Reprise de la synchronisation.");activate();delayed();return;
}
// Read/decrypt/hash may yield; take another snapshot before deciding to replace.
Expand Down Expand Up @@ -124,7 +125,7 @@ const RC_CLOUD_UI = (() => {
}catch(e){if(ticket===epoch){status="Déverrouillage refusé : "+e.message;if(user)mode="error";}}
finally{if(ticket===epoch){busy=false;repaint();}}
}
function activate(){if(timer)clearInterval(timer);timer=setInterval(tick,30000);}
function activate(){if(timer)clearInterval(timer);timer=setInterval(()=>tick(true),30000);}
function schedule(delay){if(pending)clearTimeout(pending);pending=setTimeout(()=>{pending=null;return tick();},delay||2000);}
function changed(){
if(!env||applying||mode!=="active")return;
Expand All @@ -141,14 +142,26 @@ const RC_CLOUD_UI = (() => {
await acknowledge(restored,next,ticket,remote&&remote.payload);guard(ticket);persist({...meta,restore:{hash:meta.ack.hash,revision:next}});
}finally{applying=false;}
}
async function tick(){
async function tick(periodic){
if(periodic!==true)lastFullRead=0;
if(!client||busy||!["active","conflict","error"].includes(mode))return;
if(periodic===true&&document.hidden)return;
if(!navigator.onLine){warn("Hors connexion · progression locale conservée, reprise à la reconnexion.");return;}
busy=true;const ticket=epoch;
try{
loadMeta();
if(mode!=="active"){await inspect(ticket);return;}
if(meta.pending)await flush(ticket);
// An unchanged metadata probe never replaces a payload check or creates a receipt.
// Local edits, pending writes, manual actions and resumes always read the payload.
const stamp=Date.now();
if(periodic===true&&!dirty()&&meta.ack&&meta.ack.payloadHash&&lastFullRead>0&&stamp>=lastFullRead&&stamp-lastFullRead<FULL_READ_MS&&typeof client.readRevision==="function"){
const head=await client.readRevision();guard(ticket);
const checked=Date.now();
if(head===revision&&lastFullRead>0&&checked>=lastFullRead&&checked-lastFullRead<FULL_READ_MS&&!dirty()){
status="À jour sur cet appareil et dans le cloud · révision inchangée, dernière confirmation conservée.";return;
}
}
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){
Expand All @@ -162,8 +175,8 @@ const RC_CLOUD_UI = (() => {
if(dirty()){await upload(revision,ticket);status=uploadStatus("Modifications locales sauvegardées et chiffrées dans le cloud.");}
else status="À jour sur cet appareil et dans le cloud · sauvegarde cloud confirmée.";
}
delayed();
}catch(e){if(ticket===epoch){status="Synchronisation en attente : "+e.message+" · progression locale conservée.";schedule(Math.min(60000,2000*2**Math.min(retries++,5)));}}
lastFullRead=Date.now();delayed();
}catch(e){if(ticket===epoch){lastFullRead=0;status="Synchronisation en attente : "+e.message+" · progression locale conservée.";schedule(Math.min(60000,2000*2**Math.min(retries++,5)));}}
finally{if(ticket===epoch){busy=false;repaint();}}
}
async function createFirst(){
Expand Down
43 changes: 43 additions & 0 deletions docs/cloud-security-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Modèle de menaces — sauvegarde chiffrée

Auteur : Bryan Fiarda. Révision : 24 octobre 2026. Périmètre : Reconversion Control uniquement.

Ce modèle reprend les mécanismes existants ; il ne certifie pas un service personnel ni un iPhone physique. Les migrations, le code testé, le service hébergé et la recette personnelle ont des preuves distinctes.

## Frontières de confiance et invariants

| Surface / menace | Invariant et contrôle | Preuve attendue |
| --- | --- | --- |
| Navigateur, import, DOM / XSS | Validation métier stricte, refus des champs perdus/inconnus et clés dangereuses ; échappement HTML des données privées ; CSP script-src self sans unsafe-inline ni unsafe-eval | project-checks, project.spec, cloud-final-mile et tests CSP |
| PWA / cache obsolète | Assets limités au périmètre local, build identifié, aucun cache de réponse Supabase ; reprise à la réouverture | sw.spec, tests de provenance et réouverture navigateur |
| Persistance / quota, deux onglets | Snapshot V3 écrit et relu avant cloud ; Web Lock ; un état non confirmé bloque l'envoi ; restauration atomique et rollback vérifié | atomic-restoration, atomic-restoration-browser, storage-multitab |
| Phrase et session / vol au repos | Phrase en mémoire ; option de session persistante chiffrée et domaines progression/session séparés ; pas de jeton en clair dans export ou logs | cloud-final-mile et captures de logs fictifs dans les tests navigateur |
| HTTP / CSRF, fuite, réponse perdue | HTTPS Supabase uniquement ; Bearer explicite, credentials omit, redirects refusés, no-store ; outbox persistante et relecture exacte | cloud-sync, cloud-final-mile et scénario navigateur de réponse perdue |
| API / IDOR, BOLA | Identité Supabase vérifiée ; SELECT/INSERT/UPDATE limités au propriétaire ; anon et anonymous-auth refusés ; aucune clé serveur dans le client | tests SQL sous rôles distincts, catalogue et contrôles Data API du projet confirmé |
| PostgreSQL / concurrence, rejeu | Révision initiale 1 puis incrément exact ; PATCH conditionnel sur révision précédente ; un acquittement exige la même enveloppe relue | cloud-history, cloud-final-mile, cloud-ui-races |
| Enveloppe incomplète | CHECK explicitement IS TRUE ; kdf:null et autres formes malformées refusés ; refus sans modification de tête ou d'historique | groupe de régression SQL de la PR #33 |
| Fonction privilégiée | Archivage par trigger privé capture_progress, search_path vide, noms qualifiés, droits EXECUTE/USAGE retirés aux clients ; aucun appel client au helper Supabase d'auto-RLS préexistant, dont le trigger reste actif | catalogue pg_proc / pg_namespace, refus des appels directs, test de création de table annulé après contrôle de RLS |
| Historique / corruption, effacement | 20 révisions par propriétaire, archivage et élagage dans la transaction ; clients en lecture seule ; export indépendant avant remplacement | cloud-history, récupération navigateur et contrôles réversibles hébergés autorisés |
| CI/CD / chaîne logicielle | Dépendances verrouillées, actions et image SHA épinglées, permissions minimales, PR SHA exact ; Pages séparé et protégé | npm audit, CI et tests test_pages*.py |

Le stockage canonique actuel est localStorage, pas IndexedDB. Il contient la progression locale lisible ; l'outbox et la session mémorisée sont chiffrées. Les préfixes de clés ne constituent pas une isolation entre sites partageant une même origine.

## Limites explicites

- Une compromission du JavaScript autorisé sur l'origine, une extension malveillante ou un appareil déverrouillé compromis peut lire les données disponibles dans la page. CSP et échappement réduisent les injections ; ils ne remplacent pas l'intégrité de l'hébergement, les mises à jour et le verrouillage de l'appareil.
- Les domaines AAD séparent progression et session. Une enveloppe authentifiée ne prouve pas, à elle seule, qu'elle est la dernière version : un opérateur serveur privilégié peut supprimer ou rejouer des données. Les révisions, reçus locaux, copies de récupération et exports indépendants sont complémentaires ; aucun serveur unique n'est une garantie absolue de disponibilité.
- Le client refuse une révision distante inférieure ou un contenu différent à la même révision. Un appareil neuf exige toujours un choix de source et une vérification humaine avant remplacement.
- La phrase de chiffrement perdue n'est pas récupérable depuis Supabase. Exporter toute copie locale encore lisible avant une réinitialisation.
- Une PWA iOS fermée/suspendue ne garantit pas de tâche réseau permanente. Chaque reprise vérifie la session et la source avant synchronisation.

## Compression et KDF

Les enveloppes V1 restent lisibles, avec AES-256-GCM, sels/IV aléatoires et PBKDF2-SHA256 250000 itérations. Une augmentation du KDF doit être versionnée et testée sur l'iPhone cible ; aucun changement aveugle des paramètres existants n'est acceptable.

Gzip et Brotli sont mesurés uniquement dans un banc d'essai isolé, avant chiffrement. Les objets d'étude sont explicitement distincts du DTO métier et ne doivent jamais être envoyés comme une sauvegarde applicative. Leur encodage est chiffré/authentifié, la décompression est bornée et son résultat est comparé intégralement à la source. Les essais Node ne prouvent pas une implémentation de décompression dans WebKit.

Le format V1 déployé ne transporte pas de négociation de compression. Une adoption requiert un nouveau contrat authentifié, compatibilité de lecture, limites de mémoire en flux et analyse de l'oracle de longueur lorsque des champs privés sont compressés avec du texte importé. L'étude n'active donc aucune compression en production. Les économies immédiatement compatibles viennent du DTO métier, de l'absence d'envoi identique, du regroupement des modifications et de lectures de révision légères sans nouvel acquittement.

## Critères de mise en service

Tout défaut critique ou élevé confirmé et non maîtrisé bloque l'activation. Des tests locaux ou CI verts ne remplacent ni les vérifications du bon projet, ni la délivrabilité OTP, ni la recette PC ↔ iPhone physique. Aucune fusion, publication, création d'utilisateur personnel ou progression réelle n'est autorisée par ce document.
Loading
Loading