/* SRP-6a browser client for the euler auth service. * * Interoperates byte-for-byte with packages/site/euler_site/auth/srp.py: RFC 5054 2048-bit * group, g = 2, SHA-256 throughout, and every integer fed to the hash (A, B, * S, N, g) left-padded to the byte length of N (256 bytes). The email is * normalised (trim + lowercase) exactly like the server's store key. * * The password never leaves the browser: registration derives {salt, verifier} * locally, login runs the zero-knowledge handshake. Plain script (no modules, * CSP 'self'); exposes a single global `SRP`. Also loadable under Node for the * repo's interop tests. * * The password hash inside x is Argon2id, from the vendored hash-wasm (loaded before * this script); its parameters are per account and named by the challenge (`kdf`), and * a new verifier always uses KDF below (srp.py § the password hash). */'use strict';// globalThis.SRP, defined once and idempotently: htmx may re-inject this script// when the change-password fragment swaps into the content pane a second time. No// top-level `const SRP` (that would redeclare on re-execution) — the guard makes// a second run a no-op; unqualified `SRP` in the sibling scripts resolves here.// globalThis (not window) so the Node interop test can load it too.globalThis.SRP=globalThis.SRP||(()=>{constN_HEX='AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050'+'A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50'+'E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8'+'55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B'+'CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748'+'544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6'+'AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6'+'94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73';constN=BigInt('0x'+N_HEX);constg=2n;constN_BYTES=256;constSALT_BYTES=16;// Must equal srp.py's KDF (tests/test_srp_interop.py pins it).constKDF='argon2id$m=65536,t=3,p=1';constARGON2_LABEL='euler-srp-x:';// The vendored hash-wasm global in a page; its file beside this one under Node. Looked up// at use, not here: this object outlives htmx swaps, and the bundle may land after it.functionargon2(){if(globalThis.hashwasm)returnglobalThis.hashwasm;returntypeofrequire==='function'?require('../vendor/hash-wasm/argon2.umd.min.js'):null;}constsubtle=globalThis.crypto.subtle;// ── byte/int helpers ─────────────────────────────────────────────────────────constte=newTextEncoder();functionconcat(...parts){consttotal=parts.reduce((n,p)=>n+p.length,0);constout=newUint8Array(total);letoffset=0;for(constpofparts){out.set(p,offset);offset+=p.length;}returnout;}functionbytesToHex(bytes){returnArray.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}functionhexToBytes(hex){if(hex.length%2)hex='0'+hex;constout=newUint8Array(hex.length/2);for(leti=0;i<out.length;i++)out[i]=parseInt(hex.slice(2*i,2*i+2),16);returnout;}functionbytesToInt(bytes){returnBigInt('0x'+(bytesToHex(bytes)||'0'));}// Left-pad an integer to the byte length of N (the SRP PAD operation).functionpad(value){consthex=value.toString(16).padStart(N_BYTES*2,'0');returnhexToBytes(hex);}asyncfunctionhash(...parts){returnnewUint8Array(awaitsubtle.digest('SHA-256',concat(...parts)));}asyncfunctionhashInt(...parts){returnbytesToInt(awaithash(...parts));}functionmodPow(base,exp,mod){base%=mod;letresult=1n;while(exp>0n){if(exp&1n)result=(result*base)%mod;base=(base*base)%mod;exp>>=1n;}returnresult;}functionrandomInt(bytes){constbuf=newUint8Array(bytes);globalThis.crypto.getRandomValues(buf);returnbytesToInt(buf);}functionnormalizeEmail(email){returnemail.trim().toLowerCase();}// x = H(salt | Argon2id(email ":" password, "euler-srp-x:" | salt)), with the Argon2id// parameters the account's kdf names.asyncfunctionderiveX(saltBytes,email,password,kdf){constsecret=`${email}:${password}`;constparams=/^argon2id\$m=(\d+),t=(\d+),p=(\d+)$/.exec(kdf);if(!params)thrownewError('SRP: unsupported password hash '+kdf);constlib=argon2();if(!lib)thrownewError('SRP: the Argon2 module did not load');constinner=awaitlib.argon2id({password:secret,salt:concat(te.encode(ARGON2_LABEL),saltBytes),memorySize:Number(params[1]),iterations:Number(params[2]),parallelism:Number(params[3]),hashLength:32,outputType:'binary',});returnhashInt(saltBytes,inner);}// ── registration: derive {salt, verifier, kdf} locally ──────────────────────asyncfunctioncomputeVerifier(email,password){constsalt=newUint8Array(SALT_BYTES);globalThis.crypto.getRandomValues(salt);constx=awaitderiveX(salt,normalizeEmail(email),password,KDF);return{salt:bytesToHex(salt),verifier:modPow(g,x,N).toString(16),kdf:KDF};}// ── login: the zero-knowledge handshake ──────────────────────────────────────// Usage: const c = await SRP.startLogin(email, password);// POST /auth/challenge {email} -> {salt, B, id, kdf}// const {A, M1, check} = await c.respond(salt, B, kdf);// POST /auth/verify {email, A, M1, id} -> {M2}; await check(M2) must be true.asyncfunctionstartLogin(email,password){constnorm=normalizeEmail(email);leta=0n;while(a===0n)a=randomInt(32)%(N-1n)+1n;constA=modPow(g,a,N);constk=awaithashInt(pad(N),pad(g));asyncfunctionrespond(saltHex,bHex,kdf){constB=BigInt('0x'+bHex);if(B%N===0n)thrownewError('SRP: invalid server value B');constsalt=hexToBytes(saltHex);constu=awaithashInt(pad(A),pad(B));if(u===0n)thrownewError('SRP: u must not be zero');constx=awaitderiveX(salt,norm,password,kdf);// S = (B - k * g**x) ** (a + u * x) mod N (keep the base non-negative)constbase=((B-k*modPow(g,x,N))%N+N)%N;constS=modPow(base,a+u*x,N);constK=awaithash(pad(S));consthN=awaithash(pad(N));consthG=awaithash(pad(g));constprefix=hN.map((b,i)=>b^hG[i]);constM1=awaithash(prefix,awaithash(te.encode(norm)),salt,pad(A),pad(B),K);constexpectedM2=awaithash(pad(A),M1,K);return{A:A.toString(16),M1:bytesToHex(M1),M2:bytesToHex(expectedM2),// what the server must prove back// Verify the server proof M2 — mutual authentication.check:(m2Hex)=>bytesToHex(expectedM2)===m2Hex.toLowerCase(),};}return{email:norm,respond};}return{computeVerifier,startLogin,normalizeEmail,KDF};})();if(typeofmodule!=='undefined'&&module.exports)module.exports=globalThis.SRP;// node (tests)