feat(encryptid): CryptID → EncryptID backward compatibility bridge (Phase 1)

Identity linking with P-256 signature verification for legacy CryptID users.
Adds migration/challenge, migration/verify (fresh + historical proof modes),
migration/auth (fallback JWT), and migration/status endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 20:17:10 -07:00
parent 845704d5a4
commit 2f47835699
3 changed files with 376 additions and 2 deletions

View File

@ -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<Array<{
}));
}
// ============================================================================
// LEGACY IDENTITY OPERATIONS (CryptID → EncryptID migration)
// ============================================================================
export interface StoredLegacyIdentity {
id: string;
userId: string;
provider: 'cryptid';
legacyUsername: string;
legacyPublicKey: string;
legacyPublicKeyHash: string;
verified: boolean;
migratedData: boolean;
linkedAt: string;
verifiedAt: string | null;
}
function rowToLegacyIdentity(row: any): StoredLegacyIdentity {
return {
id: row.id,
userId: row.user_id,
provider: row.provider,
legacyUsername: row.legacy_username,
legacyPublicKey: row.legacy_public_key,
legacyPublicKeyHash: row.legacy_public_key_hash,
verified: row.verified || false,
migratedData: row.migrated_data || false,
linkedAt: row.linked_at?.toISOString?.() || new Date(row.linked_at).toISOString(),
verifiedAt: row.verified_at ? (row.verified_at?.toISOString?.() || new Date(row.verified_at).toISOString()) : null,
};
}
export async function createLegacyIdentity(
userId: string,
identity: { id: string; provider: 'cryptid'; legacyUsername: string; legacyPublicKey: string; legacyPublicKeyHash: string },
): Promise<StoredLegacyIdentity> {
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<StoredLegacyIdentity | null> {
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<StoredLegacyIdentity[]> {
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<boolean> {
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 };

View File

@ -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);

View File

@ -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<boolean> {
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<string> {
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
// ============================================================================