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:
@@ -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' }
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user