/** * EncryptID Server * * Handles WebAuthn registration/authentication, session management, * and serves the .well-known/webauthn configuration. * * Storage: PostgreSQL (via db.ts) */ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { serveStatic } from 'hono/bun'; import { sign, verify } from 'hono/jwt'; import { createTransport, type Transporter } from 'nodemailer'; import { initDatabase, storeCredential, getCredential, updateCredentialUsage, getUserCredentials, storeChallenge, getChallenge, deleteChallenge, cleanExpiredChallenges, cleanExpiredRecoveryTokens, checkDatabaseHealth, createUser, setUserEmail, getUserByEmail, getUserById, storeRecoveryToken, getRecoveryToken, markRecoveryTokenUsed, 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, removeSpaceMember, getUserProfile, updateUserProfile, getUserAddresses, getAddressById, saveUserAddress, deleteUserAddress, } from './db.js'; // ============================================================================ // CONFIGURATION // ============================================================================ const CONFIG = { port: process.env.PORT || 3000, rpId: 'rspace.online', rpName: 'EncryptID', jwtSecret: (() => { const secret = process.env.JWT_SECRET; if (!secret) throw new Error('JWT_SECRET environment variable is required'); return secret; })(), sessionDuration: 15 * 60, // 15 minutes refreshDuration: 7 * 24 * 60 * 60, // 7 days smtp: { host: process.env.SMTP_HOST || 'mail.rmail.online', port: parseInt(process.env.SMTP_PORT || '587'), secure: false, // STARTTLS on 587 user: process.env.SMTP_USER || 'noreply@rspace.online', pass: process.env.SMTP_PASS || '', from: process.env.SMTP_FROM || 'EncryptID ', }, recoveryUrl: process.env.RECOVERY_URL || 'https://auth.rspace.online/recover', allowedOrigins: [ // rspace.online — RP ID domain and all subdomains 'https://rspace.online', 'https://auth.rspace.online', 'https://cca.rspace.online', 'https://demo.rspace.online', 'https://app.rspace.online', 'https://dev.rspace.online', // r* ecosystem apps (each *.online is an eTLD+1 — Related Origins limit is 5) 'https://rwallet.online', 'https://rvote.online', 'https://rmaps.online', 'https://rfiles.online', 'https://rnotes.online', 'https://rfunds.online', 'https://rtrips.online', 'https://rnetwork.online', 'https://rcart.online', 'https://rtube.online', 'https://rchats.online', 'https://rstack.online', 'https://rpubs.online', 'https://rauctions.online', 'https://ridentity.online', 'https://auth.ridentity.online', 'https://rphotos.online', 'https://rcal.online', 'https://rinbox.online', 'https://rmail.online', 'https://rsocials.online', 'https://rwork.online', 'https://rforum.online', 'https://rchoices.online', 'https://rswag.online', 'https://rdata.online', // Development 'http://localhost:3000', 'http://localhost:5173', ], }; // ============================================================================ // SMTP TRANSPORT // ============================================================================ let smtpTransport: Transporter | null = null; if (CONFIG.smtp.pass) { smtpTransport = createTransport({ host: CONFIG.smtp.host, port: CONFIG.smtp.port, secure: CONFIG.smtp.port === 465, auth: { user: CONFIG.smtp.user, pass: CONFIG.smtp.pass, }, tls: { rejectUnauthorized: false, // Internal Mailcow uses self-signed cert }, }); // Verify connection on startup smtpTransport.verify().then(() => { console.log('EncryptID: SMTP connected to', CONFIG.smtp.host); }).catch((err) => { console.error('EncryptID: SMTP connection failed —', err.message); console.error('EncryptID: Email recovery will not work until SMTP is configured'); smtpTransport = null; }); } else { console.warn('EncryptID: SMTP_PASS not set — email recovery disabled (tokens logged to console)'); } async function sendRecoveryEmail(to: string, token: string, username: string): Promise { const recoveryLink = `${CONFIG.recoveryUrl}?token=${encodeURIComponent(token)}`; if (!smtpTransport) { console.log(`EncryptID: [NO SMTP] Recovery link for ${to}: ${recoveryLink}`); return false; } await smtpTransport.sendMail({ from: CONFIG.smtp.from, to, subject: 'rStack — Account Recovery', text: [ `Hi ${username},`, '', 'A recovery request was made for your rStack account.', 'Use the link below to add a new passkey:', '', recoveryLink, '', 'This link expires in 30 minutes.', 'If you did not request this, you can safely ignore this email.', '', '— rStack Identity', ].join('\n'), html: `
🔒

rStack Identity

Hi ${username},

A recovery request was made for your rStack account. Click below to add a new passkey:

Recover Account

This link expires in 30 minutes.

If you didn't request this, you can safely ignore this email — your account is secure.

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

`, }); return true; } // ============================================================================ // HONO APP // ============================================================================ const app = new Hono(); // Middleware app.use('*', logger()); app.use('*', cors({ origin: (origin) => { // Allow all *.rspace.online subdomains dynamically (any canvas slug) if (origin === 'https://rspace.online' || origin?.endsWith('.rspace.online')) { return origin; } // Allow explicit r* ecosystem origins if (CONFIG.allowedOrigins.includes(origin)) { return origin; } return undefined; }, allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], credentials: true, })); // ============================================================================ // STATIC FILES & WELL-KNOWN // ============================================================================ // Serve .well-known/webauthn for Related Origins // Only list non-rspace.online origins here — *.rspace.online subdomains are // automatically valid because rspace.online is the RP ID. // Keep to max 5 eTLD+1 labels to stay within browser limits. app.get('/.well-known/webauthn', (c) => { const nonRspaceOrigins = CONFIG.allowedOrigins.filter( o => o.startsWith('https://') && !o.endsWith('.rspace.online') && o !== 'https://rspace.online' ); return c.json({ origins: nonRspaceOrigins }); }); // Health check — includes database connectivity app.get('/health', async (c) => { const dbHealthy = await checkDatabaseHealth(); const status = dbHealthy ? 'ok' : 'degraded'; return c.json({ status, service: 'encryptid', database: dbHealthy, timestamp: Date.now() }, dbHealthy ? 200 : 503); }); // ============================================================================ // RP ID RESOLUTION // ============================================================================ /** * Resolve RP ID for WebAuthn ceremonies. * * Always returns 'rspace.online' so that all passkeys are registered with * the same RP ID. The .well-known/webauthn endpoint lists Related Origins, * allowing browsers on other r*.online domains to use these passkeys. */ function resolveRpId(_c: any): string { return CONFIG.rpId; // Always 'rspace.online' } // ============================================================================ // REGISTRATION ENDPOINTS // ============================================================================ /** * Start registration - returns challenge and options */ app.post('/api/register/start', async (c) => { const { username, displayName } = await c.req.json(); if (!username) { return c.json({ error: 'Username required' }, 400); } // Generate challenge const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); // Generate user ID const userId = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); // Store challenge in database const challengeRecord: StoredChallenge = { challenge, userId, type: 'registration', createdAt: Date.now(), expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes }; await storeChallenge(challengeRecord); // Build registration options — use the caller's domain as RP ID const rpId = resolveRpId(c); const options = { challenge, rp: { id: rpId, name: CONFIG.rpName, }, user: { id: userId, name: username, displayName: displayName || username, }, pubKeyCredParams: [ { alg: -7, type: 'public-key' }, // ES256 { alg: -257, type: 'public-key' }, // RS256 ], authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required', }, timeout: 60000, attestation: 'none', extensions: { credProps: true, }, }; return c.json({ options, userId }); }); /** * Complete registration - verify and store credential */ app.post('/api/register/complete', async (c) => { const { challenge, credential, userId, username, email } = await c.req.json(); // Verify challenge const challengeRecord = await getChallenge(challenge); if (!challengeRecord || challengeRecord.type !== 'registration') { return c.json({ error: 'Invalid challenge' }, 400); } if (Date.now() > challengeRecord.expiresAt) { await deleteChallenge(challenge); return c.json({ error: 'Challenge expired' }, 400); } await deleteChallenge(challenge); // In production, verify the attestation properly // For now, we trust the client-side verification // Create user and store credential in database const did = `did:key:${userId.slice(0, 32)}`; await createUser(userId, username, username, did); // Set recovery email if provided during registration if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { await setUserEmail(userId, email); } // Resolve the RP ID from the caller's origin const rpId = resolveRpId(c); const storedCredential: StoredCredential = { credentialId: credential.credentialId, publicKey: credential.publicKey, userId, username, counter: 0, createdAt: Date.now(), transports: credential.transports, rpId, }; await storeCredential(storedCredential); console.log('EncryptID: Credential registered', { credentialId: credential.credentialId.slice(0, 20) + '...', userId: userId.slice(0, 20) + '...', }); // Auto-provision user space at .rspace.online try { const { communityExists, createCommunity } = await import('../../server/community-store'); const { DEFAULT_USER_NEST_POLICY, DEFAULT_USER_MODULES } = await import('../../server/community-store'); const { getAllModules } = await import('../../shared/module'); const spaceSlug = username.toLowerCase().replace(/[^a-z0-9-]/g, '-'); if (!await communityExists(spaceSlug)) { await createCommunity(username, spaceSlug, did, 'members_only', { enabledModules: DEFAULT_USER_MODULES, nestPolicy: DEFAULT_USER_NEST_POLICY, }); // Fire module hooks for (const mod of getAllModules()) { if (mod.onSpaceCreate) { try { await mod.onSpaceCreate(spaceSlug); } catch (e) { console.error(`[EncryptID] Module ${mod.id} onSpaceCreate for user space:`, e); } } } console.log(`EncryptID: Auto-provisioned space ${spaceSlug}.rspace.online for ${username}`); } } catch (e) { // Non-fatal: user is created even if space provisioning fails console.error('EncryptID: Failed to auto-provision user space:', e); } // Generate initial session token const token = await generateSessionToken(userId, username); return c.json({ success: true, userId, token, did, spaceSlug: username.toLowerCase().replace(/[^a-z0-9-]/g, '-'), }); }); // ============================================================================ // AUTHENTICATION ENDPOINTS // ============================================================================ /** * Start authentication - returns challenge */ app.post('/api/auth/start', async (c) => { const body = await c.req.json().catch(() => ({})); const { credentialId } = body; // Generate challenge const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); // Store challenge in database const challengeRecord: StoredChallenge = { challenge, type: 'authentication', createdAt: Date.now(), expiresAt: Date.now() + 5 * 60 * 1000, }; await storeChallenge(challengeRecord); // Build allowed credentials if specified let allowCredentials; if (credentialId) { const cred = await getCredential(credentialId); if (cred) { allowCredentials = [{ type: 'public-key', id: credentialId, transports: cred.transports, }]; } } // Use the caller's domain as RP ID const rpId = resolveRpId(c); const options = { challenge, rpId, userVerification: 'required', timeout: 60000, allowCredentials, }; return c.json({ options }); }); /** * Complete authentication - verify and issue token */ app.post('/api/auth/complete', async (c) => { const { challenge, credential } = await c.req.json(); // Verify challenge from database const challengeRecord = await getChallenge(challenge); if (!challengeRecord || challengeRecord.type !== 'authentication') { return c.json({ error: 'Invalid challenge' }, 400); } if (Date.now() > challengeRecord.expiresAt) { await deleteChallenge(challenge); return c.json({ error: 'Challenge expired' }, 400); } await deleteChallenge(challenge); // Look up credential from database const storedCredential = await getCredential(credential.credentialId); if (!storedCredential) { return c.json({ error: 'Unknown credential' }, 400); } // In production, verify signature against stored public key // For now, we trust the client-side verification // Update counter and last used in database await updateCredentialUsage(credential.credentialId, storedCredential.counter + 1); console.log('EncryptID: Authentication successful', { credentialId: credential.credentialId.slice(0, 20) + '...', userId: storedCredential.userId.slice(0, 20) + '...', }); // Generate session token const token = await generateSessionToken( storedCredential.userId, storedCredential.username ); return c.json({ success: true, userId: storedCredential.userId, username: storedCredential.username, token, did: `did:key:${storedCredential.userId.slice(0, 32)}`, }); }); // ============================================================================ // SESSION ENDPOINTS // ============================================================================ /** * Verify session token (supports both GET with Authorization header and POST with body) */ app.get('/api/session/verify', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ valid: false, error: 'No token' }, 401); } const token = authHeader.slice(7); try { const payload = await verify(token, CONFIG.jwtSecret, 'HS256'); return c.json({ valid: true, userId: payload.sub, username: payload.username, did: payload.did, exp: payload.exp, }); } catch { return c.json({ valid: false, error: 'Invalid token' }, 401); } }); app.post('/api/session/verify', async (c) => { const { token } = await c.req.json(); if (!token) { return c.json({ valid: false, error: 'No token' }, 400); } try { const payload = await verify(token, CONFIG.jwtSecret, 'HS256'); return c.json({ valid: true, claims: payload, userId: payload.sub, username: payload.username, did: payload.did, exp: payload.exp, }); } catch { return c.json({ valid: false, error: 'Invalid token' }); } }); /** * Refresh session token */ app.post('/api/session/refresh', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ error: 'No token' }, 401); } const token = authHeader.slice(7); try { const payload = await verify(token, CONFIG.jwtSecret, { alg: 'HS256', exp: false }); // Allow expired tokens for refresh // Issue new token const newToken = await generateSessionToken( payload.sub as string, payload.username as string ); return c.json({ token: newToken }); } catch { return c.json({ error: 'Invalid token' }, 401); } }); // ============================================================================ // USER INFO ENDPOINTS // ============================================================================ /** * Get user credentials (for listing passkeys) */ app.get('/api/user/credentials', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ error: 'Unauthorized' }, 401); } const token = authHeader.slice(7); try { const payload = await verify(token, CONFIG.jwtSecret, 'HS256'); const userId = payload.sub as string; const creds = await getUserCredentials(userId); const credentialList = creds.map(cred => ({ credentialId: cred.credentialId, createdAt: cred.createdAt, lastUsed: cred.lastUsed, transports: cred.transports, })); return c.json({ credentials: credentialList }); } catch { return c.json({ error: 'Unauthorized' }, 401); } }); // ============================================================================ // USER PROFILE ENDPOINTS // ============================================================================ // GET /api/user/profile — get the authenticated user's profile app.get('/api/user/profile', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const profile = await getUserProfile(claims.sub as string); if (!profile) return c.json({ error: 'User not found' }, 404); return c.json({ success: true, profile }); }); // PUT /api/user/profile — update the authenticated user's profile app.put('/api/user/profile', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const body = await c.req.json(); const updates: Record = {}; if (body.displayName !== undefined) updates.displayName = body.displayName; if (body.bio !== undefined) { if (typeof body.bio === 'string' && body.bio.length > 500) { return c.json({ error: 'Bio must be 500 characters or fewer' }, 400); } updates.bio = body.bio; } if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl; if (body.profileEmail !== undefined) { if (body.profileEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.profileEmail)) { return c.json({ error: 'Invalid email format' }, 400); } updates.profileEmail = body.profileEmail; } if (body.profileEmailIsRecovery !== undefined) updates.profileEmailIsRecovery = body.profileEmailIsRecovery; if (body.walletAddress !== undefined) updates.walletAddress = body.walletAddress; const profile = await updateUserProfile(claims.sub as string, updates); if (!profile) return c.json({ error: 'User not found' }, 404); return c.json({ success: true, profile }); }); // ============================================================================ // ENCRYPTED ADDRESS ENDPOINTS // ============================================================================ const MAX_ADDRESSES = 10; const VALID_LABELS = ['home', 'work', 'shipping', 'billing', 'other']; // GET /api/user/addresses — get all encrypted addresses for the user app.get('/api/user/addresses', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const addresses = await getUserAddresses(claims.sub as string); return c.json({ success: true, addresses: addresses.map(a => ({ id: a.id, ciphertext: a.ciphertext, iv: a.iv, label: a.label, labelCustom: a.labelCustom, isDefault: a.isDefault, })), }); }); // POST /api/user/addresses — save a new encrypted address app.post('/api/user/addresses', 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 body = await c.req.json(); if (!body.id || !body.ciphertext || !body.iv || !body.label) { return c.json({ error: 'Missing required fields: id, ciphertext, iv, label' }, 400); } if (!VALID_LABELS.includes(body.label)) { return c.json({ error: `Invalid label. Must be one of: ${VALID_LABELS.join(', ')}` }, 400); } // Check address limit const existing = await getUserAddresses(userId); if (existing.length >= MAX_ADDRESSES) { return c.json({ error: `Maximum ${MAX_ADDRESSES} addresses allowed` }, 400); } const address = await saveUserAddress(userId, { id: body.id, ciphertext: body.ciphertext, iv: body.iv, label: body.label, labelCustom: body.labelCustom, isDefault: body.isDefault || false, }); return c.json({ success: true, address: { id: address.id, ciphertext: address.ciphertext, iv: address.iv, label: address.label, labelCustom: address.labelCustom, isDefault: address.isDefault, }, }, 201); }); // PUT /api/user/addresses/:id — update an existing encrypted address app.put('/api/user/addresses/:id', 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 id = c.req.param('id'); const body = await c.req.json(); const existing = await getAddressById(id, userId); if (!existing) return c.json({ error: 'Address not found' }, 404); if (body.label && !VALID_LABELS.includes(body.label)) { return c.json({ error: `Invalid label. Must be one of: ${VALID_LABELS.join(', ')}` }, 400); } const address = await saveUserAddress(userId, { id, ciphertext: body.ciphertext || existing.ciphertext, iv: body.iv || existing.iv, label: body.label || existing.label, labelCustom: body.labelCustom !== undefined ? body.labelCustom : existing.labelCustom, isDefault: body.isDefault !== undefined ? body.isDefault : existing.isDefault, }); return c.json({ success: true, address: { id: address.id, ciphertext: address.ciphertext, iv: address.iv, label: address.label, labelCustom: address.labelCustom, isDefault: address.isDefault, }, }); }); // DELETE /api/user/addresses/:id — delete an encrypted address app.delete('/api/user/addresses/:id', 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 id = c.req.param('id'); const deleted = await deleteUserAddress(id, userId); if (!deleted) return c.json({ error: 'Address not found' }, 404); return c.json({ success: true }); }); // ============================================================================ // ACCOUNT SETTINGS ENDPOINTS // ============================================================================ /** * POST /api/account/email/start — send verification code to email * Body: { email } * Auth required */ app.post('/api/account/email/start', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const { email } = await c.req.json(); if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return c.json({ error: 'Valid email required' }, 400); } const code = String(Math.floor(100000 + Math.random() * 900000)); const tokenKey = `emailverify_${claims.sub}_${code}`; await storeRecoveryToken({ token: tokenKey, userId: claims.sub as string, type: 'email_verification', createdAt: Date.now(), expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes used: false, }); if (smtpTransport) { try { await smtpTransport.sendMail({ from: CONFIG.smtp.from, to: email, subject: `${code} — rStack Email Verification`, text: `Your verification code is: ${code}\n\nThis code expires in 10 minutes.\n\n— rStack Identity`, html: `

Email Verification

Your verification code is:

${code}

This code expires in 10 minutes.

`, }); } catch (err) { console.error('EncryptID: Failed to send verification email:', err); return c.json({ error: 'Failed to send verification email' }, 500); } } else { console.log(`EncryptID: [NO SMTP] Email verification code for ${email}: ${code}`); } return c.json({ success: true }); }); /** * POST /api/account/email/verify — verify code and set email * Body: { email, code } * Auth required */ app.post('/api/account/email/verify', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const { email, code } = await c.req.json(); if (!email || !code) return c.json({ error: 'Email and code required' }, 400); const tokenKey = `emailverify_${claims.sub}_${code}`; const rt = await getRecoveryToken(tokenKey); if (!rt || rt.used || Date.now() > rt.expiresAt || rt.userId !== (claims.sub as string)) { return c.json({ error: 'Invalid or expired verification code' }, 400); } await markRecoveryTokenUsed(tokenKey); await setUserEmail(claims.sub as string, email); return c.json({ success: true, email }); }); /** * POST /api/account/device/start — get WebAuthn options for registering another passkey * Auth required */ app.post('/api/account/device/start', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const user = await getUserById(claims.sub as string); if (!user) return c.json({ error: 'User not found' }, 404); const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); await storeChallenge({ challenge, userId: claims.sub as string, type: 'device_registration', createdAt: Date.now(), expiresAt: Date.now() + 5 * 60 * 1000, }); const rpId = resolveRpId(c); const options = { challenge, rp: { id: rpId, name: CONFIG.rpName }, user: { id: claims.sub as string, name: user.username, displayName: user.username, }, pubKeyCredParams: [ { alg: -7, type: 'public-key' }, { alg: -257, type: 'public-key' }, ], authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required', }, timeout: 60000, attestation: 'none', }; return c.json({ options, userId: claims.sub }); }); /** * POST /api/account/device/complete — register additional passkey for existing account * Body: { challenge, credential: { credentialId, publicKey, transports } } * Auth required */ app.post('/api/account/device/complete', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const { challenge, credential } = await c.req.json(); if (!challenge || !credential?.credentialId) { return c.json({ error: 'Challenge and credential required' }, 400); } const challengeRecord = await getChallenge(challenge); if (!challengeRecord || challengeRecord.type !== 'device_registration') { return c.json({ error: 'Invalid challenge' }, 400); } if (challengeRecord.userId !== (claims.sub as string)) { return c.json({ error: 'Challenge mismatch' }, 400); } if (Date.now() > challengeRecord.expiresAt) { await deleteChallenge(challenge); return c.json({ error: 'Challenge expired' }, 400); } await deleteChallenge(challenge); const user = await getUserById(claims.sub as string); if (!user) return c.json({ error: 'User not found' }, 404); const rpId = resolveRpId(c); await storeCredential({ credentialId: credential.credentialId, publicKey: credential.publicKey || '', userId: claims.sub as string, username: user.username, counter: 0, createdAt: Date.now(), transports: credential.transports || [], rpId, }); console.log('EncryptID: Additional device registered for', user.username); return c.json({ success: true }); }); // ============================================================================ // RECOVERY ENDPOINTS // ============================================================================ /** * Set recovery email for authenticated user */ app.post('/api/recovery/email/set', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ error: 'Unauthorized' }, 401); } try { const payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256'); const { email } = await c.req.json(); if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return c.json({ error: 'Valid email required' }, 400); } await setUserEmail(payload.sub as string, email); return c.json({ success: true, email }); } catch { return c.json({ error: 'Unauthorized' }, 401); } }); /** * Request account recovery via email — sends a recovery token */ app.post('/api/recovery/email/request', async (c) => { const { email } = await c.req.json(); if (!email) { return c.json({ error: 'Email required' }, 400); } const user = await getUserByEmail(email); // Always return success to avoid email enumeration if (!user) { return c.json({ success: true, message: 'If an account exists with this email, a recovery link has been sent.' }); } // Generate recovery token const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); const rt: StoredRecoveryToken = { token, userId: user.id, type: 'account_recovery', createdAt: Date.now(), expiresAt: Date.now() + 30 * 60 * 1000, // 30 minutes used: false, }; await storeRecoveryToken(rt); // Send recovery email via Mailcow SMTP try { await sendRecoveryEmail(email, token, user.username); } catch (err) { console.error('EncryptID: Failed to send recovery email:', err); } return c.json({ success: true, message: 'If an account exists with this email, a recovery link has been sent.' }); }); /** * Verify recovery token — returns temporary auth to register a new passkey */ app.post('/api/recovery/email/verify', async (c) => { const { token: recoveryToken } = await c.req.json(); if (!recoveryToken) { return c.json({ error: 'Recovery token required' }, 400); } const rt = await getRecoveryToken(recoveryToken); if (!rt || rt.used || Date.now() > rt.expiresAt) { return c.json({ error: 'Invalid or expired recovery token' }, 400); } await markRecoveryTokenUsed(recoveryToken); // Get user info const user = await getUserById(rt.userId); if (!user) { return c.json({ error: 'Account not found' }, 404); } // Issue a short-lived recovery session token const sessionToken = await generateSessionToken(rt.userId, user.username); return c.json({ success: true, token: sessionToken, userId: rt.userId, username: user.username, did: user.did, message: 'Recovery successful. Use this token to register a new passkey.', }); }); // ============================================================================ // HELPER FUNCTIONS // ============================================================================ async function generateSessionToken(userId: string, username: string): Promise { const now = Math.floor(Date.now() / 1000); const payload = { iss: 'https://auth.rspace.online', sub: userId, aud: CONFIG.allowedOrigins, iat: now, exp: now + CONFIG.sessionDuration, username, did: `did:key:${userId.slice(0, 32)}`, eid: { authLevel: 3, // ELEVATED (fresh WebAuthn) capabilities: { encrypt: true, sign: true, wallet: false, }, }, }; return sign(payload, CONFIG.jwtSecret); } // ============================================================================ // RECOVERY PAGE // ============================================================================ app.get('/recover', (c) => { return c.html(` rStack Identity — Account Recovery

Account Recovery

Verify your recovery link and add a new passkey to your rStack account

Verifying recovery token...
`); }); // ============================================================================ // 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 // ============================================================================ const VALID_ROLES = ['viewer', 'participant', 'moderator', 'admin']; // Helper: verify JWT and return claims, or null async function verifyTokenFromRequest(authorization: string | undefined): Promise<{ sub: string; did?: string; username?: string; } | null> { if (!authorization?.startsWith('Bearer ')) return null; const token = authorization.slice(7); try { const payload = await verify(token, CONFIG.jwtSecret, 'HS256'); return payload as { sub: string; did?: string; username?: string }; } catch { return null; } } // GET /api/spaces/:slug/members — list all members app.get('/api/spaces/:slug/members', async (c) => { const { slug } = c.req.param(); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) { return c.json({ error: 'Authentication required' }, 401); } const members = await listSpaceMembers(slug); return c.json({ members: members.map((m) => ({ userDID: m.userDID, spaceSlug: m.spaceSlug, role: m.role, joinedAt: m.joinedAt, grantedBy: m.grantedBy, })), total: members.length, }); }); // GET /api/spaces/:slug/members/:did — get one member's role app.get('/api/spaces/:slug/members/:did', async (c) => { const { slug, did } = c.req.param(); const member = await getSpaceMember(slug, decodeURIComponent(did)); if (!member) { return c.json({ error: 'Member not found' }, 404); } return c.json({ userDID: member.userDID, spaceSlug: member.spaceSlug, role: member.role, joinedAt: member.joinedAt, grantedBy: member.grantedBy, }); }); // POST /api/spaces/:slug/members — add or update a member app.post('/api/spaces/:slug/members', async (c) => { const { slug } = c.req.param(); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) { return c.json({ error: 'Authentication required' }, 401); } // Verify caller is admin in this space (or first member → becomes admin) const callerMember = await getSpaceMember(slug, claims.sub); const members = await listSpaceMembers(slug); // If space has no members, the first person to add becomes admin const isFirstMember = members.length === 0; if (!isFirstMember && (!callerMember || callerMember.role !== 'admin')) { return c.json({ error: 'Admin role required' }, 403); } const body = await c.req.json<{ userDID: string; role: string }>(); if (!body.userDID || !body.role) { return c.json({ error: 'userDID and role are required' }, 400); } if (!VALID_ROLES.includes(body.role)) { return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400); } const member = await upsertSpaceMember(slug, body.userDID, body.role, claims.sub); return c.json({ userDID: member.userDID, spaceSlug: member.spaceSlug, role: member.role, joinedAt: member.joinedAt, grantedBy: member.grantedBy, }, 201); }); // DELETE /api/spaces/:slug/members/:did — remove a member app.delete('/api/spaces/:slug/members/:did', async (c) => { const { slug, did } = c.req.param(); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) { return c.json({ error: 'Authentication required' }, 401); } const callerMember = await getSpaceMember(slug, claims.sub); if (!callerMember || callerMember.role !== 'admin') { return c.json({ error: 'Admin role required' }, 403); } const removed = await removeSpaceMember(slug, decodeURIComponent(did)); if (!removed) { return c.json({ error: 'Member not found' }, 404); } return c.json({ success: true }); }); // ============================================================================ // SERVE STATIC FILES // ============================================================================ // Serve demo page and static assets app.get('/demo.html', serveStatic({ path: './src/encryptid/demo.html' })); // Serve bundled JavaScript modules app.use('/dist/*', serveStatic({ root: './src/encryptid/' })); app.use('/demo/*', serveStatic({ root: './src/encryptid/' })); app.use('/static/*', serveStatic({ root: './public/' })); // Serve index — landing page with real auth app.get('/', (c) => { return c.html(` rStack Identity — One Passkey for the r* Ecosystem

rStack Identity

One passkey for the rStack.online ecosystem

Get started

Sign in with your passkey or create a new account. No passwords, no tracking, no third parties.

?

Loading...

SessionActive
Token expires--

Your Passkeys

Loading...

Account Security

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

Recovery Email

Recommended for cross-device login and account recovery.

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
Passkey Auth
Hardware-backed, phishing-resistant login. No passwords ever.
Guardian Recovery
3 trusted contacts, 2 to recover. No seed phrases, no single point of failure.
E2E Encryption
Derive keys from your passkey. Keys never leave your device.
Cross-App Identity
One passkey works across every app in the rStack ecosystem.
`); }); // ============================================================================ // DATABASE INITIALIZATION & SERVER START // ============================================================================ // Initialize database on startup initDatabase().catch(err => { console.error('EncryptID: Failed to initialize database', err); process.exit(1); }); // Clean expired challenges and recovery tokens every 10 minutes setInterval(() => { cleanExpiredChallenges().catch(() => {}); cleanExpiredRecoveryTokens().catch(() => {}); }, 10 * 60 * 1000); console.log(` ╔═══════════════════════════════════════════════════════════╗ ║ ║ ║ 🔐 rStack Identity (EncryptID) ║ ║ ║ ║ One passkey for the rStack.online ecosystem ║ ║ ║ ║ Port: ${CONFIG.port} ║ ║ RP ID: ${CONFIG.rpId} ║ ║ Storage: PostgreSQL ║ ║ SMTP: ${CONFIG.smtp.pass ? CONFIG.smtp.host : 'disabled (no SMTP_PASS)'}${' '.repeat(Math.max(0, 36 - (CONFIG.smtp.pass ? CONFIG.smtp.host.length : 26)))}║ ║ ║ ╚═══════════════════════════════════════════════════════════╝ `); export default { port: CONFIG.port, fetch: app.fetch, };