Passkeys: one-time invite codes to enrol another device
build-container / build (push) Successful in 2m14s

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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 14:56:19 -04:00
1 parent e8b46bb3aa
commit 6ec98d2527
4 files changed
+58 -12

No files matched your search

+33 -8
View File
@@ -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 })));
}