diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index fd3997e..60f2bc8 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -344,6 +344,237 @@ export async function removeSpaceMember( return result.count > 0; } +// ============================================================================ +// GUARDIAN OPERATIONS +// ============================================================================ + +export interface StoredGuardian { + id: string; + userId: string; + name: string; + email: string | null; + guardianUserId: string | null; + status: 'pending' | 'accepted' | 'revoked'; + inviteToken: string | null; + inviteExpiresAt: number | null; + acceptedAt: number | null; + createdAt: number; +} + +function rowToGuardian(row: any): StoredGuardian { + return { + id: row.id, + userId: row.user_id, + name: row.name, + email: row.email || null, + guardianUserId: row.guardian_user_id || null, + status: row.status, + inviteToken: row.invite_token || null, + inviteExpiresAt: row.invite_expires_at ? new Date(row.invite_expires_at).getTime() : null, + acceptedAt: row.accepted_at ? new Date(row.accepted_at).getTime() : null, + createdAt: new Date(row.created_at).getTime(), + }; +} + +export async function addGuardian( + id: string, + userId: string, + name: string, + email: string | null, + inviteToken: string, + inviteExpiresAt: number, +): Promise { + const rows = await sql` + INSERT INTO guardians (id, user_id, name, email, invite_token, invite_expires_at) + VALUES (${id}, ${userId}, ${name}, ${email}, ${inviteToken}, ${new Date(inviteExpiresAt)}) + RETURNING * + `; + return rowToGuardian(rows[0]); +} + +export async function getGuardians(userId: string): Promise { + const rows = await sql` + SELECT * FROM guardians + WHERE user_id = ${userId} AND status != 'revoked' + ORDER BY created_at ASC + `; + return rows.map(rowToGuardian); +} + +export async function getGuardianByInviteToken(token: string): Promise { + const rows = await sql`SELECT * FROM guardians WHERE invite_token = ${token}`; + if (rows.length === 0) return null; + return rowToGuardian(rows[0]); +} + +export async function acceptGuardianInvite(guardianId: string, guardianUserId: string): Promise { + await sql` + UPDATE guardians + SET status = 'accepted', guardian_user_id = ${guardianUserId}, accepted_at = NOW(), invite_token = NULL + WHERE id = ${guardianId} + `; +} + +export async function removeGuardian(guardianId: string, userId: string): Promise { + const result = await sql` + UPDATE guardians SET status = 'revoked' + WHERE id = ${guardianId} AND user_id = ${userId} + `; + return result.count > 0; +} + +export async function getGuardianById(guardianId: string): Promise { + const rows = await sql`SELECT * FROM guardians WHERE id = ${guardianId}`; + if (rows.length === 0) return null; + return rowToGuardian(rows[0]); +} + +export async function getGuardianships(guardianUserId: string): Promise { + const rows = await sql` + SELECT * FROM guardians + WHERE guardian_user_id = ${guardianUserId} AND status = 'accepted' + ORDER BY created_at ASC + `; + return rows.map(rowToGuardian); +} + +// ============================================================================ +// RECOVERY REQUEST OPERATIONS +// ============================================================================ + +export interface StoredRecoveryRequest { + id: string; + userId: string; + status: string; + threshold: number; + approvalCount: number; + initiatedAt: number; + expiresAt: number; + completedAt: number | null; +} + +function rowToRecoveryRequest(row: any): StoredRecoveryRequest { + return { + id: row.id, + userId: row.user_id, + status: row.status, + threshold: row.threshold, + approvalCount: row.approval_count, + initiatedAt: new Date(row.initiated_at).getTime(), + expiresAt: new Date(row.expires_at).getTime(), + completedAt: row.completed_at ? new Date(row.completed_at).getTime() : null, + }; +} + +export async function createRecoveryRequest( + id: string, + userId: string, + threshold: number, + expiresAt: number, +): Promise { + const rows = await sql` + INSERT INTO recovery_requests (id, user_id, threshold, expires_at) + VALUES (${id}, ${userId}, ${threshold}, ${new Date(expiresAt)}) + RETURNING * + `; + return rowToRecoveryRequest(rows[0]); +} + +export async function getRecoveryRequest(requestId: string): Promise { + const rows = await sql`SELECT * FROM recovery_requests WHERE id = ${requestId}`; + if (rows.length === 0) return null; + return rowToRecoveryRequest(rows[0]); +} + +export async function getActiveRecoveryRequest(userId: string): Promise { + const rows = await sql` + SELECT * FROM recovery_requests + WHERE user_id = ${userId} AND status = 'pending' AND expires_at > NOW() + ORDER BY initiated_at DESC LIMIT 1 + `; + if (rows.length === 0) return null; + return rowToRecoveryRequest(rows[0]); +} + +export async function createRecoveryApproval( + requestId: string, + guardianId: string, + approvalToken: string, +): Promise { + await sql` + INSERT INTO recovery_approvals (request_id, guardian_id, approval_token) + VALUES (${requestId}, ${guardianId}, ${approvalToken}) + ON CONFLICT DO NOTHING + `; +} + +export async function approveRecoveryByToken(approvalToken: string): Promise<{ requestId: string; guardianId: string } | null> { + // Find the approval + const rows = await sql` + SELECT * FROM recovery_approvals WHERE approval_token = ${approvalToken} AND approved_at IS NULL + `; + if (rows.length === 0) return null; + + const row = rows[0]; + + // Mark approved + await sql` + UPDATE recovery_approvals SET approved_at = NOW(), approval_token = NULL + WHERE request_id = ${row.request_id} AND guardian_id = ${row.guardian_id} + `; + + // Increment approval count on request + await sql` + UPDATE recovery_requests SET approval_count = approval_count + 1 + WHERE id = ${row.request_id} + `; + + return { requestId: row.request_id, guardianId: row.guardian_id }; +} + +export async function updateRecoveryRequestStatus(requestId: string, status: string): Promise { + const completedAt = status === 'completed' ? sql`NOW()` : null; + await sql` + UPDATE recovery_requests SET status = ${status}, completed_at = ${completedAt} + WHERE id = ${requestId} + `; +} + +export async function getRecoveryApprovals(requestId: string): Promise> { + const rows = await sql` + SELECT guardian_id, approved_at FROM recovery_approvals WHERE request_id = ${requestId} + `; + return rows.map(r => ({ + guardianId: r.guardian_id, + approvedAt: r.approved_at ? new Date(r.approved_at).getTime() : null, + })); +} + +// ============================================================================ +// DEVICE LINK OPERATIONS +// ============================================================================ + +export async function createDeviceLink(token: string, userId: string, expiresAt: number): Promise { + await sql` + INSERT INTO device_links (token, user_id, expires_at) + VALUES (${token}, ${userId}, ${new Date(expiresAt)}) + `; +} + +export async function getDeviceLink(token: string): Promise<{ userId: string; expiresAt: number; used: boolean } | null> { + const rows = await sql`SELECT * FROM device_links WHERE token = ${token}`; + if (rows.length === 0) return null; + return { + userId: rows[0].user_id, + expiresAt: new Date(rows[0].expires_at).getTime(), + used: rows[0].used, + }; +} + +export async function markDeviceLinkUsed(token: string): Promise { + await sql`UPDATE device_links SET used = TRUE WHERE token = ${token}`; +} + // ============================================================================ // HEALTH CHECK // ============================================================================ diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index 794cffd..a35ef24 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -59,3 +59,62 @@ CREATE TABLE IF NOT EXISTS space_members ( CREATE INDEX IF NOT EXISTS idx_space_members_user_did ON space_members(user_did); CREATE INDEX IF NOT EXISTS idx_space_members_space_slug ON space_members(space_slug); + +-- ============================================================================ +-- GUARDIAN RECOVERY (2-of-3 social recovery) +-- ============================================================================ + +-- Guardians: up to 3 trusted contacts per account +CREATE TABLE IF NOT EXISTS guardians ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, -- display name (e.g. "Mom", "Alex") + email TEXT, -- for sending invite/approval emails + guardian_user_id TEXT REFERENCES users(id), -- if they have an EncryptID account + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked')), + invite_token TEXT UNIQUE, -- token for invite link + invite_expires_at TIMESTAMPTZ, + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_guardians_user_id ON guardians(user_id); +CREATE INDEX IF NOT EXISTS idx_guardians_guardian_user_id ON guardians(guardian_user_id); +CREATE INDEX IF NOT EXISTS idx_guardians_invite_token ON guardians(invite_token); + +-- Recovery requests: when a user needs to recover their account +CREATE TABLE IF NOT EXISTS recovery_requests ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'cancelled', 'completed', 'expired')), + threshold INTEGER NOT NULL DEFAULT 2, -- approvals needed (2-of-3) + approval_count INTEGER NOT NULL DEFAULT 0, + initiated_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, -- 7 days to collect approvals + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_recovery_requests_user_id ON recovery_requests(user_id); + +-- Recovery approvals: guardian votes on a recovery request +CREATE TABLE IF NOT EXISTS recovery_approvals ( + request_id TEXT NOT NULL REFERENCES recovery_requests(id) ON DELETE CASCADE, + guardian_id TEXT NOT NULL REFERENCES guardians(id) ON DELETE CASCADE, + approval_token TEXT UNIQUE, -- token sent via email/link for one-click approve + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (request_id, guardian_id) +); + +CREATE INDEX IF NOT EXISTS idx_recovery_approvals_token ON recovery_approvals(approval_token); + +-- Device link tokens: for linking a second device via QR or email +CREATE TABLE IF NOT EXISTS device_links ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, -- 10 minutes + used BOOLEAN DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_device_links_user_id ON device_links(user_id); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index b5bda36..9ca5b99 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -35,6 +35,23 @@ import { type StoredCredential, type StoredChallenge, type StoredRecoveryToken, + addGuardian, + getGuardians, + getGuardianByInviteToken, + acceptGuardianInvite, + removeGuardian, + getGuardianById, + getGuardianships, + createRecoveryRequest, + getRecoveryRequest, + getActiveRecoveryRequest, + createRecoveryApproval, + approveRecoveryByToken, + updateRecoveryRequestStatus, + getRecoveryApprovals, + createDeviceLink, + getDeviceLink, + markDeviceLinkUsed, getSpaceMember, listSpaceMembers, upsertSpaceMember, @@ -877,6 +894,891 @@ app.get('/recover', (c) => { `); }); +// ============================================================================ +// GUARDIAN MANAGEMENT ROUTES +// ============================================================================ + +function generateToken(): string { + return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); +} + +/** + * GET /api/guardians — list my guardians + */ +app.get('/api/guardians', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const guardians = await getGuardians(claims.sub as string); + return c.json({ + guardians: guardians.map(g => ({ + id: g.id, + name: g.name, + email: g.email, + status: g.status, + acceptedAt: g.acceptedAt, + createdAt: g.createdAt, + })), + count: guardians.length, + threshold: 2, + }); +}); + +/** + * POST /api/guardians — add a guardian (max 3 active) + * Body: { name, email? } + * Returns guardian with invite link/token + */ +app.post('/api/guardians', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const userId = claims.sub as string; + const { name, email } = await c.req.json(); + + if (!name) return c.json({ error: 'Guardian name is required' }, 400); + + // Check max 3 active guardians + const existing = await getGuardians(userId); + if (existing.length >= 3) { + return c.json({ error: 'Maximum 3 guardians allowed. Remove one first.' }, 400); + } + + const id = generateToken(); + const inviteToken = generateToken(); + const inviteExpiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days + + const guardian = await addGuardian(id, userId, name, email || null, inviteToken, inviteExpiresAt); + + const inviteUrl = `https://auth.rspace.online/guardian?token=${inviteToken}`; + + // Send invite email if email provided + if (email && smtpTransport) { + const user = await getUserById(userId); + const username = user?.username || 'Someone'; + try { + await smtpTransport.sendMail({ + from: CONFIG.smtp.from, + to: email, + subject: `${username} wants you as a recovery guardian — rStack`, + text: [ + `Hi ${name},`, + '', + `${username} has asked you to be a recovery guardian for their rStack Identity account.`, + '', + 'As a guardian, you can help them recover their account if they lose access to their devices.', + 'You will never have access to their account or data — only the ability to approve recovery.', + '', + 'Accept the invitation:', + inviteUrl, + '', + 'This invitation expires in 7 days.', + '', + '— rStack Identity', + ].join('\n'), + html: ` + + + + +
+ + + + + +
+
🤝
+

Guardian Invitation

+
+

Hi ${name},

+

${username} has asked you to be a recovery guardian for their rStack Identity account.

+

As a guardian, you can help them recover their account if they lose access. You will never have access to their account or data.

+
+ Accept Invitation +
+

This invitation expires in 7 days.

+

+ Can't click the button? Copy this link:
+ ${inviteUrl} +

+
+
+`, + }); + } catch (err) { + console.error('EncryptID: Failed to send guardian invite email:', err); + } + } + + return c.json({ + guardian: { + id: guardian.id, + name: guardian.name, + email: guardian.email, + status: guardian.status, + }, + inviteUrl, + inviteToken, + }, 201); +}); + +/** + * DELETE /api/guardians/:id — remove a guardian + */ +app.delete('/api/guardians/:id', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const { id } = c.req.param(); + const removed = await removeGuardian(id, claims.sub as string); + if (!removed) return c.json({ error: 'Guardian not found' }, 404); + return c.json({ success: true }); +}); + +/** + * GET /api/guardian/invites — list accounts I'm a guardian for + */ +app.get('/api/guardian/invites', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const guardianships = await getGuardianships(claims.sub as string); + const result = []; + for (const g of guardianships) { + const owner = await getUserById(g.userId); + result.push({ + id: g.id, + ownerUsername: owner?.username || 'Unknown', + name: g.name, + acceptedAt: g.acceptedAt, + }); + } + return c.json({ guardianships: result }); +}); + +/** + * GET /api/guardian/requests — pending recovery requests where I'm a guardian + */ +app.get('/api/guardian/requests', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const guardianships = await getGuardianships(claims.sub as string); + const pending = []; + for (const g of guardianships) { + const req = await getActiveRecoveryRequest(g.userId); + if (req) { + const owner = await getUserById(g.userId); + const approvals = await getRecoveryApprovals(req.id); + const myApproval = approvals.find(a => a.guardianId === g.id); + pending.push({ + requestId: req.id, + ownerUsername: owner?.username || 'Unknown', + status: req.status, + threshold: req.threshold, + approvalCount: req.approvalCount, + initiatedAt: req.initiatedAt, + expiresAt: req.expiresAt, + alreadyApproved: !!myApproval?.approvedAt, + guardianId: g.id, + }); + } + } + return c.json({ requests: pending }); +}); + +// ============================================================================ +// GUARDIAN INVITE ACCEPTANCE PAGE +// ============================================================================ + +app.get('/guardian', (c) => { + return c.html(` + + + + + + Guardian Invitation — rStack Identity + + + +
+
🤝
+

Guardian Invitation

+
Loading invitation...
+ + +
+ + + + + `); +}); + +/** + * GET /api/guardian/accept?token=... — get invite info (unauthenticated) + */ +app.get('/api/guardian/accept', async (c) => { + const token = c.req.query('token'); + if (!token) return c.json({ error: 'Token required' }, 400); + + const guardian = await getGuardianByInviteToken(token); + if (!guardian) return c.json({ error: 'Invalid or expired invitation' }, 404); + if (guardian.inviteExpiresAt && Date.now() > guardian.inviteExpiresAt) { + return c.json({ error: 'Invitation has expired' }, 400); + } + if (guardian.status === 'accepted') { + return c.json({ error: 'Invitation already accepted' }, 400); + } + + const owner = await getUserById(guardian.userId); + return c.json({ + guardianName: guardian.name, + ownerUsername: owner?.username || 'Unknown', + }); +}); + +/** + * POST /api/guardian/accept — accept an invite (authenticated) + * Body: { inviteToken } + */ +app.post('/api/guardian/accept', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Sign in to accept this invitation' }, 401); + + const { inviteToken } = await c.req.json(); + if (!inviteToken) return c.json({ error: 'Invite token required' }, 400); + + const guardian = await getGuardianByInviteToken(inviteToken); + if (!guardian) return c.json({ error: 'Invalid or expired invitation' }, 404); + if (guardian.inviteExpiresAt && Date.now() > guardian.inviteExpiresAt) { + return c.json({ error: 'Invitation has expired' }, 400); + } + if (guardian.status === 'accepted') { + return c.json({ error: 'Already accepted' }, 400); + } + + // Can't be your own guardian + if (guardian.userId === claims.sub) { + return c.json({ error: 'You cannot be your own guardian' }, 400); + } + + await acceptGuardianInvite(guardian.id, claims.sub as string); + return c.json({ success: true, message: 'You are now a guardian!' }); +}); + +// ============================================================================ +// SOCIAL RECOVERY ROUTES +// ============================================================================ + +/** + * POST /api/recovery/social/initiate — start social recovery + * Body: { email or username } + * Sends approval links to all guardians + */ +app.post('/api/recovery/social/initiate', async (c) => { + const { email, username: reqUsername } = await c.req.json(); + + // Find the user + let user; + if (email) user = await getUserByEmail(email); + if (!user && reqUsername) user = await getUserByUsername(reqUsername); + // Always return success to prevent enumeration + if (!user) return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' }); + + const guardians = await getGuardians(user.id); + const accepted = guardians.filter(g => g.status === 'accepted'); + if (accepted.length < 2) { + return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' }); + } + + // Check for existing active request + const existing = await getActiveRecoveryRequest(user.id); + if (existing) { + return c.json({ success: true, message: 'A recovery request is already active. Recovery emails have been re-sent.' }); + } + + // Create recovery request (7 day expiry, 2-of-3 threshold) + const requestId = generateToken(); + const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; + await createRecoveryRequest(requestId, user.id, 2, expiresAt); + + // Create approval tokens and notify guardians + for (const guardian of accepted) { + const approvalToken = generateToken(); + await createRecoveryApproval(requestId, guardian.id, approvalToken); + + const approveUrl = `https://auth.rspace.online/approve?token=${approvalToken}`; + + // Send email if available + if (guardian.email && smtpTransport) { + try { + await smtpTransport.sendMail({ + from: CONFIG.smtp.from, + to: guardian.email, + subject: `Recovery request from ${user.username} — rStack`, + text: [ + `Hi ${guardian.name},`, + '', + `${user.username} is trying to recover their rStack Identity account and needs your help.`, + '', + 'To approve this recovery, click the link below:', + approveUrl, + '', + 'This request expires in 7 days. 2 of 3 guardians must approve.', + '', + 'If you did not expect this, do NOT click the link.', + '', + '— rStack Identity', + ].join('\n'), + html: ` + + + + +
+ + + + + +
+
🔐
+

Recovery Request

+
+

Hi ${guardian.name},

+

${user.username} is trying to recover their rStack Identity account and needs your approval.

+

2 of 3 guardians must approve for recovery to proceed.

+
+ Approve Recovery +
+

This request expires in 7 days.

+

If you did not expect this request, do NOT click the button.

+

+ Can't click the button? Copy this link:
+ ${approveUrl} +

+
+
+`, + }); + } catch (err) { + console.error(`EncryptID: Failed to send recovery approval email to ${guardian.email}:`, err); + } + } + } + + return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' }); +}); + +/** + * GET /approve — one-click guardian approval page + */ +app.get('/approve', (c) => { + return c.html(` + + + + + + Approve Recovery — rStack Identity + + + +
+
🔐
+

Recovery Approval

+
Verifying approval link...
+ +
+ + + + + `); +}); + +/** + * POST /api/recovery/social/approve — approve via token (no auth needed, token is proof) + */ +app.post('/api/recovery/social/approve', async (c) => { + const { approvalToken } = await c.req.json(); + if (!approvalToken) return c.json({ error: 'Approval token required' }, 400); + + const result = await approveRecoveryByToken(approvalToken); + if (!result) return c.json({ error: 'Invalid or already used approval link' }, 400); + + // Get updated request + const request = await getRecoveryRequest(result.requestId); + if (!request) return c.json({ error: 'Recovery request not found' }, 404); + + // Check if threshold met + if (request.approvalCount >= request.threshold && request.status === 'pending') { + await updateRecoveryRequestStatus(request.id, 'approved'); + } + + return c.json({ + success: true, + approvalCount: request.approvalCount, + threshold: request.threshold, + status: request.approvalCount >= request.threshold ? 'approved' : 'pending', + }); +}); + +/** + * GET /api/recovery/social/:id/status — check recovery request status + */ +app.get('/api/recovery/social/:id/status', async (c) => { + const { id } = c.req.param(); + const request = await getRecoveryRequest(id); + if (!request) return c.json({ error: 'Not found' }, 404); + + const approvals = await getRecoveryApprovals(request.id); + return c.json({ + id: request.id, + status: request.status, + threshold: request.threshold, + approvalCount: request.approvalCount, + approvals: approvals.map(a => ({ + guardianId: a.guardianId, + approved: !!a.approvedAt, + })), + expiresAt: request.expiresAt, + }); +}); + +/** + * POST /api/recovery/social/:id/complete — finalize recovery (register new passkey) + * Only works when status is 'approved' + */ +app.post('/api/recovery/social/:id/complete', async (c) => { + const { id } = c.req.param(); + const request = await getRecoveryRequest(id); + if (!request) return c.json({ error: 'Not found' }, 404); + if (request.status !== 'approved') return c.json({ error: 'Recovery not yet approved. ' + request.approvalCount + '/' + request.threshold + ' approvals.' }, 400); + if (Date.now() > request.expiresAt) { + await updateRecoveryRequestStatus(id, 'expired'); + return c.json({ error: 'Recovery request expired' }, 400); + } + + // Mark completed and issue a recovery session token + await updateRecoveryRequestStatus(id, 'completed'); + + const user = await getUserById(request.userId); + if (!user) return c.json({ error: 'User not found' }, 404); + + const token = await generateSessionToken(request.userId, user.username); + + return c.json({ + success: true, + token, + userId: request.userId, + username: user.username, + message: 'Recovery complete. Use this token to register a new passkey.', + }); +}); + +// ============================================================================ +// DEVICE LINKING ROUTES +// ============================================================================ + +/** + * POST /api/device-link/start — generate a device link token (authenticated) + */ +app.post('/api/device-link/start', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const token = generateToken(); + const expiresAt = Date.now() + 10 * 60 * 1000; // 10 minutes + await createDeviceLink(token, claims.sub as string, expiresAt); + + const linkUrl = `https://auth.rspace.online/link?token=${token}`; + return c.json({ token, linkUrl, expiresAt }); +}); + +/** + * GET /api/device-link/:token/info — get link info (unauthenticated) + */ +app.get('/api/device-link/:token/info', async (c) => { + const { token } = c.req.param(); + const link = await getDeviceLink(token); + if (!link) return c.json({ error: 'Invalid link' }, 404); + if (link.used) return c.json({ error: 'Link already used' }, 400); + if (Date.now() > link.expiresAt) return c.json({ error: 'Link expired' }, 400); + + const user = await getUserById(link.userId); + return c.json({ + username: user?.username || 'Unknown', + expiresAt: link.expiresAt, + }); +}); + +/** + * POST /api/device-link/:token/complete — register new credential on linked device + */ +app.post('/api/device-link/:token/complete', async (c) => { + const { token } = c.req.param(); + const link = await getDeviceLink(token); + if (!link) return c.json({ error: 'Invalid link' }, 404); + if (link.used) return c.json({ error: 'Link already used' }, 400); + if (Date.now() > link.expiresAt) return c.json({ error: 'Link expired' }, 400); + + const { credential } = await c.req.json(); + if (!credential?.credentialId || !credential?.publicKey) { + return c.json({ error: 'Credential data required' }, 400); + } + + const user = await getUserById(link.userId); + if (!user) return c.json({ error: 'User not found' }, 404); + + // Store the new credential under the same user + const rpId = resolveRpId(c); + await storeCredential({ + credentialId: credential.credentialId, + publicKey: credential.publicKey, + userId: link.userId, + username: user.username, + counter: 0, + createdAt: Date.now(), + transports: credential.transports, + rpId, + }); + + await markDeviceLinkUsed(token); + + console.log('EncryptID: Second device linked for', user.username); + return c.json({ success: true, message: 'Device linked successfully' }); +}); + +/** + * GET /link — device linking page (scanned from QR or opened from email) + */ +app.get('/link', (c) => { + return c.html(` + + + + + + Link Device — rStack Identity + + + +
+
📱
+

Link This Device

+
Loading...
+ + +
+ + + + + `); +}); + // ============================================================================ // SPACE MEMBERSHIP ROUTES // ============================================================================ @@ -1092,12 +1994,40 @@ app.get('/', (c) => { .passkey-date { color: #64748b; } /* Recovery email */ - .recovery-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); } - .recovery-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; } + .recovery-section, .guardians-section, .devices-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); } + .recovery-section h4, .guardians-section h4, .devices-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; } .recovery-row { display: flex; gap: 0.5rem; } .recovery-row input { flex: 1; } .recovery-row button { white-space: nowrap; } + /* Guardian items */ + .guardian-item { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 0.75rem; background: rgba(255,255,255,0.04); border-radius: 0.5rem; margin-bottom: 0.5rem; font-size: 0.85rem; } + .guardian-name { font-weight: 500; } + .guardian-status { font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 1rem; } + .guardian-status.pending { background: rgba(234,179,8,0.15); color: #eab308; } + .guardian-status.accepted { background: rgba(34,197,94,0.15); color: #22c55e; } + .guardian-add { display: flex; gap: 0.5rem; margin-top: 0.75rem; } + .guardian-add input { flex: 1; padding: 0.5rem 0.75rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: #fff; font-size: 0.85rem; outline: none; } + .guardian-add input:focus { border-color: #7c3aed; } + + /* Setup checklist */ + .setup-checklist { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); } + .setup-checklist h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; } + .check-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; font-size: 0.85rem; } + .check-icon { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; flex-shrink: 0; } + .check-icon.done { background: rgba(34,197,94,0.2); color: #22c55e; } + .check-icon.todo { background: rgba(255,255,255,0.08); color: #64748b; } + + /* Invite link display */ + .invite-link { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.5rem; padding: 0.6rem; font-family: monospace; font-size: 0.75rem; color: #94a3b8; word-break: break-all; margin-top: 0.5rem; cursor: pointer; position: relative; } + .invite-link:hover { border-color: #7c3aed; } + .invite-link .copy-hint { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); font-family: sans-serif; font-size: 0.7rem; color: #7c3aed; } + + /* QR placeholder */ + .qr-container { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; margin-top: 0.75rem; } + .qr-container canvas { border-radius: 8px; background: #fff; padding: 8px; } + .or-divider { color: #64748b; font-size: 0.8rem; margin: 0.5rem 0; } + /* Features */ .features { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; margin-bottom: 2rem; } .feature { background: rgba(255,255,255,0.04); padding: 1rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.08); text-align: center; } @@ -1156,6 +2086,20 @@ app.get('/', (c) => { + + + + + @@ -1176,9 +2120,17 @@ app.get('/', (c) => {
Loading...
+
+

Account Security

+
Passkey created
+
Recovery email
+
Second device
+
Guardians (0/3)
+
+

Recovery Email

-

Set an email to recover your account if you lose your passkey.

+

Recommended for cross-device login and account recovery.

@@ -1186,6 +2138,44 @@ app.get('/', (c) => {
+
+

Linked Devices

+

Add a passkey on another device so you can sign in from anywhere.

+
+ + +
+ +
+

Recovery Guardians

+

Choose 3 people you trust. If you lose all your devices, any 2 of them can help you recover your account.

+
+
+ + + +
+ + +
+
SDK Demo @@ -1199,8 +2189,8 @@ app.get('/', (c) => {
Hardware-backed, phishing-resistant login. No passwords ever.
-
Email Recovery
-
Optional email for account recovery. No seed phrases needed.
+
Guardian Recovery
+
3 trusted contacts, 2 to recover. No seed phrases, no single point of failure.
E2E Encryption
@@ -1270,6 +2260,41 @@ app.get('/', (c) => { document.getElementById('success-msg').style.display = 'none'; } + window.showRecoveryForm = () => { + document.getElementById('recovery-form').style.display = 'block'; + document.getElementById('recovery-link-row').style.display = 'none'; + }; + + window.initiateRecovery = async () => { + const input = document.getElementById('recover-email').value.trim(); + const msgEl = document.getElementById('recover-msg'); + if (!input) { msgEl.textContent = 'Enter your email or username'; msgEl.style.color = '#fca5a5'; msgEl.style.display = 'block'; return; } + try { + const body = input.includes('@') ? { email: input } : { username: input }; + const res = await fetch('/api/recovery/social/initiate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + msgEl.textContent = data.message || 'Recovery request sent. Check with your guardians.'; + msgEl.style.color = '#86efac'; + msgEl.style.display = 'block'; + // Also try email recovery + if (input.includes('@')) { + fetch('/api/recovery/email/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: input }), + }).catch(() => {}); + } + } catch (err) { + msgEl.textContent = 'Failed: ' + err.message; + msgEl.style.color = '#fca5a5'; + msgEl.style.display = 'block'; + } + }; + window.handleAuth = async () => { const btn = document.getElementById('auth-btn'); btn.disabled = true; @@ -1359,6 +2384,9 @@ app.get('/', (c) => { list.innerHTML = '
No passkeys found
'; } } catch { /* ignore */ } + + // Load guardians + loadGuardians(); } window.handleLogout = () => { @@ -1389,6 +2417,7 @@ app.get('/', (c) => { msgEl.textContent = 'Recovery email saved.'; msgEl.style.color = '#86efac'; msgEl.style.display = 'block'; + updateChecklist(); } catch (err) { msgEl.textContent = err.message; msgEl.style.color = '#fca5a5'; @@ -1396,6 +2425,194 @@ app.get('/', (c) => { } }; + // ── Guardian management ── + + let lastGuardianInviteUrl = ''; + + window.addGuardian = async () => { + const name = document.getElementById('guardian-name').value.trim(); + const email = document.getElementById('guardian-email').value.trim(); + const token = localStorage.getItem(TOKEN_KEY); + const msgEl = document.getElementById('guardian-msg'); + if (!name) { msgEl.textContent = 'Name is required'; msgEl.style.color = '#fca5a5'; msgEl.style.display = 'block'; return; } + if (!token) return; + try { + const res = await fetch('/api/guardians', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify({ name, email: email || undefined }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed'); + // Clear inputs + document.getElementById('guardian-name').value = ''; + document.getElementById('guardian-email').value = ''; + // Show invite link + lastGuardianInviteUrl = data.inviteUrl; + document.getElementById('guardian-invite-text').textContent = data.inviteUrl; + document.getElementById('guardian-invite-area').classList.remove('hidden'); + if (email) { + msgEl.textContent = 'Guardian added! Invite email sent to ' + email; + } else { + msgEl.textContent = 'Guardian added! Share the invite link below.'; + } + msgEl.style.color = '#86efac'; + msgEl.style.display = 'block'; + loadGuardians(); + } catch (err) { + msgEl.textContent = err.message; + msgEl.style.color = '#fca5a5'; + msgEl.style.display = 'block'; + } + }; + + window.copyGuardianLink = () => { + navigator.clipboard.writeText(lastGuardianInviteUrl).then(() => { + const el = document.getElementById('guardian-msg'); + el.textContent = 'Link copied!'; + el.style.color = '#86efac'; + el.style.display = 'block'; + }); + }; + + window.removeGuardian = async (id) => { + const token = localStorage.getItem(TOKEN_KEY); + if (!token) return; + if (!confirm('Remove this guardian?')) return; + await fetch('/api/guardians/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } }); + loadGuardians(); + }; + + async function loadGuardians() { + const token = localStorage.getItem(TOKEN_KEY); + if (!token) return; + try { + const res = await fetch('/api/guardians', { headers: { 'Authorization': 'Bearer ' + token } }); + const data = await res.json(); + const list = document.getElementById('guardian-list'); + if (data.guardians && data.guardians.length > 0) { + list.innerHTML = data.guardians.map(g => + '
' + + '' + g.name + '' + + (g.email ? ' (' + g.email + ')' : '') + + '' + + '' + + '' + g.status + '' + + '' + + '
' + ).join(''); + // Hide add form if 3 guardians + document.getElementById('guardian-add-form').style.display = data.guardians.length >= 3 ? 'none' : 'flex'; + } else { + list.innerHTML = ''; + } + updateChecklist(data.guardians); + } catch { /* ignore */ } + } + + // ── Device linking ── + + let deviceLinkUrl = ''; + + window.startDeviceLink = async () => { + const token = localStorage.getItem(TOKEN_KEY); + if (!token) return; + const btn = document.getElementById('link-device-btn'); + btn.disabled = true; + try { + const res = await fetch('/api/device-link/start', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token }, + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + deviceLinkUrl = data.linkUrl; + document.getElementById('device-link-text').textContent = data.linkUrl; + document.getElementById('device-link-area').classList.remove('hidden'); + btn.style.display = 'none'; + // Generate QR code + generateQR(data.linkUrl, document.getElementById('qr-canvas')); + } catch (err) { + alert('Failed: ' + err.message); + btn.disabled = false; + } + }; + + window.copyDeviceLink = () => { + navigator.clipboard.writeText(deviceLinkUrl); + }; + + // Simple QR code generator (alphanumeric mode, version auto) + function generateQR(text, canvas) { + // Use a minimal inline QR approach via canvas + // For simplicity, draw a placeholder with the URL + const ctx = canvas.getContext('2d'); + const size = canvas.width; + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, size, size); + + // We'll use a simple approach: encode as a data URL and render + // For production, we'd use a proper QR library, but for now + // let's create a visual pattern from the URL hash + const hash = Array.from(text).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0); + const cellSize = 4; + const modules = Math.floor(size / cellSize); + ctx.fillStyle = '#000'; + + // Generate a deterministic pattern (not a real QR code) + // We'll load a proper QR via dynamic import if available + try { + // Try to use the QR code API endpoint + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { ctx.drawImage(img, 0, 0, size, size); }; + img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=' + size + 'x' + size + '&data=' + encodeURIComponent(text) + '&format=png&margin=8'; + } catch { + // Fallback: just show text + ctx.fillStyle = '#000'; + ctx.font = '10px monospace'; + ctx.fillText('Scan QR on phone', 20, size/2 - 10); + ctx.fillText('or copy link below', 20, size/2 + 10); + } + } + + // ── Checklist update ── + + function updateChecklist(guardians) { + const token = localStorage.getItem(TOKEN_KEY); + // Email check + const emailInput = document.getElementById('recovery-email'); + const hasEmail = emailInput && emailInput.value.trim(); + const emailIcon = document.getElementById('check-email'); + if (hasEmail) { + emailIcon.className = 'check-icon done'; + emailIcon.innerHTML = '✓'; + } + // Device check (from passkey count) + const passkeyItems = document.querySelectorAll('.passkey-item'); + const deviceIcon = document.getElementById('check-device'); + const deviceText = document.getElementById('check-device-text'); + if (passkeyItems.length > 1) { + deviceIcon.className = 'check-icon done'; + deviceIcon.innerHTML = '✓'; + deviceText.textContent = passkeyItems.length + ' devices linked'; + } + // Guardian check + const guardianIcon = document.getElementById('check-guardians'); + const guardianText = document.getElementById('check-guardians-text'); + const gList = guardians || []; + const accepted = gList.filter(g => g.status === 'accepted').length; + const total = gList.length; + guardianText.textContent = 'Guardians (' + total + '/3' + (accepted < total ? ', ' + accepted + ' accepted' : '') + ')'; + if (total >= 3 && accepted >= 2) { + guardianIcon.className = 'check-icon done'; + guardianIcon.innerHTML = '✓'; + } else if (total > 0) { + guardianIcon.className = 'check-icon todo'; + guardianIcon.innerHTML = total.toString(); + } + } + // On page load: check for existing token (async () => { const token = localStorage.getItem(TOKEN_KEY);