Refactor chat system to remove E2EE and simplify messaging
Removed all E2EE chat endpoints, crypto logic, and related API routes, transitioning chat to plain text storage and transport. Updated chat send/receive endpoints to use signed actions and enforce DM privacy settings. Cleaned up Swarm chat inbox, key management, and related debug endpoints. Updated user profile to support DM privacy, improved search for handle queries, and refactored conversation/message logic to support the new model. Migrated bot settings and privacy settings to new locations.
This commit is contained in:
@@ -210,4 +210,16 @@ export const signedAPI = {
|
||||
userHandle
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Send a chat message
|
||||
*/
|
||||
async sendChat(recipientDid: string, recipientHandle: string, content: string, userDid: string, userHandle: string) {
|
||||
return signedFetch(
|
||||
'/api/chat/send',
|
||||
'chat',
|
||||
{ recipientDid, recipientHandle, content },
|
||||
userDid,
|
||||
userHandle
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,7 +29,39 @@ export interface SignedAction {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed user action
|
||||
* Verify a signed action against a specific public key
|
||||
*/
|
||||
export async function verifyActionSignature(signedAction: SignedAction, publicKeyStr: string): Promise<boolean> {
|
||||
try {
|
||||
const { sig, ...payload } = signedAction;
|
||||
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)
|
||||
const publicKey = await importPublicKey(publicKeyStr);
|
||||
|
||||
return await cryptoSubtle.verify(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
hash: { name: 'SHA-256' },
|
||||
},
|
||||
publicKey,
|
||||
sigBuffer,
|
||||
dataBytes
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Verify] Crypto exception:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed user action (looks up user in DB)
|
||||
*
|
||||
* @param signedAction - The signed action payload
|
||||
* @returns The user if signature is valid and not replayed
|
||||
@@ -48,6 +80,7 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
// 1. FRESHNESS CHECK (Fail fast before DB/Crypto)
|
||||
const now = Date.now();
|
||||
const diff = Math.abs(now - payload.ts);
|
||||
// Allow 5 minutes clock skew
|
||||
const fiveMinutesMs = 5 * 60 * 1000;
|
||||
|
||||
if (diff > fiveMinutesMs) {
|
||||
@@ -60,8 +93,6 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
});
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
@@ -70,60 +101,34 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
}
|
||||
|
||||
// 3. CRYPTOGRAPHIC VERIFICATION
|
||||
try {
|
||||
const canonicalString = canonicalize(payload);
|
||||
const encoder = new TextEncoder();
|
||||
const dataBytes = encoder.encode(canonicalString);
|
||||
const isValid = await verifyActionSignature(signedAction, user.publicKey);
|
||||
|
||||
// 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' };
|
||||
if (!isValid) {
|
||||
return { valid: false, error: 'INVALID_SIGNATURE' };
|
||||
}
|
||||
|
||||
// 4. ACTION ID HASH COMPUTATION
|
||||
const canonicalString = canonicalize(payload);
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -80,15 +80,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
await unlockIdentityHook(
|
||||
targetUser.privateKeyEncrypted,
|
||||
targetUser.privateKeyEncrypted,
|
||||
password,
|
||||
targetUser.did,
|
||||
targetUser.handle,
|
||||
targetUser.publicKey
|
||||
);
|
||||
|
||||
// Signal Protocol will auto-initialize when the chat page is opened
|
||||
|
||||
|
||||
setShowUnlockPrompt(false); // Close prompt on success
|
||||
};
|
||||
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
/**
|
||||
* Client-Side E2E Encryption using Web Crypto API
|
||||
*
|
||||
* This runs in the browser. Private keys NEVER leave the client.
|
||||
* Uses ECDH for key exchange and AES-GCM for encryption.
|
||||
*/
|
||||
|
||||
// Storage keys
|
||||
const PRIVATE_KEY_STORAGE = 'synapsis_chat_private_key';
|
||||
const PUBLIC_KEY_STORAGE = 'synapsis_chat_public_key';
|
||||
|
||||
/**
|
||||
* Generate a new ECDH key pair for chat
|
||||
*/
|
||||
export async function generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
|
||||
const keyPair = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'ECDH',
|
||||
namedCurve: 'P-256',
|
||||
},
|
||||
true, // extractable
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey);
|
||||
const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
|
||||
return {
|
||||
publicKey: bufferToBase64(publicKeyBuffer),
|
||||
privateKey: bufferToBase64(privateKeyBuffer),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store keys in localStorage (encrypted with a passphrase in production)
|
||||
*/
|
||||
export function storeKeys(publicKey: string, privateKey: string): void {
|
||||
localStorage.setItem(PUBLIC_KEY_STORAGE, publicKey);
|
||||
localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored keys
|
||||
*/
|
||||
export function getStoredKeys(): { publicKey: string | null; privateKey: string | null } {
|
||||
return {
|
||||
publicKey: localStorage.getItem(PUBLIC_KEY_STORAGE),
|
||||
privateKey: localStorage.getItem(PRIVATE_KEY_STORAGE),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if chat keys exist
|
||||
*/
|
||||
export function hasChatKeys(): boolean {
|
||||
const keys = getStoredKeys();
|
||||
return !!(keys.publicKey && keys.privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored keys (logout)
|
||||
*/
|
||||
export function clearKeys(): void {
|
||||
localStorage.removeItem(PUBLIC_KEY_STORAGE);
|
||||
localStorage.removeItem(PRIVATE_KEY_STORAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a public key from base64
|
||||
*/
|
||||
async function importPublicKey(publicKeyBase64: string): Promise<CryptoKey> {
|
||||
const keyBuffer = base64ToBuffer(publicKeyBase64);
|
||||
return window.crypto.subtle.importKey(
|
||||
'spki',
|
||||
keyBuffer,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a private key from base64
|
||||
*/
|
||||
async function importPrivateKey(privateKeyBase64: string): Promise<CryptoKey> {
|
||||
const keyBuffer = base64ToBuffer(privateKeyBase64);
|
||||
return window.crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyBuffer,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a shared AES key from ECDH
|
||||
*/
|
||||
async function deriveSharedKey(
|
||||
myPrivateKey: CryptoKey,
|
||||
theirPublicKey: CryptoKey
|
||||
): Promise<CryptoKey> {
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{ name: 'ECDH', public: theirPublicKey },
|
||||
myPrivateKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message for a recipient
|
||||
*/
|
||||
export async function encryptMessage(
|
||||
message: string,
|
||||
myPrivateKeyBase64: string,
|
||||
theirPublicKeyBase64: string
|
||||
): Promise<string> {
|
||||
const myPrivateKey = await importPrivateKey(myPrivateKeyBase64);
|
||||
const theirPublicKey = await importPublicKey(theirPublicKeyBase64);
|
||||
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const messageBytes = encoder.encode(message);
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
sharedKey,
|
||||
messageBytes
|
||||
);
|
||||
|
||||
// Combine iv + ciphertext
|
||||
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||
|
||||
return bufferToBase64(combined.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a message from a sender
|
||||
*/
|
||||
export async function decryptMessage(
|
||||
encryptedMessage: string,
|
||||
myPrivateKeyBase64: string,
|
||||
theirPublicKeyBase64: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const myPrivateKey = await importPrivateKey(myPrivateKeyBase64);
|
||||
const theirPublicKey = await importPublicKey(theirPublicKeyBase64);
|
||||
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
|
||||
|
||||
const combined = base64ToBuffer(encryptedMessage);
|
||||
|
||||
if (combined.byteLength < 12) {
|
||||
throw new Error('Message too short');
|
||||
}
|
||||
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
sharedKey,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
} catch (error) {
|
||||
console.error('Decryption failed:', error);
|
||||
return '[Message cannot be decrypted]';
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function bufferToBase64(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 base64ToBuffer(base64: string): ArrayBuffer {
|
||||
// Gracefull handle null/undefined
|
||||
if (!base64) return new ArrayBuffer(0);
|
||||
|
||||
// Check for JSON (legacy format)
|
||||
if (base64.trim().startsWith('{')) {
|
||||
console.warn('[base64ToBuffer] Detected JSON instead of Base64, returning empty buffer');
|
||||
throw new Error('Invalid message format: JSON detected');
|
||||
}
|
||||
|
||||
// Clean the string:
|
||||
// 1. Remove newlines/tabs (formatting)
|
||||
// 2. Replace spaces with '+' (common URL decoding error where + becomes space)
|
||||
// 3. Handle URL-safe chars (- -> +, _ -> /)
|
||||
const cleaned = base64.replace(/[\n\r\t]/g, '')
|
||||
.replace(/ /g, '+')
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
try {
|
||||
const binary = atob(cleaned);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
} catch (e) {
|
||||
console.error('[base64ToBuffer] Failed to decode base64:', e);
|
||||
throw new Error(`Failed to decode base64: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* Libsodium E2EE Chat Implementation
|
||||
* Keys stored encrypted in IndexedDB using storage key from identity unlock
|
||||
*/
|
||||
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
let sodiumReady = false;
|
||||
|
||||
const DB_NAME = 'synapsis_chat';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'chat_keys';
|
||||
|
||||
/**
|
||||
* Initialize libsodium (must be called before any crypto operations)
|
||||
*/
|
||||
export async function initSodium() {
|
||||
if (sodiumReady) return;
|
||||
await sodium.ready;
|
||||
sodiumReady = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open IndexedDB
|
||||
*/
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new key pair for chat encryption
|
||||
*/
|
||||
export async function generateChatKeyPair(): Promise<{
|
||||
publicKey: string; // base64
|
||||
privateKey: string; // base64
|
||||
}> {
|
||||
await initSodium();
|
||||
|
||||
const keyPair = sodium.crypto_box_keypair();
|
||||
|
||||
return {
|
||||
publicKey: sodium.to_base64(keyPair.publicKey),
|
||||
privateKey: sodium.to_base64(keyPair.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to robustly decode Base64 regardless of variant (Original vs URLSafe)
|
||||
function tryDecodeBase64(str: string): Uint8Array {
|
||||
// Use a Set to ensure uniqueness and order, explicitly allowing undefined
|
||||
const variants = new Set<number | undefined>();
|
||||
|
||||
// Prefer standard/known variants first
|
||||
if (sodium.base64_variants) {
|
||||
if (sodium.base64_variants.ORIGINAL !== undefined) variants.add(sodium.base64_variants.ORIGINAL);
|
||||
if (sodium.base64_variants.URLSAFE !== undefined) variants.add(sodium.base64_variants.URLSAFE);
|
||||
if (sodium.base64_variants.ORIGINAL_NO_PADDING !== undefined) variants.add(sodium.base64_variants.ORIGINAL_NO_PADDING);
|
||||
if (sodium.base64_variants.URLSAFE_NO_PADDING !== undefined) variants.add(sodium.base64_variants.URLSAFE_NO_PADDING);
|
||||
}
|
||||
|
||||
// Always add default (undefined) as fallback
|
||||
variants.add(undefined);
|
||||
|
||||
let lastError;
|
||||
for (const v of variants) {
|
||||
try {
|
||||
return v !== undefined
|
||||
? sodium.from_base64(str, v)
|
||||
: sodium.from_base64(str);
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('Failed to decode Base64 with any variant');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message for a recipient
|
||||
*/
|
||||
export async function encryptMessage(
|
||||
message: string,
|
||||
recipientPublicKey: string, // base64
|
||||
senderPrivateKey: string // base64
|
||||
): Promise<{
|
||||
ciphertext: string; // base64
|
||||
nonce: string; // base64
|
||||
}> {
|
||||
await initSodium();
|
||||
|
||||
try {
|
||||
const messageBytes = sodium.from_string(message);
|
||||
|
||||
// keys may be dirty or differ in variant
|
||||
const cleanRecipientKey = recipientPublicKey.trim();
|
||||
const cleanSenderKey = senderPrivateKey.trim();
|
||||
|
||||
// Robust decode for both keys independently
|
||||
// This solves the issue where Local Key is URLSAFE but Remote Key is ORIGINAL
|
||||
const recipientPubKey = tryDecodeBase64(cleanRecipientKey);
|
||||
const senderPrivKey = tryDecodeBase64(cleanSenderKey);
|
||||
|
||||
// Generate random nonce
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
||||
|
||||
// Encrypt
|
||||
const ciphertext = sodium.crypto_box_easy(
|
||||
messageBytes,
|
||||
nonce,
|
||||
recipientPubKey,
|
||||
senderPrivKey
|
||||
);
|
||||
|
||||
return {
|
||||
ciphertext: sodium.to_base64(ciphertext),
|
||||
nonce: sodium.to_base64(nonce),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[Sodium-Chat] Encryption failed:', err);
|
||||
console.error('Keys Debug:', {
|
||||
recipientLen: recipientPublicKey.length,
|
||||
senderLen: senderPrivateKey.length,
|
||||
recipientStart: recipientPublicKey.substring(0, 5)
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a message from a sender
|
||||
*/
|
||||
export async function decryptMessage(
|
||||
ciphertext: string, // base64
|
||||
nonce: string, // base64
|
||||
senderPublicKey: string, // base64
|
||||
recipientPrivateKey: string // base64
|
||||
): Promise<string> {
|
||||
await initSodium();
|
||||
|
||||
const ciphertextBytes = tryDecodeBase64(ciphertext);
|
||||
const nonceBytes = tryDecodeBase64(nonce);
|
||||
const senderPubKey = tryDecodeBase64(senderPublicKey);
|
||||
const recipientPrivKey = tryDecodeBase64(recipientPrivateKey);
|
||||
|
||||
// Decrypt
|
||||
const decrypted = sodium.crypto_box_open_easy(
|
||||
ciphertextBytes,
|
||||
nonceBytes,
|
||||
senderPubKey,
|
||||
recipientPrivKey
|
||||
);
|
||||
|
||||
return sodium.to_string(decrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store keys in IndexedDB (encrypted with storage key from memory)
|
||||
*/
|
||||
export async function storeKeys(
|
||||
userId: string,
|
||||
publicKey: string,
|
||||
privateKey: string,
|
||||
storageKey: Uint8Array
|
||||
): Promise<void> {
|
||||
await initSodium();
|
||||
|
||||
// Generate random nonce
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
|
||||
// Encrypt private key with storage key
|
||||
const privateKeyBytes = sodium.from_string(privateKey);
|
||||
const ciphertext = sodium.crypto_secretbox_easy(privateKeyBytes, nonce, storageKey);
|
||||
|
||||
// Combine nonce + ciphertext
|
||||
const combined = new Uint8Array(nonce.length + ciphertext.length);
|
||||
combined.set(nonce, 0);
|
||||
combined.set(ciphertext, nonce.length);
|
||||
|
||||
// Store in IndexedDB
|
||||
const db = await openDB();
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put({
|
||||
publicKey,
|
||||
encryptedPrivateKey: sodium.to_base64(combined)
|
||||
}, userId);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve keys from IndexedDB (decrypt with storage key from memory)
|
||||
*/
|
||||
export async function getStoredKeys(
|
||||
userId: string,
|
||||
storageKey: Uint8Array
|
||||
): Promise<{ publicKey: string; privateKey: string } | null> {
|
||||
await initSodium();
|
||||
|
||||
try {
|
||||
const db = await openDB();
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
const data = await new Promise<any>((resolve, reject) => {
|
||||
const request = store.get(userId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
|
||||
if (!data) {
|
||||
console.log('[Sodium] No stored keys found in IndexedDB for user:', userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[Sodium] Found stored keys in IndexedDB, attempting to decrypt...');
|
||||
|
||||
const { publicKey, encryptedPrivateKey } = data;
|
||||
|
||||
// Extract nonce and ciphertext
|
||||
const combined = sodium.from_base64(encryptedPrivateKey);
|
||||
const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES);
|
||||
const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES);
|
||||
|
||||
// Decrypt with storage key
|
||||
const decrypted = sodium.crypto_secretbox_open_easy(ciphertext, nonce, storageKey);
|
||||
let privateKey = sodium.to_string(decrypted);
|
||||
|
||||
// Validate that the decrypted key is actually valid Base64
|
||||
try {
|
||||
tryDecodeBase64(privateKey);
|
||||
} catch (e) {
|
||||
console.warn('[Sodium] Private key appears invalid, attempting repair...');
|
||||
|
||||
// Attempt 1: Trim whitespace
|
||||
let repaired = privateKey.trim();
|
||||
|
||||
// Attempt 2: Remove quotes (common JSON artifact)
|
||||
repaired = repaired.replace(/['"]/g, '');
|
||||
|
||||
// Attempt 3: Remove newlines
|
||||
repaired = repaired.replace(/[\n\r]/g, '');
|
||||
|
||||
try {
|
||||
tryDecodeBase64(repaired);
|
||||
console.log('[Sodium] Private key REPAIRED successfully!');
|
||||
privateKey = repaired;
|
||||
} catch (finalErr) {
|
||||
console.error('[Sodium] Decrypted private key is IRREPARABLE! Key store corrupted.');
|
||||
// We have to return null here as last resort, but we tried everything.
|
||||
// Log the length/characteristics to help debug if this happens
|
||||
console.error('Bad Key CharCodes:', privateKey.split('').map(c => c.charCodeAt(0)).slice(0, 10));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Sodium] Successfully decrypted stored keys');
|
||||
return { publicKey, privateKey };
|
||||
} catch (error) {
|
||||
console.error('[Sodium] Failed to decrypt stored keys - storage key mismatch?', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored keys from IndexedDB
|
||||
*/
|
||||
export async function clearStoredKeys(userId: string): Promise<void> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.delete(userId);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.error('[Sodium] Failed to clear keys:', error);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class InMemoryKeyStore implements KeyStore {
|
||||
private static instance: InMemoryKeyStore;
|
||||
private privateKey: CryptoKey | null = null;
|
||||
private identity: { did: string; handle: string; publicKey: string } | null = null;
|
||||
private storageKey: Uint8Array | null = null; // For encrypting chat keys
|
||||
|
||||
|
||||
private constructor() { }
|
||||
|
||||
@@ -57,18 +57,11 @@ class InMemoryKeyStore implements KeyStore {
|
||||
return this.identity;
|
||||
}
|
||||
|
||||
setStorageKey(key: Uint8Array): void {
|
||||
this.storageKey = key;
|
||||
}
|
||||
|
||||
getStorageKey(): Uint8Array | null {
|
||||
return this.storageKey;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.privateKey = null;
|
||||
this.identity = null;
|
||||
this.storageKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* React Hook for Libsodium E2EE Chat
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { keyStore } from '@/lib/crypto/user-signing';
|
||||
import * as SodiumChat from '@/lib/crypto/sodium-chat';
|
||||
|
||||
export function useSodiumChat() {
|
||||
const { user, isIdentityUnlocked } = useAuth();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [status, setStatus] = useState<string>('idle');
|
||||
const keysRef = useRef<{ publicKey: string; privateKey: string } | null>(null);
|
||||
|
||||
// Initialize and load/generate keys
|
||||
useEffect(() => {
|
||||
if (!user?.id || !isIdentityUnlocked) return;
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
setStatus('initializing');
|
||||
|
||||
await SodiumChat.initSodium();
|
||||
|
||||
// Get storage key from memory
|
||||
const storageKey = keyStore.getStorageKey();
|
||||
if (!storageKey) {
|
||||
throw new Error('Storage key not available - identity must be unlocked first');
|
||||
}
|
||||
|
||||
// Try to load existing keys (encrypted in IndexedDB)
|
||||
let keys = await SodiumChat.getStoredKeys(user.id, storageKey);
|
||||
|
||||
if (!keys) {
|
||||
// Generate new keys
|
||||
console.log('[Sodium] Generating new key pair...');
|
||||
keys = await SodiumChat.generateChatKeyPair();
|
||||
await SodiumChat.storeKeys(user.id, keys.publicKey, keys.privateKey, storageKey);
|
||||
|
||||
// Publish public key to server
|
||||
console.log('[Sodium] Publishing public key to server...');
|
||||
const response = await fetch('/api/chat/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ publicKey: keys.publicKey }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: response.statusText }));
|
||||
console.error('[Sodium] Failed to publish key:', errorData);
|
||||
throw new Error(`Failed to publish key: ${errorData.error || response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[Sodium] Keys generated and published successfully:', result);
|
||||
} else {
|
||||
console.log('[Sodium] Loaded existing keys from IndexedDB');
|
||||
|
||||
// Verify key exists on server
|
||||
console.log('[Sodium] Verifying key on server...');
|
||||
if (!user.did) {
|
||||
throw new Error('User DID not available');
|
||||
}
|
||||
const checkResponse = await fetch(`/api/chat/keys?did=${encodeURIComponent(user.did)}`, { cache: 'no-store' });
|
||||
|
||||
let shouldPublish = false;
|
||||
|
||||
if (!checkResponse.ok) {
|
||||
console.log('[Sodium] Key not found on server, re-publishing...');
|
||||
shouldPublish = true;
|
||||
} else {
|
||||
// Check if the key on server MATCHES our local key
|
||||
const serverData = await checkResponse.json();
|
||||
if (serverData.publicKey !== keys.publicKey) {
|
||||
console.warn('[Sodium] Server key mismatch! Re-publishing local key...');
|
||||
shouldPublish = true;
|
||||
} else {
|
||||
console.log('[Sodium] Key verified on server');
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldPublish) {
|
||||
// Re-publish the key
|
||||
const response = await fetch('/api/chat/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ publicKey: keys.publicKey }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: response.statusText }));
|
||||
console.error('[Sodium] Failed to re-publish key:', errorData);
|
||||
throw new Error(`Failed to re-publish key: ${errorData.error || response.statusText}`);
|
||||
}
|
||||
|
||||
console.log('[Sodium] Key re-published successfully');
|
||||
}
|
||||
}
|
||||
|
||||
keysRef.current = keys;
|
||||
setIsReady(true);
|
||||
setStatus('ready');
|
||||
} catch (error) {
|
||||
console.error('[Sodium] Initialization failed:', error);
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
}, [user?.id, user?.did, isIdentityUnlocked]);
|
||||
|
||||
const sendMessage = useCallback(async (
|
||||
recipientDid: string,
|
||||
message: string,
|
||||
recipientHandle?: string
|
||||
): Promise<void> => {
|
||||
if (!keysRef.current || !isReady || !user?.id) {
|
||||
throw new Error('Sodium not ready');
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch recipient's public key
|
||||
console.log('[Sodium] Fetching recipient public key for:', recipientDid);
|
||||
let response = await fetch(`/api/chat/keys?did=${encodeURIComponent(recipientDid)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: response.statusText }));
|
||||
console.error('[Sodium] Failed to fetch recipient keys:', errorData);
|
||||
throw new Error(`Failed to fetch recipient keys: ${errorData.error || response.statusText}`);
|
||||
}
|
||||
|
||||
const { publicKey: recipientPublicKey } = await response.json();
|
||||
console.log('[Sodium] Got recipient public key:', recipientPublicKey ? 'YES' : 'NO');
|
||||
|
||||
if (!recipientPublicKey) {
|
||||
throw new Error('Recipient has no public key');
|
||||
}
|
||||
|
||||
// Encrypt message
|
||||
console.log('[Sodium] Encrypting message...');
|
||||
const encrypted = await SodiumChat.encryptMessage(
|
||||
message,
|
||||
recipientPublicKey,
|
||||
keysRef.current.privateKey
|
||||
);
|
||||
|
||||
// Send to server
|
||||
console.log('[Sodium] Sending encrypted message to server...');
|
||||
response = await fetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipientDid,
|
||||
senderPublicKey: keysRef.current.publicKey,
|
||||
ciphertext: encrypted.ciphertext,
|
||||
nonce: encrypted.nonce,
|
||||
recipientHandle,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: response.statusText }));
|
||||
console.error('[Sodium] Failed to send message:', errorData);
|
||||
throw new Error(`Failed to send message: ${errorData.error || response.statusText}`);
|
||||
}
|
||||
|
||||
console.log('[Sodium] Message sent successfully');
|
||||
} catch (error) {
|
||||
console.error('[Sodium] Send failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [isReady, user?.id]);
|
||||
|
||||
const decryptMessage = useCallback(async (
|
||||
ciphertext: string,
|
||||
nonce: string,
|
||||
senderPublicKey: string
|
||||
): Promise<string> => {
|
||||
if (!keysRef.current || !isReady) {
|
||||
throw new Error('Sodium not ready');
|
||||
}
|
||||
|
||||
try {
|
||||
const plaintext = await SodiumChat.decryptMessage(
|
||||
ciphertext,
|
||||
nonce,
|
||||
senderPublicKey,
|
||||
keysRef.current.privateKey
|
||||
);
|
||||
|
||||
return plaintext;
|
||||
} catch (error) {
|
||||
console.error('[Sodium] Decryption failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
return {
|
||||
isReady,
|
||||
status,
|
||||
sendMessage,
|
||||
decryptMessage,
|
||||
};
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function useUserIdentity() {
|
||||
const unlockIdentity = async (privateKeyEncrypted: string, password: string, userDid?: string, userHandle?: string, userPublicKey?: string) => {
|
||||
try {
|
||||
console.log('[Identity] Unlocking with DID:', userDid, 'Handle:', userHandle);
|
||||
|
||||
|
||||
// Set identity first if provided (needed for storage key derivation)
|
||||
if (userDid && userHandle && userPublicKey) {
|
||||
keyStore.setIdentity({
|
||||
@@ -130,43 +130,12 @@ export function useUserIdentity() {
|
||||
keyStore.setPrivateKey(cryptoKey);
|
||||
console.log('[Identity] Private key stored in memory');
|
||||
|
||||
// 4. Derive and store storage key for chat encryption
|
||||
// Use libsodium's pwhash to derive a storage key from the password
|
||||
const sodiumModule = await import('libsodium-wrappers-sumo');
|
||||
await sodiumModule.default.ready;
|
||||
const sodium = sodiumModule.default;
|
||||
|
||||
// Use a fixed salt derived from the user's identity to ensure consistency
|
||||
const identity = keyStore.getIdentity();
|
||||
console.log('[Identity] Retrieved identity from keyStore:', identity);
|
||||
|
||||
if (identity) {
|
||||
const saltString = `synapsis-chat-storage-${identity.did}`;
|
||||
// Generate a fixed-length salt from the DID
|
||||
// Hash to 32 bytes, then take first 16 bytes for salt
|
||||
const fullHash = sodium.crypto_generichash(32, saltString, null);
|
||||
const salt = fullHash.slice(0, sodium.crypto_pwhash_SALTBYTES);
|
||||
|
||||
console.log('[Identity] Deriving storage key...');
|
||||
const storageKey = sodium.crypto_pwhash(
|
||||
32, // 32 bytes for secretbox
|
||||
password,
|
||||
salt,
|
||||
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
sodium.crypto_pwhash_ALG_DEFAULT
|
||||
);
|
||||
|
||||
keyStore.setStorageKey(storageKey);
|
||||
console.log('[Identity] Storage key derived and stored');
|
||||
} else {
|
||||
console.error('[Identity] No identity in keyStore - cannot derive storage key');
|
||||
}
|
||||
|
||||
// 5. Update State
|
||||
// 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.
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* Swarm Chat Cryptography
|
||||
*
|
||||
* End-to-end encryption for chat messages using hybrid encryption:
|
||||
* - AES-256-GCM for message encryption (fast, no size limit)
|
||||
* - RSA-OAEP for encrypting the AES key (secure key exchange)
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface EncryptedPayload {
|
||||
encryptedKey: string; // RSA-encrypted AES key (base64)
|
||||
iv: string; // AES initialization vector (base64)
|
||||
ciphertext: string; // AES-encrypted message (base64)
|
||||
authTag: string; // GCM authentication tag (base64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message using hybrid encryption (AES + RSA)
|
||||
*/
|
||||
export function encryptMessage(message: string, recipientPublicKey: string): string {
|
||||
try {
|
||||
// Generate a random AES-256 key
|
||||
const aesKey = crypto.randomBytes(32);
|
||||
|
||||
// Generate a random IV for AES-GCM
|
||||
const iv = crypto.randomBytes(12);
|
||||
|
||||
// Encrypt the message with AES-256-GCM
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(message, 'utf8'),
|
||||
cipher.final()
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Encrypt the AES key with RSA-OAEP
|
||||
const encryptedKey = crypto.publicEncrypt(
|
||||
{
|
||||
key: recipientPublicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: 'sha256',
|
||||
},
|
||||
aesKey
|
||||
);
|
||||
|
||||
// Package everything together
|
||||
const payload: EncryptedPayload = {
|
||||
encryptedKey: encryptedKey.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
authTag: authTag.toString('base64'),
|
||||
};
|
||||
|
||||
return JSON.stringify(payload);
|
||||
} catch (error) {
|
||||
console.error('Failed to encrypt message:', error);
|
||||
throw new Error('Encryption failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a message using hybrid encryption (AES + RSA)
|
||||
*/
|
||||
export function decryptMessage(encryptedMessage: string, privateKey: string): string {
|
||||
try {
|
||||
// Parse the encrypted payload
|
||||
const payload: EncryptedPayload = JSON.parse(encryptedMessage);
|
||||
|
||||
// Decrypt the AES key with RSA
|
||||
const aesKey = crypto.privateDecrypt(
|
||||
{
|
||||
key: privateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: 'sha256',
|
||||
},
|
||||
Buffer.from(payload.encryptedKey, 'base64')
|
||||
);
|
||||
|
||||
// Decrypt the message with AES-256-GCM
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
aesKey,
|
||||
Buffer.from(payload.iv, 'base64')
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(payload.authTag, 'base64'));
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(payload.ciphertext, 'base64')),
|
||||
decipher.final()
|
||||
]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt message:', error);
|
||||
throw new Error('Decryption failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a message payload for authenticity verification
|
||||
*/
|
||||
export function signPayload(payload: string, privateKey: string): string {
|
||||
try {
|
||||
const sign = crypto.createSign('SHA256');
|
||||
sign.update(payload);
|
||||
sign.end();
|
||||
|
||||
const signature = sign.sign(privateKey, 'base64');
|
||||
return signature;
|
||||
} catch (error) {
|
||||
console.error('Failed to sign payload:', error);
|
||||
throw new Error('Signing failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed payload
|
||||
*/
|
||||
export function verifySignature(payload: string, signature: string, publicKey: string): boolean {
|
||||
try {
|
||||
const verify = crypto.createVerify('SHA256');
|
||||
verify.update(payload);
|
||||
verify.end();
|
||||
|
||||
return verify.verify(publicKey, signature, 'base64');
|
||||
} catch (error) {
|
||||
console.error('Failed to verify signature:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Swarm Chat Types
|
||||
*
|
||||
* Type definitions for the swarm chat system.
|
||||
*/
|
||||
|
||||
export interface SwarmChatMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderHandle: string;
|
||||
senderDisplayName?: string;
|
||||
senderAvatarUrl?: string;
|
||||
senderNodeDomain?: string;
|
||||
encryptedContent: string;
|
||||
deliveredAt?: string;
|
||||
readAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SwarmChatConversation {
|
||||
id: string;
|
||||
type: 'direct' | 'group';
|
||||
participant1Id: string;
|
||||
participant2Handle: string;
|
||||
lastMessageAt?: string;
|
||||
lastMessagePreview?: string;
|
||||
unreadCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for sending a chat message to a remote node
|
||||
*/
|
||||
export interface SwarmChatMessagePayload {
|
||||
messageId: string;
|
||||
senderHandle: string;
|
||||
senderDisplayName?: string;
|
||||
senderAvatarUrl?: string;
|
||||
senderNodeDomain: string;
|
||||
recipientHandle: string;
|
||||
encryptedContent: string;
|
||||
timestamp: string;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for typing indicator
|
||||
*/
|
||||
export interface SwarmChatTypingPayload {
|
||||
senderHandle: string;
|
||||
senderNodeDomain: string;
|
||||
recipientHandle: string;
|
||||
isTyping: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for read receipt
|
||||
*/
|
||||
export interface SwarmChatReadReceiptPayload {
|
||||
messageId: string;
|
||||
readerHandle: string;
|
||||
readerNodeDomain: string;
|
||||
timestamp: string;
|
||||
}
|
||||
+2
-1
@@ -17,7 +17,8 @@ export interface User {
|
||||
isSwarm?: boolean; // Whether this user is from a Synapsis swarm node
|
||||
nodeDomain?: string | null; // Domain of the node this user is from (for swarm users)
|
||||
did?: string;
|
||||
chatPublicKey?: string;
|
||||
canReceiveDms?: boolean;
|
||||
dmPrivacy?: 'everyone' | 'following' | 'none';
|
||||
botOwner?: {
|
||||
id: string;
|
||||
handle: string;
|
||||
|
||||
Reference in New Issue
Block a user