PocketBase runs every routerAdd/cronAdd handler in its OWN isolated JSVM, so the file-level helpers/consts (nowSeconds, genCode, CODE_ALPHABET, the TTLs, and the DEVICE_APPROVE_HTML page) were invisible inside the callbacks — every route threw `ReferenceError: <name> is not defined` at request time (POST /request, the cleanup cron, GET /device). A runtime-only trap that only surfaces on a live PocketBase, which the original PR couldn't exercise. Define what each handler needs *inside* it (local scope works). No behavior change; syntax-checked with `node --check`. Fixes the deploy of device-auth (amber-app #12). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
281 lines
14 KiB
JavaScript
281 lines
14 KiB
JavaScript
/// <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; }
|
|
</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="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)||"";}
|
|
|
|
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 = qs("code").toUpperCase().trim();
|
|
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 };
|
|
}
|
|
|
|
async function loadInfo(){
|
|
if (!CODE){ setMsg("Chybí kód zařízení v odkazu.", "err"); document.getElementById("form").classList.add("hidden"); return; }
|
|
var r = await api("GET", "/api/device-auth/info?code=" + encodeURIComponent(CODE));
|
|
if (!r.ok){
|
|
setMsg("Požadavek nebyl nalezen nebo vypršel. Vytvořte na televizi nový.", "err");
|
|
document.getElementById("form").classList.add("hidden");
|
|
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("form").classList.add("hidden");
|
|
}
|
|
}
|
|
function escapeHtml(s){ return String(s).replace(/[&<>"']/g,function(c){return {"&":"&","<":"<",">":">",'"':""","'":"'"}[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);
|
|
loadInfo();
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
return e.html(200, DEVICE_APPROVE_HTML)
|
|
})
|