amber-backend/pb_hooks/device_page.pb.js

338 lines
17 KiB
JavaScript
Raw Normal View History

/// <reference path="../pb_data/types.d.ts" />
// Approve page for codeless device sign-in (epic #6, issue #12).
//
// Served at GET /device?code=CODE — the QR the TV shows encodes exactly this URL.
// The page is self-contained (no external assets, no CDN): the user signs in with
// email + password, the page derives the addon-config vault key (#20) from the
// password, seals it to the TV's ephemeral public key, and approves the request.
//
// Crypto here MUST match the app byte-for-byte (verified by the app's
// device_key_transfer_test + addon_config_crypto_interop_test against fixed
// vectors this same code produced under Node):
// - vault key: PBKDF2-HMAC-SHA256, 210000 iters, 256-bit, salt = account salt
// (addon_config_crypto.dart).
// - key transfer: X25519 (vendored TweetNaCl scalarMult, RFC 7748) → HKDF-SHA256
// (salt="", info="amber-device-key-v1") → AES-256-GCM (12B nonce, 128b tag),
// blob = ephPub[32] ‖ nonce[12] ‖ ct ‖ tag[16], base64 (device_key_transfer.dart).
//
// The password never leaves the page; the vault key is sealed to the TV and never
// reaches the server (E2E for this one blob). The minted token is delivered by the
// poll route, not here.
// NOTE: PocketBase runs this route handler in its own isolated JSVM — a file-level
// `const DEVICE_APPROVE_HTML` is invisible inside it (ReferenceError at request
// time). So the whole self-contained page is defined *inside* the handler.
routerAdd("GET", "/device", (e) => {
const DEVICE_APPROVE_HTML = `<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex">
<title>Amber přihlásit zařízení</title>
<style>
:root { color-scheme: light dark; --bg:#0e0f13; --card:#191b21; --fg:#f2e9d8;
--muted:#9aa0aa; --amber:#f0a63c; --amber2:#c9791b; --err:#ff6b6b; --ok:#5fd08a;
--line:#2a2d36; }
@media (prefers-color-scheme: light) {
:root { --bg:#f4f1ea; --card:#fff; --fg:#1b1c1f; --muted:#5b616b;
--line:#e3ddd0; } }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font-family:system-ui,
-apple-system,Segoe UI,Roboto,sans-serif; display:flex; min-height:100dvh;
align-items:center; justify-content:center; padding:20px; }
.card { width:100%; max-width:400px; background:var(--card); border-radius:16px;
padding:26px 22px; box-shadow:0 10px 40px rgba(0,0,0,.25);
border:1px solid var(--line); }
h1 { font-size:22px; margin:0 0 4px; color:var(--amber); }
p.sub { margin:0 0 18px; color:var(--muted); font-size:14px; }
.dev { background:rgba(240,166,60,.10); border:1px solid var(--amber2);
border-radius:10px; padding:10px 12px; margin-bottom:18px; font-size:14px; }
.dev b { color:var(--amber); }
label { display:block; font-size:13px; color:var(--muted); margin:12px 0 5px; }
input { width:100%; padding:12px; border-radius:10px; border:1px solid var(--line);
background:var(--bg); color:var(--fg); font-size:15px; }
input:focus { outline:2px solid var(--amber); border-color:transparent; }
button { width:100%; margin-top:18px; padding:13px; border:none; border-radius:10px;
font-size:15px; font-weight:600; cursor:pointer; }
.primary { background:var(--amber); color:#1b1206; }
.primary:disabled { opacity:.55; cursor:default; }
.ghost { background:transparent; color:var(--muted); margin-top:8px; }
.msg { margin-top:14px; font-size:14px; min-height:18px; }
.msg.err { color:var(--err); }
.msg.ok { color:var(--ok); }
.spin { display:inline-block; width:15px; height:15px; border:2px solid #1b1206;
border-top-color:transparent; border-radius:50%; animation:s .7s linear infinite;
vertical-align:-2px; margin-right:7px; }
@keyframes s { to { transform:rotate(360deg); } }
.hidden { display:none; }
.note { margin-top:16px; font-size:12px; color:var(--muted); line-height:1.5; }
.code-input { text-transform:uppercase; letter-spacing:.22em; text-align:center;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:20px;
font-weight:600; }
</style>
</head>
<body>
<div class="card">
<h1>Přihlásit zařízení</h1>
<p class="sub">Amber přihlášení televize bez kódu</p>
<div id="devbox" class="dev hidden"></div>
<div id="codeform" class="hidden">
<label for="codeInput">Kód ze zařízení</label>
<input id="codeInput" class="code-input" type="text" inputmode="text"
autocomplete="off" autocapitalize="characters" spellcheck="false"
maxlength="9" placeholder="XXXX-XXXX" />
<button id="codeGo" class="primary">Pokračovat</button>
</div>
<div id="form">
<label for="email">E-mail</label>
<input id="email" type="email" autocomplete="username" inputmode="email" />
<label for="pass">Heslo</label>
<input id="pass" type="password" autocomplete="current-password" />
<button id="approve" class="primary">Schválit zařízení</button>
<button id="decline" class="ghost">Odmítnout</button>
</div>
<div id="msg" class="msg"></div>
<p class="note">Heslo se použije jen ve vašem prohlížeči k odemčení nastavení
doplňků a odešle se televizi zašifrovaně. Na server se heslo ani klíč nikdy
neposílají.</p>
</div>
<script>
// ── vendored X25519 (TweetNaCl scalarMult, public domain, RFC 7748) ──────────
var amberX25519 = (function () {
function gf(init){var i,r=new Float64Array(16);if(init)for(i=0;i<init.length;i++)r[i]=init[i];return r;}
var _121665=gf([0xdb41,1]);
function car(o){var i,v,c=1;for(i=0;i<16;i++){v=o[i]+c+65535;c=Math.floor(v/65536);o[i]=v-c*65536;}o[0]+=c-1+37*(c-1);}
function sel(p,q,b){var t,c=~(b-1);for(var i=0;i<16;i++){t=c&(p[i]^q[i]);p[i]^=t;q[i]^=t;}}
function pack(o,n){var i,j,b,m=gf(),t=gf();for(i=0;i<16;i++)t[i]=n[i];car(t);car(t);car(t);
for(j=0;j<2;j++){m[0]=t[0]-0xffed;for(i=1;i<15;i++){m[i]=t[i]-0xffff-((m[i-1]>>16)&1);m[i-1]&=0xffff;}
m[15]=t[15]-0x7fff-((m[14]>>16)&1);b=(m[15]>>16)&1;m[14]&=0xffff;sel(t,m,1-b);}
for(i=0;i<16;i++){o[2*i]=t[i]&0xff;o[2*i+1]=t[i]>>8;}}
function unpack(o,n){var i;for(i=0;i<16;i++)o[i]=n[2*i]+(n[2*i+1]<<8);o[15]&=0x7fff;}
function A(o,a,b){for(var i=0;i<16;i++)o[i]=a[i]+b[i];}
function Z(o,a,b){for(var i=0;i<16;i++)o[i]=a[i]-b[i];}
function M(o,a,b){var v,t=new Float64Array(31);for(var i=0;i<31;i++)t[i]=0;
for(i=0;i<16;i++){v=a[i];for(var j=0;j<16;j++)t[i+j]+=v*b[j];}
for(i=0;i<15;i++)t[i]+=38*t[i+16];for(i=0;i<16;i++)o[i]=t[i];car(o);car(o);}
function S(o,a){M(o,a,a);}
function inv(o,i){var c=gf(),a;for(a=0;a<16;a++)c[a]=i[a];
for(a=253;a>=0;a--){S(c,c);if(a!==2&&a!==4)M(c,c,i);}for(a=0;a<16;a++)o[a]=c[a];}
function smult(q,n,p){var z=new Uint8Array(32),x=new Float64Array(80),r,i,
a=gf(),b=gf(),c=gf(),d=gf(),ee=gf(),f=gf();
for(i=0;i<31;i++)z[i]=n[i];z[31]=(n[31]&127)|64;z[0]&=248;unpack(x,p);
for(i=0;i<16;i++){b[i]=x[i];d[i]=a[i]=c[i]=0;}a[0]=d[0]=1;
for(i=254;i>=0;--i){r=(z[i>>>3]>>>(i&7))&1;sel(a,b,r);sel(c,d,r);
A(ee,a,c);Z(a,a,c);A(c,b,d);Z(b,b,d);S(d,ee);S(f,a);M(a,c,a);M(c,b,ee);
A(ee,a,c);Z(a,a,c);S(b,a);Z(c,d,f);M(a,c,_121665);A(a,a,d);M(c,c,a);
M(a,d,f);M(d,b,x);S(b,ee);sel(a,b,r);sel(c,d,r);}
for(i=0;i<16;i++){x[i+16]=a[i];x[i+32]=c[i];x[i+48]=b[i];x[i+64]=d[i];}
var x32=x.subarray(32),x16=x.subarray(16);inv(x32,x32);M(x16,x16,x32);pack(q,x16);return 0;}
var _9=new Uint8Array(32);_9[0]=9;
function scalarMult(n,p){var q=new Uint8Array(32);smult(q,n,p);return q;}
function scalarMultBase(n){return scalarMult(n,_9);}
return {scalarMult:scalarMult,scalarMultBase:scalarMultBase};
})();
// ── helpers ──────────────────────────────────────────────────────────────────
var subtle = crypto.subtle;
var HKDF_INFO = new TextEncoder().encode("amber-device-key-v1");
var PBKDF2_ITERS = 210000;
function b64e(u8){var s="";for(var i=0;i<u8.length;i++)s+=String.fromCharCode(u8[i]);return btoa(s);}
function b64d(s){var bin=atob(s),u8=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++)u8[i]=bin.charCodeAt(i);return u8;}
function concat(){var n=0,i;for(i=0;i<arguments.length;i++)n+=arguments[i].length;
var out=new Uint8Array(n),o=0;for(i=0;i<arguments.length;i++){out.set(arguments[i],o);o+=arguments[i].length;}return out;}
function qs(name){return new URLSearchParams(location.search).get(name)||"";}
// The human code is 8 chars shown as XXXX-XXXX but stored dash-less; normalize
// (uppercase, drop the dash/spaces, cap at 8) before any /info + /approve lookup.
function normCode(s){return String(s||"").toUpperCase().replace(/[^0-9A-Z]/g,"").slice(0,8);}
function fmtCode(s){var c=normCode(s);return c.length>4?c.slice(0,4)+"-"+c.slice(4):c;}
async function deriveVaultKeyB64(password, saltB64){
var salt=b64d(saltB64);
var base=await subtle.importKey("raw",new TextEncoder().encode(password),"PBKDF2",false,["deriveBits"]);
var bits=await subtle.deriveBits({name:"PBKDF2",hash:"SHA-256",salt:salt,iterations:PBKDF2_ITERS},base,256);
return b64e(new Uint8Array(bits));
}
async function sealTo(tvPubB64, plaintext){
var tvPub=b64d(tvPubB64);
var seed=crypto.getRandomValues(new Uint8Array(32));
var pagePub=amberX25519.scalarMultBase(seed);
var shared=amberX25519.scalarMult(seed,tvPub);
var hk=await subtle.importKey("raw",shared,"HKDF",false,["deriveBits"]);
var bits=await subtle.deriveBits({name:"HKDF",hash:"SHA-256",salt:new Uint8Array(0),info:HKDF_INFO},hk,256);
var aes=await subtle.importKey("raw",bits,"AES-GCM",false,["encrypt"]);
var nonce=crypto.getRandomValues(new Uint8Array(12));
var ct=new Uint8Array(await subtle.encrypt({name:"AES-GCM",iv:nonce,tagLength:128},aes,new TextEncoder().encode(plaintext)));
return b64e(concat(pagePub,nonce,ct));
}
function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); }
// ── page state ───────────────────────────────────────────────────────────────
var CODE = normCode(qs("code"));
var info = null;
var msgEl = document.getElementById("msg");
var approveBtn = document.getElementById("approve");
var declineBtn = document.getElementById("decline");
function setMsg(text, cls){ msgEl.className = "msg " + (cls||""); msgEl.textContent = text; }
function busy(on, label){
approveBtn.disabled = on;
approveBtn.innerHTML = on ? '<span class="spin"></span>' + (label||"Pracuji…") : "Schválit zařízení";
}
async function api(method, path, body, token){
var opt = { method:method, headers:{} };
if (body){ opt.headers["Content-Type"]="application/json"; opt.body=JSON.stringify(body); }
if (token){ opt.headers["Authorization"]=token; }
var r = await fetch(path, opt);
var data = null; try { data = await r.json(); } catch(_){}
return { ok:r.ok, status:r.status, data:data };
}
function showCodeEntry(prefill){
document.getElementById("form").classList.add("hidden");
document.getElementById("devbox").classList.add("hidden");
document.getElementById("codeform").classList.remove("hidden");
var input = document.getElementById("codeInput");
if (prefill) input.value = fmtCode(prefill);
input.focus();
}
async function loadInfo(){
if (!CODE){ showCodeEntry(""); setMsg("Zadejte kód zobrazený na televizi.", ""); return; }
var r = await api("GET", "/api/device-auth/info?code=" + encodeURIComponent(CODE));
if (!r.ok){
showCodeEntry(CODE);
setMsg("Kód nebyl nalezen nebo vypršel. Zkontrolujte ho, nebo vytvořte na televizi nový.", "err");
return;
}
info = r.data;
var box = document.getElementById("devbox");
box.classList.remove("hidden");
box.innerHTML = "Přihlásit zařízení: <b>" + (info.deviceName ? escapeHtml(info.deviceName) : "nové zařízení") + "</b>";
if (info.status && info.status !== "pending"){
setMsg("Tento požadavek už byl vyřízen.", "err");
document.getElementById("codeform").classList.add("hidden");
document.getElementById("form").classList.add("hidden");
return;
}
// code accepted → reveal the sign-in form
document.getElementById("codeform").classList.add("hidden");
document.getElementById("form").classList.remove("hidden");
setMsg("");
}
function escapeHtml(s){ return String(s).replace(/[&<>"']/g,function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c];}); }
async function doApprove(){
var email = document.getElementById("email").value.trim();
var password = document.getElementById("pass").value;
if (!email || !password){ setMsg("Zadejte e-mail a heslo.", "err"); return; }
busy(true, "Přihlašuji…");
setMsg("");
try {
// 1) sign in
var auth = await api("POST", "/api/collections/users/auth-with-password",
{ identity: email, password: password });
if (!auth.ok || !auth.data || !auth.data.token){
setMsg("Nesprávný e-mail nebo heslo.", "err"); busy(false); return;
}
var token = auth.data.token;
var uid = auth.data.record.id;
// 2) find the account's default (earliest) profile + its addon-config salt
var prof = await api("GET",
"/api/collections/profiles/records?perPage=1&sort=created&filter=" +
encodeURIComponent("user='" + uid + "'"), null, token);
var profileId = (prof.data && prof.data.items && prof.data.items[0]) ? prof.data.items[0].id : null;
var saltB64 = null;
if (profileId){
var ac = await api("GET",
"/api/collections/addon_config/records?perPage=1&filter=" +
encodeURIComponent("profile='" + profileId + "'"), null, token);
if (ac.data && ac.data.items && ac.data.items[0]) saltB64 = ac.data.items[0].salt;
}
// No addon config yet on the account → mint a fresh salt so the TV can still
// hold a vault key (it becomes canonical once a device pushes config).
if (!saltB64) saltB64 = newSaltB64();
// 3) derive the vault key from the password and seal {salt, keyB64} to the TV
busy(true, "Šifruji klíč…");
var keyB64 = await deriveVaultKeyB64(password, saltB64);
var payload = JSON.stringify({ salt: saltB64, keyB64: keyB64 });
var keyCiphertext = "";
if (info && info.devicePubKey){
keyCiphertext = await sealTo(info.devicePubKey, payload);
}
// 4) approve (mints the TV's session token server-side)
busy(true, "Schvaluji…");
var appr = await api("POST", "/api/device-auth/approve",
{ code: CODE, keyCiphertext: keyCiphertext }, token);
if (!appr.ok){
setMsg("Schválení se nezdařilo. Zkuste to prosím znovu.", "err"); busy(false); return;
}
document.getElementById("form").classList.add("hidden");
setMsg("Hotovo — televize se za chvíli přihlásí.", "ok");
} catch (err){
setMsg("Došlo k chybě. Zkuste to prosím znovu.", "err"); busy(false);
}
}
async function doDecline(){
var email = document.getElementById("email").value.trim();
var password = document.getElementById("pass").value;
if (!email || !password){ setMsg("Pro odmítnutí se přihlaste.", "err"); return; }
var auth = await api("POST", "/api/collections/users/auth-with-password",
{ identity: email, password: password });
if (!auth.ok || !auth.data || !auth.data.token){ setMsg("Nesprávný e-mail nebo heslo.", "err"); return; }
await api("POST", "/api/device-auth/decline", { code: CODE }, auth.data.token);
document.getElementById("form").classList.add("hidden");
setMsg("Požadavek byl odmítnut.", "ok");
}
approveBtn.addEventListener("click", doApprove);
declineBtn.addEventListener("click", doDecline);
// Manual code entry: when the page is opened bare (no ?code= — e.g. the TV tells
// the user to open …/device and type the XXXX-XXXX code). The QR/direct-link path
// still fills CODE from the URL and skips straight to sign-in.
var codeInput = document.getElementById("codeInput");
var codeGo = document.getElementById("codeGo");
function submitCode(){
CODE = normCode(codeInput.value);
if (CODE.length !== 8){ setMsg("Zadejte celý kód z televize (např. 25K4-CFQP).", "err"); return; }
setMsg("");
loadInfo();
}
codeInput.addEventListener("input", function(){
var before = codeInput.value.length;
codeInput.value = fmtCode(codeInput.value);
// when the auto-dash shifts length, park the caret at the end (8-char field)
if (codeInput.value.length !== before){
codeInput.setSelectionRange(codeInput.value.length, codeInput.value.length);
}
});
codeInput.addEventListener("keydown", function(ev){ if (ev.key === "Enter"){ ev.preventDefault(); submitCode(); } });
codeGo.addEventListener("click", submitCode);
// Boot: URL code → sign-in; otherwise show the manual code-entry field.
if (CODE){ loadInfo(); } else { showCodeEntry(""); }
</script>
</body>
</html>`
return e.html(200, DEVICE_APPROVE_HTML)
})