Add encrypted key support for chat and nodes
Introduces encrypted private key storage for nodes and users, updates chat message schema to support sender-side encryption, and adds supporting libraries and tests for cryptographic signing and identity unlock flows. Includes new database migrations, API route updates, and React components for identity unlock prompts.
This commit is contained in:
@@ -207,6 +207,33 @@ export async function authenticateUser(
|
||||
await db.update(users)
|
||||
.set({ privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey) })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// Update local object
|
||||
user.privateKeyEncrypted = serializeEncryptedKey(encryptedPrivateKey);
|
||||
}
|
||||
|
||||
// MIGRATION: Check if user has legacy RSA key (upgrade to ECDSA P-256)
|
||||
// RSA 2048 SPKI PEM is ~450 chars, ECDSA P-256 is ~178 chars.
|
||||
if (user.publicKey.length > 300) {
|
||||
console.log(`[Auth] Migrating user ${user.handle} from RSA to ECDSA P-256`);
|
||||
|
||||
// Generate new ECDSA key pair
|
||||
const { publicKey, privateKey } = await generateKeyPair();
|
||||
|
||||
// Encrypt new private key
|
||||
const encryptedPrivateKey = encryptPrivateKey(privateKey, password);
|
||||
|
||||
// Update DB
|
||||
await db.update(users)
|
||||
.set({
|
||||
publicKey: publicKey,
|
||||
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey)
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// Update local user object to return new keys
|
||||
user.publicKey = publicKey;
|
||||
user.privateKeyEncrypted = serializeEncryptedKey(encryptedPrivateKey);
|
||||
}
|
||||
|
||||
return user;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Federation Key Registry
|
||||
*
|
||||
* Manages the caching and retrieval of remote public keys.
|
||||
* Enforces Key Continuity: Rejects key rotation by default to prevent MITM.
|
||||
*/
|
||||
|
||||
import { db } from '@/db';
|
||||
import { remoteIdentityCache } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// Strict continuity flag: if true (default), reject any key change.
|
||||
const ALLOW_KEY_ROTATION = process.env.ALLOW_KEY_ROTATION === 'true';
|
||||
|
||||
interface RemoteIdentity {
|
||||
did: string;
|
||||
handle: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a remote public key by DID.
|
||||
* Uses DB cache first, then fetches from .well-known endpoint.
|
||||
* Enforces key continuity.
|
||||
*/
|
||||
export async function lookupRemoteKey(did: string): Promise<string> {
|
||||
// 1. Check Cache
|
||||
const cached = await db.query.remoteIdentityCache.findFirst({
|
||||
where: eq(remoteIdentityCache.did, did),
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// If valid cache exists, return it
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.publicKey;
|
||||
}
|
||||
|
||||
// 2. Fetch from Remote
|
||||
// Resolve DID to HTTP URL (Assuming Web DID or simple mapping for now)
|
||||
// For did:web:example.com -> https://example.com/.well-known/synapsis/identity/{did}
|
||||
// This logic depends on our DID method.
|
||||
// IMPORTANT: For this strict implementation, we need a reliable way to map DID -> Endpoint.
|
||||
// We'll assume the DID *contains* the domain or we have a resolver.
|
||||
// Simplification: `did:web:domain` format.
|
||||
|
||||
const domain = extractDomainFromDid(did);
|
||||
if (!domain) {
|
||||
throw new Error(`Unsupported DID format: ${did}`);
|
||||
}
|
||||
|
||||
let remoteData: RemoteIdentity;
|
||||
try {
|
||||
const res = await fetch(`https://${domain}/.well-known/synapsis/identity/${did}`);
|
||||
if (!res.ok) throw new Error(`Remote identity fetch failed: ${res.status}`);
|
||||
remoteData = await res.json();
|
||||
} catch (err) {
|
||||
// If we have an expired cache entry, we *could* fallback to it in emergency,
|
||||
// but strict security says fail.
|
||||
console.error(`[KeyRegistry] Failed to fetch key for ${did}`, err);
|
||||
throw new Error('RELAY_UNREACHABLE');
|
||||
}
|
||||
|
||||
// Optimize: Validation
|
||||
if (remoteData.did !== did || !remoteData.publicKey) {
|
||||
throw new Error('Invalid remote identity response');
|
||||
}
|
||||
|
||||
// 3. Key Continuity Check
|
||||
if (cached) {
|
||||
if (cached.publicKey !== remoteData.publicKey) {
|
||||
if (!ALLOW_KEY_ROTATION) {
|
||||
console.error(`[KeyRegistry] KEY_CHANGED detected for ${did}. Old: ${cached.publicKey.slice(0, 10)}... New: ${remoteData.publicKey.slice(0, 10)}...`);
|
||||
throw new Error('KEY_CHANGED: Remote key rotation rejected by policy.');
|
||||
}
|
||||
// If allowed, we proceed (TOFU update)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update Cache
|
||||
const expiresAt = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour TTL
|
||||
|
||||
await db.insert(remoteIdentityCache).values({
|
||||
did,
|
||||
publicKey: remoteData.publicKey,
|
||||
fetchedAt: now,
|
||||
expiresAt,
|
||||
}).onConflictDoUpdate({
|
||||
target: remoteIdentityCache.did,
|
||||
set: {
|
||||
publicKey: remoteData.publicKey,
|
||||
fetchedAt: now,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return remoteData.publicKey;
|
||||
}
|
||||
|
||||
function extractDomainFromDid(did: string): string | null {
|
||||
// did:web:example.com
|
||||
// did:synapsis:example.com
|
||||
const parts = did.split(':');
|
||||
if (parts.length >= 3) {
|
||||
return parts[2];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Server-side signature verification for user actions
|
||||
*
|
||||
* Strict Verification Rules:
|
||||
* - ECDSA P-256 (ES256) ONLY.
|
||||
* - DB-backed deduplication (signed_action_dedupe).
|
||||
* - Strict 5-minute freshness window.
|
||||
* - Canonical verification (must match client exactly).
|
||||
*/
|
||||
|
||||
import { db } from '@/db';
|
||||
import { users, signedActionDedupe } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { canonicalize, importPublicKey, base64UrlToBase64 } from '@/lib/crypto/user-signing';
|
||||
// Note: user-signing helpers are isomorphic (work in Node via webcrypto polyfill/availability)
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Use Node's webcrypto for server-side if not global
|
||||
const cryptoSubtle = globalThis.crypto?.subtle || require('crypto').webcrypto.subtle;
|
||||
|
||||
export interface SignedAction {
|
||||
action: string;
|
||||
data: any;
|
||||
did: string;
|
||||
handle: string;
|
||||
ts: number;
|
||||
nonce: string;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed user action
|
||||
*
|
||||
* @param signedAction - The signed action payload
|
||||
* @returns The user if signature is valid and not replayed
|
||||
*/
|
||||
export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
valid: boolean;
|
||||
user?: typeof users.$inferSelect;
|
||||
error?: string;
|
||||
}> {
|
||||
if (!db) {
|
||||
return { valid: false, error: 'Database not available' };
|
||||
}
|
||||
|
||||
const { sig, ...payload } = signedAction;
|
||||
|
||||
// 1. FRESHNESS CHECK (Fail fast before DB/Crypto)
|
||||
const now = Date.now();
|
||||
const diff = Math.abs(now - payload.ts);
|
||||
const fiveMinutesMs = 5 * 60 * 1000;
|
||||
|
||||
if (diff > fiveMinutesMs) {
|
||||
return { valid: false, error: 'INVALID_TIMESTAMP: Request too old or in future' };
|
||||
}
|
||||
|
||||
// 2. FETCH USER & KEY
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.did, payload.did),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// If federation, we might need to look up in remote_identity_cache here.
|
||||
// For now, assume local user or user must exist in users table (synced).
|
||||
return { valid: false, error: 'User not found' };
|
||||
}
|
||||
|
||||
if (user.handle !== payload.handle) {
|
||||
return { valid: false, error: 'Handle mismatch' };
|
||||
}
|
||||
|
||||
// 3. CRYPTOGRAPHIC VERIFICATION
|
||||
try {
|
||||
const canonicalString = canonicalize(payload);
|
||||
const encoder = new TextEncoder();
|
||||
const dataBytes = encoder.encode(canonicalString);
|
||||
|
||||
// Convert signature from Base64Url to buffer
|
||||
const sigBase64 = base64UrlToBase64(sig);
|
||||
const sigBuffer = Buffer.from(sigBase64, 'base64');
|
||||
|
||||
// Import public key (stored as SPKI Base64 in DB)
|
||||
const publicKey = await importPublicKey(user.publicKey);
|
||||
|
||||
const isValid = await cryptoSubtle.verify(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
hash: { name: 'SHA-256' },
|
||||
},
|
||||
publicKey,
|
||||
sigBuffer,
|
||||
dataBytes
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return { valid: false, error: 'INVALID_SIGNATURE' };
|
||||
}
|
||||
|
||||
// 4. ACTION ID HASH COMPUTATION
|
||||
// SHA-256(canonicalPayload)
|
||||
// We use the same canonical string we just verified.
|
||||
const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex');
|
||||
|
||||
// 5. REPLAY PROTECTION (DB)
|
||||
try {
|
||||
await db.insert(signedActionDedupe).values({
|
||||
actionId: actionIdHash,
|
||||
did: payload.did,
|
||||
nonce: payload.nonce,
|
||||
ts: payload.ts,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Check for unique constraint violation (duplicate key)
|
||||
if (err.code === '23505') { // Postgres unique_violation code
|
||||
return { valid: false, error: 'REPLAYED_NONCE' };
|
||||
}
|
||||
console.error('[Verify] Dedupe error:', err);
|
||||
throw err; // Internal error
|
||||
}
|
||||
|
||||
return { valid: true, user };
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Verify] Verification exception:', error);
|
||||
return { valid: false, error: 'VERIFICATION_ERROR' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require a signed action
|
||||
* Throws an error if signature is invalid
|
||||
*/
|
||||
export async function requireSignedAction(signedAction: SignedAction): Promise<typeof users.$inferSelect> {
|
||||
const result = await verifyUserAction(signedAction);
|
||||
|
||||
if (!result.valid) {
|
||||
throw new Error(result.error || 'Invalid signature');
|
||||
}
|
||||
|
||||
return result.user!;
|
||||
}
|
||||
Reference in New Issue
Block a user