Change-password (signed-in): prove the CURRENT password over SRP, then submit the {salt, verifier} derived from the NEW one — neither password leaves the browser.
/* Change-password (signed-in): prove the CURRENT password over SRP, then submit * the {salt, verifier} derived from the NEW one — neither password leaves the * browser. Distinct from the forgot/reset flow (no mailbox trip). * * Wires on both the standalone auth page (DOMContentLoaded) and the content * shell's left pane (htmx:afterSwap, when this script is re-injected with the * fragment). Idempotent: a form is wired once (dataset guard). On success it * re-keys the vault and then signs this session out too: the change already ended * every other session, and this one still holds the old vault key in its shell. * The next sign-in, with the new password, opens the re-keyed vault. */'use strict';(()=>{constMIN_LENGTH=16;// keep in sync with packages/site/euler_site/auth/policy.pyfunctionpolicyError(password){if(password.length<MIN_LENGTH)return`Use at least ${MIN_LENGTH} characters.`;constclasses=[/[a-z]/,/[A-Z]/,/[0-9]/,/[^a-zA-Z0-9]/];if(!classes.every(re=>re.test(password))){return'Use all four classes: lowercase, uppercase, digits, and specials.';}returnnull;}asyncfunctionpost(path,body){returnfetch(path,{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify(body),});}asyncfunctionsucceed(){// Sign out here as well, the same way the user menu does (forget PK, disarm the// terminal's leave-page guard), then land on the sign-in page, which returns to// /account. The sign-out is best-effort: the session is already the only one left,// and a failed POST still leaves the browser on the sign-in page.if(window.Vault)window.Vault.clear();constterminal=document.getElementById('terminal');if(terminal&&terminal.contentWindow){terminal.contentWindow.postMessage({euler:'disarm'},window.location.origin);}try{awaitpost('/auth/logout',{});}catch(err){/* landing on sign-in either way */}window.location.replace('/login?next='+encodeURIComponent('/account'));}functionwire(form){if(!form||form.dataset.wired)return;form.dataset.wired='1';consterrorBox=form.querySelector('#form-error');constsubmit=form.querySelector('#submit-btn');constfail=(message)=>{errorBox.textContent=message;errorBox.hidden=false;submit.disabled=false;};form.addEventListener('submit',async(event)=>{event.preventDefault();errorBox.hidden=true;submit.disabled=true;constcurrent=form.querySelector('#current').value;constnext=form.querySelector('#new1').value;constagain=form.querySelector('#new2').value;if(next!==again)returnfail('The new passwords do not match.');constproblem=policyError(next);if(problem)returnfail(problem);if(next===current)returnfail('The new password must differ from the current one.');constemail=form.dataset.email;try{// 1 · prove the current password (the same handshake as signing in)constclient=awaitSRP.startLogin(email,current);constchallenge=awaitpost('/auth/challenge',{email:client.email});if(!challenge.ok)returnfail('Could not start the change. Try again.');const{salt,B,id,kdf}=awaitchallenge.json();constproof=awaitclient.respond(salt,B,kdf);// 2 · derive the new credentials and swap them in one atomic exchangeconstderived=awaitSRP.computeVerifier(email,next);constchange=awaitpost('/auth/password',{A:proof.A,M1:proof.M1,id,salt:derived.salt,verifier:derived.verifier,kdf:derived.kdf,});if(change.status===401)returnfail('The current password is incorrect.');if(!change.ok)returnfail('The change was refused. Try again.');const{M2}=awaitchange.json();if(!proof.check(M2))returnfail('Server failed mutual authentication — password unchanged? Sign in to verify.');// 3 · the secrets survive a password change, under a fresh vault key: re-key// the vault to the new password's PK. The old PK is derived the way the vault// was made (its kdf and salt, from /vault/status), so a vault from before Argon2// moves across too. Best-effort — no vault yet is made at the next sign-in; 409// means the vault was wrapped under something else (the account panel recovers).try{if(window.Vault){conststatus=awaitfetch('/vault/status',{credentials:'same-origin'});constinfo=status.ok?awaitstatus.json():{vault:false};if(info.vault){constnewPk=awaitVault.derivePk(next,derived.salt);constoldPk=awaitVault.derivePkFor(current,info);awaitVault.postJson('/vault/rekey',{old_pk:oldPk,new_pk:newPk,new_salt:derived.salt});}}}catch(err){/* recovery stays available on the account panel */}awaitsucceed();}catch(err){fail('Change failed: '+(err&&err.message?err.message:'unexpected error'));}});}constinit=()=>wire(document.getElementById('password-form'));if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',init);// standalone auth page}else{init();// re-run per htmx swap}// Belt-and-suspenders for the in-shell case, registered once (the wire() guard// makes it idempotent even if the script also re-ran on the swap).if(window.htmx&&!window.__pwAfterSwap){window.__pwAfterSwap=true;document.body.addEventListener('htmx:afterSwap',init);}})();