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:
Christopher
2026-01-27 17:13:28 -08:00
parent 25f71e320b
commit 5903022f8a
46 changed files with 7457 additions and 113 deletions
+27
View File
@@ -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;
+108
View File
@@ -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;
}
+141
View File
@@ -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!;
}