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
+12 -2
View File
@@ -58,9 +58,12 @@ export interface SwarmLikePayload {
actorDisplayName: string;
actorAvatarUrl?: string;
actorNodeDomain: string;
actorDid: string; // User's DID
actorPublicKey: string; // User's public key for verification
interactionId: string;
timestamp: string;
};
userSignature: string; // User's cryptographic signature
}
export interface SwarmUnlikePayload {
@@ -249,7 +252,7 @@ export async function deliverSwarmMention(
}
/**
* Generic interaction delivery
* Generic interaction delivery with cryptographic signature
*/
async function deliverSwarmInteraction(
targetDomain: string,
@@ -265,6 +268,13 @@ async function deliverSwarmInteraction(
const url = `${baseUrl}${endpoint}`;
// SECURITY: Sign the payload with the node's private key
const { signPayload, getNodePrivateKey } = await import('./signature');
const privateKey = await getNodePrivateKey();
const signature = signPayload(payload, privateKey);
const signedPayload = { ...payload as object, signature };
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
@@ -274,7 +284,7 @@ async function deliverSwarmInteraction(
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
body: JSON.stringify(signedPayload),
signal: controller.signal,
});
+133
View File
@@ -0,0 +1,133 @@
/**
* Node Keypair Management
*
* Each Synapsis node has its own RSA keypair for signing swarm interactions.
* The private key is encrypted and stored in the database.
* The public key is exposed via /api/node for verification.
*/
import { db, nodes } from '@/db';
import { eq } from 'drizzle-orm';
import { generateKeyPair } from '@/lib/crypto/keys';
import crypto from 'crypto';
const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
/**
* Encrypt the node private key using AUTH_SECRET
*/
function encryptPrivateKey(privateKey: string): string {
const secret = process.env.AUTH_SECRET;
if (!secret) {
throw new Error('AUTH_SECRET not configured');
}
// Derive a key from AUTH_SECRET
const key = crypto.scryptSync(secret, 'node-key-salt', 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
let encrypted = cipher.update(privateKey, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Return iv:authTag:encrypted
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
/**
* Decrypt the node private key using AUTH_SECRET
*/
function decryptPrivateKey(encryptedData: string): string {
const secret = process.env.AUTH_SECRET;
if (!secret) {
throw new Error('AUTH_SECRET not configured');
}
const [ivHex, authTagHex, encrypted] = encryptedData.split(':');
const key = crypto.scryptSync(secret, 'node-key-salt', 32);
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Get or generate the node's keypair
* Returns the private key (decrypted) and public key
*/
export async function getNodeKeypair(): Promise<{ privateKey: string; publicKey: string }> {
if (!db) {
throw new Error('Database not available');
}
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Try to get existing node
let node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
});
// If node doesn't exist, create it
if (!node) {
const { publicKey, privateKey } = await generateKeyPair();
const encryptedPrivateKey = encryptPrivateKey(privateKey);
const [newNode] = await db.insert(nodes).values({
domain,
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A swarm social network node',
publicKey,
privateKeyEncrypted: encryptedPrivateKey,
}).returning();
return { privateKey, publicKey };
}
// If node exists but has no keys, generate them
if (!node.publicKey || !node.privateKeyEncrypted) {
const { publicKey, privateKey } = await generateKeyPair();
const encryptedPrivateKey = encryptPrivateKey(privateKey);
await db.update(nodes)
.set({
publicKey,
privateKeyEncrypted: encryptedPrivateKey,
updatedAt: new Date(),
})
.where(eq(nodes.id, node.id));
return { privateKey, publicKey };
}
// Decrypt and return existing keys
const privateKey = decryptPrivateKey(node.privateKeyEncrypted);
return { privateKey, publicKey: node.publicKey };
}
/**
* Get just the node's public key (for exposing via API)
*/
export async function getNodePublicKey(): Promise<string | null> {
if (!db) return null;
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
});
if (!node?.publicKey) {
// Generate keys if they don't exist
const { publicKey } = await getNodeKeypair();
return publicKey;
}
return node.publicKey;
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Swarm Signature Verification
*
* Cryptographic signatures for all swarm interactions to prevent forgery.
* Each node signs requests with their private key, and recipients verify
* using the sender's public key.
*/
import crypto from 'crypto';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
/**
* Sign a payload with the node's private key
*/
export function signPayload(payload: any, privateKey: string): string {
const canonicalPayload = JSON.stringify(payload, Object.keys(payload).sort());
const sign = crypto.createSign('SHA256');
sign.update(canonicalPayload);
sign.end();
return sign.sign(privateKey, 'base64');
}
/**
* Verify a signature using the sender's public key
*/
export function verifySignature(payload: any, signature: string, publicKey: string): boolean {
try {
const canonicalPayload = JSON.stringify(payload, Object.keys(payload).sort());
const verify = crypto.createVerify('SHA256');
verify.update(canonicalPayload);
verify.end();
return verify.verify(publicKey, signature, 'base64');
} catch (error) {
console.error('[Signature] Verification failed:', error);
return false;
}
}
/**
* Fetch and cache a node's public key
*/
export async function getNodePublicKey(domain: string): Promise<string | null> {
try {
// Check if we have a cached node info
const protocol = domain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${domain}/api/node`, {
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
console.error(`[Signature] Failed to fetch node info from ${domain}: ${response.status}`);
return null;
}
const data = await response.json();
return data.publicKey || null;
} catch (error) {
console.error(`[Signature] Error fetching public key from ${domain}:`, error);
return null;
}
}
/**
* Verify a swarm request signature
*
* @param payload - The request payload (without signature field)
* @param signature - The signature to verify
* @param senderDomain - The domain of the sender node
* @returns true if signature is valid, false otherwise
*/
export async function verifySwarmRequest(
payload: any,
signature: string,
senderDomain: string
): Promise<boolean> {
// Get the sender node's public key
const publicKey = await getNodePublicKey(senderDomain);
if (!publicKey) {
console.error(`[Signature] Could not get public key for ${senderDomain}`);
return false;
}
// Verify the signature
return verifySignature(payload, signature, publicKey);
}
/**
* Verify a user interaction signature
*
* For user-specific interactions (like, follow, etc), we verify using
* the user's public key, not the node's.
*
* @param payload - The request payload (without signature field)
* @param signature - The signature to verify
* @param userHandle - The full handle of the user (handle@domain)
* @returns true if signature is valid, false otherwise
*/
export async function verifyUserInteraction(
payload: any,
signature: string,
userHandle: string,
userDomain: string
): Promise<boolean> {
try {
// Try to get cached user
const fullHandle = `${userHandle}@${userDomain}`;
let user = await db?.query.users.findFirst({
where: eq(users.handle, fullHandle),
});
let publicKey: string | null = null;
if (user?.publicKey && user.publicKey.startsWith('-----BEGIN')) {
publicKey = user.publicKey;
} else {
// Fetch from remote node
const protocol = userDomain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${userDomain}/api/users/${userHandle}`);
if (!response.ok) {
console.error(`[Signature] Failed to fetch user ${userHandle}@${userDomain}: ${response.status}`);
return false;
}
const userData = await response.json();
publicKey = userData.user?.publicKey || userData.publicKey;
// Cache the user if we don't have them
if (!user && publicKey && db) {
await db.insert(users).values({
did: userData.user?.did || `did:swarm:${userDomain}:${userHandle}`,
handle: fullHandle,
displayName: userData.user?.displayName || userHandle,
avatarUrl: userData.user?.avatarUrl,
publicKey,
}).onConflictDoNothing();
} else if (user && publicKey && db) {
// Update cached user's public key
await db.update(users)
.set({ publicKey })
.where(eq(users.id, user.id));
}
}
if (!publicKey) {
console.error(`[Signature] No public key found for ${fullHandle}`);
return false;
}
// Verify the signature
return verifySignature(payload, signature, publicKey);
} catch (error) {
console.error(`[Signature] Error verifying user interaction:`, error);
return false;
}
}
/**
* Get the node's private key
*/
export async function getNodePrivateKey(): Promise<string> {
const { getNodeKeypair } = await import('./node-keys');
const { privateKey } = await getNodeKeypair();
return privateKey;
}
/**
* Create a signed payload for sending to another node
*/
export async function createSignedPayload(payload: any): Promise<{ payload: any; signature: string }> {
const privateKey = await getNodePrivateKey();
const signature = signPayload(payload, privateKey);
return { payload, signature };
}