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:
parent
845704d5a4
commit
2f47835699
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue