| <!doctype html> |
| <html lang="en" data-theme="dark"> |
| <head> |
| <meta charset="utf-8"/> |
| <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/> |
| <title>AmanPay — Your biometrics are your card</title> |
| <meta name="description" content="Cardless biometric banking. Pay with your face, fingerprint and voice — no plastic, no card numbers."/> |
| <link rel="manifest" href="/manifest.json"/> |
| <meta name="theme-color" content="#0b1020"/> |
| <meta name="apple-mobile-web-app-capable" content="yes"/> |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/> |
| <meta name="apple-mobile-web-app-title" content="AmanPay"/> |
| <link rel="apple-touch-icon" href="/icon-192.png"/> |
| <link rel="icon" type="image/png" href="/icon-192.png"/> |
| <style> |
| :root{ |
| --bg:#0b1020; --surface:#121a30; --surface-2:#1a2440; --border:#26314f; |
| --text:#e8ecf6; --muted:#94a3c4; --primary:#6366f1; --primary-600:#4f46e5; |
| --success:#10b981; --danger:#f43f5e; --warn:#f59e0b; --ring:rgba(99,102,241,.35); |
| --radius:16px; --shadow:0 10px 30px rgba(0,0,0,.35); |
| --font:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif; |
| } |
| :root[data-theme="light"]{ |
| --bg:#f5f7fc; --surface:#ffffff; --surface-2:#f0f3fa; --border:#e2e8f4; |
| --text:#0f172a; --muted:#5b6b8c; --shadow:0 10px 30px rgba(30,41,90,.10); |
| } |
| *{box-sizing:border-box} |
| body{margin:0;font-family:var(--font);background:var(--bg);color:var(--text); |
| -webkit-font-smoothing:antialiased;line-height:1.5} |
| a{color:var(--primary)} |
| .wrap{max-width:960px;margin:0 auto;padding:0 20px} |
| /* header */ |
| header{position:sticky;top:0;z-index:20;backdrop-filter:blur(12px); |
| background:color-mix(in srgb,var(--bg) 80%,transparent);border-bottom:1px solid var(--border)} |
| .bar{display:flex;align-items:center;gap:16px;height:64px} |
| .brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:18px} |
| .logo{width:32px;height:32px;border-radius:9px;background:linear-gradient(135deg,var(--primary),#22d3ee); |
| display:grid;place-items:center;color:#fff;font-weight:800;box-shadow:0 4px 14px var(--ring)} |
| nav{display:flex;gap:4px;margin-left:auto;flex-wrap:wrap} |
| .tab{border:0;background:transparent;color:var(--muted);padding:8px 14px;border-radius:10px; |
| font-weight:600;font-size:14px;cursor:pointer;transition:.15s} |
| .tab:hover{color:var(--text);background:var(--surface-2)} |
| .tab.active{color:#fff;background:var(--primary-600)} |
| .icon-btn{border:1px solid var(--border);background:var(--surface);color:var(--text); |
| width:38px;height:38px;border-radius:10px;cursor:pointer;font-size:16px} |
| /* trust strip */ |
| .trust{display:flex;flex-wrap:wrap;gap:8px;padding:12px 0} |
| .chip{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:var(--muted); |
| background:var(--surface);border:1px solid var(--border);padding:6px 11px;border-radius:999px} |
| .chip b{color:var(--text);font-weight:600} |
| /* cards */ |
| .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius); |
| padding:22px;box-shadow:var(--shadow)} |
| .card h2{margin:0 0 4px;font-size:20px} |
| .card .sub{color:var(--muted);font-size:14px;margin-bottom:18px} |
| .grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px} |
| @media(max-width:680px){.grid2{grid-template-columns:1fr}nav{display:none}.mobnav{display:flex}} |
| label{display:block;font-size:13px;font-weight:600;color:var(--muted);margin:0 0 6px} |
| input[type=text]{width:100%;background:var(--surface-2);border:1px solid var(--border); |
| color:var(--text);border-radius:11px;padding:12px 14px;font-size:15px;outline:none} |
| input[type=text]:focus{border-color:var(--primary);box-shadow:0 0 0 4px var(--ring)} |
| /* dropzone */ |
| .drop{border:1.5px dashed var(--border);border-radius:14px;background:var(--surface-2); |
| aspect-ratio:1;display:grid;place-items:center;cursor:pointer;overflow:hidden;position:relative;transition:.15s} |
| .drop:hover{border-color:var(--primary)} |
| .drop.filled{border-style:solid} |
| .drop img,.drop video{width:100%;height:100%;object-fit:cover} |
| .drop .ph{text-align:center;color:var(--muted);padding:14px;font-size:13px} |
| .drop .ph .big{font-size:26px;margin-bottom:6px} |
| .rowbtns{display:flex;gap:8px;margin-top:8px} |
| .mini{flex:1;border:1px solid var(--border);background:var(--surface);color:var(--text); |
| padding:8px;border-radius:10px;font-size:12.5px;font-weight:600;cursor:pointer} |
| .mini:hover{background:var(--surface-2)} |
| .btn{width:100%;border:0;border-radius:12px;padding:14px;font-size:15.5px;font-weight:700;cursor:pointer; |
| background:linear-gradient(135deg,var(--primary),var(--primary-600));color:#fff;margin-top:18px;transition:.15s} |
| .btn:hover{filter:brightness(1.07)} |
| .btn:disabled{opacity:.6;cursor:progress} |
| .btn.ghost{background:var(--surface-2);color:var(--text);border:1px solid var(--border)} |
| /* result */ |
| .result{margin-top:18px;border-radius:14px;padding:18px;border:1px solid var(--border);display:none} |
| .result.show{display:block;animation:pop .25s ease} |
| @keyframes pop{from{transform:scale(.98);opacity:0}to{transform:scale(1);opacity:1}} |
| .verdict{display:flex;align-items:center;gap:12px;font-size:22px;font-weight:800} |
| .dot{width:12px;height:12px;border-radius:50%} |
| .ok{color:var(--success)} .ok .dot{background:var(--success);box-shadow:0 0 0 6px rgba(16,185,129,.18)} |
| .no{color:var(--danger)} .no .dot{background:var(--danger);box-shadow:0 0 0 6px rgba(244,63,94,.18)} |
| .meter{height:8px;border-radius:999px;background:var(--surface-2);overflow:hidden;margin:14px 0 6px} |
| .meter>span{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--primary),#22d3ee);transition:width .5s} |
| .chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px} |
| .stat{background:var(--surface-2);border:1px solid var(--border);border-radius:10px;padding:8px 12px;font-size:12.5px} |
| .stat b{display:block;font-size:15px;margin-top:2px;color:var(--text)} |
| .badge{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700;padding:5px 10px;border-radius:999px} |
| .badge.g{background:rgba(16,185,129,.14);color:var(--success)} |
| .badge.r{background:rgba(244,63,94,.14);color:var(--danger)} |
| .badge.b{background:rgba(99,102,241,.16);color:#a5b4fc} |
| /* report card */ |
| .tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px} |
| .tile{background:var(--surface-2);border:1px solid var(--border);border-radius:14px;padding:16px} |
| .tile .k{color:var(--muted);font-size:12.5px} |
| .tile .v{font-size:22px;font-weight:800;margin-top:4px} |
| .tile .t{font-size:12px;color:var(--muted);margin-top:6px} |
| .hide{display:none} |
| .foot{color:var(--muted);font-size:12.5px;text-align:center;padding:26px 0} |
| .spin{display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,.4); |
| border-top-color:#fff;border-radius:50%;animation:sp .7s linear infinite;vertical-align:-3px} |
| @keyframes sp{to{transform:rotate(360deg)}} |
| .mobnav{display:none;gap:6px;overflow:auto;padding:10px 0} |
| /* camera oval overlay + guidance (IDV pattern) */ |
| .drop .oval{position:absolute;inset:0;pointer-events:none} |
| .drop .oval svg{width:100%;height:100%} |
| .camhint{position:absolute;left:0;right:0;bottom:8px;text-align:center;font-size:12px;font-weight:600; |
| color:#fff;text-shadow:0 1px 6px rgba(0,0,0,.8);pointer-events:none} |
| .reassure{display:flex;gap:10px;align-items:flex-start;background:var(--surface-2);border:1px solid var(--border); |
| border-radius:12px;padding:12px 14px;color:var(--muted);font-size:13px;margin:2px 0 4px} |
| .reassure .i{font-size:16px} |
| .warn-b{color:var(--warn)} .warn-b .dot{background:var(--warn);box-shadow:0 0 0 6px rgba(245,158,11,.18)} |
| .mfacard{border:1px solid var(--border);border-radius:14px;padding:16px;margin-top:14px;background:var(--surface-2)} |
| .mfahead{display:flex;align-items:center;gap:10px;font-size:15px;margin-bottom:12px} |
| .mfahead .fst{margin-left:auto;font-size:12px;color:var(--muted);font-weight:600} |
| .fdot{width:11px;height:11px;border-radius:50%;background:var(--muted);flex:none} |
| .fdot.on{background:var(--success);box-shadow:0 0 0 5px rgba(16,185,129,.18)} |
| .fdot.err{background:var(--danger)} |
| #livecam video{width:100%;height:100%;object-fit:cover} |
| .pill{font-size:12px;padding:5px 10px;border-radius:999px;border:1px solid var(--border);background:var(--surface)} |
| .pill.done{background:rgba(16,185,129,.14);color:var(--success);border-color:transparent} |
| .hero{padding:26px 0 10px} |
| .hero h1{margin:0 0 8px;font-size:34px;line-height:1.1;letter-spacing:-.5px; |
| background:linear-gradient(120deg,var(--text),var(--primary));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent} |
| .hero p{margin:0;color:var(--muted);font-size:15px;max-width:640px} |
| @media(max-width:680px){.hero h1{font-size:26px}} |
| .acct{display:flex;align-items:center;gap:12px;background:var(--surface-2);border:1px solid var(--border); |
| border-radius:12px;padding:12px 14px;margin-bottom:8px} |
| .acct .ic{width:38px;height:38px;border-radius:9px;background:linear-gradient(135deg,var(--primary),#22d3ee);display:grid;place-items:center;color:#fff;font-weight:800} |
| .acct .bal{margin-left:auto;font-weight:700} |
| .txrow{display:flex;justify-content:space-between;font-size:13px;color:var(--muted);padding:6px 0;border-bottom:1px solid var(--border)} |
| /* extra security layers panel */ |
| .layers{margin-top:16px;background:var(--surface-2);border:1px solid var(--border);border-radius:14px;padding:14px 16px} |
| .layers>h3{margin:0 0 10px;font-size:15px;display:flex;align-items:center;gap:8px} |
| .layers>h3 span{font-weight:400;font-size:12px;color:var(--muted)} |
| .layer{display:flex;align-items:center;gap:12px;padding:11px 0;border-top:1px solid var(--border)} |
| .layer.col{flex-direction:column;align-items:stretch} |
| .layer .lrow{display:flex;gap:12px;align-items:center} |
| .layer .li{width:34px;height:34px;flex:none;border-radius:9px;background:var(--surface);border:1px solid var(--border);display:grid;place-items:center;font-size:17px} |
| .layer .lc{flex:1} |
| .layer .lt{font-weight:600;font-size:14px} |
| .layer .ld{font-size:12px;color:var(--muted);margin-top:2px} |
| .layer .lst{font-size:12px;margin-top:4px;color:var(--success)} |
| .layer .mini{flex:none} |
| .sw{flex:none;position:relative;width:46px;height:26px;cursor:pointer} |
| .sw input{opacity:0;width:0;height:0;position:absolute} |
| .sw span{position:absolute;inset:0;background:var(--border);border-radius:20px;transition:.2s} |
| .sw span:before{content:"";position:absolute;width:20px;height:20px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s} |
| .sw input:checked+span{background:var(--primary)} |
| .sw input:checked+span:before{transform:translateX(20px)} |
| .chans{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px} |
| .chans label{display:flex;align-items:center;gap:7px;font-size:13px;background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:7px 11px;cursor:pointer} |
| .chans input{width:auto} |
| </style> |
| </head> |
| <body> |
| <header><div class="wrap bar"> |
| <div class="brand"><div class="logo">A</div> AmanPay</div> |
| <nav id="nav"></nav> |
| <button class="icon-btn" id="theme" title="Toggle theme">◐</button> |
| </div> |
| <div class="wrap"><div class="mobnav" id="mobnav"></div></div> |
| </header> |
|
|
| <main class="wrap" style="padding-top:8px;padding-bottom:40px"> |
| <div class="hero"> |
| <h1>Your biometrics are your card.</h1> |
| <p>No plastic. No card numbers. No passwords. Enroll once with your face, fingerprint & voice — then pay from any linked account with a single biometric check.</p> |
| </div> |
| <div class="trust"> |
| <span class="chip">🔒 <b>On-device</b> — biometrics never leave your device</span> |
| <span class="chip">🛡️ <b>ISO/IEC 24745</b> cancelable templates</span> |
| <span class="chip">✅ <b>FIDO PAD L2</b> deepfake defense</span> |
| <span class="chip">🔑 <b>Passkey</b> device-bound</span> |
| </div> |
|
|
| |
| <section data-view="pay" class="card"> |
| <h2>Pay — cardless</h2> |
| <div class="sub">Authorize a payment from a linked account with your biometrics. No card, no OTP.</div> |
| <label>User ID</label> |
| <input type="text" id="p_user" placeholder="alice" value="alice"/> |
| <div class="rowbtns" style="margin-top:10px"> |
| <button class="mini" id="p_demo">▶ Load demo (auto-fill everything)</button> |
| <button class="mini" id="p_setup">+ Set up wallet (link demo account)</button> |
| </div> |
| <div style="color:var(--muted);font-size:12px;margin-top:6px">New here? Click <b>Load demo</b> — it enrolls a sample identity and fills the form so you can pay in one click.</div> |
| <div id="p_accts" style="margin-top:12px"></div> |
| <div class="grid2" style="margin-top:16px"> |
| <div><label>Amount</label><input type="text" id="p_amt" placeholder="42.50"/></div> |
| <div><label>Merchant</label><input type="text" id="p_merch" placeholder="Blue Bottle Coffee"/></div> |
| </div> |
| <div class="grid2" style="margin-top:14px"> |
| <div><label>Face</label><div class="dz" data-target="p_face"></div></div> |
| <div><label>Fingerprint</label><div class="dz" data-target="p_fp"></div> |
| <button class="mini" id="p_fpscan" style="margin-top:6px;width:100%">👆 Scan finger on this device</button> |
| <div id="p_fpscanst" style="font-size:11.5px;color:var(--muted);margin-top:4px"></div></div> |
| </div> |
| <div style="margin-top:12px"> |
| <button class="mini" id="p_rec" style="max-width:180px">🎙️ Record voice (3s)</button> |
| <span id="p_recst" style="color:var(--muted);font-size:12.5px;margin-left:8px">optional</span> |
| </div> |
|
|
| |
| <div class="layers"> |
| <h3>🛡️ Extra security layers <span>optional — stack as many as you like</span></h3> |
|
|
| <div class="layer"> |
| <div class="li">🔒</div> |
| <div class="lc"><div class="lt">Device fingerprint / Face ID</div> |
| <div class="ld">Enroll this device's built-in sensor — Touch ID, laptop fingerprint pad, Windows Hello, Android fingerprint.</div> |
| <div class="lst" id="p_devst"></div></div> |
| <button class="mini" id="p_dev">Enroll</button> |
| </div> |
|
|
| <div class="layer"> |
| <div class="li">📍</div> |
| <div class="lc"><div class="lt">Location check (GPS)</div> |
| <div class="ld">Flags impossible travel / unusual locations. Real travel is fine — it just asks for one extra confirmation.</div></div> |
| <label class="sw"><input type="checkbox" id="p_geo" checked/><span></span></label> |
| </div> |
|
|
| <div class="layer col"> |
| <div class="lrow"><div class="li">🔔</div> |
| <div class="lc"><div class="lt">Payment confirmation</div> |
| <div class="ld">Get an approve/deny alert for each payment (dynamic-linked to the exact amount + merchant). Pick your channels:</div></div></div> |
| <div class="chans" id="p_chans"> |
| <label><input type="checkbox" value="push" checked/> 📲 Push</label> |
| <label><input type="checkbox" value="sms"/> 💬 SMS text</label> |
| <label><input type="checkbox" value="call"/> 📞 Phone call</label> |
| <label><input type="checkbox" value="email"/> ✉️ Email</label> |
| </div> |
| <div class="grid2" style="margin-top:10px"> |
| <input type="text" id="p_email" placeholder="email address"/> |
| <input type="text" id="p_phone" placeholder="phone +1… (for SMS / call)"/> |
| </div> |
| </div> |
| </div> |
|
|
| <button class="btn" id="p_pay">🔓 Pay with biometrics</button> |
| <div class="result" id="p_res"></div> |
| <div id="p_stepup" style="margin-top:12px"></div> |
| <div id="p_hist" style="margin-top:14px"></div> |
| </section> |
|
|
| |
| <section data-view="auth" class="card hide"> |
| <h2>Authenticate</h2> |
| <div class="sub">Verify an enrolled user with face + fingerprint.</div> |
| <div class="reassure"><span class="i">🛡️</span><div>We run a quick, passive security check — liveness and deepfake defense — to protect your account. Your biometrics are processed on-device and never stored.</div></div> |
| <label>User ID</label> |
| <input type="text" id="a_user" placeholder="alice"/> |
| <div class="grid2" style="margin-top:16px"> |
| <div><label>Face</label><div class="dz" data-target="a_face"></div></div> |
| <div><label>Fingerprint</label><div class="dz" data-target="a_fp"></div></div> |
| </div> |
| <button class="btn" id="a_btn">Authenticate</button> |
| <div class="result" id="a_res"></div> |
| </section> |
|
|
| |
| <section data-view="enroll" class="card hide"> |
| <h2>Enroll</h2> |
| <div class="sub">Register a new user's biometrics (stored as a cancelable template).</div> |
| <label>User ID</label> |
| <input type="text" id="e_user" placeholder="alice"/> |
| <div class="grid2" style="margin-top:16px"> |
| <div><label>Face</label><div class="dz" data-target="e_face"></div></div> |
| <div><label>Fingerprint</label><div class="dz" data-target="e_fp"></div> |
| <button class="mini" id="e_fpscan" style="margin-top:6px;width:100%">👆 Scan finger on this device</button> |
| <div id="e_fpscanst" style="font-size:11.5px;color:var(--muted);margin-top:4px"></div></div> |
| </div> |
| <div style="font-size:12px;color:var(--muted);margin-top:-2px">The upload box takes an optical fingerprint <b>image</b> (for the recognition model). “Scan finger on this device” uses your device’s built-in sensor (Touch ID / fingerprint pad) via a passkey — the sensor image never leaves your device.</div> |
| <button class="btn" id="e_btn">Enroll</button> |
| <div class="result" id="e_res"></div> |
| </section> |
|
|
| |
| <section data-view="verify" class="card hide"> |
| <h2>Verify · 1:1</h2> |
| <div class="sub">Compare two biometric pairs (no enrollment needed).</div> |
| <div class="grid2"> |
| <div><label>Face A</label><div class="dz" data-target="v_f1"></div></div> |
| <div><label>Fingerprint A</label><div class="dz" data-target="v_p1"></div></div> |
| <div><label>Face B</label><div class="dz" data-target="v_f2"></div></div> |
| <div><label>Fingerprint B</label><div class="dz" data-target="v_p2"></div></div> |
| </div> |
| <button class="btn" id="v_btn">Verify</button> |
| <div class="result" id="v_res"></div> |
| </section> |
|
|
| |
| <section data-view="report" class="card hide"> |
| <h2>Standards report card</h2> |
| <div class="sub">Live conformance across recognition, PAD/FIDO, and ISO/IEC 24745.</div> |
| <button class="btn ghost" id="r_btn" style="margin-top:0;width:auto;padding:10px 18px">Refresh</button> |
| <div id="r_body" style="margin-top:16px"></div> |
| </section> |
|
|
| |
| <section data-view="unified" class="card hide"> |
| <h2>Unified identity</h2> |
| <div class="sub">One identity from three sensors — face + fingerprint + voice — fused by quality-weighted score fusion, gated by liveness & deepfake defense.</div> |
| <div class="reassure"><span class="i">🛡️</span><div>Each modality is weighted by its live capture quality; a missing or degraded one is gracefully de-emphasized. An attacker must defeat all three at once.</div></div> |
| <label>User ID</label> |
| <input type="text" id="u_user" placeholder="alice"/> |
| <div class="grid2" style="margin-top:16px"> |
| <div><label>Face</label><div class="dz" data-target="u_face"></div></div> |
| <div><label>Fingerprint</label><div class="dz" data-target="u_fp"></div></div> |
| </div> |
| <div style="margin-top:14px"> |
| <label>Voice passphrase (recorded audio)</label> |
| <button class="btn ghost" id="u_rec" style="width:auto;margin:0;padding:10px 16px">🎙️ Record 3s</button> |
| <span id="u_recst" style="color:var(--muted);font-size:13px;margin-left:10px">no recording</span> |
| </div> |
| <div class="rowbtns" style="margin-top:16px"> |
| <button class="btn" id="u_enroll" style="margin:0">Enroll identity</button> |
| <button class="btn ghost" id="u_auth" style="margin:0">Authenticate</button> |
| </div> |
| <div class="result" id="u_res"></div> |
| </section> |
|
|
| |
| <section data-view="mfa" class="card hide"> |
| <h2>Multi-factor security</h2> |
| <div class="sub">Active liveness + device fingerprint + spoken passphrase. Requires HTTPS (camera/mic/WebAuthn).</div> |
| <div class="reassure"><span class="i">🛡️</span><div>Everything runs on your device: the camera checks you're a live person (not a photo or deepfake), your device fingerprint unlocks a secure key, and your voice passphrase adds a third layer. Nothing is uploaded until you choose to authenticate.</div></div> |
|
|
| |
| <div class="mfacard"> |
| <div class="mfahead"><span id="f1dot" class="fdot"></span><b>1 · Active liveness</b><span id="f1st" class="fst">not started</span></div> |
| <div class="grid2"> |
| <div class="drop" id="livecam" style="cursor:default"><div class="ph"><div class="big">🎥</div>Camera preview + challenges</div></div> |
| <div> |
| <div id="challenge" style="font-size:15px;font-weight:600;min-height:22px;color:var(--muted)">Press start — follow the prompts (smile, blink, turn).</div> |
| <div id="chprog" class="chips" style="margin-top:10px"></div> |
| <button class="btn ghost" id="liveBtn" style="margin-top:14px">Start liveness check</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="mfacard"> |
| <div class="mfahead"><span id="f2dot" class="fdot"></span><b>2 · Device fingerprint / Face ID</b><span id="f2st" class="fst">not started</span></div> |
| <div style="color:var(--muted);font-size:13px;margin-bottom:10px">Uses your laptop/phone secure sensor via WebAuthn — a device-bound key, no image leaves the device.</div> |
| <button class="btn ghost" id="waReg" style="width:auto;margin:0 8px 0 0;padding:10px 16px">Register device</button> |
| <button class="btn ghost" id="waAuth" style="width:auto;margin:0;padding:10px 16px">Verify with fingerprint</button> |
| </div> |
|
|
| |
| <div class="mfacard"> |
| <div class="mfahead"><span id="f3dot" class="fdot"></span><b>3 · Voice passphrase</b><span id="f3st" class="fst">not started</span></div> |
| <div style="color:var(--muted);font-size:13px;margin-bottom:10px">Say your secret passphrase — a text-dependent voice factor. Transcript stays on-device.</div> |
| <input type="text" id="voicePhrase" placeholder="expected passphrase (e.g. my voice is my password)" style="margin-bottom:10px"/> |
| <button class="btn ghost" id="voiceBtn" style="width:auto;margin:0;padding:10px 16px">🎙️ Record & verify</button> |
| <div id="voiceOut" style="color:var(--muted);font-size:13px;margin-top:10px"></div> |
| </div> |
|
|
| <div class="result" id="mfa_res"></div> |
| </section> |
|
|
| <div class="foot">AmanPay · multimodal biometric authentication · <span id="dev">—</span></div> |
| </main> |
|
|
| <script> |
| const API = ""; |
| const $ = s => document.querySelector(s); |
| const views = ["pay","unified","auth","mfa","enroll","verify","report"]; |
| const labels = {pay:"Pay",unified:"Unified ID",auth:"Authenticate",mfa:"Multi-factor",enroll:"Enroll",verify:"Verify",report:"Report card"}; |
| const store = {}; |
| |
| |
| function buildNav(el){ el.innerHTML = views.map(v=>`<button class="tab" data-tab="${v}">${labels[v]}</button>`).join(""); } |
| buildNav($("#nav")); buildNav($("#mobnav")); |
| function show(v){ |
| document.querySelectorAll("[data-view]").forEach(s=>s.classList.toggle("hide",s.dataset.view!==v)); |
| document.querySelectorAll(".tab").forEach(t=>t.classList.toggle("active",t.dataset.tab===v)); |
| if(v==="report") loadReport(); |
| } |
| document.querySelectorAll(".tab").forEach; |
| document.addEventListener("click",e=>{const t=e.target.closest(".tab"); if(t) show(t.dataset.tab);}); |
| show("pay"); |
| |
| |
| $("#theme").onclick=()=>{const r=document.documentElement; |
| r.dataset.theme = r.dataset.theme==="dark"?"light":"dark";}; |
| |
| |
| function initDrop(dz){ |
| const key = dz.dataset.target, isFace = key.includes("face")||/_f\d/.test(key); |
| dz.classList.add("drop"); |
| dz.innerHTML = `<div class="ph"><div class="big">⬆️</div>Drop image or click${isFace?" · or use camera":""}</div>`; |
| const file = document.createElement("input"); file.type="file"; file.accept="image/*"; file.style.display="none"; |
| dz.appendChild(file); |
| dz.onclick = ()=> file.click(); |
| file.onchange = ()=>{ const f=file.files[0]; if(!f)return; const rd=new FileReader(); |
| rd.onload=()=>setImg(dz,key,rd.result); rd.readAsDataURL(f); }; |
| dz.ondragover=e=>{e.preventDefault();dz.style.borderColor="var(--primary)";}; |
| dz.ondragleave=()=>dz.style.borderColor=""; |
| dz.ondrop=e=>{e.preventDefault();dz.style.borderColor="";const f=e.dataTransfer.files[0]; |
| if(f){const rd=new FileReader();rd.onload=()=>setImg(dz,key,rd.result);rd.readAsDataURL(f);}}; |
| if(isFace){ |
| const wrap=document.createElement("div"); |
| const cam=document.createElement("button"); cam.className="mini"; cam.textContent="📷 Camera"; |
| const row=document.createElement("div"); row.className="rowbtns"; row.appendChild(cam); |
| dz.parentElement.appendChild(row); |
| cam.onclick=ev=>{ev.stopPropagation();startCam(dz,key,cam);}; |
| } |
| } |
| function setImg(dz,key,dataURL){ store[key]=dataURL; dz.classList.add("filled"); |
| dz.innerHTML=`<img src="${dataURL}"/>`; } |
| async function startCam(dz,key,btn){ |
| try{ |
| const stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:"user"}}); |
| const v=document.createElement("video"); v.autoplay=true;v.playsInline=true;v.srcObject=stream; |
| dz.classList.add("filled"); dz.innerHTML=""; dz.appendChild(v); |
| |
| const ov=document.createElement("div"); ov.className="oval"; |
| ov.innerHTML=`<svg viewBox="0 0 100 100" preserveAspectRatio="none"><defs><mask id="m"><rect width="100" height="100" fill="#fff"/><ellipse cx="50" cy="46" rx="27" ry="36" fill="#000"/></mask></defs><rect width="100" height="100" fill="rgba(11,16,32,.55)" mask="url(#m)"/><ellipse cx="50" cy="46" rx="27" ry="36" fill="none" stroke="#6366f1" stroke-width="1.2"/></svg>`; |
| const hint=document.createElement("div"); hint.className="camhint"; hint.textContent="Position your face in the oval"; |
| dz.appendChild(ov); dz.appendChild(hint); |
| const tips=["Position your face in the oval","Hold still","Center your face","Good lighting helps"]; |
| let ti=0; const timer=setInterval(()=>{hint.textContent=tips[++ti%tips.length];},1600); |
| v.addEventListener("_stop",()=>clearInterval(timer)); |
| btn.textContent="📸 Capture"; |
| btn.onclick=ev=>{ev.stopPropagation(); |
| const c=document.createElement("canvas");c.width=v.videoWidth;c.height=v.videoHeight; |
| c.getContext("2d").drawImage(v,0,0); |
| clearInterval(timer); stream.getTracks().forEach(t=>t.stop()); |
| setImg(dz,key,c.toDataURL("image/jpeg",0.92)); btn.textContent="📷 Camera"; |
| btn.onclick=e2=>{e2.stopPropagation();startCam(dz,key,btn);}; |
| }; |
| }catch(err){ alert("Camera unavailable — use upload instead."); } |
| } |
| document.querySelectorAll(".dz").forEach(initDrop); |
| |
| |
| async function post(path,body){ |
| const r=await fetch(API+path,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}); |
| return {ok:r.ok, status:r.status, data: await r.json().catch(()=>({}))}; |
| } |
| function need(keys){ for(const k of keys) if(!store[k]) return "Please provide all images."; return null; } |
| function busy(btn,on,txt){ btn.disabled=on; btn.dataset.t=btn.dataset.t||btn.textContent; |
| btn.innerHTML = on? `<span class="spin"></span> ${txt||"Working…"}` : btn.dataset.t; } |
| function pct(x){ return Math.round(Math.max(0,Math.min(1,x))*100); } |
| function statChip(l,v){ return `<div class="stat">${l}<b>${v}</b></div>`; } |
| |
| |
| $("#a_btn").onclick=async()=>{ |
| const uid=$("#a_user").value.trim(); const res=$("#a_res"); |
| if(!uid){res.className="result show";res.innerHTML="⚠️ Enter a user ID.";return;} |
| const m=need(["a_face","a_fp"]); if(m){res.className="result show";res.innerHTML="⚠️ "+m;return;} |
| busy($("#a_btn"),true,"Authenticating…"); |
| const {ok,status,data}=await post("/authenticate",{user_id:uid,face_image:store.a_face,fingerprint_image:store.a_fp}); |
| busy($("#a_btn"),false); |
| res.className="result show"; |
| if(!ok){ res.innerHTML=`<div class="verdict no"><span class="dot"></span>Couldn't complete</div> |
| <div style="color:var(--muted);font-size:13.5px;margin-top:6px">${friendly(data.detail,status)}</div>`; return; } |
| |
| const sim=data.similarity||0, th=data.threshold||0.6; |
| let state = data.success ? "pass" : (!data.is_deepfake && sim>=th-0.12 ? "review" : "fail"); |
| const V={pass:["ok","✅ Access granted"],review:["warn-b","🔎 Additional verification needed"],fail:["no","🚫 Access denied"]}; |
| const df = data.deepfake_score!=null? `<span class="badge ${data.is_deepfake?'r':'g'}">${data.is_deepfake?'🛑 deepfake/injection':'🎥 real capture'} ${data.deepfake_score.toFixed(2)}</span>`:""; |
| const prot = data.protected? `<span class="badge b">🔒 protected template</span>`:""; |
| const why = data.is_deepfake ? "Synthetic or injected media detected." : |
| state==="review" ? "Close to threshold — a second capture or step-up check is recommended." : |
| !data.success ? "Biometrics didn't match the enrolled user." : ""; |
| res.innerHTML=` |
| <div class="verdict ${V[state][0]}"><span class="dot"></span>${V[state][1]}</div> |
| <div class="meter"><span style="width:${pct(data.confidence??sim)}%"></span></div> |
| <div style="color:var(--muted);font-size:13px">Confidence ${pct(data.confidence??0)}% · similarity ${sim.toFixed(3)} (threshold ${th.toFixed(2)})</div> |
| <div class="chips"> |
| ${statChip("Face liveness", (data.face_liveness||0).toFixed(2))} |
| ${statChip("Fingerprint PAD", (data.fp_liveness||0).toFixed(2))} |
| ${data.face_quality!=null?statChip("Capture quality", (data.face_quality).toFixed(2)+" / "+(data.fp_quality).toFixed(2)):""} |
| </div> |
| <div class="chips">${df} ${prot}</div> |
| ${why?`<div style="color:var(--muted);font-size:13px;margin-top:10px">${why}</div>`:""}`; |
| }; |
| function friendly(detail,status){ |
| const d=(detail||"").toLowerCase(); |
| if(d.includes("no face")) return "We couldn't detect a face — move to better lighting and center your face in the frame, then try again."; |
| if(status===422) return "That image didn't work. Please retake with a clearer, well-lit photo."; |
| if(status===503) return "The service is starting up — please try again in a moment."; |
| return detail || ("Something went wrong (error "+status+"). Please try again."); |
| } |
| |
| |
| $("#e_btn").onclick=async()=>{ |
| const uid=$("#e_user").value.trim(); const res=$("#e_res"); |
| if(!uid){res.className="result show";res.innerHTML="⚠️ Enter a user ID.";return;} |
| if(!store.e_face){res.className="result show";res.innerHTML="⚠️ Provide a face (camera or upload).";return;} |
| if(!store.e_fp && !store.p_devok){res.className="result show";res.innerHTML='⚠️ Provide a fingerprint image, or tap “👆 Scan finger on this device”.';return;} |
| busy($("#e_btn"),true,"Enrolling…"); |
| const body={user_id:uid,face_image:store.e_face}; |
| if(store.e_fp)body.fingerprint_image=store.e_fp; |
| const {ok,data}=await post("/unified/enroll",body); |
| busy($("#e_btn"),false); |
| res.className="result show"; |
| if(ok&&data.success){ |
| const mods=(data.modalities||[]); |
| res.innerHTML=`<div class="verdict ok"><span class="dot"></span>Enrolled ${uid}</div> |
| <div class="chips" style="margin-top:8px"> |
| ${mods.map(m=>`<span class="badge g">${m}</span>`).join("")} |
| ${store.p_devok?'<span class="badge g">👆 device fingerprint</span>':''} |
| <span class="badge b">🔒 cancelable template</span> |
| <span class="badge b">☁️ saved to Hugging Face</span></div>`; |
| } else { |
| res.innerHTML=`<div class="verdict no"><span class="dot"></span>Enrollment failed</div><div style="color:var(--muted)">${(data&&(data.reason||data.detail))||''}</div>`; |
| } |
| }; |
| |
| |
| $("#v_btn").onclick=async()=>{ |
| const res=$("#v_res"); const m=need(["v_f1","v_p1","v_f2","v_p2"]); |
| if(m){res.className="result show";res.innerHTML="⚠️ "+m;return;} |
| busy($("#v_btn"),true,"Comparing…"); |
| const {ok,data}=await post("/verify",{face1:store.v_f1,fp1:store.v_p1,face2:store.v_f2,fp2:store.v_p2}); |
| busy($("#v_btn"),false); |
| if(!ok){res.className="result show";res.innerHTML="❌ "+(data.detail||"Error");return;} |
| res.className="result show"; |
| res.innerHTML=`<div class="verdict ok"><span class="dot"></span>Similarity</div> |
| <div class="chips"> |
| ${statChip("Fused",(data.fused_similarity).toFixed(3))} |
| ${statChip("Face",(data.face_similarity).toFixed(3))} |
| ${statChip("Fingerprint",(data.fingerprint_similarity).toFixed(3))}</div>`; |
| }; |
| |
| |
| $("#r_btn").onclick=()=>loadReport(true); |
| async function loadReport(refresh){ |
| const b=$("#r_body"); b.innerHTML=`<span class="spin"></span> Loading…`; |
| let d; |
| try{ |
| const ac=new AbortController(); const to=setTimeout(()=>ac.abort(), refresh?45000:15000); |
| const r=await fetch(API+"/report-card"+(refresh?"?refresh=true":""),{signal:ac.signal}); |
| clearTimeout(to); d=await r.json(); |
| }catch(e){ |
| b.innerHTML=`<div style="color:var(--muted);font-size:13px">Report card is taking too long to compute on this host${refresh?" (live refresh)":""}. It's cached on the server — try again shortly, or open the API at <code>/report-card</code>.</div>`; |
| return; |
| } |
| const rec=d.recognition||{}, pad=(d.presentation_attack_detection||{}).deepfake||{}, tp=d.template_protection||{}, st=d.standards_conformance||{}, dep=d.deployment||{}; |
| const yn=v=>v?`<span class="badge g">✓ ${v==true?'yes':v}</span>`:`<span class="badge r">—</span>`; |
| b.innerHTML=` |
| <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px"> |
| <span class="badge ${tp.compliant?'g':'r'}">ISO/IEC 24745 ${tp.compliant?'compliant':'review'}</span> |
| <span class="badge b">FIDO PAD Level ${st.FIDO_biometric_PAD_level??'—'}</span> |
| <span class="badge b">On-device: ${dep.on_device_ONNX?'yes':'no'}</span> |
| <span class="badge ${dep.biometric_stored_server_side?'r':'g'}">No server-side biometric</span> |
| </div> |
| <div class="tiles"> |
| <div class="tile"><div class="k">Face recognition (LFW)</div><div class="v">${fmtEER(rec.face_LFW_EER)}</div><div class="t">EER · CASIA-WebFace→LFW</div></div> |
| <div class="tile"><div class="k">Fusion (NIST BSSR1)</div><div class="v">${fmtEER(rec.fusion_BSSR1_score_EER)}</div><div class="t">EER · genuine multimodal</div></div> |
| <div class="tile"><div class="k">Deepfake detection</div><div class="v">${fmtEER(pad.detection_EER)}</div><div class="t">EER · FIDO PAD L${pad.fido_level??'—'}</div></div> |
| <div class="tile"><div class="k">Unlinkability (24745)</div><div class="v">${(tp.unlinkability_D_sys??'—')}</div><div class="t">D_sys · 0 = unlinkable</div></div> |
| <div class="tile"><div class="k">Irreversibility</div><div class="v">${(tp.irreversibility_bit_entropy??'—')}</div><div class="t">bit entropy</div></div> |
| <div class="tile"><div class="k">Renewability</div><div class="v">${(tp.renewability_cross_key_sim??'—')}</div><div class="t">cross-key sim</div></div> |
| </div>`; |
| } |
| function fmtEER(x){ return x==null? "—" : (x*100).toFixed(2)+"%"; } |
| |
| |
| const factors={live:false,fp:false,voice:false}; |
| function setFactor(n,ok,txt){ |
| const map={live:["f1dot","f1st"],fp:["f2dot","f2st"],voice:["f3dot","f3st"]}; |
| const [dot,st]=map[n]; const de=document.getElementById(dot); |
| de.className="fdot"+(ok?" on":(ok===false&&txt&&txt.includes('fail')?" err":"")); |
| document.getElementById(st).textContent=txt; if(ok!==null)factors[n]=ok; renderMfa(); |
| } |
| function renderMfa(){ |
| const n=Object.values(factors).filter(Boolean).length; |
| const res=$("#mfa_res"); |
| if(n===0){res.className="result";return;} |
| res.className="result show"; |
| const done=n>=2; |
| res.innerHTML=`<div class="verdict ${done?'ok':'warn-b'}"><span class="dot"></span>${n} / 3 factors verified${done?' — strong authentication':''}</div> |
| <div class="chips"> |
| <span class="pill ${factors.live?'done':''}">${factors.live?'✓':'○'} liveness</span> |
| <span class="pill ${factors.fp?'done':''}">${factors.fp?'✓':'○'} device fingerprint</span> |
| <span class="pill ${factors.voice?'done':''}">${factors.voice?'✓':'○'} voice passphrase</span></div>`; |
| } |
| |
| |
| let landmarker=null; |
| async function loadLandmarker(){ |
| if(landmarker)return landmarker; |
| const v=await import("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/vision_bundle.mjs"); |
| const fs=await v.FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm"); |
| landmarker=await v.FaceLandmarker.createFromOptions(fs,{baseOptions:{modelAssetPath: |
| "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task"}, |
| outputFaceBlendshapes:true,runningMode:"VIDEO",numFaces:1}); |
| return landmarker; |
| } |
| $("#liveBtn").onclick=async()=>{ |
| const box=$("#livecam"), prompt=$("#challenge"), prog=$("#chprog"), btn=$("#liveBtn"); |
| setFactor('live',null,"starting…"); btn.disabled=true; |
| let stream; |
| try{ stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:"user"}}); } |
| catch(e){ setFactor('live',false,"camera blocked"); prompt.textContent="Camera unavailable — use HTTPS and allow access."; btn.disabled=false; return; } |
| const vid=document.createElement("video"); vid.autoplay=true;vid.playsInline=true;vid.muted=true;vid.srcObject=stream; |
| box.innerHTML=""; box.appendChild(vid); await vid.play(); |
| let lm; try{ prompt.textContent="Loading on-device face model…"; lm=await loadLandmarker(); } |
| catch(e){ setFactor('live',false,"model load failed"); prompt.textContent="Couldn't load the liveness model (needs internet)."; stream.getTracks().forEach(t=>t.stop()); btn.disabled=false; return; } |
| |
| const LB={smile:"😊 Smile",blink:"😑 Blink",turn_left:"👈 Turn left",turn_right:"👉 Turn right"}; |
| let sid=null, seq; |
| try{ const c=await fetch(API+"/liveness/challenge",{method:"POST",headers:{"Content-Type":"application/json"}, |
| body:JSON.stringify({user_id:($("#a_user").value||"user"),n:3})}).then(r=>r.json()); |
| sid=c.session_id; seq=c.challenges; } |
| catch(e){ seq=["smile","blink","turn_left"]; } |
| prog.innerHTML=seq.map((c,i)=>`<span class="pill" id="ch${i}">○ ${LB[c]||c}</span>`).join(""); |
| let idx=0, startT=performance.now(); |
| function bshape(cats,name){const c=cats.find(x=>x.categoryName===name);return c?c.score:0;} |
| function tick(){ |
| if(idx>=seq.length){ |
| stream.getTracks().forEach(t=>t.stop()); |
| const cv=document.createElement("canvas");cv.width=vid.videoWidth;cv.height=vid.videoHeight; |
| cv.getContext("2d").drawImage(vid,0,0); store.a_face=cv.toDataURL("image/jpeg",0.9); |
| prompt.innerHTML="Verifying with server…"; btn.disabled=false; btn.textContent="Redo"; |
| if(!sid){ prompt.innerHTML="✅ Live person confirmed (local) — face captured."; setFactor('live',true,"passed (local)"); return; } |
| fetch(API+"/liveness/verify",{method:"POST",headers:{"Content-Type":"application/json"}, |
| body:JSON.stringify({session_id:sid,completed:seq,face_image:store.a_face})}) |
| .then(r=>r.json()).then(rv=>{ const ok=!!rv.success; |
| prompt.innerHTML=ok?"✅ Live person confirmed (server-verified) — face captured.":("❌ "+(rv.reason||"liveness failed")); |
| setFactor('live',ok,ok?"passed (server-verified)":"failed"); }) |
| .catch(()=>{ prompt.innerHTML="✅ Live person confirmed — face captured."; setFactor('live',true,"passed (local)"); }); |
| return; |
| } |
| const r=lm.detectForVideo(vid,performance.now()); |
| const kind=seq[idx]; prompt.textContent="Please: "+(LB[kind]||kind); |
| let pass=false; |
| if(r.faceBlendshapes&&r.faceBlendshapes[0]){ |
| const b=r.faceBlendshapes[0].categories; |
| if(kind==="smile") pass=(bshape(b,"mouthSmileLeft")+bshape(b,"mouthSmileRight"))/2>0.45; |
| if(kind==="blink") pass=(bshape(b,"eyeBlinkLeft")+bshape(b,"eyeBlinkRight"))/2>0.5; |
| if(kind==="turn_left"||kind==="turn_right"){ const lm2=r.faceLandmarks&&r.faceLandmarks[0]; |
| if(lm2){const nose=lm2[1].x, re=lm2[33].x, le=lm2[263].x, ratio=(nose-re)/((le-re)||1); |
| pass = kind==="turn_left" ? ratio>0.6 : ratio<0.4; } |
| } |
| } |
| if(pass){ document.getElementById("ch"+idx).className="pill done"; |
| document.getElementById("ch"+idx).textContent="✓ "+(LB[seq[idx]]||seq[idx]).replace(/^\S+\s/,''); idx++; startT=performance.now(); } |
| else if(performance.now()-startT>12000){ setFactor('live',false,"timed out"); prompt.textContent="Timed out — try again."; stream.getTracks().forEach(t=>t.stop()); btn.disabled=false; return; } |
| requestAnimationFrame(tick); |
| } |
| requestAnimationFrame(tick); |
| }; |
| |
| |
| let waCred=null; |
| function rand(){return crypto.getRandomValues(new Uint8Array(32));} |
| $("#waReg").onclick=async()=>{ |
| if(!window.PublicKeyCredential){setFactor('fp',false,"WebAuthn unsupported");return;} |
| try{ |
| const uid=($("#a_user").value||"amanpay-user"); |
| const c=await navigator.credentials.create({publicKey:{challenge:rand(),rp:{name:"AmanPay"}, |
| user:{id:new TextEncoder().encode(uid),name:uid,displayName:uid}, |
| pubKeyCredParams:[{type:"public-key",alg:-7},{type:"public-key",alg:-257}], |
| authenticatorSelection:{authenticatorAttachment:"platform",userVerification:"required",residentKey:"preferred"},timeout:60000}}); |
| waCred=c.rawId; setFactor('fp',null,"device registered — now verify"); |
| }catch(e){ setFactor('fp',false,"registration cancelled/failed"); } |
| }; |
| $("#waAuth").onclick=async()=>{ |
| if(!window.PublicKeyCredential){setFactor('fp',false,"WebAuthn unsupported");return;} |
| try{ |
| const opts={challenge:rand(),userVerification:"required",timeout:60000}; |
| if(waCred)opts.allowCredentials=[{type:"public-key",id:waCred}]; |
| await navigator.credentials.get({publicKey:opts}); |
| setFactor('fp',true,"verified (Touch ID / Hello)"); |
| }catch(e){ setFactor('fp',false,"verification failed"); } |
| }; |
| |
| |
| $("#voiceBtn").onclick=()=>{ |
| const SR=window.SpeechRecognition||window.webkitSpeechRecognition; |
| const out=$("#voiceOut"); const want=($("#voicePhrase").value||"").trim().toLowerCase(); |
| if(!want){out.textContent="Enter your expected passphrase first.";return;} |
| if(!SR){out.textContent="Speech recognition not supported in this browser (works in Chrome/Edge). Voice-biometric backend is the next step.";return;} |
| const rec=new SR(); rec.lang="en-US"; rec.interimResults=false; rec.maxAlternatives=1; |
| setFactor('voice',null,"listening…"); out.textContent="🎙️ Listening — say your passphrase…"; |
| rec.onresult=e=>{ const t=e.results[0][0].transcript.trim().toLowerCase(); |
| const norm=s=>s.replace(/[^a-z0-9 ]/g,"").replace(/\s+/g," ").trim(); |
| const ok=norm(t)===norm(want); |
| out.textContent=`Heard: "${t}" — ${ok?"✅ passphrase matched":"❌ did not match"}`; |
| setFactor('voice',ok,ok?"passphrase matched":"no match (fail)"); }; |
| rec.onerror=ev=>{ out.textContent="Mic error: "+ev.error; setFactor('voice',false,"mic error"); }; |
| rec.start(); |
| }; |
| |
| |
| function encodeWav(samples, sr){ |
| const b=new ArrayBuffer(44+samples.length*2), v=new DataView(b); |
| const ws=(o,s)=>{for(let i=0;i<s.length;i++)v.setUint8(o+i,s.charCodeAt(i));}; |
| ws(0,"RIFF");v.setUint32(4,36+samples.length*2,true);ws(8,"WAVE");ws(12,"fmt "); |
| v.setUint32(16,16,true);v.setUint16(20,1,true);v.setUint16(22,1,true); |
| v.setUint32(24,sr,true);v.setUint32(28,sr*2,true);v.setUint16(32,2,true);v.setUint16(34,16,true); |
| ws(36,"data");v.setUint32(40,samples.length*2,true); |
| let o=44; for(let i=0;i<samples.length;i++){const s=Math.max(-1,Math.min(1,samples[i]));v.setInt16(o,s<0?s*0x8000:s*0x7FFF,true);o+=2;} |
| let bin="",by=new Uint8Array(b); for(let i=0;i<by.length;i++)bin+=String.fromCharCode(by[i]); |
| return "data:audio/wav;base64,"+btoa(bin); |
| } |
| async function recordWav(seconds){ |
| const stream=await navigator.mediaDevices.getUserMedia({audio:true}); |
| const ctx=new (window.AudioContext||window.webkitAudioContext)(); |
| const src=ctx.createMediaStreamSource(stream), proc=ctx.createScriptProcessor(4096,1,1); |
| const chunks=[]; src.connect(proc); proc.connect(ctx.destination); |
| proc.onaudioprocess=e=>chunks.push(new Float32Array(e.inputBuffer.getChannelData(0))); |
| await new Promise(r=>setTimeout(r,seconds*1000)); |
| proc.disconnect(); src.disconnect(); stream.getTracks().forEach(t=>t.stop()); |
| const sr=ctx.sampleRate; ctx.close(); |
| let len=chunks.reduce((a,c)=>a+c.length,0), buf=new Float32Array(len), off=0; |
| chunks.forEach(c=>{buf.set(c,off);off+=c.length;}); |
| return encodeWav(buf,sr); |
| } |
| $("#u_rec").onclick=async()=>{ |
| const st=$("#u_recst"); st.textContent="🔴 recording 3s…"; |
| try{ store.u_voice=await recordWav(3); st.textContent="✅ voice recorded"; } |
| catch(e){ st.textContent="mic unavailable (needs HTTPS + permission)"; } |
| }; |
| async function uCall(path){ |
| const uid=$("#u_user").value.trim(), res=$("#u_res"); |
| if(!uid){res.className="result show";res.innerHTML="⚠️ Enter a user ID.";return;} |
| const body={user_id:uid}; |
| if(store.u_face)body.face_image=store.u_face; |
| if(store.u_fp)body.fingerprint_image=store.u_fp; |
| if(store.u_voice)body.voice_audio=store.u_voice; |
| if(!body.face_image&&!body.fingerprint_image&&!body.voice_audio){res.className="result show";res.innerHTML="⚠️ Provide at least one modality (face, fingerprint, or voice).";return;} |
| const btn=path==="enroll"?$("#u_enroll"):$("#u_auth"); busy(btn,true); |
| const {ok,data}=await post("/unified/"+path,body); busy(btn,false); |
| res.className="result show"; |
| if(!ok){res.innerHTML="❌ "+(data.detail||"error");return;} |
| if(path==="enroll"){ |
| res.innerHTML = data.success |
| ? `<div class="verdict ok"><span class="dot"></span>Enrolled ${uid}</div><div class="chips">${(data.modalities||[]).map(m=>`<span class="pill done">✓ ${m}</span>`).join("")}</div>` |
| : `<div class="verdict no"><span class="dot"></span>Enroll failed</div><div style="color:var(--muted)">${data.reason||''}</div>`; |
| return; |
| } |
| const g=data.success; |
| const bars=Object.entries(data.per_modality||{}).map(([m,s])=>{ |
| const w=(data.quality_weights||{})[m]||0; |
| return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between;font-size:12.5px;color:var(--muted)"><span>${m}</span><span>sim ${s.toFixed(3)} · weight ${(w*100).toFixed(0)}%</span></div><div class="meter"><span style="width:${pct(s)}%"></span></div></div>`; |
| }).join(""); |
| const dfb=data.deepfake_score!=null?`<div class="chips" style="margin-top:10px"><span class="badge ${data.reason&&(''+data.reason).includes('Deepfake')?'r':'g'}">deepfake ${data.deepfake_score.toFixed(2)}</span></div>`:""; |
| res.innerHTML=`<div class="verdict ${g?'ok':'no'}"><span class="dot"></span>${g?'✅ Identity verified':'🚫 Access denied'}</div> |
| <div style="color:var(--muted);font-size:13px;margin-top:4px">Unified similarity ${(data.similarity||0).toFixed(3)} (threshold ${(data.threshold||0.6).toFixed(2)}) · ${(data.modalities||[]).length} modalities, quality-weighted</div> |
| ${bars}${dfb}${data.reason?`<div style="color:var(--muted);font-size:13px;margin-top:8px">${data.reason}</div>`:""}`; |
| } |
| $("#u_enroll").onclick=()=>uCall("enroll"); |
| $("#u_auth").onclick=()=>uCall("authenticate"); |
| |
| |
| async function loadAccounts(){ |
| const uid=$("#p_user").value.trim(); if(!uid)return; |
| const a=await fetch(API+"/wallet/accounts?user_id="+encodeURIComponent(uid)).then(r=>r.json()).catch(()=>({accounts:[]})); |
| $("#p_accts").innerHTML=(a.accounts||[]).map(x=>`<div class="acct"><div class="ic">🏦</div><div><div style="font-weight:600">${x.bank} · ${x.kind}</div><div style="font-size:12px;color:var(--muted)">token ${x.token.slice(0,12)}… · ••${x.last4}</div></div><div class="bal">$${x.balance.toFixed(2)}</div></div>`).join("") || `<div style="color:var(--muted);font-size:13px">No linked account — set up your wallet.</div>`; |
| return a.accounts||[]; |
| } |
| async function loadHist(){ |
| const uid=$("#p_user").value.trim(); if(!uid)return; |
| const h=await fetch(API+"/wallet/transactions?user_id="+encodeURIComponent(uid)).then(r=>r.json()).catch(()=>({transactions:[]})); |
| const rows=(h.transactions||[]).slice(-6).reverse().map(t=>`<div class="txrow"><span>${t.merchant}</span><span>$${t.amount.toFixed(2)} · ${t.status}</span></div>`).join(""); |
| $("#p_hist").innerHTML= rows? `<div style="font-size:12.5px;color:var(--muted);margin-bottom:4px">Recent</div>`+rows : ""; |
| } |
| $("#p_setup").onclick=async()=>{ |
| const uid=$("#p_user").value.trim(); if(!uid)return; |
| await post("/wallet/consent",{user_id:uid,purpose:"biometric payments",duration:"1 year"}); |
| await post("/wallet/link",{user_id:uid,bank:"Chase",kind:"checking",funding_ref:"4242424242424242"}); |
| await loadAccounts(); |
| }; |
| function paintDz(target,dataURL){ const dz=document.querySelector(`.dz[data-target="${target}"]`); |
| if(dz){ store[target]=dataURL; dz.classList.add("filled"); dz.innerHTML=`<img src="${dataURL}"/>`; } } |
| $("#p_demo").onclick=async()=>{ |
| const btn=$("#p_demo"), res=$("#p_res"); busy(btn,true,"Seeding demo…"); |
| const uid=$("#p_user").value.trim()||"alice"; $("#p_user").value=uid; |
| const {ok,data}=await post("/demo/seed",{user_id:uid}); busy(btn,false); |
| if(!ok||!data.success){ res.className="result show"; res.innerHTML="⚠️ Demo seed failed — is the server up with example samples?"; return; } |
| const s=data.samples||{}; |
| if(s.face_image) paintDz("p_face",s.face_image); |
| if(s.fingerprint_image) paintDz("p_fp",s.fingerprint_image); |
| if(s.voice_audio){ store.p_voice=s.voice_audio; $("#p_recst").textContent="✅ demo voice loaded"; } |
| if(!$("#p_amt").value) $("#p_amt").value="18.75"; |
| if(!$("#p_merch").value) $("#p_merch").value="Demo Cafe"; |
| await loadAccounts(); await loadHist(); |
| res.className="result show"; |
| res.innerHTML=`<div class="verdict ok"><span class="dot"></span>Demo ready — enrolled ${(data.modalities||[]).join(", ")}</div><div style="color:var(--muted);font-size:13px;margin-top:6px">Account linked. Hit <b>Pay with biometrics</b> to authorize.</div>`; |
| }; |
| $("#p_rec").onclick=async()=>{ const st=$("#p_recst"); st.textContent="🔴 recording…"; |
| try{ store.p_voice=await recordWav(3); st.textContent="✅ recorded"; }catch(e){ st.textContent="mic unavailable"; } }; |
| |
| function sampleCenterRGB(video){ |
| const c=document.createElement("canvas"); c.width=64; c.height=64; |
| const g=c.getContext("2d"); g.drawImage(video,0,0,64,64); |
| const d=g.getImageData(20,20,24,24).data; let r=0,gg=0,b=0,n=0; |
| for(let i=0;i<d.length;i+=4){r+=d[i];gg+=d[i+1];b+=d[i+2];n++;} |
| return [r/n,gg/n,b/n]; |
| } |
| const FLASH={red:"#ff2d2d",green:"#25e625",blue:"#2d6bff",yellow:"#ffe11a",cyan:"#1ae0e6",magenta:"#ff2de0"}; |
| async function runFlashChallenge(uid,res){ |
| |
| if(!confirm("Security check: your screen will briefly flash colors to confirm you're live.\n\n⚠️ If you have photosensitive epilepsy, press Cancel and use “Device biometric” or “Approve payment” instead.")){ |
| return {ok:false, detail:"cancelled (photosensitivity)"}; |
| } |
| let stream; |
| try{ stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:"user"}}); } |
| catch(e){ return {ok:false,err:"Camera needed for the security check."}; } |
| const v=document.createElement("video"); v.autoplay=true;v.playsInline=true;v.muted=true;v.srcObject=stream; |
| await v.play().catch(()=>{}); await new Promise(r=>setTimeout(r,300)); |
| const ov=document.createElement("div"); |
| ov.style.cssText="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;transition:background .12s;background:#f2f2f2"; |
| ov.innerHTML=`<div style="width:210px;height:270px;border-radius:50%;overflow:hidden;box-shadow:0 0 0 6px rgba(0,0,0,.15)"></div> |
| <div style="position:absolute;bottom:42px;left:0;right:0;text-align:center;font:600 15px system-ui;color:#111">Hold still — verifying it's really you…</div>`; |
| ov.querySelector("div").appendChild(v); v.style.cssText="width:100%;height:100%;object-fit:cover"; |
| document.body.appendChild(ov); |
| const ch=await fetch(API+"/pad/challenge",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({user_id:uid,n:4})}).then(r=>r.json()); |
| const wait=ms=>new Promise(r=>setTimeout(r,ms)); |
| ov.style.background="#eaeaea"; await wait(450); const baseline=sampleCenterRGB(v); |
| const meas=[]; |
| for(const col of ch.sequence){ ov.style.background=FLASH[col]||"#fff"; await wait(430); meas.push(sampleCenterRGB(v)); } |
| ov.style.background="#eaeaea"; await wait(120); |
| stream.getTracks().forEach(t=>t.stop()); ov.remove(); |
| const v2=await fetch(API+"/pad/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({session_id:ch.session_id,baseline,measurements:meas})}).then(r=>r.json()); |
| return {ok:!!v2.success,score:v2.score,detail:v2.reason}; |
| } |
| |
| |
| function getGeo(){ return new Promise(res=>{ |
| if(!$("#p_geo").checked || !navigator.geolocation) return res(null); |
| navigator.geolocation.getCurrentPosition( |
| p=>res({lat:+p.coords.latitude.toFixed(4), lon:+p.coords.longitude.toFixed(4)}), |
| ()=>res(null), {timeout:6000, maximumAge:60000}); |
| }); } |
| |
| |
| |
| |
| function b64urlToBuf(s){ s=s.replace(/-/g,'+').replace(/_/g,'/'); s+='='.repeat((4-s.length%4)%4); |
| const bin=atob(s), b=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++)b[i]=bin.charCodeAt(i); return b.buffer; } |
| function bufToB64url(buf){ const b=new Uint8Array(buf); let s=''; for(let i=0;i<b.length;i++)s+=String.fromCharCode(b[i]); |
| return btoa(s).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); } |
| async function platformAuthAvailable(){ |
| try{ return !!(window.PublicKeyCredential && await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()); } |
| catch(e){ return false; } |
| } |
| async function registerDevice(uid){ |
| const {data:opts}=await post("/webauthn/register/begin",{user_id:uid}); |
| opts.challenge=b64urlToBuf(opts.challenge); opts.user.id=b64urlToBuf(opts.user.id); |
| if(opts.excludeCredentials)opts.excludeCredentials=opts.excludeCredentials.map(c=>({...c,id:b64urlToBuf(c.id)})); |
| const c=await navigator.credentials.create({publicKey:opts}); |
| const credential={id:c.id,rawId:bufToB64url(c.rawId),type:c.type, |
| response:{attestationObject:bufToB64url(c.response.attestationObject),clientDataJSON:bufToB64url(c.response.clientDataJSON)}, |
| clientExtensionResults:c.getClientExtensionResults?c.getClientExtensionResults():{}}; |
| const {data}=await post("/webauthn/register/complete",{user_id:uid,credential}); |
| return !!data.success; |
| } |
| async function authenticateDevice(uid){ |
| const beg=await post("/webauthn/authenticate/begin",{user_id:uid}); |
| if(!beg.ok) return null; |
| const opts=beg.data; opts.challenge=b64urlToBuf(opts.challenge); |
| if(opts.allowCredentials)opts.allowCredentials=opts.allowCredentials.map(c=>({...c,id:b64urlToBuf(c.id)})); |
| const c=await navigator.credentials.get({publicKey:opts}); |
| const credential={id:c.id,rawId:bufToB64url(c.rawId),type:c.type, |
| response:{authenticatorData:bufToB64url(c.response.authenticatorData),clientDataJSON:bufToB64url(c.response.clientDataJSON), |
| signature:bufToB64url(c.response.signature),userHandle:c.response.userHandle?bufToB64url(c.response.userHandle):null}}; |
| const {data}=await post("/webauthn/authenticate/complete",{user_id:uid,credential}); |
| return !!data.success; |
| } |
| |
| async function deviceScan(uid){ |
| uid=uid||"amanpay-user"; |
| try{ |
| let ok=await authenticateDevice(uid); |
| if(ok===null){ ok=await registerDevice(uid); return {ok, mode:"enrolled"}; } |
| return {ok, mode:"verified"}; |
| }catch(e){ return {ok:false, mode:"error", err:(e&&e.name)||""}; } |
| } |
| function wireFingerScan(btnId, stId, userId){ |
| const b=document.getElementById(btnId), st=document.getElementById(stId); |
| if(!b) return; |
| b.onclick=async()=>{ |
| if(!(await platformAuthAvailable())){ |
| st.textContent="No fingerprint / Face sensor available here (needs a device with a fingerprint sensor, over HTTPS on a domain — not a bare IP). Upload an image instead."; |
| st.style.color="var(--warn)"; return; |
| } |
| busy(b,true,"Touch the sensor…"); |
| const uid=(document.getElementById(userId)||{}).value.trim()||"amanpay-user"; |
| const r=await deviceScan(uid); busy(b,false); |
| store.p_devok=r.ok; |
| if(r.ok){ st.textContent = r.mode==="enrolled" |
| ? "✅ Device fingerprint enrolled & saved to your AmanPay record" |
| : "✅ Fingerprint verified on this device"; st.style.color="var(--success)"; |
| b.textContent="👆 Re-scan finger"; } |
| else { st.textContent="Scan cancelled/failed"+(r.err?` (${r.err})`:"")+". On a bare-IP URL passkeys are blocked — use the hosted https URL, or upload an image."; st.style.color="var(--warn)"; } |
| }; |
| } |
| wireFingerScan("p_fpscan","p_fpscanst","p_user"); |
| wireFingerScan("e_fpscan","e_fpscanst","e_user"); |
| platformAuthAvailable().then(ok=>{ if(!ok){ ["p_fpscanst","e_fpscanst"].forEach(id=>{ |
| const el=document.getElementById(id); if(el){el.textContent="This device/URL has no usable fingerprint sensor — use the image upload.";}});}}); |
| async function deviceBiometric(uid){ return (await deviceScan(uid)).ok; } |
| $("#p_dev").onclick=async()=>{ const b=$("#p_dev"), st=$("#p_devst"); busy(b,true,"Scan…"); |
| const ok=await deviceBiometric($("#p_user").value.trim()); busy(b,false); store.p_devok=ok; |
| b.textContent = ok? "Re-verify" : "Enroll"; |
| st.textContent = ok? "✅ Enrolled — this device's biometric will be used" : "⚠️ Not available on this device/browser (needs Touch ID / fingerprint + HTTPS)"; |
| st.style.color = ok? "var(--success)" : "var(--warn)"; }; |
| function chosenChannels(){ return [...document.querySelectorAll('#p_chans input:checked')].map(c=>c.value).concat("console"); } |
| async function saveNotifyPrefs(){ |
| const uid=$("#p_user").value.trim(); if(!uid)return; |
| await post("/notify/prefs",{user_id:uid,channels:chosenChannels(), |
| email:$("#p_email").value||null,phone:$("#p_phone").value||null}); |
| } |
| async function doPay(extra){ |
| const uid=$("#p_user").value.trim(), accts=await loadAccounts(); |
| const geo=await getGeo(); |
| const body={user_id:uid,account_id:accts[0].account_id,amount:parseFloat($("#p_amt").value),merchant:$("#p_merch").value||"Merchant"}; |
| if(store.p_face)body.face_image=store.p_face; |
| if(store.p_fp)body.fingerprint_image=store.p_fp; |
| if(store.p_voice)body.voice_audio=store.p_voice; |
| if(geo){body.lat=geo.lat;body.lon=geo.lon;} |
| if(store.p_devok)body.device_verified=true; |
| return (await post("/wallet/pay",Object.assign(body,extra||{}))).data; |
| } |
| function renderPaid(res,data){ |
| const t=data.transaction; |
| res.innerHTML=`<div class="verdict ok"><span class="dot"></span>✅ Paid $${t.amount.toFixed(2)} to ${t.merchant}</div> |
| <div class="chips" style="margin-top:10px"> |
| <span class="badge g">SCA ✓ inherence + possession</span> |
| <span class="badge b">${data.decision==="frictionless"?("frictionless · "+((data.risk||{}).exemption||"low-risk")):"step-up ✓ live"}</span> |
| <span class="badge b">🔏 ${t.signature.slice(0,14)}…</span></div> |
| <div style="color:var(--muted);font-size:13px;margin-top:8px">Biometric ${((data.biometric||{}).similarity||0).toFixed(3)} · ${((data.biometric||{}).modalities||[]).join(", ")} · risk ${((data.risk||{}).risk??0)} · new balance $${t.new_balance.toFixed(2)}</div> |
| ${data.confirmation?`<div style="color:var(--muted);font-size:12px;margin-top:6px">🔔 Confirmation sent via ${(data.confirmation.channels||[]).join(", ")} (code ${data.confirmation.code||"—"}, bound to this payment)</div>`:""}`; |
| } |
| $("#p_pay").onclick=async()=>{ |
| const uid=$("#p_user").value.trim(), res=$("#p_res"); |
| const accts=await loadAccounts(); |
| if(!accts.length){res.className="result show";res.innerHTML="⚠️ Set up your wallet first (link an account).";return;} |
| const amt=parseFloat($("#p_amt").value); if(!amt||amt<=0){res.className="result show";res.innerHTML="⚠️ Enter an amount.";return;} |
| if(!store.p_face&&!store.p_fp&&!store.p_voice){res.className="result show";res.innerHTML="⚠️ Provide a biometric (face, fingerprint, or voice).";return;} |
| await saveNotifyPrefs(); |
| busy($("#p_pay"),true,"Authorizing…"); |
| let data=await doPay(); busy($("#p_pay"),false); |
| res.className="result show"; $("#p_stepup").innerHTML=""; |
| if(data.challenge_required){ |
| renderStepUp(res,data,uid); return; |
| } |
| if(data.success){ renderPaid(res,data); await loadAccounts(); await loadHist(); } |
| else { res.innerHTML=`<div class="verdict no"><span class="dot"></span>🚫 Payment ${data.decision==="decline"?"declined":"not completed"}</div><div style="color:var(--muted);font-size:13px;margin-top:6px">${data.reason||(data.biometric||{}).reason||''}</div>`; } |
| }; |
| async function retryPay(res,uid,extra){ |
| busy($("#p_pay"),true,"Authorizing…"); const d=await doPay(extra); busy($("#p_pay"),false); |
| $("#p_stepup").innerHTML=""; |
| if(d.success){ renderPaid(res,d); await loadAccounts(); await loadHist(); } |
| else if(d.challenge_required){ renderStepUp(res,d,uid); } |
| else { res.innerHTML=`<div class="verdict no"><span class="dot"></span>🚫 ${d.reason||"Not completed"}</div>`; } |
| } |
| function renderStepUp(res,data,uid){ |
| const amt=parseFloat($("#p_amt").value)||0, merch=$("#p_merch").value||"Merchant"; |
| const cid=(data.confirmation||{}).confirmation_id, chans=((data.confirmation||{}).channels||[]).join(", "); |
| res.innerHTML=`<div class="verdict"><span class="dot"></span>🔐 Extra verification needed</div> |
| <div style="color:var(--muted);font-size:13px;margin-top:6px">Risk ${(data.risk||{}).risk} — ${((data.risk||{}).reasons||[]).join(" · ")}. Choose one:</div>`; |
| $("#p_stepup").innerHTML=` |
| <div class="rowbtns" style="gap:10px;flex-wrap:wrap"> |
| <button class="mini" id="su_dev">🔒 Device biometric</button> |
| <button class="mini" id="su_oob">✅ Approve $${amt.toFixed(2)} to ${merch}</button> |
| <button class="mini" id="su_live">🔦 Active liveness</button> |
| </div> |
| <div style="color:var(--muted);font-size:12px;margin-top:6px">A confirmation was also sent via: ${chans||"console"} (dynamic-linked to this exact payment).</div>`; |
| $("#su_dev").onclick=async()=>{ const ok=await deviceBiometric(uid); if(ok){store.p_devok=true; retryPay(res,uid,{device_verified:true});} |
| else $("#su_dev").textContent="🔒 unavailable/cancelled"; }; |
| $("#su_oob").onclick=async()=>{ await post("/notify/respond",{confirmation_id:cid,approve:true}); |
| retryPay(res,uid,{confirmation_id:cid}); }; |
| $("#su_live").onclick=async()=>{ const fl=await runFlashChallenge(uid,res); |
| if(fl.ok) retryPay(res,uid,{liveness_ok:true}); else res.innerHTML+=`<div style="color:var(--danger);font-size:13px;margin-top:6px">Liveness failed — try another option.</div>`; }; |
| } |
| |
| loadAccounts(); loadHist(); |
| |
| |
| fetch(API+"/health").then(r=>r.json()).then(d=>{$("#dev").textContent = "device: "+(d.device||"—");}).catch(()=>{}); |
| |
| |
| if("serviceWorker" in navigator){ window.addEventListener("load",()=>{ |
| navigator.serviceWorker.register("/sw.js").catch(()=>{}); }); } |
| </script> |
| </body> |
| </html> |
|
|