From 6ec98d25278afdcf2b81523d81fa6d762ed05cc6 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:56:19 -0400 Subject: [PATCH] Passkeys: one-time invite codes to enrol another device A signed-in device creates a short-lived, single-use code in Settings ("Invite a device"); the new device enters it on the sign-in page to create its own passkey. Codes are throttled like other secrets and cannot be used by signed-out clients to create more codes. Co-Authored-By: Claude Sonnet 5.5 --- auth.js | 41 +++++++++++++++++++++++++++++++++-------- public/app.js | 10 +++++++++- public/login.js | 15 ++++++++++++++- public/webauthn.js | 4 ++-- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/auth.js b/auth.js index d72b2a5..ae50c4c 100644 --- a/auth.js +++ b/auth.js @@ -46,16 +46,31 @@ function startSession(req, res) { } const challenges = new Map(); // cid -> { challenge, kind, exp } -const newChallenge = (challenge, kind) => { +const newChallenge = (challenge, kind, extra) => { for (const [k, v] of challenges) if (v.exp < Date.now()) challenges.delete(k); const cid = crypto.randomBytes(16).toString('base64url'); - challenges.set(cid, { challenge, kind, exp: Date.now() + 5 * 60e3 }); + challenges.set(cid, { challenge, kind, extra, exp: Date.now() + 5 * 60e3 }); return cid; }; const takeChallenge = (cid, kind) => { const c = challenges.get(cid); challenges.delete(cid); return c && c.kind === kind && c.exp > Date.now() ? c.challenge : null; }; +const takeRecord = cid => { const c = challenges.get(cid); challenges.delete(cid); return c && c.exp > Date.now() ? c : null; }; + +// One-time invite codes: a signed-in device creates one, a new device uses it to enrol its own passkey. +const invites = new Map(); // code -> expiry +const INVITE_MINUTES = 10; +const newInvite = () => { + for (const [k, v] of invites) if (v < Date.now()) invites.delete(k); + const a = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no look-alike characters + let code = ''; for (const b of crypto.randomBytes(8)) code += a[b % a.length]; + code = code.slice(0, 4) + '-' + code.slice(4); + invites.set(code, Date.now() + INVITE_MINUTES * 60e3); + return code; +}; +const normInvite = c => { const t = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); return t.length === 8 ? t.slice(0, 4) + '-' + t.slice(4) : ''; }; +const inviteValid = c => { const k = normInvite(c); return !!k && (invites.get(k) || 0) > Date.now() ? k : null; }; const hits = new Map(); // crude per-IP throttle on anything that checks a secret const throttled = req => { @@ -93,9 +108,12 @@ async function handle(req, res) { if (p === '/api/auth/register/options' && req.method === 'POST') { const authed = isAuthed(req); + let kind = 'reg-authed', extra; if (!authed) { if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' }); - if (store.creds.length || !equalStr(body.setupCode || '', SETUP_CODE)) return send(res, 403, { error: 'Wrong setup code' }); + if (!store.creds.length && equalStr(body.setupCode || '', SETUP_CODE)) kind = 'reg-setup'; + else if (body.inviteCode && inviteValid(body.inviteCode)) { kind = 'reg-invite'; extra = inviteValid(body.inviteCode); } + else return send(res, 403, { error: body.inviteCode ? 'That invite code is wrong or has expired' : 'Wrong setup code' }); } const options = await generateRegistrationOptions({ rpName: 'NE Hub', rpID, userName: 'NE Hub owner', userID: Buffer.from(store.userId, 'base64url'), @@ -103,20 +121,24 @@ async function handle(req, res) { excludeCredentials: store.creds.map(c => ({ id: c.id, transports: c.transports })), authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, }); - return send(res, 200, { cid: newChallenge(options.challenge, authed ? 'reg-authed' : 'reg-setup'), options }); + return send(res, 200, { cid: newChallenge(options.challenge, kind, extra), options }); } if (p === '/api/auth/register/verify' && req.method === 'POST') { const authed = isAuthed(req); if (!authed && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' }); - const challenge = takeChallenge(body.cid, authed ? 'reg-authed' : 'reg-setup'); - if (!challenge) return send(res, 400, { error: 'Request expired, try again' }); - if (!authed && store.creds.length) return send(res, 403, { error: 'Not allowed' }); - const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID }); + const rec = takeRecord(body.cid); + if (!rec) return send(res, 400, { error: 'Request expired, try again' }); + const okKind = rec.kind === 'reg-authed' ? authed + : rec.kind === 'reg-setup' ? !store.creds.length + : rec.kind === 'reg-invite' ? !!inviteValid(rec.extra) : false; + if (!okKind) return send(res, 403, { error: 'Not allowed' }); + const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID }); if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' }); const c = v.registrationInfo.credential; store.creds.push({ id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: c.transports || [], name: String(body.name || 'Passkey').slice(0, 40), created: Date.now() }); save(); + if (rec.kind === 'reg-invite') invites.delete(rec.extra); // single use if (!authed) startSession(req, res); return send(res, 200, { ok: true }); } @@ -141,6 +163,9 @@ async function handle(req, res) { } // Everything below needs a signed-in session. if (!isAuthed(req)) return send(res, 401, { error: 'Not signed in' }); + if (p === '/api/auth/invite' && req.method === 'POST') { + return send(res, 200, { code: newInvite(), minutes: INVITE_MINUTES }); + } if (p === '/api/auth/passkeys' && req.method === 'GET') { return send(res, 200, store.creds.map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null }))); } diff --git a/public/app.js b/public/app.js index 8ef5a32..dafeae5 100644 --- a/public/app.js +++ b/public/app.js @@ -779,7 +779,7 @@ function viewSettings() { const signed = !!sf.token(); $('#view').innerHTML = `

Settings

Passkeys

Loading…
-
+

Connections

Seafile${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}
PlexChecking…
@@ -820,6 +820,14 @@ function viewSettings() { $('#addpk').onclick = () => textSheet('Add a passkey', 'Name this device', 'Create', async name => { try { await passkeyRegister('', name); toast('Passkey added'); loadPks(); } catch (e) { toast(e.name === 'NotAllowedError' ? 'Cancelled' : e.message); } }); + $('#invpk').onclick = async () => { + try { + const { code, minutes } = await authPost('/api/auth/invite'); + const el = openSheet('Invite a device', 'On the new device, open this app, tap "New device? Use an invite code", and enter:', `

${esc(code)}

+

Works once, and expires in ${minutes} minutes.

`); + el.querySelector('#sx').onclick = closeSheet; + } catch (e) { toast(e.message); } + }; $('#hubout').onclick = async () => { await authPost('/api/auth/logout'); location.replace('/login'); }; const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); }; } diff --git a/public/login.js b/public/login.js index 9d6300c..737ebbb 100644 --- a/public/login.js +++ b/public/login.js @@ -14,7 +14,20 @@ async function init() { $('#sub').textContent = 'Sign in with your passkey'; $('#ui').innerHTML = ''; $('#go').onclick = signIn; - signIn(); // prompt straight away; the button is there if it's dismissed + $('#ui').insertAdjacentHTML('beforeend', '

New device? Use an invite code

'); + $('#inv').onclick = e => { + e.preventDefault(); $('#err').textContent = ''; $('#sub').textContent = 'Add this device'; + $('#ui').innerHTML = `

On a device that is already signed in, open Settings and tap Invite a device.

+ + +

Back to sign in

`; + $('#f').onsubmit = async ev => { + ev.preventDefault(); $('#err').textContent = ''; + try { await passkeyRegister('', $('#nm').value.trim() || 'Passkey', $('#ic').value.trim()); location.replace('/'); } catch (err) { fail(err); } + }; + }; + // Offer the passkey straight away unless the user came here to use an invite code. + signIn(); } else { $('#sub').textContent = 'First-time setup: create a passkey for this device'; $('#ui').innerHTML = `
diff --git a/public/webauthn.js b/public/webauthn.js index 3d27962..2898d78 100644 --- a/public/webauthn.js +++ b/public/webauthn.js @@ -9,8 +9,8 @@ async function authPost(path, body, method = 'POST') { if (!r.ok) throw new Error(j.error || 'Request failed (' + r.status + ')'); return j; } -async function passkeyRegister(setupCode, name) { - const { cid, options } = await authPost('/api/auth/register/options', { setupCode }); +async function passkeyRegister(setupCode, name, inviteCode) { + const { cid, options } = await authPost('/api/auth/register/options', { setupCode, inviteCode }); options.challenge = b64u.dec(options.challenge); options.user.id = b64u.dec(options.user.id); (options.excludeCredentials || []).forEach(c => { c.id = b64u.dec(c.id); });