diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 9ff07c0..13a8a42 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -43,7 +43,7 @@ export interface StoredCredential { export interface StoredChallenge { challenge: string; userId?: string; - type: 'registration' | 'authentication' | 'device_registration' | 'wallet_link'; + type: 'registration' | 'authentication' | 'device_registration' | 'wallet_link' | 'legacy_migration'; createdAt: number; expiresAt: number; } @@ -2069,4 +2069,101 @@ export async function listAllUsersWithTrust(spaceSlug: string): Promise { + const rows = await sql` + INSERT INTO legacy_identities (id, user_id, provider, legacy_username, legacy_public_key, legacy_public_key_hash, verified) + VALUES (${identity.id}, ${userId}, ${identity.provider}, ${identity.legacyUsername}, ${identity.legacyPublicKey}, ${identity.legacyPublicKeyHash}, TRUE) + ON CONFLICT (provider, legacy_public_key_hash) DO UPDATE SET + user_id = ${userId}, + legacy_username = ${identity.legacyUsername}, + legacy_public_key = ${identity.legacyPublicKey}, + verified = TRUE, + verified_at = NOW() + RETURNING * + `; + return rowToLegacyIdentity(rows[0]); +} + +export async function getLegacyIdentityByPublicKeyHash(provider: string, hash: string): Promise { + const [row] = await sql` + SELECT * FROM legacy_identities + WHERE provider = ${provider} AND legacy_public_key_hash = ${hash} + `; + return row ? rowToLegacyIdentity(row) : null; +} + +export async function getLegacyIdentitiesByUser(userId: string): Promise { + const rows = await sql` + SELECT * FROM legacy_identities + WHERE user_id = ${userId} + ORDER BY linked_at ASC + `; + return rows.map(rowToLegacyIdentity); +} + +export async function verifyLegacyIdentity(id: string): Promise { + const result = await sql` + UPDATE legacy_identities + SET verified = TRUE, verified_at = NOW() + WHERE id = ${id} + `; + return result.count > 0; +} + +export async function getUserByLegacyPublicKeyHash(provider: string, hash: string): Promise<{ + userId: string; + username: string; + did: string | null; + legacyIdentityId: string; +} | null> { + const [row] = await sql` + SELECT u.id as user_id, u.username, u.did, li.id as legacy_identity_id + FROM users u + JOIN legacy_identities li ON li.user_id = u.id + WHERE li.provider = ${provider} AND li.legacy_public_key_hash = ${hash} AND li.verified = TRUE + `; + if (!row) return null; + return { + userId: row.user_id, + username: row.username, + did: row.did || null, + legacyIdentityId: row.legacy_identity_id, + }; +} + export { sql }; diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index 1182346..9d58586 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -319,7 +319,7 @@ CREATE INDEX IF NOT EXISTS idx_identity_invites_client_id ON identity_invites(cl ALTER TABLE challenges DROP CONSTRAINT IF EXISTS challenges_type_check; ALTER TABLE challenges ADD CONSTRAINT challenges_type_check - CHECK (type IN ('registration', 'authentication', 'device_registration', 'wallet_link')); + CHECK (type IN ('registration', 'authentication', 'device_registration', 'wallet_link', 'legacy_migration')); -- ============================================================================ -- LINKED EXTERNAL WALLETS (SIWE-verified wallet associations) @@ -431,3 +431,24 @@ CREATE TABLE IF NOT EXISTS trust_scores ( CREATE INDEX IF NOT EXISTS idx_trust_scores_target ON trust_scores(target_did, authority, space_slug); CREATE INDEX IF NOT EXISTS idx_trust_scores_space ON trust_scores(space_slug, authority); + +-- ============================================================================ +-- LEGACY IDENTITY LINKS (CryptID → EncryptID migration) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS legacy_identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider IN ('cryptid')), + legacy_username TEXT NOT NULL, + legacy_public_key TEXT NOT NULL, + legacy_public_key_hash TEXT NOT NULL, + verified BOOLEAN DEFAULT FALSE, + migrated_data BOOLEAN DEFAULT FALSE, + linked_at TIMESTAMPTZ DEFAULT NOW(), + verified_at TIMESTAMPTZ, + UNIQUE (provider, legacy_public_key_hash) +); + +CREATE INDEX IF NOT EXISTS idx_legacy_identities_user ON legacy_identities(user_id); +CREATE INDEX IF NOT EXISTS idx_legacy_identities_pubkey ON legacy_identities(legacy_public_key_hash); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 45718a3..853adec 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -100,6 +100,10 @@ import { getLinkedWallets, deleteLinkedWallet, linkedWalletExists, + createLegacyIdentity, + getLegacyIdentityByPublicKeyHash, + getLegacyIdentitiesByUser, + getUserByLegacyPublicKeyHash, consumeChallenge, createDelegation, getDelegation, @@ -190,6 +194,8 @@ const CONFIG = { 'https://rchoices.online', 'https://rswag.online', 'https://rdata.online', + // canvas-website (CryptID migration) + 'https://jeffemmett-canvas.pages.dev', // Development 'http://localhost:3000', 'http://localhost:5173', @@ -3044,6 +3050,256 @@ app.delete('/encryptid/api/wallet-link/:id', async (c) => { return c.json({ success: true }); }); +// ============================================================================ +// CRYPTID LEGACY MIGRATION HELPERS +// ============================================================================ + +/** + * Verify a CryptID P-256 ECDSA signature. + * CryptID uses raw ECDSA P-256 keypairs — the public key is a base64-encoded + * raw key (65 bytes uncompressed), signature is base64-encoded DER/raw. + */ +async function verifyCryptIDSignature( + publicKeyBase64: string, + signatureBase64: string, + challenge: string, +): Promise { + try { + const pubKeyBytes = Buffer.from(publicKeyBase64, 'base64'); + const sigBytes = Buffer.from(signatureBase64, 'base64'); + const challengeBytes = new TextEncoder().encode(challenge); + + const key = await crypto.subtle.importKey( + 'raw', + pubKeyBytes, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'], + ); + + return await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + sigBytes, + challengeBytes, + ); + } catch (err) { + console.error('EncryptID: CryptID signature verification failed', err); + return false; + } +} + +/** SHA-256 hash of a public key base64 string → hex for dedup */ +async function hashPublicKey(publicKeyBase64: string): Promise { + const buf = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(publicKeyBase64), + ); + return Buffer.from(buf).toString('hex'); +} + +// ============================================================================ +// CRYPTID → ENCRYPTID MIGRATION ROUTES +// ============================================================================ + +// POST /encryptid/api/migration/challenge — Generate a migration challenge. +// For clients that still have private key access (fresh challenge-response). +app.post('/encryptid/api/migration/challenge', async (c) => { + const body = await c.req.json(); + const { legacyUsername, legacyPublicKey } = body; + + if (!legacyUsername || !legacyPublicKey) { + return c.json({ error: 'Missing required fields: legacyUsername, legacyPublicKey' }, 400); + } + if (typeof legacyUsername !== 'string' || legacyUsername.length > 64) { + return c.json({ error: 'Invalid legacyUsername' }, 400); + } + if (typeof legacyPublicKey !== 'string' || legacyPublicKey.length > 256) { + return c.json({ error: 'Invalid legacyPublicKey' }, 400); + } + + const challenge = crypto.randomUUID(); + await storeChallenge({ + challenge, + type: 'legacy_migration', + createdAt: Date.now(), + expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes + }); + + return c.json({ challenge }); +}); + +// POST /encryptid/api/migration/verify — Verify CryptID ownership and link to EncryptID. +// Two proof modes: +// Fresh: { migrationChallenge, freshSignature, legacyUsername, legacyPublicKey } +// Historical: { originalChallenge, originalSignature, legacyUsername, legacyPublicKey } +// If Authorization header present → links to existing EncryptID user (Mode A). +// If no auth → returns pending registration URL (Mode B). +app.post('/encryptid/api/migration/verify', async (c) => { + const body = await c.req.json(); + const { legacyUsername, legacyPublicKey } = body; + + if (!legacyUsername || !legacyPublicKey) { + return c.json({ error: 'Missing required fields: legacyUsername, legacyPublicKey' }, 400); + } + if (typeof legacyUsername !== 'string' || legacyUsername.length > 64) { + return c.json({ error: 'Invalid legacyUsername' }, 400); + } + if (typeof legacyPublicKey !== 'string' || legacyPublicKey.length > 256) { + return c.json({ error: 'Invalid legacyPublicKey' }, 400); + } + + // Determine proof mode and verify signature + let verified = false; + + if (body.migrationChallenge && body.freshSignature) { + // Fresh mode: verify against a challenge we issued + const challenge = await getChallenge(body.migrationChallenge); + if (!challenge || challenge.type !== 'legacy_migration') { + return c.json({ error: 'Invalid or expired migration challenge' }, 400); + } + await deleteChallenge(body.migrationChallenge); + + verified = await verifyCryptIDSignature(legacyPublicKey, body.freshSignature, body.migrationChallenge); + } else if (body.originalChallenge && body.originalSignature) { + // Historical mode: replay the CryptID registration authData + verified = await verifyCryptIDSignature(legacyPublicKey, body.originalSignature, body.originalChallenge); + } else { + return c.json({ error: 'Must provide either {migrationChallenge, freshSignature} or {originalChallenge, originalSignature}' }, 400); + } + + if (!verified) { + return c.json({ error: 'Signature verification failed' }, 400); + } + + const pubKeyHash = await hashPublicKey(legacyPublicKey); + + // Check if this legacy identity is already linked + const existing = await getLegacyIdentityByPublicKeyHash('cryptid', pubKeyHash); + + // Mode A: Authenticated user → link to their EncryptID account + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (claims) { + if (existing && existing.userId !== claims.sub) { + return c.json({ error: 'This CryptID identity is already linked to another account' }, 409); + } + + const identity = await createLegacyIdentity(claims.sub, { + id: existing?.id || crypto.randomUUID(), + provider: 'cryptid', + legacyUsername, + legacyPublicKey, + legacyPublicKeyHash: pubKeyHash, + }); + + return c.json({ + success: true, + mode: 'linked', + legacyIdentityId: identity.id, + legacyUsername: identity.legacyUsername, + linkedToUser: claims.sub, + }); + } + + // Mode B: No auth → create a pending migration token for registration + if (existing?.verified) { + return c.json({ + success: true, + mode: 'already_linked', + message: 'This CryptID identity is already linked to an EncryptID account. Log in to your EncryptID account.', + }); + } + + // Store a short-lived migration token (reuse challenges table) + const migrationToken = crypto.randomUUID(); + await storeChallenge({ + challenge: migrationToken, + type: 'legacy_migration', + createdAt: Date.now(), + expiresAt: Date.now() + 30 * 60 * 1000, // 30 minutes to complete registration + }); + + // Stash the verified legacy info in the challenge metadata area + // (We'll re-verify on the registration side, so this is just a convenience token) + return c.json({ + success: true, + mode: 'pending_registration', + migrationToken, + legacyUsername, + registrationUrl: `https://auth.rspace.online/register?migration=${migrationToken}&legacyUsername=${encodeURIComponent(legacyUsername)}&legacyPublicKey=${encodeURIComponent(legacyPublicKey)}`, + }); +}); + +// POST /encryptid/api/migration/auth — Fallback auth for transition period. +// Allows CryptID users with a verified linked identity to get a JWT. +app.post('/encryptid/api/migration/auth', async (c) => { + const body = await c.req.json(); + const { legacyPublicKey, signature, challenge } = body; + + if (!legacyPublicKey || !signature || !challenge) { + return c.json({ error: 'Missing required fields: legacyPublicKey, signature, challenge' }, 400); + } + if (typeof legacyPublicKey !== 'string' || legacyPublicKey.length > 256) { + return c.json({ error: 'Invalid legacyPublicKey' }, 400); + } + if (typeof signature !== 'string' || signature.length > 256) { + return c.json({ error: 'Invalid signature' }, 400); + } + if (typeof challenge !== 'string' || challenge.length > 256) { + return c.json({ error: 'Invalid challenge' }, 400); + } + + // Verify the challenge was issued by us (from /api/auth/start) + const storedChallenge = await getChallenge(challenge); + if (!storedChallenge) { + return c.json({ error: 'Invalid or expired challenge' }, 400); + } + await deleteChallenge(challenge); + + // Verify the P-256 signature + const verified = await verifyCryptIDSignature(legacyPublicKey, signature, challenge); + if (!verified) { + return c.json({ error: 'Signature verification failed' }, 400); + } + + // Look up the linked EncryptID user + const pubKeyHash = await hashPublicKey(legacyPublicKey); + const linkedUser = await getUserByLegacyPublicKeyHash('cryptid', pubKeyHash); + if (!linkedUser) { + return c.json({ error: 'No linked EncryptID account found. Complete migration first.' }, 404); + } + + // Issue a standard session token + const token = await generateSessionToken(linkedUser.userId, linkedUser.username); + + return c.json({ + token, + userId: linkedUser.userId, + username: linkedUser.username, + did: linkedUser.did, + authMethod: 'legacy_cryptid', + }); +}); + +// GET /encryptid/api/migration/status — Check linked legacy identities. +app.get('/encryptid/api/migration/status', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + const identities = await getLegacyIdentitiesByUser(claims.sub); + return c.json({ + legacyIdentities: identities.map((li) => ({ + id: li.id, + provider: li.provider, + legacyUsername: li.legacyUsername, + verified: li.verified, + migratedData: li.migratedData, + linkedAt: li.linkedAt, + verifiedAt: li.verifiedAt, + })), + }); +}); + // ============================================================================ // SPACE MEMBERSHIP ROUTES // ============================================================================