/* Vault client: derive the password key PK in the browser and drive * the per-user service's vault endpoints. The password never leaves the browser — * PK = Argon2id(password, "euler-vault-pk:" | SRP salt), from the vendored hash-wasm * (base.html loads it first), is sent only to the signed-in user's OWN service (same * origin, TLS), which unwraps the vault key with it. PK can unwrap VK, so it is * handled as a secret: kept in sessionStorage (this tab only, gone when the tab closes), * never logged, never sent anywhere else. Loaded on every page through base.html (the * sign-in form, auto-unlock, account-panel recovery). CSP-clean; Node-loadable for the * interop tests. */'use strict';(()=>{if(globalThis.Vault)return;// idempotent across htmx fragment re-injection// Keep in sync with packages/solver/solver/crypto/wire.py: VAULT_KDF and VAULT_ARGON2_LABEL// (tests/test_srp_interop.py pins them).constKDF='argon2id$m=65536,t=3,p=1';constARGON2_LABEL='euler-vault-pk:';constPK_KEY='euler.vault.pk';constSALT_KEY='euler.vault.salt';consthexToBytes=(hex)=>Uint8Array.from(hex.match(/../g).map((h)=>parseInt(h,16)));functionargon2(){if(globalThis.hashwasm)returnglobalThis.hashwasm;returntypeofrequire==='function'?require('../vendor/hash-wasm/argon2.umd.min.js'):null;}/* PK from the password + salt: Argon2id with kdf's parameters (default: the current ones) — * identical to the server's derive_password_key. */asyncfunctionderivePk(password,saltHex,kdf){constencoded=newTextEncoder().encode(password);constparams=/^argon2id\$m=(\d+),t=(\d+),p=(\d+)$/.exec(kdf||KDF);if(!params)thrownewError('vault: unsupported password hash '+kdf);constlib=argon2();if(!lib)thrownewError('vault: the Argon2 module did not load');constlabel=newTextEncoder().encode(ARGON2_LABEL);constsalt=newUint8Array(label.length+saltHex.length/2);salt.set(label);salt.set(hexToBytes(saltHex),label.length);returnlib.argon2id({password:encoded,salt,memorySize:Number(params[1]),iterations:Number(params[2]),parallelism:Number(params[3]),hashLength:32,outputType:'hex',});}/* PK for an existing vault, with its own recorded parameters: `info` is /vault/status's body. */functionderivePkFor(password,info){returnderivePk(password,info.salt,info.kdf);}functionstore(pkHex,saltHex){try{sessionStorage.setItem(PK_KEY,pkHex);sessionStorage.setItem(SALT_KEY,saltHex);}catch(e){/* private mode: unlock simply won't be automatic */}}functionstored(){try{constpk=sessionStorage.getItem(PK_KEY);constsalt=sessionStorage.getItem(SALT_KEY);returnpk&&salt?{pk,salt}:null;}catch(e){returnnull;}}functionclear(){try{sessionStorage.removeItem(PK_KEY);sessionStorage.removeItem(SALT_KEY);}catch(e){/* nothing stored anyway */}}asyncfunctionpostJson(path,body){returnfetch(path,{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify(body),});}/* Idempotent session unlock: 'unlocked' | 'no-pk' | 'stale' | 'error'. * First login initialises the vault server-side (the salt rides along). */asyncfunctionunlock(){constcreds=stored();if(!creds)return'no-pk';try{conststatus=awaitfetch('/vault/status',{credentials:'same-origin'});if(!status.ok)return'error';if((awaitstatus.json()).unlocked)return'unlocked';constresp=awaitpostJson('/vault/unlock',{pk:creds.pk,salt:creds.salt});if(resp.ok)return'unlocked';returnresp.status===409?'stale':'error';}catch(e){return'error';}}globalThis.Vault={derivePk,derivePkFor,store,stored,clear,unlock,postJson,KDF};})();if(typeofmodule!=='undefined'&&module.exports)module.exports=globalThis.Vault;// node (tests)