Files
Synapsis/src/lib/swarm/signature.ts
T
cyph3rasi b4a9d82d05 Add docker publish flow, node blocklist & API updates
Replace docker/metadata GH Action with a docker-publish.sh driven workflow (supports auto-versioning, extra tags, multi-arch builds and optional builder). Update docs and READMEs to use the updater and new publish flow. Add server-side swarm/node blocklist support and admin nodes API. Improve auth endpoints (me, logout, switch, session accounts) and support account switching. Extend bots API to support custom LLM provider and optional endpoint. Enforce node blocklist on chat send/receive, refactor link preview handling into dedicated preview modules, and enhance notifications and posts like/repost handlers to accept both signed actions and regular auth flows. Misc: remove admin update UI details, add helper libs (media previews, node-blocklist, reposts, notifications, etc.), and minor housekeeping (pyc, workflow tweaks).
2026-03-10 12:40:05 -07:00

214 lines
6.6 KiB
TypeScript

/**
* 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';
import { canonicalize } from '@/lib/crypto/user-signing';
import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
/**
* Sign a payload with the node's private key
*/
export function signPayload(payload: any, privateKey: string): string {
const canonicalPayload = canonicalize(payload);
const sign = crypto.createSign('SHA256');
sign.update(canonicalPayload);
sign.end();
return sign.sign(privateKey, 'base64');
}
function normalizePublicKey(publicKey: string): crypto.KeyObject | string {
if (publicKey.includes('BEGIN PUBLIC KEY')) {
return publicKey;
}
const cleanKey = publicKey.replace(/[\s\n\r]/g, '');
return crypto.createPublicKey({
key: Buffer.from(cleanKey, 'base64'),
format: 'der',
type: 'spki',
});
}
/**
* Verify a signature using the sender's public key
*/
export function verifySignature(payload: any, signature: string, publicKey: string): boolean {
try {
const canonicalPayload = canonicalize(payload);
const verify = crypto.createVerify('SHA256');
verify.update(canonicalPayload);
verify.end();
return verify.verify(normalizePublicKey(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 {
const normalizedDomain = normalizeNodeDomain(domain);
if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
return null;
}
// Check if we have a cached node info
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${normalizedDomain}/api/node`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${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> {
const normalizedDomain = normalizeNodeDomain(senderDomain);
if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
return false;
}
// Get the sender node's public key
const publicKey = await getNodePublicKey(normalizedDomain);
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 {
const normalizedDomain = normalizeNodeDomain(userDomain);
if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
return false;
}
// Try to get cached user
const fullHandle = `${userHandle}@${normalizedDomain}`;
let user = await db?.query.users.findFirst({
where: eq(users.handle, fullHandle),
});
let publicKey: string | null = null;
if (user?.publicKey) {
publicKey = user.publicKey;
} else {
// Fetch from remote node
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${normalizedDomain}/api/users/${userHandle}`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
console.error(`[Signature] Failed to fetch user ${userHandle}@${normalizedDomain}: ${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:${normalizedDomain}:${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 };
}