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🔑z6Mk... DIDs
  from database instead of deriving truncated did🔑${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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 11:29:20 -07:00
parent db61e54d7b
commit 8bd8348529
4 changed files with 813 additions and 121 deletions

View File

@ -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<TokenLedgerDoc>(docId);
// Create fUSDC token if it doesn't exist
if (!doc) {
doc = Automerge.change(Automerge.init<TokenLedgerDoc>(), 'init fUSDC ledger', (d) => {
const init = tokenLedgerSchema.init();
Object.assign(d, init);
});
_syncServer.setDoc(docId, doc);
}
if (!doc.token.name) {
_syncServer.changeDoc<TokenLedgerDoc>(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;
}

View File

@ -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<StoredRecoveryTok
return {
token: row.token,
userId: row.user_id,
type: row.type as 'email_verify' | 'account_recovery',
type: row.type as 'email_verify' | 'account_recovery' | 'magic_login',
createdAt: new Date(row.created_at).getTime(),
expiresAt: new Date(row.expires_at).getTime(),
used: row.used,

View File

@ -56,7 +56,7 @@ CREATE INDEX IF NOT EXISTS idx_challenges_expires_at ON challenges(expires_at);
CREATE TABLE IF NOT EXISTS recovery_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('email_verify', 'account_recovery')),
type TEXT NOT NULL CHECK (type IN ('email_verify', 'account_recovery', 'magic_login')),
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN DEFAULT FALSE
@ -65,6 +65,15 @@ CREATE TABLE IF NOT EXISTS recovery_tokens (
CREATE INDEX IF NOT EXISTS idx_recovery_tokens_user_id ON recovery_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_recovery_tokens_expires_at ON recovery_tokens(expires_at);
-- Migration: add magic_login type to existing recovery_tokens CHECK constraint
DO $$
BEGIN
ALTER TABLE recovery_tokens DROP CONSTRAINT IF EXISTS recovery_tokens_type_check;
ALTER TABLE recovery_tokens ADD CONSTRAINT recovery_tokens_type_check
CHECK (type IN ('email_verify', 'account_recovery', 'magic_login'));
EXCEPTION WHEN others THEN NULL;
END $$;
-- Space membership: source of truth for user roles across the r*.online ecosystem
CREATE TABLE IF NOT EXISTS space_members (
space_slug TEXT NOT NULL,

View File

@ -123,6 +123,8 @@ import {
getUserUPAddress,
setUserUPAddress,
getUserByUPAddress,
updateUserDid,
migrateSpaceMemberDid,
} from './db.js';
import {
isMailcowConfigured,
@ -555,7 +557,13 @@ app.post('/api/register/start', async (c) => {
},
};
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: `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
<tr><td style="padding:32px 32px 24px;text-align:center;">
<div style="font-size:36px;margin-bottom:8px;">&#9993;</div>
<h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">rStack Sign In</h1>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
<p>Hi <strong>${user.username}</strong>,</p>
<p>Click below to sign in to your rStack account:</p>
</td></tr>
<tr><td style="padding:0 32px 24px;text-align:center;">
<a href="${loginLink}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Sign In</a>
</td></tr>
<tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
<p>This link expires in <strong>15 minutes</strong> and can only be used once.</p>
<p style="background:rgba(234,179,8,0.1);border:1px solid rgba(234,179,8,0.2);border-radius:8px;padding:8px 12px;margin-top:8px;color:#eab308;font-size:12px;">
Wallet and signing features are unavailable via email login. Use a device with your passkey for full functionality.
</p>
<p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
Can't click the button? Copy this link:<br>
<span style="color:#94a3b8;word-break:break-all;">${loginLink}</span>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`,
});
} 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(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rStack Identity Sign In</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; }
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 440px; width: 100%; text-align: center; }
.icon { font-size: 3rem; margin-bottom: 0.75rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
p { color: #94a3b8; font-size: 0.9rem; line-height: 1.5; margin-bottom: 1rem; }
.warning { background: rgba(234,179,8,0.1); border: 1px solid rgba(234,179,8,0.2); border-radius: 8px; padding: 0.75rem; color: #eab308; font-size: 0.8rem; margin-bottom: 1rem; }
.error { color: #fca5a5; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 0.9rem; border: none; cursor: pointer; }
</style>
</head>
<body>
<div class="card">
${valid && sessionToken ? `
<div class="icon">&#10003;</div>
<h1>Signed In</h1>
<p>Welcome back, <strong>${user!.username}</strong>!</p>
<div class="warning">You're signed in via email link. Wallet and signing features require your passkey device.</div>
<p>Redirecting...</p>
<script>
localStorage.setItem('encryptid_token', ${JSON.stringify(sessionToken)});
setTimeout(() => { window.location.href = '/'; }, 1500);
</script>
` : `
<div class="icon">&#10060;</div>
<h1>Link Expired</h1>
<p class="error">This login link is invalid or has already been used.</p>
<a href="/" class="btn">Back to Sign In</a>
`}
</div>
</body>
</html>`);
});
// ============================================================================
// 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,8 +6652,42 @@ app.get('/', (c) => {
<div id="error-msg" class="error"></div>
<div id="success-msg" class="success"></div>
<!-- Register fields (hidden in signin mode) -->
<div id="register-fields" style="display:none">
<!-- Sign-in mode (default) -->
<div id="signin-fields">
<div class="form-group">
<label for="signin-email">Email or Username</label>
<input id="signin-email" type="text" placeholder="you@example.com or username" autocomplete="email" />
</div>
<button class="btn-primary" id="auth-btn" onclick="handleAuth()">Sign In with Passkey</button>
<div id="magic-link-section" style="display:none;margin-top:0.75rem">
<div style="text-align:center;color:#64748b;font-size:0.8rem;margin-bottom:0.5rem">or</div>
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Email me a login link</button>
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>
<div style="text-align:center;margin-top:0.75rem;font-size:0.8rem;color:#64748b" id="signin-hint">
Enter your email to find your account, or leave blank to use any passkey on this device
</div>
</div>
<!-- Registration stepper (hidden until register tab) -->
<div id="register-stepper" style="display:none">
<div class="stepper-progress" id="stepper-progress">
<div class="stepper-dot active" data-step="1"></div>
<div class="stepper-line" data-line="1"></div>
<div class="stepper-dot" data-step="2"></div>
<div class="stepper-line" data-line="2"></div>
<div class="stepper-dot" data-step="3"></div>
<div class="stepper-line" data-line="3"></div>
<div class="stepper-dot" data-step="4"></div>
<div class="stepper-line" data-line="4"></div>
<div class="stepper-dot" data-step="5"></div>
</div>
<!-- Step 1: Choose Your Identity -->
<div class="stepper step-card active" id="reg-step-1">
<div class="step-icon">&#128100;</div>
<div class="step-title">Choose Your Identity</div>
<div class="step-desc">Your username is your identity across rStack. No passwords your device IS your key.</div>
<div class="form-group">
<label for="username">Username</label>
<input id="username" type="text" placeholder="Choose a username" autocomplete="username" />
@ -6366,9 +6696,71 @@ app.get('/', (c) => {
<label for="reg-email">Email <span style="color:#475569;font-weight:400">(optional for account recovery)</span></label>
<input id="reg-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
<button class="btn-primary" id="step1-btn" onclick="handleStep1()">Continue</button>
</div>
<button class="btn-primary" id="auth-btn" onclick="handleAuth()">Sign In with Passkey</button>
<!-- Step 2: Create Your Passkey -->
<div class="stepper step-card" id="reg-step-2">
<div class="step-icon">&#128274;</div>
<div class="step-title">Create Your Passkey</div>
<div class="step-desc">A passkey is a cryptographic credential stored on your device. It replaces passwords with biometrics (fingerprint, face) or your device PIN. Passkeys are phishing-resistant and never leave your device.</div>
<button class="btn-primary" id="step2-btn" onclick="handleStep2()">Create Passkey</button>
</div>
<!-- Step 3: Your Universal Identity (DID) -->
<div class="stepper step-card" id="reg-step-3">
<div class="step-icon">&#128273;</div>
<div class="step-title">Your Universal Identity</div>
<div id="step3-prf-content">
<div class="step-desc">Your passkey derived a unique Decentralized Identifier (DID) a cryptographic identity that works across every app. Encryption and signing keys were also derived. All keys stay on your device.</div>
<div class="did-display" id="step3-did" onclick="copyDid(this)">
<span id="step3-did-text">Loading...</span>
<span class="copy-toast">click to copy</span>
</div>
<div class="key-status" style="margin-top:0.75rem">
<div class="key-row"><span class="key-label">Encryption</span><span class="key-value" id="key-encrypt">&#10003; Derived</span></div>
<div class="key-row"><span class="key-label">Signing</span><span class="key-value" id="key-sign">&#10003; Derived</span></div>
<div class="key-row"><span class="key-label">DID</span><span class="key-value" id="key-did">&#10003; Derived</span></div>
<div class="key-row"><span class="key-label">EOA Wallet</span><span class="key-value" id="key-eoa">&#10003; Derived</span></div>
</div>
</div>
<div id="step3-no-prf" style="display:none">
<div class="step-note">Full key derivation requires a compatible authenticator with PRF support. You can upgrade your identity later from a supported device.</div>
</div>
<button class="btn-primary" onclick="goToStep(4)">Continue</button>
</div>
<!-- Step 4: Your Wallet -->
<div class="stepper step-card" id="reg-step-4">
<div class="step-icon">&#128176;</div>
<div class="step-title">Your Wallet</div>
<div id="step4-prf-content">
<div class="step-desc">Your passkey derived an Ethereum wallet address. This wallet can hold tokens and sign transactions no seed phrases or browser extensions needed.</div>
<div class="eoa-display" id="step4-eoa">Loading...</div>
<div class="step-desc" style="margin-top:0.75rem;font-size:0.8rem">A CRDT-based local wallet is also active for offline-first token management.</div>
</div>
<div id="step4-no-prf" style="display:none">
<div class="step-note">Wallet derivation requires PRF-capable authenticator. You can add a wallet later from settings.</div>
</div>
<button class="btn-primary" onclick="goToStep(5)">Continue</button>
</div>
<!-- Step 5: Secure Your Account -->
<div class="stepper step-card" id="reg-step-5">
<div class="step-icon">&#128737;</div>
<div class="step-title">Secure Your Account</div>
<div class="step-desc">Social recovery replaces seed phrases. Choose trusted people who can help you recover your account if you lose all devices.</div>
<div class="key-status">
<div class="key-row"><span class="key-label">Passkey</span><span class="key-value">&#10003; Created</span></div>
<div class="key-row"><span class="key-label">Recovery email</span><span class="key-value" id="step5-email">&#9675; Not set</span></div>
<div class="key-row"><span class="key-label">Second device</span><span class="key-value pending">&#9675; Not added</span></div>
<div class="key-row"><span class="key-label">Guardians</span><span class="key-value pending">&#9675; 0 of 3</span></div>
<div class="key-row"><span class="key-label">Vault backup</span><span class="key-value pending">&#9675; Not configured</span></div>
</div>
<button class="btn-primary" id="step5-btn" onclick="finishRegistration()">Go to Dashboard</button>
<button class="btn-secondary" onclick="finishRegistration()">Set up later</button>
</div>
</div>
<div class="link-row" id="recovery-link-row">
<a href="#" onclick="showRecoveryForm(); return false;">Lost your device? Recover account</a>
@ -6544,14 +6936,206 @@ app.get('/', (c) => {
const TOKEN_KEY = 'encryptid_token';
let currentMode = 'signin';
// Registration stepper state
let regStep = 1;
let regData = {}; // stores username, email, userId, serverOptions, prfSalt, clientDid, eoaAddress, token, did
// Expose to inline onclick handlers
window.switchTab = (mode) => {
currentMode = mode;
document.getElementById('tab-signin').classList.toggle('active', mode === 'signin');
document.getElementById('tab-register').classList.toggle('active', mode === 'register');
document.getElementById('register-fields').style.display = mode === 'register' ? 'block' : 'none';
document.getElementById('auth-btn').textContent = mode === 'signin' ? 'Sign In with Passkey' : 'Register with Passkey';
document.getElementById('register-stepper').style.display = mode === 'register' ? 'block' : 'none';
document.getElementById('signin-fields').style.display = mode === 'signin' ? 'block' : 'none';
document.getElementById('recovery-link-row').style.display = mode === 'signin' ? 'block' : 'none';
hideMessages();
if (mode === 'register') {
regStep = 1;
updateStepper();
}
};
function updateStepper() {
for (let i = 1; i <= 5; i++) {
const stepEl = document.getElementById('reg-step-' + i);
const dotEl = document.querySelector('.stepper-dot[data-step="' + i + '"]');
if (stepEl) stepEl.classList.toggle('active', i === regStep);
if (dotEl) {
dotEl.classList.toggle('active', i === regStep);
dotEl.classList.toggle('done', i < regStep);
}
}
for (let i = 1; i <= 4; i++) {
const lineEl = document.querySelector('.stepper-line[data-line="' + i + '"]');
if (lineEl) lineEl.classList.toggle('done', i < regStep);
}
}
window.goToStep = (step) => {
regStep = step;
updateStepper();
};
window.copyDid = (el) => {
const text = el.querySelector('span')?.textContent || el.textContent;
navigator.clipboard.writeText(text).then(() => {
const toast = el.querySelector('.copy-toast');
if (toast) { toast.textContent = 'copied!'; setTimeout(() => toast.textContent = 'click to copy', 1500); }
});
};
// Step 1: Validate username
window.handleStep1 = async () => {
const username = document.getElementById('username').value.trim();
if (!username) { showError('Username is required'); return; }
if (username.length < 2) { showError('Username must be at least 2 characters'); return; }
const email = document.getElementById('reg-email').value.trim();
const btn = document.getElementById('step1-btn');
btn.disabled = true;
btn.textContent = 'Checking...';
hideMessages();
try {
// Check username availability
const checkRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName: username }),
});
if (!checkRes.ok) {
const err = await checkRes.json().catch(() => ({}));
throw new Error(err.error || 'Username may be taken');
}
const { options, userId, prfSalt } = await checkRes.json();
regData = { username, email, userId, serverOptions: options, prfSalt };
goToStep(2);
} catch (err) {
showError(err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Continue';
}
};
// Step 2: Create passkey with PRF
window.handleStep2 = async () => {
const btn = document.getElementById('step2-btn');
btn.disabled = true;
btn.textContent = 'Waiting for authenticator...';
hideMessages();
try {
const { serverOptions, prfSalt, username, email, userId } = regData;
// Build PRF extension
const prfExtension = prfSalt ? {
prf: { eval: { first: new Uint8Array(base64urlToBuffer(prfSalt)) } },
} : {};
const credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || 'rspace.online', name: serverOptions.rp?.name || 'EncryptID' },
user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: 'public-key' },
{ alg: -257, type: 'public-key' },
],
authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
attestation: 'none',
timeout: 60000,
extensions: { credProps: true, ...prfExtension },
},
});
if (!credential) throw new Error('Failed to create credential');
const response = credential.response;
const publicKey = response.getPublicKey?.();
const credentialData = {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : '',
transports: response.getTransports?.() || [],
};
// Check PRF results
let clientDid = null, eoaAddress = null, prfSupported = false;
const extResults = credential.getClientExtensionResults?.();
const prfResults = extResults?.prf?.results;
if (prfResults?.first) {
prfSupported = true;
try {
const km = getKeyManager();
await km.initFromPRF(prfResults.first);
const keys = await km.getKeys();
clientDid = keys.did;
eoaAddress = keys.eoaAddress;
} catch (e) {
console.warn('EncryptID: PRF key derivation failed', e);
}
}
// Complete registration with server
const regBody = {
challenge: serverOptions.challenge,
credential: credentialData,
userId, username,
};
if (email) regBody.email = email;
if (clientDid) regBody.clientDid = clientDid;
if (eoaAddress) regBody.eoaAddress = eoaAddress;
const res = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(regBody),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Registration failed');
// Save to regData for subsequent steps
regData.token = data.token;
regData.did = data.did;
regData.clientDid = clientDid;
regData.eoaAddress = eoaAddress;
regData.prfSupported = prfSupported;
localStorage.setItem(TOKEN_KEY, data.token);
// Populate step 3
if (prfSupported && clientDid) {
document.getElementById('step3-did-text').textContent = clientDid;
document.getElementById('step3-prf-content').style.display = 'block';
document.getElementById('step3-no-prf').style.display = 'none';
} else {
document.getElementById('step3-prf-content').style.display = 'none';
document.getElementById('step3-no-prf').style.display = 'block';
}
// Populate step 4
if (prfSupported && eoaAddress) {
document.getElementById('step4-eoa').textContent = eoaAddress;
document.getElementById('step4-prf-content').style.display = 'block';
document.getElementById('step4-no-prf').style.display = 'none';
} else {
document.getElementById('step4-prf-content').style.display = 'none';
document.getElementById('step4-no-prf').style.display = 'block';
}
// Populate step 5
if (email) {
document.getElementById('step5-email').textContent = '&#10003; ' + email;
document.getElementById('step5-email').classList.remove('pending');
}
// Skip steps 3-4 if no PRF
goToStep(prfSupported ? 3 : 5);
} catch (err) {
showError(err.message);
btn.textContent = 'Create Passkey';
btn.disabled = false;
}
};
window.finishRegistration = () => {
const { token, username, did, prfSupported, clientDid, eoaAddress } = regData;
showProfile(token, username, did, { prfSupported, clientDid, eoaAddress });
};
function showError(msg) {
@ -6604,79 +7188,52 @@ app.get('/', (c) => {
}
};
// handleAuth is now sign-in only (registration uses stepper)
window.handleAuth = async () => {
const btn = document.getElementById('auth-btn');
btn.disabled = true;
hideMessages();
try {
if (currentMode === 'register') {
const username = document.getElementById('username').value.trim();
if (!username) { showError('Username is required'); btn.disabled = false; return; }
const email = document.getElementById('reg-email').value.trim();
btn.textContent = 'Creating passkey...';
// Server-initiated registration flow
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName: username }),
});
if (!startRes.ok) throw new Error('Failed to start registration');
const { options: serverOptions, userId } = await startRes.json();
const credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || 'rspace.online', name: serverOptions.rp?.name || 'EncryptID' },
user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: 'public-key' },
{ alg: -257, type: 'public-key' },
],
authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
attestation: 'none',
timeout: 60000,
},
});
if (!credential) throw new Error('Failed to create credential');
const response = credential.response;
const publicKey = response.getPublicKey?.();
const credentialData = {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : '',
transports: response.getTransports?.() || [],
};
const regBody = {
challenge: serverOptions.challenge,
credential: credentialData,
userId,
username,
};
if (email) regBody.email = email;
const res = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(regBody),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Registration failed');
localStorage.setItem(TOKEN_KEY, data.token);
showProfile(data.token, username, data.did);
} else {
btn.textContent = 'Waiting for passkey...';
hideMessages();
// Server-initiated auth flow
// Get email/username if provided to scope the passkey picker
const signinInput = document.getElementById('signin-email').value.trim();
const isEmail = signinInput.includes('@');
try {
// Server-initiated auth flow — pass email/username to scope credentials
const authBody = {};
if (signinInput) {
if (isEmail) authBody.email = signinInput;
else authBody.username = signinInput;
}
const startRes = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
body: JSON.stringify(authBody),
});
if (!startRes.ok) throw new Error('Failed to start authentication');
const { options: serverOptions } = await startRes.json();
const { options: serverOptions, prfSalt, userFound } = await startRes.json();
// If user provided email/username but no account found, show magic link option
if (signinInput && !userFound) {
showError('No account found. Check your email/username or register a new account.');
if (isEmail) {
document.getElementById('magic-link-section').style.display = 'block';
}
btn.textContent = 'Sign In with Passkey';
btn.disabled = false;
return;
}
// Show magic link option when email is provided (in case passkey isn't on this device)
if (isEmail) {
document.getElementById('magic-link-section').style.display = 'block';
}
// Build PRF extension for sign-in
const prfExtension = prfSalt ? {
prf: { eval: { first: new Uint8Array(base64urlToBuffer(prfSalt)) } },
} : {};
const credential = await navigator.credentials.get({
publicKey: {
@ -6684,9 +7241,11 @@ app.get('/', (c) => {
rpId: serverOptions.rpId || 'rspace.online',
userVerification: 'required',
timeout: 60000,
allowCredentials: serverOptions.allowCredentials,
extensions: { ...prfExtension },
},
});
if (!credential) throw new Error('Authentication failed');
if (!credential) throw new Error('Authentication cancelled');
const res = await fetch('/api/auth/complete', {
method: 'POST',
@ -6700,16 +7259,98 @@ app.get('/', (c) => {
if (!res.ok) throw new Error(data.error || 'Authentication failed');
localStorage.setItem(TOKEN_KEY, data.token);
showProfile(data.token, data.username, data.did);
// Check for PRF output and auto-upgrade DID if needed
const extResults = credential.getClientExtensionResults?.();
const prfResults = extResults?.prf?.results;
if (prfResults?.first) {
try {
const km = getKeyManager();
await km.initFromPRF(prfResults.first);
const keys = await km.getKeys();
// Check if server DID is truncated (doesn't start with z6Mk)
const serverDid = data.did || '';
const didPart = serverDid.replace('did:key:', '');
if (keys.did && !didPart.startsWith('z6Mk') && !didPart.startsWith('z')) {
// Auto-upgrade DID
try {
const upgradeRes = await fetch('/api/account/upgrade-did', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + data.token,
},
body: JSON.stringify({
clientDid: keys.did,
eoaAddress: keys.eoaAddress,
}),
});
if (upgradeRes.ok) {
const upgradeData = await upgradeRes.json();
if (upgradeData.token) {
localStorage.setItem(TOKEN_KEY, upgradeData.token);
data.token = upgradeData.token;
data.did = keys.did;
console.log('EncryptID: DID auto-upgraded to', keys.did);
}
}
} catch (e) {
console.warn('EncryptID: DID upgrade failed', e);
}
}
} catch (e) {
console.warn('EncryptID: PRF key derivation during sign-in failed', e);
}
}
showProfile(data.token, data.username, data.did);
} catch (err) {
// If passkey auth was cancelled/failed but email was provided, suggest magic link
if (isEmail && err.name === 'NotAllowedError') {
showError('Passkey not found on this device. Use the email link below to sign in.');
document.getElementById('magic-link-section').style.display = 'block';
} else {
showError(err.message || 'Authentication failed');
btn.textContent = currentMode === 'signin' ? 'Sign In with Passkey' : 'Register with Passkey';
}
btn.textContent = 'Sign In with Passkey';
btn.disabled = false;
}
};
async function showProfile(token, username, did) {
// Magic link: send login email
window.sendMagicLink = async () => {
const email = document.getElementById('signin-email').value.trim();
const msgEl = document.getElementById('magic-link-msg');
const btn = document.getElementById('magic-link-btn');
if (!email || !email.includes('@')) {
msgEl.textContent = 'Enter your email address above first';
msgEl.style.color = '#fca5a5';
msgEl.style.display = 'block';
return;
}
btn.disabled = true;
btn.textContent = 'Sending...';
try {
const res = await fetch('/api/auth/magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json();
msgEl.textContent = data.message || 'Check your email for a login link.';
msgEl.style.color = '#86efac';
msgEl.style.display = 'block';
btn.textContent = 'Link sent!';
} catch (err) {
msgEl.textContent = 'Failed to send: ' + err.message;
msgEl.style.color = '#fca5a5';
msgEl.style.display = 'block';
btn.textContent = 'Email me a login link';
btn.disabled = false;
}
};
async function showProfile(token, username, did, extra = {}) {
// If there's a redirect param, navigate there after login
const redirectUrl = new URLSearchParams(location.search).get('redirect');
if (redirectUrl && redirectUrl.startsWith('/')) {
@ -7258,7 +7899,7 @@ app.post('/api/delegations', async (c) => {
return c.json({ error: 'weight must be between 0 (exclusive) and 1 (inclusive)' }, 400);
}
const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`;
const delegatorDid = claims.did as string;
if (delegateDid === delegatorDid) {
return c.json({ error: 'Cannot delegate to yourself' }, 400);
@ -7328,7 +7969,7 @@ app.get('/api/delegations/from', async (c) => {
const spaceSlug = c.req.query('space');
if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);
const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`;
const delegatorDid = claims.did as string;
const delegations = await listDelegationsFrom(delegatorDid, spaceSlug);
return c.json({ delegations });
});
@ -7341,7 +7982,7 @@ app.get('/api/delegations/to', async (c) => {
const spaceSlug = c.req.query('space');
if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);
const delegateDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`;
const delegateDid = claims.did as string;
const delegations = await listDelegationsTo(delegateDid, spaceSlug);
return c.json({ delegations });
});
@ -7355,7 +7996,7 @@ app.patch('/api/delegations/:id', async (c) => {
const existing = await getDelegation(id);
if (!existing) return c.json({ error: 'Delegation not found' }, 404);
const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`;
const delegatorDid = claims.did as string;
if (existing.delegatorDid !== delegatorDid) {
return c.json({ error: 'Only the delegator can modify a delegation' }, 403);
}
@ -7416,7 +8057,7 @@ app.delete('/api/delegations/:id', async (c) => {
const existing = await getDelegation(id);
if (!existing) return c.json({ error: 'Delegation not found' }, 404);
const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`;
const delegatorDid = claims.did as string;
if (existing.delegatorDid !== delegatorDid) {
return c.json({ error: 'Only the delegator can revoke a delegation' }, 403);
}