feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
/// <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.
|
|
|
|
|
|
2026-07-19 17:48:03 +00:00
|
|
|
// 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.
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
routerAdd("GET", "/device", (e) => {
|
2026-07-19 17:48:03 +00:00
|
|
|
const DEVICE_APPROVE_HTML = `<!doctype html>
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
<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; }
|
device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.
Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).
Docs: note the optional ?code= + normalization in device-auth-contract.md.
Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 09:06:51 +00:00
|
|
|
.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; }
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
</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>
|
|
|
|
|
|
device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.
Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).
Docs: note the optional ?code= + normalization in device-auth-contract.md.
Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 09:06:51 +00:00
|
|
|
<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>
|
|
|
|
|
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
<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)||"";}
|
device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.
Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).
Docs: note the optional ?code= + normalization in device-auth-contract.md.
Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 09:06:51 +00:00
|
|
|
// 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;}
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
|
|
|
|
|
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 ───────────────────────────────────────────────────────────────
|
device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.
Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).
Docs: note the optional ?code= + normalization in device-auth-contract.md.
Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 09:06:51 +00:00
|
|
|
var CODE = normCode(qs("code"));
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.
Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).
Docs: note the optional ?code= + normalization in device-auth-contract.md.
Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 09:06:51 +00:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
async function loadInfo(){
|
device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.
Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).
Docs: note the optional ?code= + normalization in device-auth-contract.md.
Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 09:06:51 +00:00
|
|
|
if (!CODE){ showCodeEntry(""); setMsg("Zadejte kód zobrazený na televizi.", ""); return; }
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
var r = await api("GET", "/api/device-auth/info?code=" + encodeURIComponent(CODE));
|
|
|
|
|
if (!r.ok){
|
device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.
Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).
Docs: note the optional ?code= + normalization in device-auth-contract.md.
Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 09:06:51 +00:00
|
|
|
showCodeEntry(CODE);
|
|
|
|
|
setMsg("Kód nebyl nalezen nebo vypršel. Zkontrolujte ho, nebo vytvořte na televizi nový.", "err");
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
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");
|
device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.
Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).
Docs: note the optional ?code= + normalization in device-auth-contract.md.
Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 09:06:51 +00:00
|
|
|
document.getElementById("codeform").classList.add("hidden");
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
document.getElementById("form").classList.add("hidden");
|
device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.
Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).
Docs: note the optional ?code= + normalization in device-auth-contract.md.
Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 09:06:51 +00:00
|
|
|
return;
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
}
|
device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.
Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).
Docs: note the optional ?code= + normalization in device-auth-contract.md.
Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 09:06:51 +00:00
|
|
|
// code accepted → reveal the sign-in form
|
|
|
|
|
document.getElementById("codeform").classList.add("hidden");
|
|
|
|
|
document.getElementById("form").classList.remove("hidden");
|
|
|
|
|
setMsg("");
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
}
|
|
|
|
|
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);
|
device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.
Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).
Docs: note the optional ?code= + normalization in device-auth-contract.md.
Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 09:06:51 +00:00
|
|
|
|
|
|
|
|
// 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(""); }
|
feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:36:15 +00:00
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`
|
2026-07-19 17:48:03 +00:00
|
|
|
return e.html(200, DEVICE_APPROVE_HTML)
|
|
|
|
|
})
|