From 88b8e37349695dbbf8cabdf00e9774155901b4d7 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:42:55 +0100 Subject: [PATCH 1/5] feat: Google PKCE auth and explicit private-device access Preserve V3, encrypted outbox, AES-GCM envelopes and conditional writes. Add isolated Auth/browser tests and a public-only course distribution. --- .github/scripts/stage-public-courses.py | 38 ++++++++++ .github/scripts/verify-pages.py | 2 +- .github/workflows/tests.yml | 4 ++ TESTING.md | 6 ++ access-mode.js | 33 +++++++++ cloud-transport.js | 34 +++++++-- cloud-ui.js | 52 ++++++++++++-- courses-public.html | 19 +++++ courses-public.js | 16 +++++ docs/google-auth.md | 93 +++++++++++++++++++++++++ index.html | 32 +++++---- sw.js | 13 +++- tests/atomic-restoration-browser.cjs | 10 +-- tests/cloud-final-mile-browser.cjs | 10 +-- tests/cloud-oauth-browser.cjs | 73 +++++++++++++++++++ tests/cloud-oauth.cjs | 76 ++++++++++++++++++++ tests/cloud-security-browser.cjs | 2 +- tests/cloud-ui-browser.cjs | 3 +- tests/learning.spec.cjs | 4 +- tests/mobile.spec.cjs | 28 ++++---- tests/private-device.cjs | 13 ++++ tests/project.spec.cjs | 6 +- tests/server.cjs | 2 +- tests/storage-multitab.cjs | 10 +-- tests/sw.spec.cjs | 14 ++-- tests/test_pages_public_reader.py | 56 +++++++++++++++ 26 files changed, 577 insertions(+), 72 deletions(-) create mode 100644 .github/scripts/stage-public-courses.py create mode 100644 access-mode.js create mode 100644 courses-public.html create mode 100644 courses-public.js create mode 100644 docs/google-auth.md create mode 100644 tests/cloud-oauth-browser.cjs create mode 100644 tests/cloud-oauth.cjs create mode 100644 tests/private-device.cjs create mode 100644 tests/test_pages_public_reader.py diff --git a/.github/scripts/stage-public-courses.py b/.github/scripts/stage-public-courses.py new file mode 100644 index 0000000..4154e1d --- /dev/null +++ b/.github/scripts/stage-public-courses.py @@ -0,0 +1,38 @@ +"""Stage a public-only reader from committed blobs. Never uploads or publishes.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess + +FILES = ("courses-public.html", "courses-public.js", "data.js", "course-foundations.js", + "course-race.js", "course-operations.js", "learning.js", "style.css") + + +def stage(sha, destination): + # Resolve refs once, then read only this exact immutable revision. + sha = subprocess.check_output(["git", "rev-parse", "--verify", sha + "^{commit}"], text=True).strip() + destination = Path(destination) + if destination.exists(): + raise ValueError("Destination must not exist; no existing files are replaced") + content = {} + for name in FILES: + entry = subprocess.check_output(["git", "ls-tree", sha, "--", name], text=True).split() + if len(entry) != 4 or entry[:2] != ["100644", "blob"]: + raise ValueError("Missing regular public source: " + name) + content["index.html" if name == "courses-public.html" else name] = subprocess.check_output(["git", "show", sha + ":" + name]) + destination.mkdir(parents=True) + manifest = {"sha": sha, "kind": "public-courses-only", "files": {}} + for name, data in content.items(): + (destination / name).write_bytes(data) + manifest["files"][name] = hashlib.sha256(data).hexdigest() + (destination / "public-provenance.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return manifest + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("sha") + parser.add_argument("destination") + args = parser.parse_args() + print(json.dumps(stage(args.sha, args.destination))) diff --git a/.github/scripts/verify-pages.py b/.github/scripts/verify-pages.py index d3608a4..6855ccf 100644 --- a/.github/scripts/verify-pages.py +++ b/.github/scripts/verify-pages.py @@ -14,7 +14,7 @@ WORKFLOW_NAME = "Reconversion Control · tests mobiles" REQUIRED_JOBS = {"provenance", "mobile (chromium)", "mobile (webkit)"} PUBLIC_FILES = ( - "index.html", "project-plan.js", "app.js", "data.js", "course-foundations.js", + "index.html", "access-mode.js", "courses-public.html", "courses-public.js", "project-plan.js", "app.js", "data.js", "course-foundations.js", "course-race.js", "course-operations.js", "learning.js", "english.js", "drive-learning.js", "daily-five.js", "deep-dives.js", "evidence-checks.js", "cloud-transport.js", "cloud-ui.js", "style.css", "sw.js", "manifest.webmanifest", diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e6674a0..3794bce 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -91,12 +91,16 @@ jobs: node --check evidence-checks.js node --check cloud-transport.js node --check cloud-ui.js + node --check access-mode.js + node --check courses-public.js node --check sw.js - name: Exercices probants et modules IT sourcés run: | node tests/deep-checks.cjs node tests/project-checks.cjs node tests/cloud-sync.spec.cjs + node tests/cloud-oauth.cjs + BROWSER=${{ matrix.browser }} node tests/cloud-oauth-browser.cjs node tests/cloud-final-mile.cjs node tests/cloud-history.cjs BROWSER=${{ matrix.browser }} node tests/cloud-final-mile-browser.cjs diff --git a/TESTING.md b/TESTING.md index 5d9feb4..cc8e404 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,5 +1,11 @@ # Vérification mobile +## Google OAuth et accès public — 25/10/2026 + +`node tests/cloud-oauth.cjs` vérifie PKCE S256, le callback exact, l'identité vérifiée, les annulations concurrentes et le renouvellement/révocation de session. `BROWSER=chromium node tests/cloud-oauth-browser.cjs` et la variante WebKit exercent deux clients fictifs, restauration, fermeture/réouverture et réponse perdue. Le fournisseur Auth et la Data API sont simulés ; aucun compte Google réel, utilisateur personnel ou iPhone physique n'est validé par ces tests. Le protocole réel et les prérequis sont décrits dans [docs/google-auth.md](docs/google-auth.md). + +Les suites historiques choisissent explicitement un appareil personnel fictif avant leur scénario. Le lecteur public est testé séparément, avec canari de progression préexistante : aucun chargement du stockage personnel. `python3 -m unittest discover -s tests -p 'test_pages*.py'` valide aussi le build public à liste fermée, depuis les blobs du commit courant. + La CI teste la navigation tactile et la recherche dans Chromium et WebKit avec le profil iPhone 11 Pro Max. Elle couvre Safari (414 × 715), l’application en plein écran (414 × 896), le paysage (896 × 414), un petit écran (320 × 568) et le bureau (1280 × 900). diff --git a/access-mode.js b/access-mode.js new file mode 100644 index 0000000..f124e89 --- /dev/null +++ b/access-mode.js @@ -0,0 +1,33 @@ +"use strict"; +/* Runs before application scripts: public access never reads personal storage. + The device choice is an explicit user statement, not device attestation. */ +const RC_ACCESS_MODE=(()=>{ + const KEY="reconversion-control.device.mode.v1";let returned=null,started=false; + const u=new URL(location.href),codes=u.searchParams.getAll("code"); + const tokenKeys=["access_token","refresh_token","provider_token","provider_refresh_token"]; + const implicit=tokenKeys.some(k=>u.searchParams.has(k)||new URLSearchParams(u.hash.slice(1)).has(k)); + if(codes.length||u.searchParams.has("error")||implicit){ + returned=implicit||codes.length>1?{error:"Retour de connexion refusé"}:u.searchParams.has("error")?{error:"Connexion Google annulée ou refusée"}:{code:codes[0]}; + ["code","error","error_description","error_code",...tokenKeys].forEach(k=>u.searchParams.delete(k));if(implicit)u.hash=""; + history.replaceState(null,"",u.pathname+u.search+u.hash); + } + function trusted(){try{return sessionStorage.getItem(KEY)==="personal";}catch(e){return false;}} + async function enter(){ + if(started)return;started=true; + try{sessionStorage.setItem(KEY,"personal");}catch(e){} // Local-only use may still run with storage unavailable. + document.querySelector("#rc-access-screen").hidden=true;document.querySelector("#rc-private-shell").hidden=false; + try{ + for(const source of document.querySelectorAll("script[data-rc-private-src]"))await new Promise((resolve,reject)=>{const script=document.createElement("script");script.src=source.getAttribute("data-rc-private-src");script.onload=resolve;script.onerror=()=>reject(Error("Chargement incomplet"));document.body.appendChild(script);}); + document.documentElement.dataset.rcPrivateReady="yes"; + }catch(e){const screen=document.querySelector("#rc-access-screen");screen.hidden=false;screen.textContent="Application incomplète : recharge avec le réseau. Aucun stockage existant effacé.";} + } + function ready(){ + if(trusted()){enter();return;} + const screen=document.querySelector("#rc-access-screen"); + screen.innerHTML='

Choisir l’accès

Sur un ordinateur professionnel ou partagé, consulte les cours publics sans connexion, sans progression personnelle et sans phrase secrète.

Consulter les cours sans connexion

'; + if(returned)document.querySelector("#rc-access-message").textContent="Retour de connexion reçu sans contexte personnel autorisé. Recommence depuis ton appareil personnel."; + document.querySelector("#rc-private-enter").onclick=()=>{if(!document.querySelector("#rc-private-confirm").checked){document.querySelector("#rc-access-message").textContent="Confirme que cet appareil est personnel, ou utilise les cours publics.";return;}enter();}; + } + document.addEventListener("DOMContentLoaded",ready); + return Object.freeze({takeOAuthReturn:()=>{const value=returned;returned=null;return value;},publicURL:()=>new URL("./courses-public.html",location.href).href}); +})(); diff --git a/cloud-transport.js b/cloud-transport.js index c2ba2fe..0c63f9b 100644 --- a/cloud-transport.js +++ b/cloud-transport.js @@ -21,6 +21,31 @@ const RC_CLOUD_TRANSPORT = (() => { function row(value){if(!value||!validRevision(value.revision)||!value.payload||typeof value.payload!=="object")throw Error("Réponse cloud invalide");bounded(value.payload);return value;} function create(cfg,options={}){ const base=config(cfg.url,cfg.key);let session=null,refreshing=null,generation=0,sessionNeedsSave=false,saving=null; + const expectedUser=options.expectedUser||cfg.owner||null,oauthKey="reconversion-control.oauth.pending.v1:"+base.url;let oauthGeneration=0; + if(expectedUser!==null&&!/^[0-9a-f-]{36}$/i.test(expectedUser))throw Error("Compte attendu invalide"); + function oauthStore(){const s=options.oauthStorage||(typeof sessionStorage!=="undefined"?sessionStorage:null);if(!s)throw Error("Stockage temporaire indisponible : connexion Google refusée");return s;} + function callbackURL(value){const u=new URL(value);if(u.search||u.hash||u.username||u.password)throw Error("Retour OAuth invalide");if(u.href!=="https://ipoower.github.io/Reconversion-Control/"&&!(u.protocol==="http:"&&["127.0.0.1","localhost"].includes(u.hostname)&&!u.username&&!u.password))throw Error("Retour OAuth non autorisé");return u.href;} + async function beginOAuth(callback){ + const ticket=generation,attempt=++oauthGeneration; + const target=callbackURL(callback),s=oauthStore(),verifier=b64(crypto.getRandomValues(new Uint8Array(32))).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,""); + const challenge=b64(new Uint8Array(await crypto.subtle.digest("SHA-256",te.encode(verifier)))).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,""); + if(ticket!==generation||attempt!==oauthGeneration)throw Error("Tentative Google annulée"); + const raw=JSON.stringify({v:1,project:base.url,provider:"google",callback:target,created:Date.now(),verifier});s.setItem(oauthKey,raw);if(s.getItem(oauthKey)!==raw)throw Error("Préparation OAuth non confirmée"); + const u=new URL(base.url+"/auth/v1/authorize");u.searchParams.set("provider","google");u.searchParams.set("redirect_to",target);u.searchParams.set("scopes","openid email profile");u.searchParams.set("code_challenge",challenge);u.searchParams.set("code_challenge_method","s256");u.searchParams.set("prompt","select_account");return u.href; + } + function cancelOAuth(){oauthGeneration++;try{const s=oauthStore();s.removeItem(oauthKey);if(s.getItem(oauthKey)!==null)throw Error("Suppression OAuth non confirmée");}catch(e){throw Error("Suppression de la tentative Google non confirmée");}} + async function completeOAuth(code,callback){ + const ticket=generation,s=oauthStore(),raw=s.getItem(oauthKey);cancelOAuth(); + if(!raw||raw.length>4096||typeof code!=="string"||!/^[-a-zA-Z0-9_]{16,1024}$/.test(code))throw Error("Retour Google non sollicité ou invalide"); + let p;try{p=JSON.parse(raw);}catch(e){throw Error("Tentative Google illisible");} + if(!p||p.v!==1||p.project!==base.url||p.provider!=="google"||p.callback!==callbackURL(callback)||!Number.isFinite(p.created)||Date.now()300000||typeof p.verifier!=="string"||!/^[-A-Za-z0-9_]{43}$/.test(p.verifier))throw Error("Tentative Google absente, expirée ou incompatible"); + const result=await request("/auth/v1/token?grant_type=pkce",{method:"POST",body:{auth_code:code,code_verifier:p.verifier}}); + if(!result||typeof result.access_token!=="string"||result.access_token.length>16384||!result.user)throw Error("Session Google invalide"); + const verified=await request("/auth/v1/user",{headers:{Authorization:"Bearer "+result.access_token}}); + if(ticket!==generation||!verified||verified.id!==result.user.id||verified.is_anonymous!==false||!Array.isArray(verified.identities)||!verified.identities.some(i=>i&&i.provider==="google"))throw Error("Identité Google non confirmée"); + const id=await accept({...result,user:verified},ticket); + return {id,email:typeof verified.email==="string"?verified.email:"",provider:"google"}; + } async function request(path,init={}){ const headers={apikey:base.key,"Content-Type":"application/json",...(init.headers||{})}; if(init.secure){const ticket=generation,token=await access();if(ticket!==generation)throw Error("Session annulée");headers.Authorization="Bearer "+token;} @@ -46,7 +71,7 @@ const RC_CLOUD_TRANSPORT = (() => { if(ticket!==generation)throw Error("Session annulée"); if(!j||!j.user||j.user.is_anonymous===true)throw Error("Authentification non confirmée"); const next={project:base.url,access_token:j.access_token,refresh_token:j.refresh_token,expires_at:Date.now()+Number(j.expires_in||3600)*1000,user:j.user.id}; - if(!validSession(next)||session&&session.user!==next.user)throw Error("Authentification non confirmée"); + if(!validSession(next)||expectedUser&&next.user!==expectedUser||session&&session.user!==next.user)throw Error("Authentification non confirmée : compte inattendu"); session=next;sessionNeedsSave=true;await persistSession(ticket); if(ticket!==generation)throw Error("Session annulée"); return session.user; @@ -66,7 +91,7 @@ const RC_CLOUD_TRANSPORT = (() => { return refreshing; } async function resume(saved){ - if(!validSession(saved))throw Error("Session mémorisée incompatible");session={...saved};const ticket=generation; + if(!validSession(saved)||expectedUser&&saved.user!==expectedUser)throw Error("Session mémorisée incompatible");session={...saved};const ticket=generation; const user=await request("/auth/v1/user",{secure:true}); if(ticket!==generation||!session||!user||user.id!==session.user||user.is_anonymous===true){session=null;throw Error("Session non confirmée");}return session.user; } @@ -107,8 +132,9 @@ const RC_CLOUD_TRANSPORT = (() => { const rows=await request("/rest/v1/reconversion_progress_history?user_id=eq."+encodeURIComponent(session.user)+"&revision=eq."+revision+"&select=revision,payload&limit=1",{secure:true}); 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,readRevision,write,history,historyVersion,logout}; + async function rememberSession(){if(!session)throw Error("Non connecté");sessionNeedsSave=true;await persistSession(generation);} + async function logout(){const previous=session;generation++;session=null;refreshing=null;try{cancelOAuth();}catch(e){}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,beginOAuth,completeOAuth,cancelOAuth,rememberSession,resume,read,readRevision,write,history,historyVersion,logout}; } async function encrypt(snapshot,phrase,context){ if(typeof phrase!=="string"||phrase.length<12||phrase.length>1024)throw Error("Phrase de chiffrement : 12 à 1024 caractères"); diff --git a/cloud-ui.js b/cloud-ui.js index a2677a4..ff7dcd2 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; + let env,client,cfg,email="",phrase="",remember=false,user="",scope="",meta={v:1},remote=null,remoteData=null,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(); @@ -23,7 +23,8 @@ const RC_CLOUD_UI = (() => { 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 msg='

'+safe(status)+'

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

URL et clé PUBLIQUE Supabase personnelles ; jamais de service_role.

Facultatif : évite un nouveau code email à chaque ouverture. La phrase reste nécessaire. À éviter sur un PC partagé ; un accès malveillant à cette page peut lire les secrets une fois déverrouillés.

'+msg; + 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+'
'; if(mode==="initial")controls='

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

'+disconnect+'
'; @@ -44,12 +45,49 @@ const RC_CLOUD_UI = (() => { const encrypted=await T().sealSession(session,phrase);guard(ticket);save(SESSION,{v:1,project:cfg.url,payload:encrypted}); }}); } + function readConfig(){ + const next=T().config(document.querySelector("#rc-sync-url").value,document.querySelector("#rc-sync-key").value); + if(cfg&&next.url===cfg.url&&cfg.owner)next.owner=cfg.owner;return next; + } + async function startGoogle(){ + if(busy)return;busy=true;const ticket=++epoch; + try{ + local();cfg=readConfig();save(CONFIG,cfg);remember=false;phrase="";client=makeClient(ticket); + const callback=new URL("./",location.href);callback.search="";callback.hash=""; + const target=await client.beginOAuth(callback.href);guard(ticket);busy=false;location.assign(target); + }catch(e){if(ticket===epoch){status="Connexion Google impossible : "+e.message;busy=false;repaint();}} + } + async function receiveGoogle(result){ + if(!cfg){status="Retour Google sans configuration : aucune donnée modifiée.";repaint();return;} + const ticket=++epoch;remember=false;phrase="";busy=true; + try{ + client=makeClient(ticket); + if(result.error){client.cancelOAuth();throw Error(result.error);} + const callback=new URL("./",location.href);callback.search="";callback.hash=""; + googleIdentity=await client.completeOAuth(result.code,callback.href);guard(ticket);user=googleIdentity.id; + mode="oauth";status="Compte vérifié. Aucun instantané téléchargé ou envoyé avant déverrouillage et choix de source."; + }catch(e){if(ticket===epoch){user="";googleIdentity=null;client=null;status="Connexion Google refusée : "+e.message;}} + finally{if(ticket===epoch){busy=false;repaint();}} + } + async function unlockGoogle(){ + if(busy||!client||!googleIdentity)return;busy=true;const ticket=epoch; + try{ + if(!document.querySelector("#rc-sync-confirm-account").checked)throw Error("Confirme le compte affiché avant de continuer."); + local();phrase=document.querySelector("#rc-sync-phrase").value; + if(phrase.length<12||phrase.length>1024)throw Error("Phrase secrète : 12 à 1024 caractères."); + if(cfg.owner&&cfg.owner!==user)throw Error("Compte différent du compte attendu : aucune progression transférée."); + cfg={...cfg,owner:user};save(CONFIG,cfg);remember=!!document.querySelector("#rc-sync-remember").checked; + if(remember)await client.rememberSession();else if(load(SESSION))remove(SESSION); + guard(ticket);loadMeta();await inspect(ticket); + }catch(e){if(ticket===epoch){status="Déverrouillage refusé : "+e.message;if(!["oauth","initial","choose"].includes(mode))mode="error";}} + finally{if(ticket===epoch){busy=false;repaint();}} + } async function start(){ if(busy)return;busy=true;const ticket=++epoch; try{ local(); // Read-only/stale tabs must not rotate a shared authentication session. const mail=document.querySelector("#rc-sync-email").value.trim(); - cfg=T().config(document.querySelector("#rc-sync-url").value,document.querySelector("#rc-sync-key").value); + cfg=readConfig(); remember=!!document.querySelector("#rc-sync-remember").checked;save(CONFIG,cfg); client=makeClient(ticket);await client.sendCode(mail);guard(ticket);email=mail;mode="otp";status="Vérifie ta boîte mail puis saisis le code reçu."; }catch(e){if(ticket===epoch)status="Connexion impossible : "+e.message;} @@ -233,19 +271,19 @@ 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="";remote=remoteData=null;revision=null;baseline="";meta={v:1};historyRows=[];busy=false;mode="setup"; + client=null;phrase="";email="";user="";googleIdentity=null;remote=remoteData=null;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();}} } function bind(){ const on=(id,fn)=>{const el=document.querySelector("#"+id);if(el)el.onclick=fn;}; - on("rc-sync-request",start);on("rc-sync-verify",confirmOtp);on("rc-sync-unlock",unlock);on("rc-sync-create",createFirst);on("rc-sync-take",chooseCloud);on("rc-sync-push",chooseLocal);on("rc-sync-disconnect",disconnect);on("rc-sync-now",tick);on("rc-sync-history",history); + on("rc-sync-google",startGoogle);on("rc-sync-google-unlock",unlockGoogle);on("rc-sync-request",start);on("rc-sync-verify",confirmOtp);on("rc-sync-unlock",unlock);on("rc-sync-create",createFirst);on("rc-sync-take",chooseCloud);on("rc-sync-push",chooseLocal);on("rc-sync-disconnect",disconnect);on("rc-sync-now",tick);on("rc-sync-history",history); if(document.querySelectorAll)document.querySelectorAll("[data-sync-history]").forEach(el=>{el.onclick=()=>restoreHistory(Number(el.dataset.syncHistory));}); } function attach(options){ env=options; - try{const saved=load(CONFIG);if(saved)cfg=T().config(saved.url,saved.key);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.";}} catch(e){status="Cloud suspendu : configuration locale illisible. Progression locale conservée.";} if(typeof window!=="undefined"&&window.addEventListener){ window.addEventListener("online",tick); @@ -253,6 +291,8 @@ const RC_CLOUD_UI = (() => { window.addEventListener("pageshow",()=>{if(mode==="active")tick();}); } if(document.addEventListener)document.addEventListener("visibilitychange",()=>{if(!document.hidden&&mode==="active")tick();}); + const returned=typeof RC_ACCESS_MODE!=="undefined"?RC_ACCESS_MODE.takeOAuthReturn():null; + if(returned)Promise.resolve().then(()=>receiveGoogle(returned)); return {render:view,bind,changed}; } return {attach}; diff --git a/courses-public.html b/courses-public.html new file mode 100644 index 0000000..e749bea --- /dev/null +++ b/courses-public.html @@ -0,0 +1,19 @@ + + + + + + + Reconversion Control · cours publics + + +
+

Reconversion Control · cours publics

+

Lecture sans compte et sans progression personnelle. Aucun déverrouillage, aucune synchronisation et aucun enregistrement de réponses sur cet appareil.

+

Un ordinateur professionnel peut être supervisé. Ce lecteur ne rend pas ta navigation confidentielle vis-à-vis de son administrateur.

+ +
+ +
+ + diff --git a/courses-public.js b/courses-public.js new file mode 100644 index 0000000..eae8ff6 --- /dev/null +++ b/courses-public.js @@ -0,0 +1,16 @@ +"use strict"; +/* Public teaching catalog only. No storage, Auth, analytics, imports or service worker. */ +(()=>{ + const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); + const list=document.querySelector("#public-courses"),article=document.querySelector("#public-lesson"),search=document.querySelector("#public-search"); + const title=l=>{const t=RC_TOPICS.find(t=>t.id===l.id);return t?t.title:l.title||l.id;}; + const answer=(name,text)=>'
'+esc(name)+'

'+esc(text)+'

'; + function show(id){ + const l=RC_LESSONS.find(l=>l.id===id);if(!l)return; + article.hidden=false; + article.innerHTML='

'+esc(title(l))+'

'+esc(l.provenance||"Support historique · adapté pour apprendre")+'

Objectifs

'+l.sections.map(p=>'

'+esc(p.title)+'

'+p.body.map(t=>'

'+esc(t)+'

').join("")+(p.code?'
'+esc(p.code)+'
':"")+'
').join("")+'

Pièges

Entretien

'+esc(l.interview.question)+'

'+answer("Voir la réponse",l.interview.answer)+'

Exercice

'+esc(l.exercise.prompt)+'

'+answer("Voir le corrigé",l.exercise.answer)+(l.quiz||[]).map(q=>'

'+esc(q.question)+'

'+answer("Réponse",q.answer)).join("")+'

Glossaire

'+(l.glossary||[]).map(g=>'
'+esc(g.term)+'
'+esc(g.definition)+'
').join("")+'

Sources

'; + article.querySelector("h2").focus(); + } + function render(){const q=search.value.toLocaleLowerCase("fr");list.innerHTML=RC_LESSONS.filter(l=>(title(l)+" "+l.sections.map(p=>p.title+" "+p.body.join(" ")).join(" ")).toLocaleLowerCase("fr").includes(q)).map(l=>'').join("");list.querySelectorAll("[data-public-lesson]").forEach(b=>b.onclick=()=>show(b.dataset.publicLesson));} + search.oninput=render;render(); +})(); diff --git a/docs/google-auth.md b/docs/google-auth.md new file mode 100644 index 0000000..44be915 --- /dev/null +++ b/docs/google-auth.md @@ -0,0 +1,93 @@ +# Google OAuth sans domaine — Reconversion Control + +Auteur : Bryan Fiarda. 25 octobre 2026. + +Ce changement prolonge le candidat #33 (4570c111…). Il ne fusionne ni ne publie le code, ne crée pas d'utilisateur personnel et ne transmet pas de progression personnelle. Aucune migration SQL n'est nécessaire. AES-256-GCM/PBKDF2, DTO cloud V2 issu du snapshot atomique V3, outbox, révisions, RLS et rétention 20 restent inchangés. + +## Deux usages + +- **Cours publics** : `courses-public.html` ne charge ni app.js, ni cloud-ui, ni transport Auth. Il ne lit ni localStorage/IndexedDB, ni session personnelle, et ne conserve pas de réponses. CSP connect-src none ; pas d'analytics ni de service worker propre. Le démarrage normal propose cet accès avant tout chargement de progression. +- **Appareil personnel** : choix explicite avant de charger l'application et son stockage. La déclaration est un choix d'usage, pas une détection fiable d'un PC administré. Google identifie le compte ; la phrase indépendante déverrouille ensuite le chiffrement. Une session mémorisée demeure chiffrée et facultative. La fermeture sans mémorisation perd la session en mémoire, pas le snapshot local. + +Un PC professionnel peut enregistrer écran, frappes, fichiers et trafic. Ne jamais y ouvrir le mode personnel ou saisir une phrase. Une déconnexion ne supprime pas la progression locale en clair. Aucune purge de stockage n'est effectuée. Une URL de cours sous la même origine ne constitue pas une isolation : les chemins GitHub Pages d'un même hôte partagent localStorage. Le lecteur public peut être distribué séparément sur une **origine gratuite distincte**, sans déplacer les données personnelles. Le build public décrit plus bas est préparé ; aucun nouvel hébergement n'est créé ou publié. + +## Contrat de connexion + +1. Depuis un appareil personnel, préparer un vérificateur PKCE aléatoire 32 octets et son SHA-256 S256. Une tentative unique, liée au projet et au callback exact, est conservée dans sessionStorage pour cinq minutes maximum. Aucun access/refresh token ni phrase n'y est conservé. +2. Navigation de premier niveau vers Supabase `/auth/v1/authorize`, provider google, scopes **openid email profile** uniquement. Supabase conserve le contrôle de son state signé ; l'application ne le remplace pas. Pas de One Tap, iframe, script Google externe, Google Drive, Gmail ou accès offline aux API Google. +3. Le bootstrap retire immédiatement code et erreurs de l'URL avant de charger le code personnel. Le code OAuth temporaire apparaît nécessairement dans le GET de callback ; le nettoyage d'historique ne peut effacer les journaux du fournisseur d'hébergement. PKCE lie ce code à son vérificateur local ; aucun Bearer/refresh token n'est accepté depuis une URL. +4. Échange unique POST `/auth/v1/token?grant_type=pkce`, puis GET `/auth/v1/user` avec le Bearer Supabase reçu. Vérifier UUID, non-anonymat et identité Google. Un UUID différent du propriétaire déjà confirmé est refusé avant tout accès REST de progression. Le décodage local d'un JWT n'est pas utilisé comme preuve d'identité. +5. L'utilisateur confirme le compte affiché et saisit sa phrase. L'UUID du propriétaire est mémorisé comme métadonnée de configuration, jamais remplacé silencieusement. Les sessions héritées et les anciens formats cryptographiques restent lisibles ; ne pas déplacer un ancien journal vers un nouvel UUID. +6. Seulement ensuite : lecture/déchiffrement/validation, choix explicite de source initiale et envoi conditionnel. Aucun premier upload provoqué par Google seul. Les jetons de fournisseur Google sont ignorés ; seules les données de session Supabase nécessaires passent à la mémorisation chiffrée existante. + +Un retour sans tentative, avec mauvais projet, callback, vérificateur, date future ou identité inattendue échoue sans restaurer ni envoyer de progression. Une réponse Auth perdue exige de recommencer la connexion ; elle n'autorise pas une réinitialisation du stockage. Les réponses de sauvegarde perdues suivent toujours la réconciliation idempotente existante. + +## Paramètres administratifs nécessaires — ne pas confondre préparation et exécution + +| Paramètre | Valeur | +| --- | --- | +| Projet Supabase unique | jsxbpxlipjmkszfektis | +| Projet ancien exclu | zjydqnkyqshpnteabpat | +| Client Google | Application Web, dans un projet Google Cloud personnel consacré à cette app | +| Origine JavaScript | https://ipoower.github.io (sans chemin) | +| URI de callback Google | **https://jsxbpxlipjmkszfektis.supabase.co/auth/v1/callback** | +| Site URL et redirect Supabase | **https://ipoower.github.io/Reconversion-Control/**, entrée exacte, aucune wildcard | +| Scopes | openid, userinfo.email, userinfo.profile, sans autre permission | +| Secret client | Uniquement dans la configuration serveur Supabase, jamais dans le dépôt, l'app, un export ou une conversation | +| Skip nonce checks | Désactivé | +| Utilisateurs sans email / anonymes | Refusés | + +Le domaine Supabase fourni peut servir de callback ; aucun achat de domaine, SMTP ou abonnement Google Identity Platform n'est nécessaire à ce parcours. Le branding personnalisé et un domaine personnalisé sont des sujets distincts. Les exceptions Google aux restrictions Testing pour les trois scopes d'identité signifient que **la liste des test users n'est pas une barrière d'accès privée**. Les inscriptions Supabase doivent être fermées avant l'exploitation personnelle ; ne pas compter sur un bouton masqué comme contrôle d'autorisation. + +Préparer, sous GO distinct de création d'utilisateur personnel, un unique utilisateur confirmé par le mécanisme **Auth Admin légitime**, puis vérifier en environnement de recette l'association automatique Google à son UUID avec inscriptions fermées. L'Admin createUser ne requiert pas de SMTP ; il ne faut pas écrire directement dans auth.users ou désactiver globalement la confirmation email. Le choix exact de l'identité et son contrôle appartiennent à l'utilisateur. Tout échec d'association bloque l'activation ; aucun contournement RLS ni nouvelle fenêtre d'inscription publique ne doit être improvisé. + +**État constaté le 25/10 :** projet Healthy ; catalogue identique au contrôle précédent, zéro utilisateur et zéro ligne des deux tables. Google et GitHub désactivés, email activé, anonymes refusés, nonce checks conservés, Site URL localhost:3000 et redirects vides. Aucun réglage modifié pendant l'audit. La console Google Cloud affiche « Site Unavailable » dans le navigateur de travail après une seule relance ; le client OAuth n'a donc pas été créé et le provider n'a pas été activé. L'accès administrateur Supabase seul ne suffit pas à inventer les identifiants Google. Aucun plugin disponible identifié ne fournit l'administration OAuth Google Cloud. + +## Sessions, iPhone et récupération + +Le moteur Supabase existant renouvelle ses propres sessions. La révocation Google, la session Supabase et la clé de chiffrement sont distinctes. Déconnecter l'app ne déconnecte pas le compte Google du navigateur ; un access token Supabase déjà émis peut rester valide jusqu'à expiration malgré la révocation de son refresh token. La phrase reste en mémoire ; le chiffrement de session ne protège pas un appareil compromis pendant son déverrouillage. + +Safari et la PWA installée peuvent avoir des stockages distincts. PKCE exige de retrouver le même vérificateur dans le contexte qui a lancé la connexion. Une bascule de contexte doit produire un refus clair et une nouvelle connexion depuis le bon contexte, jamais un transfert de tokens par URL, copier-coller ou stockage en clair. Le comportement effectif des redirections doit être vérifié sur l'iPhone physique. Une PWA fermée/suspendue ne synchronise pas en permanence ; reprise après ouverture, déverrouillage et réseau. + +Avant toute recette personnelle : exports PC et iPhone séparés, copie indépendante, réimport vérifié dans un profil isolé, choix de source. La perte du compte Google exige sa récupération ou une intervention Auth contrôlée conservant l'UUID ; ne pas recréer un utilisateur en prétendant qu'il retrouve automatiquement les sauvegardes de l'ancien. La perte de la phrase ne peut pas être réparée par Google ou Supabase. Conserver toute copie locale lisible et ses exports. + +## Validation + +Distribution publique isolée, préparée à partir d'un commit exact, sans publication : + +```sh +python3 .github/scripts/stage-public-courses.py HEAD /tmp/reconversion-cours-publics +``` + +La destination doit être nouvelle. Elle contient uniquement les huit fichiers pédagogiques autorisés et leur manifeste SHA-256 ; ni application personnelle, ni Auth, ni service worker, ni secrets. Un hébergement statique gratuit sur une origine distincte reste à choisir et autoriser. Aucun service supplémentaire n'est nécessaire à la synchronisation personnelle. + +```sh +node tests/cloud-oauth.cjs +BROWSER=chromium node tests/cloud-oauth-browser.cjs +BROWSER=webkit node tests/cloud-oauth-browser.cjs +``` + +La nouvelle suite navigateur vérifie accès public sans données privées, retour PKCE, confirmation du compte et de la phrase, deux clients, sauvegarde chiffrée relue, restauration après fermeture/réouverture, mauvaise phrase, hors ligne, réponse perdue, mauvais compte et logs canaris. **Tout HTTP fournisseur est intercepté.** Les tests SQL/cryptographiques/concurrence et de publication existants restent exécutés. Leur amorçage choisit explicitement l'appareil personnel ; aucune assertion métier n'est supprimée pour contourner le nouvel écran d'accès. + +Cette CI ne valide ni Google réel, ni une nouvelle session Auth hébergée, ni un iPhone physique. La recette hébergée doit utiliser deux identités de test légitimes et des données synthétiques dans un périmètre isolé explicitement autorisé, sans conserver tokens/HAR/logs Auth bruts. Les comptes fictifs SQL dans une transaction annulée ne remplacent pas cette preuve. Les comptes et fixtures hébergés persistants ne sont pas supprimés sans autorisation. + +## Sécurité GitHub et livraison + +Main e8b5dd49… est non protégée et aucun ruleset n'est déclaré au contrôle du 25/10. Le dépôt public permet une protection gratuite : PR obligatoire, provenance + mobile (chromium) + mobile (webkit) requis sur SHA exact et base à jour, force push/suppression de main interdits, pas de bypass administrateur. Une revue GitHub obligatoire par un autre auteur nécessite un second reviewer qualifié : l'auteur iPoower ne peut pas approuver sa propre PR. Ne pas activer une règle impossible à satisfaire pour ensuite ajouter un contournement. + +Conserver séparément github-pages : iPoower approbateur, aucun bypass, self-review possible, branche main seule. Les paramètres Pages étaient vérifiés dans les archives du 24/10 ; leur configuration administrative n'a pas été changée ici. Le workflow et les contrôles de provenance conservent leurs conditions fail-closed. Aucun déploiement de cette branche n'est autorisé. + +Sources officielles consultées le 25/10 : + +- https://supabase.com/docs/guides/auth/social-sign-in/auth-google +- https://supabase.com/docs/guides/auth/sessions/pkce-flow +- https://github.com/supabase/auth/blob/master/openapi.yaml +- https://support.google.com/cloud/answer/15549945?hl=en +- https://developers.google.com/identity/protocols/oauth2/production-readiness/brand-verification +- https://supabase.com/docs/guides/auth/general-configuration +- https://supabase.com/docs/reference/javascript/auth-admin-createuser +- https://supabase.com/docs/guides/auth/auth-identity-linking +- https://webkit.org/blog/14787/webkit-features-in-safari-17-2/ +- https://developer.apple.com/videos/play/wwdc2023/10120/ +- https://supabase.com/pricing +- https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches diff --git a/index.html b/index.html index 5cd9cd0..5000ceb 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,7 @@ + Reconversion Control @@ -17,6 +18,8 @@ +
+