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
+159
View File
@@ -0,0 +1,159 @@
/**
* Signed Fetch - Client-side API wrapper
*
* Automatically signs all user actions with their private key before
* sending to the server. This ensures cryptographic proof of authenticity.
*/
import { createSignedAction, hasUserPrivateKey } from '@/lib/crypto/user-signing';
export interface SignedFetchOptions {
method?: string;
body?: any;
headers?: Record<string, string>;
}
/**
* Make a signed API request
*
* @param url - The API endpoint
* @param action - The action being performed (e.g., 'like', 'follow', 'post')
* @param data - The action data
* @param userDid - The user's DID
* @param userHandle - The user's handle
* @param options - Additional fetch options
*/
export async function signedFetch(
url: string,
action: string,
data: any,
userDid: string,
userHandle: string,
options: SignedFetchOptions = {}
): Promise<Response> {
// Check if user has their private key loaded
if (!hasUserPrivateKey()) {
throw new Error('User identity not unlocked. Please log in again.');
}
// Create signed action
// Note: createSignedAction now generates nonce and ts internally
const signedAction = await createSignedAction(action, data, userDid, userHandle);
// Make the request
return fetch(url, {
method: options.method || 'POST',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
body: JSON.stringify(signedAction),
});
}
/**
* Helper for common actions
*/
export const signedAPI = {
/**
* Like a post
*/
async likePost(postId: string, userDid: string, userHandle: string) {
return signedFetch(
`/api/posts/${postId}/like`,
'like',
{ postId },
userDid,
userHandle
);
},
/**
* Unlike a post
*/
async unlikePost(postId: string, userDid: string, userHandle: string) {
return signedFetch(
`/api/posts/${postId}/like`,
'unlike',
{ postId },
userDid,
userHandle,
{ method: 'DELETE' }
);
},
/**
* Follow a user
*/
async followUser(targetHandle: string, userDid: string, userHandle: string) {
return signedFetch(
`/api/users/${targetHandle}/follow`,
'follow',
{ targetHandle },
userDid,
userHandle
);
},
/**
* Unfollow a user
*/
async unfollowUser(targetHandle: string, userDid: string, userHandle: string) {
return signedFetch(
`/api/users/${targetHandle}/follow`,
'unfollow',
{ targetHandle },
userDid,
userHandle,
{ method: 'DELETE' }
);
},
/**
* Create a post
*/
async createPost(
content: string,
mediaIds: string[],
linkPreview: any,
replyToId: string | undefined,
isNsfw: boolean,
userDid: string,
userHandle: string
) {
return signedFetch(
'/api/posts',
'post',
{ content, mediaIds, linkPreview, replyToId, isNsfw },
userDid,
userHandle
);
},
/**
* Repost a post
*/
async repostPost(postId: string, userDid: string, userHandle: string) {
return signedFetch(
`/api/posts/${postId}/repost`,
'repost',
{ postId },
userDid,
userHandle
);
},
/**
* Unrepost a post
*/
async unrepostPost(postId: string, userDid: string, userHandle: string) {
return signedFetch(
`/api/posts/${postId}/repost`,
'unrepost',
{ postId },
userDid,
userHandle,
{ method: 'DELETE' }
);
},
};
+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!;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* AuthContext Type Tests
*
* Tests the AuthContext interface updates for cryptographic user signing
* Validates: Requirements US-1.1, US-1.2, US-1.4, US-1.5
*/
import { describe, it, expect } from 'vitest';
import type { User } from './AuthContext';
describe('AuthContext Interface', () => {
it('should include cryptographic fields in User interface', () => {
// Create a user object with all required fields
const user: User = {
id: '1',
handle: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
did: 'did:synapsis:test123',
publicKey: 'test-public-key',
privateKeyEncrypted: 'encrypted-key',
};
// Verify all fields are present
expect(user.id).toBe('1');
expect(user.handle).toBe('testuser');
expect(user.displayName).toBe('Test User');
expect(user.avatarUrl).toBe('https://example.com/avatar.jpg');
expect(user.did).toBe('did:synapsis:test123');
expect(user.publicKey).toBe('test-public-key');
expect(user.privateKeyEncrypted).toBe('encrypted-key');
});
it('should allow optional cryptographic fields', () => {
// Create a user object without cryptographic fields
const user: User = {
id: '1',
handle: 'testuser',
displayName: 'Test User',
};
// Verify basic fields are present
expect(user.id).toBe('1');
expect(user.handle).toBe('testuser');
expect(user.displayName).toBe('Test User');
// Verify cryptographic fields are optional
expect(user.did).toBeUndefined();
expect(user.publicKey).toBeUndefined();
expect(user.privateKeyEncrypted).toBeUndefined();
});
});
+78 -1
View File
@@ -1,32 +1,55 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { useUserIdentity } from '@/lib/hooks/useUserIdentity';
export interface User {
id: string;
handle: string;
displayName: string;
avatarUrl?: string;
did?: string;
publicKey?: string;
privateKeyEncrypted?: string;
}
interface AuthContextType {
user: User | null;
isAdmin: boolean;
loading: boolean;
isIdentityUnlocked: boolean;
did: string | null;
handle: string | null;
checkAdmin: () => Promise<void>;
unlockIdentity: (password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
user: null,
isAdmin: false,
loading: true,
isIdentityUnlocked: false,
did: null,
handle: null,
checkAdmin: async () => { },
unlockIdentity: async () => { },
logout: async () => { },
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(true);
// Integrate useUserIdentity hook
const {
identity,
isUnlocked,
initializeIdentity,
unlockIdentity: unlockIdentityHook,
clearIdentity,
} = useUserIdentity();
const checkAdmin = async () => {
try {
@@ -38,6 +61,37 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
/**
* Unlock the user's identity with their password
*/
const unlockIdentity = async (password: string) => {
if (!user?.privateKeyEncrypted) {
throw new Error('No encrypted private key available');
}
await unlockIdentityHook(user.privateKeyEncrypted, password);
};
/**
* Logout the user and clear their identity
*/
const logout = async () => {
try {
// Call the logout API endpoint
await fetch('/api/auth/logout', { method: 'POST' });
// Clear the user's identity (private key from localStorage)
clearIdentity();
// Clear the user state
setUser(null);
setIsAdmin(false);
} catch (error) {
console.error('[Auth] Logout failed:', error);
throw error;
}
};
useEffect(() => {
const loadAuth = async () => {
setLoading(true);
@@ -46,14 +100,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (res.ok) {
const data = await res.json();
setUser(data.user);
// Initialize identity if we have the required data
if (data.user?.did && data.user?.publicKey && data.user?.privateKeyEncrypted) {
await initializeIdentity({
did: data.user.did,
handle: data.user.handle,
publicKey: data.user.publicKey,
privateKeyEncrypted: data.user.privateKeyEncrypted,
});
}
if (data.user) {
await checkAdmin();
}
} else {
setUser(null);
clearIdentity();
}
} catch {
setUser(null);
clearIdentity();
} finally {
setLoading(false);
}
@@ -63,7 +130,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);
return (
<AuthContext.Provider value={{ user, isAdmin, loading, checkAdmin }}>
<AuthContext.Provider value={{
user,
isAdmin,
loading,
isIdentityUnlocked: isUnlocked,
did: identity?.did || null,
handle: identity?.handle || null,
checkAdmin,
unlockIdentity,
logout,
}}>
{children}
</AuthContext.Provider>
);
+4 -4
View File
@@ -1,20 +1,20 @@
/**
* Cryptographic Key Generation
*
* Generates RSA key pairs for signing posts and verifying identity.
* Generates ECDSA P-256 key pairs for signing posts and verifying identity.
*/
import * as crypto from 'crypto';
/**
* Generate an RSA key pair for signing
* Generate an ECDSA P-256 key pair for signing
*/
export async function generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
return new Promise((resolve, reject) => {
crypto.generateKeyPair(
'rsa',
'ec',
{
modulusLength: 2048,
namedCurve: 'P-256',
publicKeyEncoding: {
type: 'spki',
format: 'pem',
+54
View File
@@ -0,0 +1,54 @@
/**
* Tests for client-side private key decryption
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { decryptPrivateKey, isEncryptedPrivateKey } from './private-key-client';
// Mock Web Crypto API for Node.js test environment
beforeAll(() => {
if (typeof window === 'undefined') {
// @ts-ignore
global.window = {
crypto: require('crypto').webcrypto
};
}
});
describe('private-key-client', () => {
describe('isEncryptedPrivateKey', () => {
it('should return true for valid encrypted key format', () => {
const encrypted = JSON.stringify({
encrypted: 'base64data',
salt: 'base64salt',
iv: 'base64iv'
});
expect(isEncryptedPrivateKey(encrypted)).toBe(true);
});
it('should return false for invalid format', () => {
expect(isEncryptedPrivateKey('not json')).toBe(false);
expect(isEncryptedPrivateKey('')).toBe(false);
expect(isEncryptedPrivateKey('{}')).toBe(false);
});
});
describe('decryptPrivateKey', () => {
it('should throw error when not in browser', async () => {
const originalWindow = global.window;
// @ts-ignore
delete global.window;
await expect(
decryptPrivateKey({ encrypted: 'test', salt: 'test', iv: 'test' }, 'password')
).rejects.toThrow('Decryption can only be performed in the browser');
// @ts-ignore
global.window = originalWindow;
});
// Note: Full decryption test would require a valid encrypted key
// which would need to be generated with the server-side encryption function
// This is tested in integration tests
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Private Key Encryption/Decryption (Client-Side)
*
* Client-side version of private key decryption using Web Crypto API.
* This allows the browser to decrypt the user's private key with their password.
*/
export interface EncryptedPrivateKey {
encrypted: string; // Base64 encoded ciphertext + auth tag
salt: string; // Base64 encoded salt for PBKDF2
iv: string; // Base64 encoded initialization vector
}
/**
* Decrypt a private key with the user's password (client-side using Web Crypto API)
*/
export async function decryptPrivateKey(encryptedData: string | EncryptedPrivateKey, password: string): Promise<string> {
if (typeof window === 'undefined') {
throw new Error('Decryption can only be performed in the browser');
}
// Parse encrypted data if it's a string
const data: EncryptedPrivateKey = typeof encryptedData === 'string'
? JSON.parse(encryptedData)
: encryptedData;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// Derive key from password using PBKDF2
const passwordKey = await window.crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey']
);
const aesKey = await window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: base64ToBuffer(data.salt),
iterations: 100000,
hash: 'SHA-256',
},
passwordKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
// Decode the combined encrypted data + auth tag
const combined = base64ToBuffer(data.encrypted);
// Split encrypted data and auth tag (auth tag is last 16 bytes)
const authTagLength = 16;
const encryptedContent = combined.slice(0, combined.byteLength - authTagLength);
const authTag = combined.slice(combined.byteLength - authTagLength);
// Combine encrypted content and auth tag for AES-GCM
const ciphertext = new Uint8Array(combined.byteLength);
ciphertext.set(new Uint8Array(encryptedContent), 0);
ciphertext.set(new Uint8Array(authTag), encryptedContent.byteLength);
// Decrypt using AES-256-GCM
try {
const decrypted = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: base64ToBuffer(data.iv),
},
aesKey,
ciphertext
);
return decoder.decode(decrypted);
} catch (error) {
console.error('[Crypto] Decryption failed:', error);
throw new Error('Failed to decrypt private key. Incorrect password?');
}
}
/**
* Helper: Convert base64 string to ArrayBuffer
*/
function base64ToBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
/**
* Deserialize encrypted private key from database
*/
export function deserializeEncryptedKey(serialized: string): EncryptedPrivateKey {
return JSON.parse(serialized);
}
/**
* Check if a stored value is an encrypted private key (vs plaintext)
*/
export function isEncryptedPrivateKey(value: string): boolean {
if (!value) return false;
try {
const parsed = JSON.parse(value);
return !!(parsed.encrypted && parsed.salt && parsed.iv);
} catch {
return false;
}
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Property Tests for Cryptographic User Signing
*
* Verifies:
* 1. Key generation and storage
* 2. Canonical serialization
* 3. Signing process
* 4. Verification process
* 5. Replay protection logic (mocked DB)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
generateKeyPair,
keyStore,
createSignedAction,
canonicalize,
exportPublicKey,
importPublicKey,
base64UrlToBase64
} from './user-signing';
import { verifyUserAction, type SignedAction } from '../auth/verify-signature';
// Mock DB interactions
const mockDbMethods = {
findFirst: vi.fn(),
values: vi.fn(() => ({ onConflictDoUpdate: vi.fn() })),
insert: vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate: vi.fn() })) }))
};
// We need to hoist the variable if we use it in vi.mock
// Or simpler for this case, simply define it inline or use a factory that doesn't capture outer scope incorrectly.
vi.mock('@/db', () => ({
db: {
query: {
users: { findFirst: vi.fn() },
remoteIdentityCache: { findFirst: vi.fn() }
},
insert: vi.fn(() => ({ values: vi.fn() })),
},
users: { did: 'did', publicKey: 'publicKey' },
signedActionDedupe: { actionId: 'actionId' },
remoteIdentityCache: { did: 'did' },
}));
// Access the mocked module to manipulate it in tests
import { db } from '@/db';
// Mock Database unique constraint error for replay test
const duplicateKeyError = new Error('Duplicate key');
(duplicateKeyError as any).code = '23505';
describe('Cryptographic User Signing', () => {
let userKeyPair: CryptoKeyPair;
let userPublicKeyBase64: string;
const testDid = 'did:web:test.com:alice';
const testHandle = 'alice';
beforeEach(async () => {
// Setup fresh identity
userKeyPair = await generateKeyPair();
keyStore.setPrivateKey(userKeyPair.privateKey);
userPublicKeyBase64 = await exportPublicKey(userKeyPair.publicKey);
vi.clearAllMocks();
});
it('should canonicalize objects strictly', () => {
const obj1 = { b: 1, a: 2 };
const obj2 = { a: 2, b: 1 };
expect(canonicalize(obj1)).toBe('{"a":2,"b":1}');
expect(canonicalize(obj2)).toBe('{"a":2,"b":1}');
expect(canonicalize(obj1)).toBe(canonicalize(obj2));
});
it('should throw on invalid canonical types', () => {
expect(() => canonicalize({ d: new Date() })).toThrow(/Date objects not allowed/);
expect(() => canonicalize({ n: NaN })).toThrow(/Number is not finite/);
});
it('should create a valid signed action', async () => {
const payload = { content: 'Hello World' };
const action = 'create_post';
const signed = await createSignedAction(action, payload, testDid, testHandle);
expect(signed).toHaveProperty('sig');
expect(signed).toHaveProperty('nonce');
expect(signed).toHaveProperty('ts');
expect(signed.action).toBe(action);
expect(signed.did).toBe(testDid);
});
it('should verify a valid signed action', async () => {
const payload = { content: 'Hello World' };
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
// Mock DB finding the user
(db.query.users.findFirst as any).mockResolvedValue({
id: 'uuid-123',
did: testDid,
handle: testHandle,
publicKey: userPublicKeyBase64,
});
// Mock DB insert (dedupe) success
(db.insert as any).mockReturnValue({
values: vi.fn().mockResolvedValue(true)
});
const result = await verifyUserAction(signed);
expect(result.valid).toBe(true);
expect(result.user).toBeDefined();
// Verify dedupe insert was called
expect(db.insert).toHaveBeenCalled();
});
it('should reject invalid signature', async () => {
const payload = { content: 'Hello World' };
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
// Tamper with data
signed.data.content = 'Hacked';
(db.query.users.findFirst as any).mockResolvedValue({
id: 'uuid-123',
did: testDid,
handle: testHandle,
publicKey: userPublicKeyBase64,
});
const result = await verifyUserAction(signed);
expect(result.valid).toBe(false);
expect(result.error).toBe('INVALID_SIGNATURE');
});
it('should reject replay attacks via DB constraint', async () => {
const payload = { content: 'Replay Me' };
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
(db.query.users.findFirst as any).mockResolvedValue({
id: 'uuid-123',
did: testDid,
handle: testHandle,
publicKey: userPublicKeyBase64,
});
// Mock Duplicate Key Error
const duplicateKeyError = new Error('Duplicate key');
(duplicateKeyError as any).code = '23505';
// Second attempt fails with unique violation
(db.insert as any).mockReturnValue({
values: vi.fn().mockRejectedValue(duplicateKeyError)
});
// Verify failure path
const result = await verifyUserAction(signed);
expect(result.valid).toBe(false);
expect(result.error).toBe('REPLAYED_NONCE');
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* User Signing Tests
*
* Tests for user-level cryptographic signing functionality
* Validates: Requirements US-1.2, US-1.4, US-6.3, US-6.4
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
getUserPrivateKey,
setUserPrivateKey,
clearUserPrivateKey,
hasUserPrivateKey
} from './user-signing';
// Mock localStorage for Node environment
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
// Set up global mocks
global.localStorage = localStorageMock as any;
global.window = { localStorage: localStorageMock } as any;
describe('User Private Key Management', () => {
// Clean up localStorage before and after each test
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
describe('setUserPrivateKey and getUserPrivateKey', () => {
it('should store and retrieve private key from localStorage', () => {
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
// Store the key
setUserPrivateKey(testKey);
// Retrieve the key
const retrievedKey = getUserPrivateKey();
// Verify it matches
expect(retrievedKey).toBe(testKey);
});
it('should return null when no key is stored', () => {
const retrievedKey = getUserPrivateKey();
expect(retrievedKey).toBeNull();
});
it('should overwrite existing key when setting a new one', () => {
const firstKey = '-----BEGIN PRIVATE KEY-----\nfirst-key\n-----END PRIVATE KEY-----';
const secondKey = '-----BEGIN PRIVATE KEY-----\nsecond-key\n-----END PRIVATE KEY-----';
// Store first key
setUserPrivateKey(firstKey);
expect(getUserPrivateKey()).toBe(firstKey);
// Store second key
setUserPrivateKey(secondKey);
expect(getUserPrivateKey()).toBe(secondKey);
});
});
describe('clearUserPrivateKey', () => {
it('should remove private key from localStorage', () => {
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
// Store the key
setUserPrivateKey(testKey);
expect(getUserPrivateKey()).toBe(testKey);
// Clear the key
clearUserPrivateKey();
// Verify it's removed
expect(getUserPrivateKey()).toBeNull();
});
it('should be idempotent (safe to call multiple times)', () => {
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
// Store and clear
setUserPrivateKey(testKey);
clearUserPrivateKey();
// Clear again (should not throw)
expect(() => clearUserPrivateKey()).not.toThrow();
expect(getUserPrivateKey()).toBeNull();
});
it('should work when no key was stored', () => {
// Clear when nothing is stored (should not throw)
expect(() => clearUserPrivateKey()).not.toThrow();
expect(getUserPrivateKey()).toBeNull();
});
});
describe('hasUserPrivateKey', () => {
it('should return true when key is stored', () => {
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
setUserPrivateKey(testKey);
expect(hasUserPrivateKey()).toBe(true);
});
it('should return false when no key is stored', () => {
expect(hasUserPrivateKey()).toBe(false);
});
it('should return false after key is cleared', () => {
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
setUserPrivateKey(testKey);
expect(hasUserPrivateKey()).toBe(true);
clearUserPrivateKey();
expect(hasUserPrivateKey()).toBe(false);
});
});
describe('localStorage key name', () => {
it('should use the correct localStorage key', () => {
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
setUserPrivateKey(testKey);
// Verify the key is stored with the correct name
const storedValue = localStorage.getItem('synapsis_user_private_key');
expect(storedValue).toBe(testKey);
});
});
});
+297
View File
@@ -0,0 +1,297 @@
/**
* User-Level Cryptographic Signing
*
* Strict Implementation Rules:
* - ECDSA P-256 (ES256) ONLY. No RSA.
* - Private keys NEVER in localStorage (decrypted).
* - Keys stored in memory only (InMemoryKeyStore).
* - Canonicalization: JSON with sorted keys, no floats, no dates.
* - Nonce: 16+ bytes random base64url.
*/
import { v4 as uuidv4 } from 'uuid';
// ============================================
// KEY STORAGE (In-Memory Only)
// ============================================
export interface KeyStore {
setPrivateKey(key: CryptoKey): void;
getPrivateKey(): CryptoKey | null;
clear(): void;
}
class InMemoryKeyStore implements KeyStore {
private static instance: InMemoryKeyStore;
private privateKey: CryptoKey | null = null;
private constructor() { }
static getInstance(): InMemoryKeyStore {
if (!InMemoryKeyStore.instance) {
InMemoryKeyStore.instance = new InMemoryKeyStore();
}
return InMemoryKeyStore.instance;
}
setPrivateKey(key: CryptoKey): void {
if (key.type !== 'private' || key.algorithm.name !== 'ECDSA') {
throw new Error('Invalid key type: Must be ECDSA private key');
}
this.privateKey = key;
}
getPrivateKey(): CryptoKey | null {
return this.privateKey;
}
clear(): void {
this.privateKey = null;
}
}
export const keyStore = InMemoryKeyStore.getInstance();
export function hasUserPrivateKey(): boolean {
return keyStore.getPrivateKey() !== null;
}
export function clearUserPrivateKey(): void {
keyStore.clear();
}
// ============================================
// CRYPTO HELPERS (WebCrypto / Node)
// ============================================
// Detect environment for Crypto
const cryptoSubtle = typeof window !== 'undefined'
? window.crypto.subtle // Browser
: (globalThis.crypto as any)?.subtle || require('crypto').webcrypto?.subtle; // Node
if (!cryptoSubtle) {
throw new Error('WebCrypto is not supported in this environment');
}
/**
* Generate a new ECDSA P-256 KeyPair
* Non-extractable private key by default (good practice),
* but we need to export it to encrypt with password.
*/
export async function generateKeyPair(): Promise<CryptoKeyPair> {
return await cryptoSubtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true, // extractable (needed for export/encyrption)
['sign', 'verify']
);
}
/**
* Export Private Key to PKCS8 (for encryption/storage)
*/
export async function exportPrivateKey(key: CryptoKey): Promise<ArrayBuffer> {
return await cryptoSubtle.exportKey('pkcs8', key);
}
/**
* Import Private Key from PKCS8 (after decryption)
*/
export async function importPrivateKey(keyData: ArrayBuffer | Uint8Array): Promise<CryptoKey> {
return await cryptoSubtle.importKey(
'pkcs8',
keyData,
{
name: 'ECDSA',
namedCurve: 'P-256',
},
false, // not exportable once imported in memory
['sign']
);
}
/**
* Export Public Key to SPKI (for distribution)
* We usually want this as PEM or JWK. Let's stick to PEM buffer for consistency
* with existing storage, or simpler: just keep SPKI buffer and base64 it.
*/
export async function exportPublicKey(key: CryptoKey): Promise<string> {
const exported = await cryptoSubtle.exportKey('spki', key);
return arrayBufferToBase64(exported);
}
/**
* Import Public Key from SPKI Base64 (for verification)
*/
export async function importPublicKey(base64Key: string): Promise<CryptoKey> {
const binary = base64ToArrayBuffer(base64Key);
return await cryptoSubtle.importKey(
'spki',
binary,
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['verify']
);
}
// ============================================
// SIGNING & CANONICALIZATION
// ============================================
/**
* Strict Canonicalization for Signing
* - Request strict sorted keys
* - No Dates, Maps, Sets, Functions
* - No NaN, Infinity
*/
export function canonicalize(obj: any): string {
if (obj === undefined) return ''; // Should not happen for valid inputs
if (obj === null) return 'null';
if (typeof obj === 'number') {
if (!Number.isFinite(obj)) {
throw new Error('De-serialization failed: Number is not finite');
}
return obj.toString();
}
if (typeof obj === 'boolean') {
return obj.toString();
}
if (typeof obj === 'string') {
return JSON.stringify(obj);
}
if (Array.isArray(obj)) {
const items = obj.map(item => canonicalize(item)).join(',');
return `[${items}]`;
}
if (typeof obj === 'object') {
// Reject forbidden types
if (obj instanceof Date) throw new Error('Serialization failed: Date objects not allowed');
if (obj instanceof RegExp) throw new Error('Serialization failed: RegExp objects not allowed');
const keys = Object.keys(obj).sort();
const pairs = keys.map(key => {
const val = canonicalize(obj[key]);
return `${JSON.stringify(key)}:${val}`;
});
return `{${pairs.join(',')}}`;
}
throw new Error(`Serialization failed: Unsupported type ${typeof obj}`);
}
/**
* Create a Signed Action
* @returns { SignedAction } Includes ts, nonce, and strict signature
*/
export async function createSignedAction(
action: string,
data: any,
userDid: string,
userHandle: string
): Promise<{
action: string;
data: any;
did: string;
handle: string;
ts: number;
nonce: string;
sig: string;
}> {
const privateKey = keyStore.getPrivateKey();
if (!privateKey) {
throw new Error('User private key not available. Please log in (unlock identity).');
}
const ts = Date.now();
// 16 bytes entropy for nonce
const nonceBytes = new Uint8Array(16);
crypto.getRandomValues(nonceBytes);
const nonce = arrayBufferToBase64Url(nonceBytes);
// Payload strictly ordered for signing
// We construct the object that matches the canonical structure EXACTLY
const payloadToSign = {
action,
data,
did: userDid,
handle: userHandle,
nonce,
ts
};
const canonicalString = canonicalize(payloadToSign);
const encoder = new TextEncoder();
const dataBytes = encoder.encode(canonicalString);
const signatureBuffer = await cryptoSubtle.sign(
{
name: 'ECDSA',
hash: { name: 'SHA-256' },
},
privateKey,
dataBytes
);
const sig = arrayBufferToBase64Url(signatureBuffer);
return {
...payloadToSign,
sig
};
}
// ============================================
// UTILS
// ============================================
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function arrayBufferToBase64Url(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
/**
* Helper to convert Base64URL to Base64 (standard) for importing keys if needed
*/
export function base64UrlToBase64(base64Url: string): string {
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) {
base64 += '=';
}
return base64;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* User Identity Hook
*
* Manages the user's cryptographic identity using in-memory storage.
* strict: NO localStorage for decrypted keys.
*/
import { useState, useEffect } from 'react';
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
import {
keyStore,
importPrivateKey,
generateKeyPair,
exportPrivateKey,
exportPublicKey,
base64UrlToBase64
} from '@/lib/crypto/user-signing';
export interface UserIdentity {
did: string;
handle: string;
publicKey: string;
isUnlocked: boolean;
}
export function useUserIdentity() {
const [identity, setIdentity] = useState<UserIdentity | null>(null);
const [isUnlocked, setIsUnlocked] = useState(false);
// Check status on mount / updates
useEffect(() => {
const hasKey = !!keyStore.getPrivateKey();
setIsUnlocked(hasKey);
}, []);
/**
* Initialize identity from user data & password
*/
const initializeIdentity = async (userData: {
did: string;
handle: string;
publicKey: string;
privateKeyEncrypted: string;
}, password?: string) => {
// If password provided, attempt unlock
if (password) {
await unlockIdentity(userData.privateKeyEncrypted, password);
} else {
// Just set public identity info if locked
setIdentity({
did: userData.did,
handle: userData.handle,
publicKey: userData.publicKey,
isUnlocked: !!keyStore.getPrivateKey()
});
}
};
/**
* Unlock the identity with a password
*/
const unlockIdentity = async (privateKeyEncrypted: string, password: string) => {
try {
// 1. Decrypt the PEM/String from server (which is actually a base64 encoded PKCS8 export usually?)
// Wait, existing implementation returns a string.
// We need to verify what `decryptPrivateKey` returns.
// Assuming it returns the decrypted string (Base64 of PKCS8)
const privateKeyPemOrBase64 = await decryptPrivateKey(privateKeyEncrypted, password);
// Clean up if it's PEM to get Base64
let privateKeyBase64 = privateKeyPemOrBase64;
if (privateKeyBase64.includes('-----BEGIN')) {
privateKeyBase64 = privateKeyBase64
.replace(/-----BEGIN [A-Z ]+-----/, '')
.replace(/-----END [A-Z ]+-----/, '')
.replace(/\s/g, '');
}
// 2. Import into CryptoKey
// We need ArrayBuffer
const binaryDer = Buffer.from(privateKeyBase64, 'base64');
const cryptoKey = await importPrivateKey(binaryDer); // This is P-256 specific now
// 3. Store in Memory
keyStore.setPrivateKey(cryptoKey);
// 4. Update State
setIdentity(prev => prev ? { ...prev, isUnlocked: true } : null); // We need the other data...
setIsUnlocked(true);
// If we didn't have identity wrapper set yet, we might need it.
// Usually initializeIdentity handles both.
} catch (error) {
console.error('[Identity] Failed to unlock identity:', error);
throw new Error('Failed to unlock identity. Incorrect password?');
}
};
/**
* Lock the identity
*/
const lockIdentity = () => {
keyStore.clear();
setIsUnlocked(false);
setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null);
};
/**
* Clear the identity (logout)
*/
const clearIdentity = () => {
keyStore.clear();
setIdentity(null);
setIsUnlocked(false);
};
return {
identity,
isUnlocked,
initializeIdentity,
unlockIdentity,
lockIdentity,
clearIdentity,
};
}
+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 };
}