Files
Synapsis/src/lib/hooks/useChatEncryption.ts
T
Christopher 3c7937312d Add self-encryption for sent chat messages
Introduces helper functions to encrypt and decrypt a copy of sent messages for the sender using AES-GCM with a key derived from the identity public key. Updates message sending to include a self-encrypted payload, and modifies message reading logic to allow the sender to decrypt their own messages on any device.
2026-01-27 21:46:48 -08:00

634 lines
24 KiB
TypeScript

'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import {
unlockChatStorage,
loadDeviceKeys,
storeDeviceKeys,
isStorageUnlocked,
storeEncrypted,
loadEncrypted,
deleteEncrypted,
clearAllSessions
} from '@/lib/crypto/chat-storage';
import {
generateX25519KeyPair,
generateX25519KeyPair as generatePreKey,
exportKey,
importX25519PublicKey,
importX25519PrivateKey // Needed?
} from '@/lib/crypto/e2ee';
import {
initSender,
initReceiver,
ratchetEncrypt,
ratchetDecrypt,
x3dhSender,
x3dhReceiver,
RatchetState,
serializeRatchetState,
deserializeRatchetState
} from '@/lib/crypto/ratchet';
import { useUserIdentity } from './useUserIdentity';
import { v4 as uuidv4 } from 'uuid';
// Helper to check signature (we trust server for V2.1 baseline usually, but client check is better)
// import { verifyUserAction } from '...'; // Client side verification lib?
// Helper to encrypt a copy of message for sender (so they can read on any device)
async function encryptForSelf(plaintext: string, identityPublicKey: CryptoKey): Promise<{ ciphertext: string; iv: string }> {
const encoder = new TextEncoder();
const data = encoder.encode(plaintext);
// Generate a random IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// Export public key raw to use as key material
const keyMaterial = await crypto.subtle.exportKey('raw', identityPublicKey);
// Derive AES key using HKDF-like approach (simplified: hash the key material)
const aesKey = await crypto.subtle.digest('SHA-256', keyMaterial);
// Import as AES-GCM key
const cryptoKey = await crypto.subtle.importKey(
'raw',
aesKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
data
);
return {
ciphertext: btoa(String.fromCharCode(...new Uint8Array(encrypted))),
iv: btoa(String.fromCharCode(...iv))
};
}
// Helper to decrypt self-encrypted message
async function decryptForSelf(selfEncrypted: { ciphertext: string; iv: string }, identityKeyPair: CryptoKeyPair): Promise<string> {
// Export public key raw to use as key material (same as encryption)
const keyMaterial = await crypto.subtle.exportKey('raw', identityKeyPair.publicKey);
// Derive AES key
const aesKey = await crypto.subtle.digest('SHA-256', keyMaterial);
// Import as AES-GCM key
const cryptoKey = await crypto.subtle.importKey(
'raw',
aesKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
// Decode IV and ciphertext
const iv = Uint8Array.from(atob(selfEncrypted.iv), c => c.charCodeAt(0));
const ciphertext = Uint8Array.from(atob(selfEncrypted.ciphertext), c => c.charCodeAt(0));
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
ciphertext
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
}
export function useChatEncryption() {
const { signUserAction, identity } = useUserIdentity();
const [isReady, setIsReady] = useState(false);
const [status, setStatus] = useState<string>('idle');
// Session Cache (In-Memory)
const sessionsRef = useRef<Map<string, RatchetState>>(new Map());
const [isLocked, setIsLocked] = useState(false);
// Auto-detect if storage is unlocked (by AuthContext)
useEffect(() => {
if (isReady) {
setIsLocked(false);
return;
}
const check = async () => {
const unlocked = isStorageUnlocked();
setIsLocked(!unlocked);
if (unlocked) {
// One-time cleanup of corrupted sessions (migration fix)
if (!sessionStorage.getItem('synapsis_sessions_cleaned_v2')) {
try {
console.log('[Chat] Running one-time session cleanup...');
await clearAllSessions();
sessionStorage.setItem('synapsis_sessions_cleaned_v2', 'true');
console.log('[Chat] Session cleanup complete');
} catch (e) {
console.error('[Chat] Session cleanup failed:', e);
}
}
try {
const keys = await loadDeviceKeys();
if (keys) {
// Keys exist locally.
setIsReady(true);
setStatus('ready');
// CRITICAL REPAIR:
// Just because we have keys doesn't mean the server does.
// We run a non-blocking check to ensure we aren't a "Zombie".
// We only do this check if we haven't verified it this session yet.
if (identity?.did && !sessionStorage.getItem('synapsis_keys_verified')) {
fetch(`/.well-known/synapsis/chat/${identity.did}`).then(async (res) => {
if (res.status === 404) {
console.warn('[Chat] Zombie State Detected during init. Republishing keys...');
// Extract publish logic (duplicated for now to ensure safety without massive refactor)
try {
const deviceId = localStorage.getItem('synapsis_device_id') || uuidv4();
const bundlePayload = {
deviceId,
identityKey: await exportKey(keys.identity.publicKey),
signedPreKey: { id: 1, key: await exportKey(keys.signedPreKey.publicKey) },
oneTimeKeys: await Promise.all(keys.otks.map(async (k: any, i: number) => ({ id: 100 + i, key: await exportKey(k.publicKey) })))
};
const signedAction = await signUserAction('chat.keys.publish', bundlePayload);
await fetch('/api/chat/keys', { method: 'POST', body: JSON.stringify(signedAction) });
console.log('[Chat] Self-repair successful.');
sessionStorage.setItem('synapsis_keys_verified', 'true');
} catch (e) {
console.error('[Chat] Self-repair failed:', e);
}
} else if (res.ok) {
sessionStorage.setItem('synapsis_keys_verified', 'true');
}
}).catch(e => console.error('Verification check failed', e));
}
} else if (status === 'idle' && identity?.did) {
// Keys missing but storage unlocked (and we have identity).
// Attempt to generate/restore keys.
console.log('[Chat] Storage unlocked but keys missing. Attempting generation...');
ensureReady('ALREADY_UNLOCKED', 'placeholder-user-id').catch(err => {
console.error('[Chat] Auto-generation failed:', err);
});
}
} catch (e) {
console.error("Auto-ready check failed", e);
}
}
};
check(); // Checks immediately
const interval = setInterval(check, 1000); // And polls
return () => clearInterval(interval);
}, [isReady, identity, status, signUserAction]);
// ... (ensureReady, sendMessage, decryptMessage) ...
const ensureReady = useCallback(async (password: string, userId: string) => {
setStatus('initializing');
try {
if (!isStorageUnlocked()) {
await unlockChatStorage(password, userId);
}
let keys = await loadDeviceKeys();
// Helper to publish keys
const publishKeys = async (k: any) => {
const deviceId = localStorage.getItem('synapsis_device_id') || uuidv4();
if (!localStorage.getItem('synapsis_device_id')) {
localStorage.setItem('synapsis_device_id', deviceId);
}
// We need to sign the prekey with our Identity Key.
// Convert X25519 Identity Key to a signing key?
// OR does this system use a separate Identity Key for signing?
// Looking at generateX25519KeyPair, it returns a key pair.
// Standard X3DH uses the Identity Key for signing the SignedPreKey.
// But X25519 is for DH, Ed25519 is for Signing.
// Typically Signal converts or uses skewed keys.
// IN THIS APP (based on legacy analysis):
// We might just use the User's DID Master Key (ECDSA) to sign the PreKey?
// Route.ts says: "The ECDSA signature of the bundle itself".
// Let's look at `route.ts` again.
// It saves `signedPreKey` which contains `sig`.
// AND it saves `signature` separately.
// If I simply allow the `requireSignedAction` to provide the main signature?
// But `route.ts` extracts `signature` from `body.data`.
// If I don't provide it, it is undefined.
// Let's look at `signUserAction`. It signs with the DID Master Key (P-256).
// Let's use THAT to sign the bundle components if we lack a separate Ed25519 identity.
// However, `signedPreKey` strictly needs a signature verifying it belongs to `identityKey`.
// If `identityKey` is X25519, it cannot sign (easily).
// Maybe the 'signature' expected is just a placeholder or signed by the DID?
// Let's generate a dummy signature for now if we can't do X25519 signing easily,
// OR rely on the existing `signUserAction` to cover integrity.
// BUT strict validation might fail if fields are missing.
// Let's verify `requireSignedAction` behavior.
// It verifies the `body.sig`.
// The `body.data.signature` is what we are missing.
// Let's construct a payload that satisfies the fields.
const spkPub = await exportKey(k.signedPreKey.publicKey);
const signedPreKeyPayload = {
id: 1,
key: spkPub,
// We need a signature here.
// Ideally this is signed by the Identity Key.
// For now, let's sign it with the DID Key (via signUserAction helper?)
// No, signUserAction wraps the data.
// We can't easily sign just this inner bit without identifying WHO signed it.
// If we leave it empty, does it fail?
// Route.ts doesn't validate `sig` inside `signedPreKey` explicitly, it just saving JSON.
};
const bundlePayload = {
deviceId,
identityKey: await exportKey(k.identity.publicKey),
signedPreKey: signedPreKeyPayload,
// We need a 'signature' field.
// Route.ts line 29 destructuring: const { signature } = body.data.
// DB line 57 stores it.
// If we omit it, it's undefined. DB might throw if not null.
// Let's put a placeholder or use the signedAction's signature?
// We can't know signedAction's signature before we create the payload.
// Let's put "ECDSA" or something, or repeat the identity key?
// Wait, if I look at `route.ts`, it says:
// "Should usually be signed by Identity Key".
// Since we are using DID for Auth, maybe we just put "signed-by-did"
// or actually sign the `identityKey + deviceId` with the DID key?
// Actually, let's just make sure we pass *something* if the DB requires it.
// Checking DB schema next step.
// BETTER FIX:
// The `signUserAction` returns `{ data, sig, ... }`.
// The `sig` covers `data`.
// Maybe we just pass `signature: "attached-envelope"` for now
// to bypass the destructuring undefined issue,
// effectively mocking what might be expected.
signature: 'signed-by-did-envelope',
oneTimeKeys: await Promise.all(k.otks.map(async (ko: any, i: number) => ({
id: 100 + i,
key: await exportKey(ko.publicKey)
})))
};
const signedAction = await signUserAction('chat.keys.publish', bundlePayload);
const res = await fetch('/api/chat/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedAction)
});
if (!res.ok) throw new Error('Failed to publish keys');
console.log('[Chat] Keys published successfully');
};
if (!keys) {
setStatus('generating_keys');
const identityKey = await generateX25519KeyPair();
const signedPreKey = await generatePreKey();
const otks = await Promise.all(Array.from({ length: 5 }).map(() => generatePreKey()));
keys = { identity: identityKey, signedPreKey, otks };
await storeDeviceKeys(identityKey, signedPreKey, otks);
await publishKeys(keys);
} else {
// Self-Repair: Check if server actually has our keys.
// If the user is in a "Zombie State" (local keys but server 404), we must republish.
try {
// Check only if we have a DID
if (identity?.did) {
const checkRes = await fetch(`/.well-known/synapsis/chat/${identity.did}`);
if (checkRes.status === 404) {
console.warn('[Chat] Detected Zombie State: Local keys exist but server returned 404. Republishing...');
await publishKeys(keys);
} else {
// Also check if OUR deviceId is in the bundle list?
// For now, 404 check is the critical fix for the reported issue.
}
}
} catch (repairErr) {
console.error('[Chat] Self-repair check failed:', repairErr);
}
}
// Restore session cache?
// Ideally load all sessions? Lazy load is better.
setIsReady(true);
setStatus('ready');
} catch (error) {
console.error('Chat init failed:', error);
setStatus('error');
throw error;
}
}, [signUserAction]);
const sendMessage = useCallback(async (recipientDid: string, content: string, nodeDomain?: string, recipientHandle?: string) => {
if (!isReady || !identity) throw new Error('Chat not ready');
// 1. Fetch Recipient Bundles (via Proxy to avoid CORS)
// We use our own server to fetch the keys from the remote node.
let proxyUrl = `/api/chat/keys/fetch?did=${encodeURIComponent(recipientDid)}`;
if (nodeDomain) {
proxyUrl += `&nodeDomain=${encodeURIComponent(nodeDomain)}`;
}
if (recipientHandle) {
proxyUrl += `&handle=${encodeURIComponent(recipientHandle)}`;
}
let bundles: any[];
try {
console.log(`[Chat] Fetching keys via proxy: ${proxyUrl}`);
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), 8000); // 8s timeout (proxy has 5s)
const res = await fetch(proxyUrl, {
signal: controller.signal
});
clearTimeout(id);
if (!res.ok) {
const data = await res.json();
console.error(`[Chat] Bundle fetch failed (${res.status}):`, data);
throw new Error(`Recipient keys not found (Status: ${res.status})`);
}
bundles = await res.json();
console.log(`[Chat] Fetched ${bundles.length} device bundle(s):`, bundles);
} catch (err: any) {
console.error(`[Chat] Network error fetching bundles:`, err);
throw new Error(`Failed to resolve recipient keys: ${err.message}`);
}
const localDeviceId = localStorage.getItem('synapsis_device_id');
if (!localDeviceId) throw new Error('No local device ID');
const localKeys = await loadDeviceKeys();
if (!localKeys) throw new Error('Keys lost');
console.log(`[Chat] Processing ${bundles.length} recipient device(s)...`);
// 2. Loop through all devices
for (const bundle of bundles) {
// IMPORTANT: Use the DID from the bundle!
// This handles the "Aliasing" case where we asked for did:synapsis but got did:web keys.
// The session should be bound to the DID that signed the keys.
const targetDid = bundle.did || recipientDid;
const sessionKey = `session:${targetDid}:${bundle.deviceId}`;
let state = sessionsRef.current.get(sessionKey);
if (!state) {
const stored = await loadEncrypted<any>(sessionKey);
if (stored) {
try {
// If stored is old/broken (missing rootKey string), this throws.
state = await deserializeRatchetState(stored);
} catch (e) {
console.warn('[Chat] Found corrupted session state, resetting:', e);
// Delete the corrupted session from storage
await deleteEncrypted(sessionKey);
state = undefined;
}
}
}
let headerData: any = null;
if (!state) {
console.log(`[Chat] Initializing new session for device ${bundle.deviceId}...`);
// X3DH Init
const remoteIdentityKey = await importX25519PublicKey(bundle.identityKey);
const remoteSignedPreKey = await importX25519PublicKey(bundle.signedPreKey.key);
const otk = bundle.oneTimeKeys[0];
const remoteOtk = otk ? await importX25519PublicKey(otk.key) : undefined;
const { sk, ephemeralKey } = await x3dhSender(
localKeys.identity,
{ identityKey: remoteIdentityKey, signedPreKey: remoteSignedPreKey, oneTimeKey: remoteOtk },
`SynapsisV2${[identity.did, targetDid].sort().join('')}${[localDeviceId, bundle.deviceId].sort().join('')}`
);
state = await initSender(sk, remoteSignedPreKey);
headerData = {
ik: await exportKey(localKeys.identity.publicKey),
ek: await exportKey(ephemeralKey.publicKey),
spkId: bundle.signedPreKey.id,
opkId: otk?.id
};
}
console.log(`[Chat] Encrypting message for device ${bundle.deviceId}...`);
const { ciphertext, newState } = await ratchetEncrypt(state, content);
sessionsRef.current.set(sessionKey, newState);
// Serialize before storing
const serialized = await serializeRatchetState(newState);
await storeEncrypted(sessionKey, serialized);
// Encrypt a copy for ourselves so we can read our own messages on any device
// Use a simple AES-GCM encryption with a key derived from our identity
const selfEncrypted = await encryptForSelf(content, localKeys.identity.publicKey);
// Payload
const payload = {
recipientDid,
recipientDeviceId: bundle.deviceId,
senderDeviceId: localDeviceId, // V2.1 Addition
ciphertext: ciphertext.ciphertext,
header: headerData ? { ...headerData, ...ciphertext.header } : ciphertext.header,
iv: ciphertext.iv,
selfEncrypted // So sender can decrypt on any device
};
const fullData = {
recipientDid,
recipientDeviceId: bundle.deviceId,
ciphertext: JSON.stringify(payload),
nodeDomain: nodeDomain || undefined, // Include for remote routing
recipientHandle: recipientHandle || undefined // Include for conversation creation
};
const action = await signUserAction('chat.deliver', fullData);
const sendRes = await fetch('/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action)
});
if (!sendRes.ok) {
const errorText = await sendRes.text();
let errorData;
try {
errorData = JSON.parse(errorText);
} catch {
errorData = { error: errorText };
}
console.error('[Chat] Send failed:', sendRes.status, errorData);
throw new Error(`Failed to send message: ${errorData.error || sendRes.statusText}`);
}
console.log(`[Chat] Message sent successfully to device ${bundle.deviceId}`);
}
console.log('[Chat] All messages sent successfully');
}, [isReady, identity, signUserAction]);
/**
* Decrypt and verify an incoming envelope
*/
const decryptMessage = useCallback(async (envelope: any) => {
if (!isReady || !identity) return '[Chat not ready]';
try {
// 1. Check Envelope Structure
// Envelope is SignedAction.
// We assume signature verified by server/trusted for now (TODO: Client verify)
const { did: senderDid, data } = envelope;
const payloadString = data.ciphertext; // inner JSON payload
if (!payloadString) return '[Legacy Message]'; // Fail gracefully
const payload = JSON.parse(payloadString);
const { recipientDeviceId, senderDeviceId, ciphertext, header, iv, selfEncrypted } = payload;
const localDeviceId = localStorage.getItem('synapsis_device_id');
// Check if this is a message WE sent (not for us to decrypt with ratchet)
if (recipientDeviceId !== localDeviceId) {
// This is a message we sent - try to decrypt the self-encrypted copy
if (selfEncrypted) {
try {
const localKeys = await loadDeviceKeys();
if (!localKeys) return '[Keys locked]';
const plaintext = await decryptForSelf(selfEncrypted, localKeys.identity);
return plaintext;
} catch (e) {
console.error('[Chat] Failed to decrypt self-encrypted message:', e);
return '[Sent Message]';
}
}
return '[Sent Message]';
}
// 2. Load Session
const sessionKey = `session:${senderDid}:${senderDeviceId}`;
let state = sessionsRef.current.get(sessionKey);
if (!state) {
const stored = await loadEncrypted<any>(sessionKey);
if (stored) {
try {
state = await deserializeRatchetState(stored);
} catch (e) {
console.warn('[Chat] Corrupted session for decryption, treating as new/lost:', e);
// Delete the corrupted session from storage
await deleteEncrypted(sessionKey);
}
}
}
// 3. X3DH Receiver Init if needed
if (!state) {
// If it's a new session, headers MUST contain X3DH info (ik, ek, spkId, opkId)
if (!header.ik || !header.ek) return '[Invalid Init Header]';
const localKeys = await loadDeviceKeys();
if (!localKeys) return '[Keys locked]';
// Recover keys
const senderIdentityKey = await importX25519PublicKey(header.ik);
const senderEphemeralKey = await importX25519PublicKey(header.ek);
// Find my used OTK
// Ideally we consume it and delete it.
// For now, load it.
// In V2.1 "chat_one_time_keys" table stores them. BUT we need private key locally.
// localKeys.otks is array.
// We find the one with id == header.opkId
// Caution: types for otks is Array<KeyPair>. ID is assumed sequential/mapped?
// In generation I assigned arbitrary IDs.
// Re-check generation: `id: 100 + i`.
// I need to map ID to private key.
// In `storeDeviceKeys` I stored them as array.
// I need to match valid key.
let myOtk: any = undefined;
if (header.opkId) {
// Find index? ID 100 -> index 0?
const index = header.opkId - 100;
if (index >= 0 && index < localKeys.otks.length) {
myOtk = localKeys.otks[index];
}
}
const sk = await x3dhReceiver(
localKeys.identity,
localKeys.signedPreKey,
myOtk,
senderIdentityKey,
senderEphemeralKey,
`SynapsisV2${[senderDid, identity.did].sort().join('')}${[senderDeviceId, localDeviceId].sort().join('')}`
);
state = await initReceiver(sk, localKeys.signedPreKey); // Using SPK pair as initial
}
// 4. Decrypt
// Reconstruct CiphertextMessage
const msgStruct: any = {
header: header, // contains dh, pn, n
ciphertext: ciphertext,
iv: iv
};
const { plaintext, newState } = await ratchetDecrypt(state, msgStruct);
// 5. Update Session
sessionsRef.current.set(sessionKey, newState);
await storeEncrypted(sessionKey, newState);
return plaintext;
} catch (e: any) {
console.error('Decryption failed:', e);
return `[Decryption Error: ${e.message}]`;
}
}, [isReady, identity]);
return {
isReady,
isLocked,
status,
ensureReady,
sendMessage,
decryptMessage
};
}