From 8bd834852965f3dca7fb36576a2ce5d768033f7b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 20 Mar 2026 11:29:20 -0700 Subject: [PATCH] feat(encryptid): fix DID consistency, add PRF key derivation, stepper signup, and magic link login - Fix DID mismatch: server now stores and reads proper did:key:z6Mk... DIDs from database instead of deriving truncated did:key:${slice(0,32)} - Add PRF extension to WebAuthn create/get flows for client-side key derivation - Derive DID, signing keys, encryption keys, and EOA wallet from passkey PRF - Auto-upgrade truncated DIDs to proper format on sign-in - Add POST /api/account/upgrade-did endpoint for DID migration - Add 5-step educational registration wizard (identity, passkey, DID, wallet, security) - Add email/username field to sign-in for scoped passkey selection - Add magic link email login for external devices without passkeys - Add POST /api/auth/magic-link and GET /magic-login verification page - Add mintWelcomeBalance() for 5 fUSDC to new users - Store EOA wallet address during registration when PRF available Co-Authored-By: Claude Opus 4.6 --- server/token-service.ts | 42 ++ src/encryptid/db.ts | 4 +- src/encryptid/schema.sql | 11 +- src/encryptid/server.ts | 877 +++++++++++++++++++++++++++++++++------ 4 files changed, 813 insertions(+), 121 deletions(-) diff --git a/server/token-service.ts b/server/token-service.ts index 4936561..e25fd1a 100644 --- a/server/token-service.ts +++ b/server/token-service.ts @@ -150,3 +150,45 @@ export async function seedCUSDC() { console.error('[TokenService] Failed to mint cUSDC to jeff'); } } + +/** Mint $5 fUSDC welcome balance for a new user. */ +export function mintWelcomeBalance(did: string, username: string): boolean { + if (!_syncServer) { + console.warn('[TokenService] SyncServer not initialized, skipping welcome balance'); + return false; + } + + const tokenId = 'fusdc'; + const docId = tokenDocId(tokenId); + let doc = _syncServer.getDoc(docId); + + // Create fUSDC token if it doesn't exist + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init fUSDC ledger', (d) => { + const init = tokenLedgerSchema.init(); + Object.assign(d, init); + }); + _syncServer.setDoc(docId, doc); + } + + if (!doc.token.name) { + _syncServer.changeDoc(docId, 'define fUSDC token', (d) => { + d.token.id = 'fusdc'; + d.token.name = 'Fake USDC'; + d.token.symbol = 'fUSDC'; + d.token.decimals = 6; + d.token.description = 'Test stablecoin for new users — $5 welcome balance'; + d.token.icon = '💵'; + d.token.color = '#2775ca'; + d.token.createdAt = Date.now(); + d.token.createdBy = 'system'; + }); + } + + // Mint 5 fUSDC (5 × 10^6 base units at 6 decimals) + const success = mintTokens(tokenId, did, username, 5_000_000, 'Welcome balance', 'system'); + if (success) { + console.log(`[TokenService] Welcome balance: 5 fUSDC minted to ${username} (${did})`); + } + return success; +} diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 73a55a3..9b5f1df 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -255,7 +255,7 @@ export async function migrateSpaceMemberDid(oldDid: string, newDid: string): Pro export interface StoredRecoveryToken { token: string; userId: string; - type: 'email_verify' | 'account_recovery'; + type: 'email_verify' | 'account_recovery' | 'magic_login'; createdAt: number; expiresAt: number; used: boolean; @@ -282,7 +282,7 @@ export async function getRecoveryToken(token: string): Promise { }, }; - return c.json({ options, userId }); + // Compute PRF salt: SHA-256("encryptid-prf-salt-master-key-v1") + // This matches the client-side generatePRFSalt('master-key') in webauthn.ts + const prfSaltData = new TextEncoder().encode('encryptid-prf-salt-master-key-v1'); + const prfSaltHash = await crypto.subtle.digest('SHA-256', prfSaltData); + const prfSalt = Buffer.from(prfSaltHash).toString('base64url'); + + return c.json({ options, userId, prfSalt }); }); /** @@ -645,6 +653,14 @@ app.post('/api/register/complete', async (c) => { console.error('EncryptID: Failed to auto-provision user space:', e); } + // Mint welcome balance (non-fatal) + try { + const { mintWelcomeBalance } = await import('../../server/token-service'); + mintWelcomeBalance(did, username); + } catch (e) { + console.warn('EncryptID: Failed to mint welcome balance:', e); + } + // Generate initial session token const token = await generateSessionToken(userId, username); @@ -666,7 +682,7 @@ app.post('/api/register/complete', async (c) => { */ app.post('/api/auth/start', async (c) => { const body = await c.req.json().catch(() => ({})); - const { credentialId } = body; + const { credentialId, email, username: loginUsername } = body; // Generate challenge const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); @@ -680,8 +696,9 @@ app.post('/api/auth/start', async (c) => { }; await storeChallenge(challengeRecord); - // Build allowed credentials if specified + // Build allowed credentials — scope to user if email/username provided let allowCredentials; + let userFound = false; if (credentialId) { const cred = await getCredential(credentialId); if (cred) { @@ -690,6 +707,23 @@ app.post('/api/auth/start', async (c) => { id: credentialId, transports: cred.transports, }]; + userFound = true; + } + } else if (email || loginUsername) { + // Look up user by email or username, then get their credentials + const user = email + ? await getUserByEmail(email.trim().toLowerCase()) + : await getUserByUsername(loginUsername.trim()); + if (user) { + userFound = true; + const creds = await getUserCredentials(user.id); + if (creds.length > 0) { + allowCredentials = creds.map(c => ({ + type: 'public-key' as const, + id: c.credentialId, + transports: c.transports, + })); + } } } @@ -703,7 +737,12 @@ app.post('/api/auth/start', async (c) => { allowCredentials, }; - return c.json({ options }); + // Include PRF salt for key derivation during sign-in + const authPrfSaltData = new TextEncoder().encode('encryptid-prf-salt-master-key-v1'); + const authPrfSaltHash = await crypto.subtle.digest('SHA-256', authPrfSaltData); + const prfSalt = Buffer.from(authPrfSaltHash).toString('base64url'); + + return c.json({ options, prfSalt, userFound }); }); /** @@ -875,6 +914,64 @@ app.get('/api/user/credentials', async (c) => { } }); +// ============================================================================ +// DID MIGRATION ENDPOINT +// ============================================================================ + +/** + * Upgrade a user's DID from truncated format to proper did:key:z6Mk... + * Called automatically during sign-in when client detects a DID mismatch. + */ +app.post('/api/account/upgrade-did', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const { clientDid, eoaAddress } = await c.req.json(); + if (!clientDid || typeof clientDid !== 'string' || !clientDid.startsWith('did:key:z')) { + return c.json({ error: 'Invalid DID format — must start with did:key:z' }, 400); + } + + const userId = claims.sub as string; + const oldDid = claims.did as string; + + // Don't upgrade if already proper format + if (oldDid === clientDid) { + return c.json({ success: true, did: oldDid, message: 'DID already up to date' }); + } + + // Update user's DID in database + await updateUserDid(userId, clientDid); + + // Migrate space memberships from old DID to new DID + let migratedSpaces = 0; + if (oldDid) { + migratedSpaces = await migrateSpaceMemberDid(oldDid, clientDid); + } + + // Store wallet address if provided + if (eoaAddress && typeof eoaAddress === 'string' && eoaAddress.startsWith('0x')) { + await updateUserProfile(userId, { walletAddress: eoaAddress }); + } + + console.log(`EncryptID: DID upgraded for user ${userId}`, { + oldDid: oldDid?.slice(0, 30) + '...', + newDid: clientDid.slice(0, 30) + '...', + migratedSpaces, + hasWallet: !!eoaAddress, + }); + + // Generate new JWT with updated DID + const username = claims.username as string; + const token = await generateSessionToken(userId, username); + + return c.json({ + success: true, + did: clientDid, + token, + migratedSpaces, + }); +}); + // ============================================================================ // USER PROFILE ENDPOINTS // ============================================================================ @@ -1580,6 +1677,169 @@ app.post('/api/recovery/email/verify', async (c) => { }); }); +// ============================================================================ +// MAGIC LINK LOGIN +// ============================================================================ + +/** + * Request a magic login link — sends an email with a one-time login URL. + * For users signing in from a device that doesn't have their passkey. + * Session issued has wallet capabilities disabled (no PRF keys available). + */ +app.post('/api/auth/magic-link', async (c) => { + const { email } = await c.req.json(); + + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return c.json({ error: 'Valid email required' }, 400); + } + + const user = await getUserByEmail(email.trim().toLowerCase()); + // Always return success to avoid email enumeration + if (!user) { + return c.json({ success: true, message: 'If an account exists with this email, a login link has been sent.' }); + } + + // Generate magic login token + const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); + const rt: StoredRecoveryToken = { + token, + userId: user.id, + type: 'magic_login', + createdAt: Date.now(), + expiresAt: Date.now() + 15 * 60 * 1000, // 15 minutes + used: false, + }; + await storeRecoveryToken(rt); + + // Send magic login email + const loginLink = `https://auth.rspace.online/magic-login?token=${encodeURIComponent(token)}`; + if (smtpTransport) { + try { + await smtpTransport.sendMail({ + from: CONFIG.smtp.from, + to: email.trim().toLowerCase(), + subject: 'rStack — Sign In Link', + text: [ + `Hi ${user.username},`, + '', + 'Use the link below to sign in to your rStack account:', + '', + loginLink, + '', + 'This link expires in 15 minutes and can only be used once.', + 'Note: Wallet features are unavailable when signing in via email link. Use a device with your passkey for full functionality.', + '', + 'If you did not request this, you can safely ignore this email.', + '', + '— rStack Identity', + ].join('\n'), + html: ` + + + + + + +
+ + + + + +
+
+

rStack Sign In

+
+

Hi ${user.username},

+

Click below to sign in to your rStack account:

+
+ Sign In +
+

This link expires in 15 minutes and can only be used once.

+

+ Wallet and signing features are unavailable via email login. Use a device with your passkey for full functionality. +

+

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

+
+
+ +`, + }); + } catch (err) { + console.error('EncryptID: Failed to send magic login email:', err); + } + } else { + console.log(`EncryptID: [NO SMTP] Magic login link for ${email}: ${loginLink}`); + } + + return c.json({ success: true, message: 'If an account exists with this email, a login link has been sent.' }); +}); + +/** + * Magic login page — verifies token and issues session + */ +app.get('/magic-login', async (c) => { + const token = c.req.query('token'); + if (!token) { + return c.redirect('/'); + } + + const rt = await getRecoveryToken(token); + const valid = rt && !rt.used && rt.type === 'magic_login' && Date.now() <= rt.expiresAt; + + if (valid) { + await markRecoveryTokenUsed(token); + } + + const user = valid ? await getUserById(rt.userId) : null; + const sessionToken = user ? await generateSessionToken(rt.userId, user.username) : null; + + // Return a page that stores the token and redirects + return c.html(` + + + + + + rStack Identity — Sign In + + + +
+${valid && sessionToken ? ` +
+

Signed In

+

Welcome back, ${user!.username}!

+
You're signed in via email link. Wallet and signing features require your passkey device.
+

Redirecting...

+ +` : ` +
+

Link Expired

+

This login link is invalid or has already been used.

+ Back to Sign In +`} +
+ +`); +}); + // ============================================================================ // HELPER FUNCTIONS // ============================================================================ @@ -3468,7 +3728,7 @@ app.post('/api/spaces/:slug/members', async (c) => { return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400); } - const grantedByDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`; + const grantedByDid = claims.did as string; const member = await upsertSpaceMember(slug, body.userDID, body.role, grantedByDid); return c.json({ userDID: member.userDID, @@ -3621,7 +3881,7 @@ app.post('/api/invites/:token/accept', async (c) => { if (!accepted) return c.json({ error: 'Failed to accept invite' }, 500); // Add to space_members with the invite's role (use DID, not raw userId) - const userDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`; + const userDid = claims.did as string; await upsertSpaceMember(accepted.spaceSlug, userDid, accepted.role, accepted.invitedBy); return c.json({ @@ -4783,8 +5043,11 @@ app.post('/api/invites/identity/:token/claim', async (c) => { // Auto-join space if specified if (invite.spaceSlug) { - const did = `did:key:${payload.sub.slice(0, 32)}`; - await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, `did:key:${invite.invitedByUserId.slice(0, 32)}`); + const inviteeUser = await getUserById(payload.sub); + const did = inviteeUser?.did || `did:key:${payload.sub.slice(0, 32)}`; + const inviterUser = await getUserById(invite.invitedByUserId); + const inviterDid = inviterUser?.did || `did:key:${invite.invitedByUserId.slice(0, 32)}`; + await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, inviterDid); } // Auto-add email to OIDC client allowlist if this is a client invite @@ -6328,6 +6591,39 @@ app.get('/', (c) => { .link-row a { color: #7c3aed; text-decoration: none; } .link-row a:hover { text-decoration: underline; } + /* Registration stepper */ + .stepper { display: none; } + .stepper.active { display: block; } + .stepper-progress { display: flex; align-items: center; justify-content: center; gap: 0; margin-bottom: 1.5rem; padding: 0 1rem; } + .stepper-dot { width: 10px; height: 10px; border-radius: 50%; background: rgba(255,255,255,0.15); transition: all 0.3s; flex-shrink: 0; } + .stepper-dot.active { background: #7c3aed; box-shadow: 0 0 8px rgba(124,58,237,0.4); } + .stepper-dot.done { background: #22c55e; } + .stepper-line { height: 2px; flex: 1; background: rgba(255,255,255,0.1); transition: background 0.3s; } + .stepper-line.done { background: #22c55e; } + + .step-card { animation: stepIn 0.3s ease; } + @keyframes stepIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } + + .step-icon { font-size: 2rem; text-align: center; margin-bottom: 0.75rem; } + .step-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem; } + .step-desc { font-size: 0.85rem; color: #94a3b8; line-height: 1.5; margin-bottom: 1rem; } + + .key-status { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; } + .key-row { display: flex; justify-content: space-between; align-items: center; padding: 0.3rem 0; font-size: 0.8rem; } + .key-label { color: #94a3b8; } + .key-value { color: #22c55e; font-family: monospace; font-size: 0.75rem; } + .key-value.pending { color: #64748b; } + + .did-display { background: rgba(124,58,237,0.1); border: 1px solid rgba(124,58,237,0.2); border-radius: 0.5rem; padding: 0.6rem 0.75rem; font-family: monospace; font-size: 0.7rem; color: #c4b5fd; word-break: break-all; cursor: pointer; position: relative; } + .did-display:hover { border-color: #7c3aed; } + .did-display .copy-toast { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); font-size: 0.7rem; color: #7c3aed; font-family: sans-serif; } + + .eoa-display { background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.2); border-radius: 0.5rem; padding: 0.6rem 0.75rem; font-family: monospace; font-size: 0.75rem; color: #86efac; word-break: break-all; margin-top: 0.5rem; } + + .step-note { font-size: 0.8rem; color: #64748b; font-style: italic; padding: 0.5rem 0.75rem; background: rgba(255,255,255,0.03); border-radius: 0.5rem; margin-bottom: 1rem; } + .btn-secondary { display: block; width: 100%; padding: 0.75rem; border: 1px solid rgba(255,255,255,0.15); background: transparent; color: #e2e8f0; border-radius: 0.5rem; font-size: 0.9rem; cursor: pointer; transition: all 0.2s; margin-top: 0.5rem; } + .btn-secondary:hover { border-color: #7c3aed; color: #fff; } + @media (max-width: 480px) { .features { grid-template-columns: 1fr; } .header h1 { font-size: 2rem; } @@ -6356,19 +6652,115 @@ app.get('/', (c) => {
- -