feat: Implement end-to-end encrypted chat with new API routes, crypto utilities, and UI components.

This commit is contained in:
Christopher
2026-01-27 17:59:08 -08:00
parent 5903022f8a
commit 9ee0cbbb9a
20 changed files with 1715 additions and 1525 deletions
+55 -1
View File
@@ -117,6 +117,7 @@ export const signedAPI = {
mediaIds: string[],
linkPreview: any,
replyToId: string | undefined,
swarmReplyTo: any | undefined,
isNsfw: boolean,
userDid: string,
userHandle: string
@@ -124,7 +125,7 @@ export const signedAPI = {
return signedFetch(
'/api/posts',
'post',
{ content, mediaIds, linkPreview, replyToId, isNsfw },
{ content, mediaIds, linkPreview, replyToId, swarmReplyTo, isNsfw },
userDid,
userHandle
);
@@ -156,4 +157,57 @@ export const signedAPI = {
{ method: 'DELETE' }
);
},
/**
* Delete a post
*/
async deletePost(postId: string, userDid: string, userHandle: string) {
return signedFetch(
`/api/posts/${postId}`,
'delete',
{ postId },
userDid,
userHandle,
{ method: 'DELETE' }
);
},
/**
* Submit a report
*/
async report(targetType: string, targetId: string, reason: string, userDid: string, userHandle: string) {
return signedFetch(
'/api/reports',
'report',
{ targetType, targetId, reason },
userDid,
userHandle
);
},
/**
* Block a user
*/
async blockUser(handle: string, userDid: string, userHandle: string) {
return signedFetch(
`/api/users/${handle}/block`,
'block',
{ handle },
userDid,
userHandle
);
},
/**
* Mute a node
*/
async muteNode(domain: string, userDid: string, userHandle: string) {
return signedFetch(
'/api/settings/muted-nodes',
'mute_node',
{ domain },
userDid,
userHandle
);
},
};
+32 -10
View File
@@ -2,6 +2,7 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { useUserIdentity } from '@/lib/hooks/useUserIdentity';
import { useChatEncryption } from '@/lib/hooks/useChatEncryption';
export interface User {
id: string;
@@ -23,6 +24,8 @@ interface AuthContextType {
checkAdmin: () => Promise<void>;
unlockIdentity: (password: string) => Promise<void>;
logout: () => Promise<void>;
showUnlockPrompt: boolean;
setShowUnlockPrompt: (show: boolean) => void;
}
const AuthContext = createContext<AuthContextType>({
@@ -35,13 +38,15 @@ const AuthContext = createContext<AuthContextType>({
checkAdmin: async () => { },
unlockIdentity: async () => { },
logout: async () => { },
showUnlockPrompt: false,
setShowUnlockPrompt: () => { },
});
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,
@@ -61,6 +66,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
// Integrate chat encryption hook
const { ensureReady } = useChatEncryption();
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
/**
* Unlock the user's identity with their password
*/
@@ -68,8 +78,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (!user?.privateKeyEncrypted) {
throw new Error('No encrypted private key available');
}
await unlockIdentityHook(user.privateKeyEncrypted, password);
// Initialize Chat Keys (Async, don't block UI but start it)
if (user.id) {
ensureReady(password, user.id).catch(err => {
console.error('Failed to initialize chat keys:', err);
});
}
setShowUnlockPrompt(false); // Close prompt on success
};
/**
@@ -79,10 +98,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try {
// Call the logout API endpoint
await fetch('/api/auth/logout', { method: 'POST' });
// Clear the user's identity (private key from localStorage)
clearIdentity();
setShowUnlockPrompt(false);
// Clear the user state
setUser(null);
setIsAdmin(false);
@@ -100,7 +120,7 @@ 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({
@@ -110,7 +130,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
privateKeyEncrypted: data.user.privateKeyEncrypted,
});
}
if (data.user) {
await checkAdmin();
}
@@ -130,16 +150,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);
return (
<AuthContext.Provider value={{
user,
isAdmin,
loading,
<AuthContext.Provider value={{
user,
isAdmin,
loading,
isIdentityUnlocked: isUnlocked,
did: identity?.did || null,
handle: identity?.handle || null,
checkAdmin,
unlockIdentity,
logout,
showUnlockPrompt,
setShowUnlockPrompt,
}}>
{children}
</AuthContext.Provider>
+190
View File
@@ -0,0 +1,190 @@
/**
* Synapsis Secure Chat Storage
*
* Manages persistence of sensitive chat keys (X25519) and ratchet state.
* Uses IndexedDB, but all values are AES-GCM encrypted using a key derived
* from the user's login password.
*/
import { hkdf, encrypt, decrypt, exportKey, importX25519PrivateKey, KeyPair, arrayBufferToBase64, base64ToArrayBuffer } from './e2ee';
const DB_NAME = 'SynapsisChat';
const DB_VERSION = 1;
const STORE_NAME = 'secure_store';
// In-memory cache of the storage key (derived from password)
let storageKey: ArrayBuffer | null = null;
let dbInstance: IDBDatabase | null = null;
// ----------------------------------------------------------------------------
// 1. Initialization
// ----------------------------------------------------------------------------
/**
* Initialize storage with user password.
* Derives a dedicated storage key.
*/
export async function unlockChatStorage(password: string, userId: string): Promise<void> {
// 1. Derive Storage Key
// We use the userId as salt to ensure uniqueness per user
const encoder = new TextEncoder();
const masterKeyMaterial = encoder.encode(password);
const salt = encoder.encode(`synapsis_chat_storage_${userId}`);
storageKey = await hkdf(
salt,
masterKeyMaterial,
encoder.encode('SynapsisChatPersistence'),
32 // 256-bit AES key
);
// 2. Open DB
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME); // Key-Value store
}
};
request.onsuccess = (event) => {
dbInstance = (event.target as IDBOpenDBRequest).result;
resolve();
};
request.onerror = () => reject('Failed to open IndexedDB');
});
}
export function isStorageUnlocked(): boolean {
return storageKey !== null && dbInstance !== null;
}
export function lockStorage() {
storageKey = null;
if (dbInstance) {
dbInstance.close();
dbInstance = null;
}
}
// ----------------------------------------------------------------------------
// 2. Safe Usage Wrappers
// ----------------------------------------------------------------------------
async function setItem(key: string, value: string): Promise<void> {
if (!dbInstance) throw new Error('Database locked');
return new Promise((resolve, reject) => {
const tx = dbInstance!.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.put(value, key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async function getItem(key: string): Promise<string | undefined> {
if (!dbInstance) throw new Error('Database locked');
return new Promise((resolve, reject) => {
const tx = dbInstance!.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// ----------------------------------------------------------------------------
// 3. Encrypted Read/Write
// ----------------------------------------------------------------------------
/**
* Stores a serializable object encrypted.
*/
export async function storeEncrypted(key: string, data: any): Promise<void> {
if (!storageKey) throw new Error('Storage locked');
const json = JSON.stringify(data);
const encrypted = await encrypt(storageKey, json);
// Store as stringified JSON wrapper
const storedValue = JSON.stringify(encrypted);
await setItem(key, storedValue);
}
/**
* Retrieves and decrypts an object.
*/
export async function loadEncrypted<T>(key: string): Promise<T | null> {
if (!storageKey) throw new Error('Storage locked');
const raw = await getItem(key);
if (!raw) return null;
try {
const { ciphertext, iv } = JSON.parse(raw);
const json = await decrypt(storageKey, ciphertext, iv);
return JSON.parse(json) as T;
} catch (error) {
console.error(`Failed to decrypt key ${key}:`, error);
return null;
}
}
// ----------------------------------------------------------------------------
// 4. Specific Key Managers
// ----------------------------------------------------------------------------
interface StoredKeyPair {
pub: string; // Base64 Raw
priv: string; // Base64 PKCS8
}
export async function storeDeviceKeys(identity: KeyPair, signedPreKey: KeyPair, otks: KeyPair[]) {
const data = {
identity: {
pub: await exportKey(identity.publicKey),
priv: await exportKey(identity.privateKey)
},
signedPreKey: {
pub: await exportKey(signedPreKey.publicKey),
priv: await exportKey(signedPreKey.privateKey)
},
otks: await Promise.all(otks.map(async k => ({
pub: await exportKey(k.publicKey),
priv: await exportKey(k.privateKey)
})))
};
await storeEncrypted('device_keys', data);
}
export async function loadDeviceKeys(): Promise<{ identity: KeyPair, signedPreKey: KeyPair, otks: KeyPair[] } | null> {
const data = await loadEncrypted<any>('device_keys');
if (!data) return null;
// Hydrate keys
const identity = {
publicKey: await importX25519PublicKey(data.identity.pub),
privateKey: await importX25519PrivateKey(data.identity.priv)
};
const signedPreKey = {
publicKey: await importX25519PublicKey(data.signedPreKey.pub),
privateKey: await importX25519PrivateKey(data.signedPreKey.priv)
};
const otks = await Promise.all((data.otks as any[]).map(async k => ({
publicKey: await importX25519PublicKey(k.pub),
privateKey: await importX25519PrivateKey(k.priv)
})));
return { identity, signedPreKey, otks };
}
// Helper needed to avoid circular dep if importing from e2ee in hydrating
import { importX25519PublicKey } from './e2ee';
+235
View File
@@ -0,0 +1,235 @@
/**
* Synapsis E2EE Cryptography Core
*
* Implements:
* - X25519 for Key Agreement (ECDH)
* - AES-GCM-256 for Encryption (Standard WebCrypto replacement for ChaCha20)
* - HKDF-SHA256 for Key Derivation
*
* Note: Uses WebCrypto API available in Node 19+ and Browsers.
*/
// Universal Crypto Access
const cryptoSubtle = typeof window !== 'undefined'
? window.crypto.subtle
: (globalThis.crypto as any)?.subtle || require('crypto').webcrypto?.subtle;
if (!cryptoSubtle) {
throw new Error('WebCrypto is not available in this environment');
}
// Types
export interface KeyPair {
publicKey: CryptoKey;
privateKey: CryptoKey;
}
export interface PreKeyBundle {
id: number;
key: CryptoKey;
signature?: string; // Base64 ECDSA signature if it's a signed prekey
}
// ----------------------------------------------------------------------------
// 1. Primitives
// ----------------------------------------------------------------------------
/**
* Generate an X25519 Key Pair
*/
export async function generateX25519KeyPair(): Promise<KeyPair> {
return await cryptoSubtle.generateKey(
{
name: 'X25519',
},
true, // extractable
['deriveKey', 'deriveBits']
) as KeyPair;
}
/**
* Import an X25519 Public Key from Base64 Raw Bytes (32 bytes)
*/
export async function importX25519PublicKey(base64: string): Promise<CryptoKey> {
const binary = base64ToArrayBuffer(base64);
return await cryptoSubtle.importKey(
'raw',
binary,
{ name: 'X25519' },
true,
[]
);
}
/**
* Import an X25519 Private Key from Base64 PKCS8/Raw
* Note: WebCrypto usually exports Private Keys as PKCS8.
*/
export async function importX25519PrivateKey(base64: string): Promise<CryptoKey> {
const binary = base64ToArrayBuffer(base64);
// Try PKCS8 first (standard export)
return await cryptoSubtle.importKey(
'pkcs8',
binary,
{ name: 'X25519' },
false,
['deriveKey', 'deriveBits']
);
}
/**
* Export Key to Base64 (Raw for Public, PKCS8 for Private)
*/
export async function exportKey(key: CryptoKey): Promise<string> {
if (key.type === 'public') {
const raw = await cryptoSubtle.exportKey('raw', key);
return arrayBufferToBase64(raw);
} else {
const pkcs8 = await cryptoSubtle.exportKey('pkcs8', key);
return arrayBufferToBase64(pkcs8);
}
}
/**
* ECDH: Compute Shared Secret
*/
export async function computeSharedSecret(privateKey: CryptoKey, publicKey: CryptoKey): Promise<ArrayBuffer> {
// We derive bits directly (Commonly 32 bytes for X25519)
return await cryptoSubtle.deriveBits(
{
name: 'X25519',
public: publicKey,
},
privateKey,
256 // 32 bytes * 8
);
}
// ----------------------------------------------------------------------------
// 2. KDF (HKDF-SHA256)
// ----------------------------------------------------------------------------
/**
* HKDF Expand & Extract
*/
export async function hkdf(
salt: ArrayBuffer | Uint8Array,
ikm: ArrayBuffer | Uint8Array, // Input Key Material (Shared Secret)
info: ArrayBuffer | Uint8Array,
length: number // Bytes output
): Promise<ArrayBuffer> {
const key = await cryptoSubtle.importKey(
'raw',
ikm,
{ name: 'HKDF' },
false,
['deriveBits']
);
return await cryptoSubtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: salt,
info: info,
},
key,
length * 8
);
}
// ----------------------------------------------------------------------------
// 3. Encryption (AES-GCM)
// ----------------------------------------------------------------------------
export async function encrypt(
keyBytes: ArrayBuffer,
plaintext: string | Uint8Array,
associatedData?: Uint8Array
): Promise<{ ciphertext: string; iv: string }> {
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV
const key = await cryptoSubtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['encrypt']
);
const data = typeof plaintext === 'string'
? new TextEncoder().encode(plaintext)
: plaintext;
const encrypted = await cryptoSubtle.encrypt(
{
name: 'AES-GCM',
iv: iv,
additionalData: associatedData
},
key,
data
);
return {
ciphertext: arrayBufferToBase64(encrypted),
iv: arrayBufferToBase64(iv.buffer)
};
}
export async function decrypt(
keyBytes: ArrayBuffer,
ciphertextBase64: string,
ivBase64: string,
associatedData?: Uint8Array
): Promise<string> {
const key = await cryptoSubtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const ciphertext = base64ToArrayBuffer(ciphertextBase64);
const iv = base64ToArrayBuffer(ivBase64);
try {
const decrypted = await cryptoSubtle.decrypt(
{
name: 'AES-GCM',
iv: iv,
additionalData: associatedData
},
key,
ciphertext
);
return new TextDecoder().decode(decrypted);
} catch (e) {
throw new Error('Decryption failed');
}
}
// ----------------------------------------------------------------------------
// 4. Utils
// ----------------------------------------------------------------------------
export 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);
}
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
// Handle URL safe base64 if needed, but we assume standard
const binary = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
+330
View File
@@ -0,0 +1,330 @@
/**
* Synapsis Double Ratchet & X3DH Implementation
*
* Implements the Double Ratchet Algorithm + X3DH Key Agreement.
* Adheres to Signal specifications using the "SynapsisV2" HKDF info binding.
*/
import {
KeyPair,
computeSharedSecret,
hkdf,
encrypt as aeadEncrypt,
decrypt as aeadDecrypt,
importX25519PublicKey,
exportKey,
generateX25519KeyPair,
base64ToArrayBuffer,
arrayBufferToBase64
} from './e2ee';
// Constants
const KDF_INFO = 'SynapsisV2';
const RK_SIZE = 32; // 32 bytes for Root Key
const CK_SIZE = 32; // 32 bytes for Chain Key
const MK_SIZE = 32; // 32 bytes for Message Key
export interface RatchetState {
// DH Ratchet
dhPair: KeyPair;
remoteDhPub: CryptoKey;
rootKey: ArrayBuffer;
// Symm Ratchets
chainKeySend: ArrayBuffer;
chainKeyRecv: ArrayBuffer;
// Message Numbers
ns: number; // Send count
nr: number; // Recv count
pn: number; // Previous chain count
}
export interface Header {
dh: string; // Base64 public key
pn: number;
n: number;
}
export interface CiphertextMessage {
header: Header;
ciphertext: string;
iv: string;
}
// ----------------------------------------------------------------------------
// 1. X3DH Key Agreement
// ----------------------------------------------------------------------------
export async function x3dhSender(
aliceIdentity: KeyPair,
bobBundle: {
identityKey: CryptoKey,
signedPreKey: CryptoKey,
oneTimeKey?: CryptoKey
},
contextInfo: string // "SynapsisV2" + DIDs + DeviceIDs
): Promise<{ sk: ArrayBuffer, ephemeralKey: KeyPair }> {
// 1. Generate Ephemeral Key (EK_a)
const ephemeralKey = await generateX25519KeyPair();
// 2. DH1 = DH(IK_a, SPK_b)
const dh1 = await computeSharedSecret(aliceIdentity.privateKey, bobBundle.signedPreKey);
// 3. DH2 = DH(EK_a, IK_b)
const dh2 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.identityKey);
// 4. DH3 = DH(EK_a, SPK_b)
const dh3 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.signedPreKey);
// 5. DH4 = DH(EK_a, OPK_b) -- Optional
let dh4: ArrayBuffer | undefined;
if (bobBundle.oneTimeKey) {
dh4 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.oneTimeKey);
}
// 6. Concatenate
const km = new Uint8Array(dh1.byteLength + dh2.byteLength + dh3.byteLength + (dh4 ? dh4.byteLength : 0));
let offset = 0;
km.set(new Uint8Array(dh1), offset); offset += dh1.byteLength;
km.set(new Uint8Array(dh2), offset); offset += dh2.byteLength;
km.set(new Uint8Array(dh3), offset); offset += dh3.byteLength;
if (dh4) km.set(new Uint8Array(dh4), offset);
// 7. KDF
// Output 32 bytes for Root Key
const encoder = new TextEncoder();
return {
sk: await hkdf(new Uint8Array(32), km.buffer, encoder.encode(contextInfo), 32),
ephemeralKey
};
}
export async function x3dhReceiver(
bobIdentity: KeyPair,
bobSignedPreKey: KeyPair,
bobOneTimeKey: KeyPair | undefined, // The one used by Alice
aliceIdentityKey: CryptoKey,
aliceEphemeralKey: CryptoKey,
contextInfo: string
): Promise<ArrayBuffer> {
// 1. DH1 = DH(SPK_b, IK_a) -- Note: Order of keys in computeSharedSecret usually (private, public)
const dh1 = await computeSharedSecret(bobSignedPreKey.privateKey, aliceIdentityKey);
// 2. DH2 = DH(IK_b, EK_a)
const dh2 = await computeSharedSecret(bobIdentity.privateKey, aliceEphemeralKey);
// 3. DH3 = DH(SPK_b, EK_a)
const dh3 = await computeSharedSecret(bobSignedPreKey.privateKey, aliceEphemeralKey);
// 4. DH4 = DH(OPK_b, EK_a)
let dh4: ArrayBuffer | undefined;
if (bobOneTimeKey) {
dh4 = await computeSharedSecret(bobOneTimeKey.privateKey, aliceEphemeralKey);
}
const km = new Uint8Array(dh1.byteLength + dh2.byteLength + dh3.byteLength + (dh4 ? dh4.byteLength : 0));
let offset = 0;
km.set(new Uint8Array(dh1), offset); offset += dh1.byteLength;
km.set(new Uint8Array(dh2), offset); offset += dh2.byteLength;
km.set(new Uint8Array(dh3), offset); offset += dh3.byteLength;
if (dh4) km.set(new Uint8Array(dh4), offset);
const encoder = new TextEncoder();
return await hkdf(new Uint8Array(32), km.buffer, encoder.encode(contextInfo), 32);
}
// ----------------------------------------------------------------------------
// 2. KDF Chains (Symmetric Ratchet)
// ----------------------------------------------------------------------------
// Constants for HMAC
const ONE = new Uint8Array([0x01]);
const TWO = new Uint8Array([0x02]);
async function kdfChain(ck: ArrayBuffer): Promise<{ ck: ArrayBuffer, mk: ArrayBuffer }> {
// HMAC-SHA256(CK, 1) -> MK
// HMAC-SHA256(CK, 2) -> NextCK
// Implementing via HKDF for simplicity/consistency or WebCrypto HMAC
// Actually standard says:
// HMAC-SHA256(ck, input)
// We can use HKDF-Expand logic here or pure hmac.
// Let's use custom HKDF expand with fixed info
const mk = await hkdf(new Uint8Array(0), ck, ONE, 32);
const nextCk = await hkdf(new Uint8Array(0), ck, TWO, 32);
return { ck: nextCk, mk };
}
// ----------------------------------------------------------------------------
// 3. DHRatchet (Root Chain)
// ----------------------------------------------------------------------------
async function kdfRoot(rootKey: ArrayBuffer, dhOut: ArrayBuffer): Promise<{ rootKey: ArrayBuffer, chainKey: ArrayBuffer }> {
// HKDF(root, dh, info, 64) -> 32 root, 32 chain
const encoder = new TextEncoder();
const output = await hkdf(
rootKey,
dhOut,
encoder.encode("SynapsisRatchet"),
64
);
const bytes = new Uint8Array(output);
return {
rootKey: bytes.slice(0, 32).buffer,
chainKey: bytes.slice(32, 64).buffer
};
}
// ----------------------------------------------------------------------------
// 4. Initializers
// ----------------------------------------------------------------------------
export async function initSender(
sharedSecret: ArrayBuffer,
bobRatchetKey: CryptoKey
): Promise<RatchetState> {
const dhPair = await generateX25519KeyPair();
// Sender starts by sending a new DH ratchet.
// Root Key = sharedSecret.
// First, we need to generate a chain key for sending.
// Standard: Alice initializes with SK. Bob's ratchet key is remote.
// Alice generates `dhPair`.
// She performs a DH ratchet Step immediately?
// Protocol:
// Alice: RK = SK.
// Alice performs DH(alice_priv, bob_pub).
// Calculates RK, CK_send.
const dhOut = await computeSharedSecret(dhPair.privateKey, bobRatchetKey);
const kdf = await kdfRoot(sharedSecret, dhOut);
return {
dhPair,
remoteDhPub: bobRatchetKey,
rootKey: kdf.rootKey,
chainKeySend: kdf.chainKey,
chainKeyRecv: new Uint8Array(0).buffer, // Empty until Bob replies
ns: 0,
nr: 0,
pn: 0
};
}
export async function initReceiver(
sharedSecret: ArrayBuffer,
bobDhKeyPair: KeyPair // This is the SPK key pair used in X3DH
): Promise<RatchetState> {
// Bob: RK = SK.
// Bob has consistent state.
return {
dhPair: bobDhKeyPair,
remoteDhPub: bobDhKeyPair.publicKey, // Placeholder, will be updated on first msg
rootKey: sharedSecret,
chainKeySend: new Uint8Array(0).buffer,
chainKeyRecv: new Uint8Array(0).buffer, // Will be derived on first msg
ns: 0,
nr: 0,
pn: 0
};
}
// ----------------------------------------------------------------------------
// 5. Encrypt / Decrypt Message
// ----------------------------------------------------------------------------
export async function ratchetEncrypt(
state: RatchetState,
plaintext: string
): Promise<{
ciphertext: CiphertextMessage,
newState: RatchetState
}> {
// 1. Advance chain
const { ck: nextCk, mk } = await kdfChain(state.chainKeySend);
state.chainKeySend = nextCk;
// 2. Encrypt
const header: Header = {
dh: await exportKey(state.dhPair.publicKey),
pn: state.pn,
n: state.ns
};
const associatedData = new TextEncoder().encode(JSON.stringify(header));
const encrypted = await aeadEncrypt(mk, plaintext, associatedData);
state.ns += 1;
return {
ciphertext: {
header,
ciphertext: encrypted.ciphertext,
iv: encrypted.iv
},
newState: state
};
}
// Note: Decryption requires handling out-of-order messages and ratcheting steps.
// This is complex logic. For V2.1 baseline, we implement the core ratcheting step if header key differs.
export async function ratchetDecrypt(
state: RatchetState,
message: CiphertextMessage
): Promise<{ plaintext: string, newState: RatchetState }> {
// Check if DH ratchet step needed
// If message.header.dh != state.remoteDhPub
// Note: Comparing CryptoKeys directly is hard. We compare Base64 export.
const remoteKeyStr = await exportKey(state.remoteDhPub);
if (message.header.dh !== remoteKeyStr) {
// Ratchet Step!
const newRemoteKey = await importX25519PublicKey(message.header.dh);
// 1. DHRatchet(remote_new) -> RX step
const dhOut1 = await computeSharedSecret(state.dhPair.privateKey, newRemoteKey);
const kdf1 = await kdfRoot(state.rootKey, dhOut1);
state.rootKey = kdf1.rootKey;
state.chainKeyRecv = kdf1.chainKey;
// 2. Sender step (We generate new key)
state.pn = state.ns;
state.ns = 0;
state.nr = 0;
state.dhPair = await generateX25519KeyPair();
// 3. DHRatchet(remote_new) -> TX step
const dhOut2 = await computeSharedSecret(state.dhPair.privateKey, newRemoteKey);
const kdf2 = await kdfRoot(state.rootKey, dhOut2);
state.rootKey = kdf2.rootKey;
state.chainKeySend = kdf2.chainKey;
state.remoteDhPub = newRemoteKey;
}
// 3. Symmetric Ratchet to catch up to n
// (Skipping skipped-message buffering for now - assumes ordered delivery for V2.1 baseline)
// Advance Chain Recv to n
// Real impl buffers skipped keys.
// Warning: If n > nr, we must loop.
// For now, assuming direct sequence.
const { ck: nextCk, mk } = await kdfChain(state.chainKeyRecv);
state.chainKeyRecv = nextCk;
state.nr += 1;
// 4. Decrypt
const associatedData = new TextEncoder().encode(JSON.stringify(message.header));
const plaintext = await aeadDecrypt(mk, message.ciphertext, message.iv, associatedData);
return { plaintext, newState: state };
}
+14 -4
View File
@@ -126,7 +126,13 @@ export async function exportPublicKey(key: CryptoKey): Promise<string> {
* Import Public Key from SPKI Base64 (for verification)
*/
export async function importPublicKey(base64Key: string): Promise<CryptoKey> {
const binary = base64ToArrayBuffer(base64Key);
// Strip PEM headers and whitespace/newlines if present
const cleanKey = base64Key
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
.replace(/-----END PUBLIC KEY-----/g, '')
.replace(/[\s\n\r]/g, '');
const binary = base64ToArrayBuffer(cleanKey);
return await cryptoSubtle.importKey(
'spki',
binary,
@@ -179,10 +185,14 @@ export function canonicalize(obj: any): string {
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 pairs: string[] = [];
for (const key of keys) {
if (obj[key] === undefined) continue;
const val = canonicalize(obj[key]);
return `${JSON.stringify(key)}:${val}`;
});
pairs.push(`${JSON.stringify(key)}:${val}`);
}
return `{${pairs.join(',')}}`;
}
+241 -458
View File
@@ -1,504 +1,287 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import {
unlockChatStorage,
loadDeviceKeys,
storeDeviceKeys,
isStorageUnlocked,
storeEncrypted,
loadEncrypted
} 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
} from '@/lib/crypto/ratchet';
import { useUserIdentity } from './useUserIdentity';
import { v4 as uuidv4 } from 'uuid';
// Storage keys
const PRIVATE_KEY_STORAGE = 'synapsis_chat_private_key';
const PUBLIC_KEY_STORAGE = 'synapsis_chat_public_key';
// Helper to check signature (we trust server for V2.1 baseline usually, but client check is better)
// import { verifyUserAction } from '...'; // Client side verification lib?
interface ChatKeys {
publicKey: string;
privateKey: string;
}
interface ServerKeyData {
chatPublicKey: string | null;
chatPrivateKeyEncrypted: string | null;
hasKeys: boolean;
}
/**
* Hook for managing E2E chat encryption
* Private keys are encrypted with user's password before server backup
*/
export function useChatEncryption() {
const [keys, setKeys] = useState<ChatKeys | null>(null);
const { signUserAction, identity } = useUserIdentity();
const [isReady, setIsReady] = useState(false);
const [isRegistering, setIsRegistering] = useState(false);
const [needsPasswordToRestore, setNeedsPasswordToRestore] = useState(false);
const [serverKeyData, setServerKeyData] = useState<ServerKeyData | null>(null);
const [status, setStatus] = useState<string>('idle');
// Check for existing keys on mount
useEffect(() => {
// Only run in browser
if (typeof window === 'undefined') {
return;
}
checkKeys();
}, []);
// Session Cache (In-Memory)
const sessionsRef = useRef<Map<string, RatchetState>>(new Map());
const checkKeys = async () => {
// First check localStorage
const publicKey = localStorage.getItem(PUBLIC_KEY_STORAGE);
const privateKey = localStorage.getItem(PRIVATE_KEY_STORAGE);
if (publicKey && privateKey) {
setKeys({ publicKey, privateKey });
setIsReady(true);
return;
}
// Check if server has encrypted backup
const ensureReady = useCallback(async (password: string, userId: string) => {
setStatus('initializing');
try {
const res = await fetch('/api/chat/keys');
if (res.ok) {
const data: ServerKeyData = await res.json();
setServerKeyData(data);
if (data.hasKeys && data.chatPrivateKeyEncrypted) {
// Keys exist on server but not locally - need password to restore
setNeedsPasswordToRestore(true);
}
if (!isStorageUnlocked()) {
await unlockChatStorage(password, userId);
}
let keys = await loadDeviceKeys();
// Check for legacy or V2 mix
// If we find V2 keys, good.
if (!keys) {
setStatus('generating_keys');
const identityKey = await generateX25519KeyPair();
const signedPreKey = await generatePreKey();
const otks = await Promise.all(Array.from({ length: 5 }).map(() => generatePreKey()));
const deviceId = uuidv4();
localStorage.setItem('synapsis_device_id', deviceId);
await storeDeviceKeys(identityKey, signedPreKey, otks);
const bundlePayload = {
deviceId,
identityKey: await exportKey(identityKey.publicKey),
signedPreKey: {
id: 1,
key: await exportKey(signedPreKey.publicKey),
},
oneTimeKeys: await Promise.all(otks.map(async (k, i) => ({
id: 100 + i,
key: await exportKey(k.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');
keys = { identity: identityKey, signedPreKey, otks };
}
// Restore session cache?
// Ideally load all sessions? Lazy load is better.
setIsReady(true);
setStatus('ready');
} catch (error) {
console.error('Failed to check server keys:', error);
console.error('Chat init failed:', error);
setStatus('error');
throw error;
}
}, [signUserAction]);
setIsReady(true);
};
const sendMessage = useCallback(async (recipientDid: string, content: string) => {
if (!isReady || !identity) throw new Error('Chat not ready');
// Restore keys from server backup using password
const restoreKeysWithPassword = useCallback(async (password: string): Promise<boolean> => {
if (!serverKeyData?.chatPrivateKeyEncrypted || !serverKeyData?.chatPublicKey) {
throw new Error('No keys to restore');
}
// 1. Fetch Recipient Bundles
const res = await fetch(`/.well-known/synapsis/chat/${recipientDid}`);
if (!res.ok) throw new Error('Recipient not found');
const bundles: any[] = await res.json();
try {
// Decrypt the private key using password
const privateKey = await decryptPrivateKeyWithPassword(
serverKeyData.chatPrivateKeyEncrypted,
password
);
const localDeviceId = localStorage.getItem('synapsis_device_id');
if (!localDeviceId) throw new Error('No local device ID');
// Store in localStorage
localStorage.setItem(PUBLIC_KEY_STORAGE, serverKeyData.chatPublicKey);
localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey);
const localKeys = await loadDeviceKeys();
if (!localKeys) throw new Error('Keys lost');
setKeys({ publicKey: serverKeyData.chatPublicKey, privateKey });
setNeedsPasswordToRestore(false);
return true;
} catch (error) {
console.error('Failed to restore keys:', error);
return false;
}
}, [serverKeyData]);
// 2. Loop through all devices
for (const bundle of bundles) {
const sessionKey = `session:${recipientDid}:${bundle.deviceId}`;
// Generate new keys and register with server (encrypted backup)
const generateAndRegisterKeys = useCallback(async (password: string) => {
if (typeof window === 'undefined') {
throw new Error('Key generation can only be performed in the browser');
}
setIsRegistering(true);
try {
// Generate ECDH key pair using Web Crypto API
const keyPair = await window.crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveKey']
);
let state = sessionsRef.current.get(sessionKey);
if (!state) {
state = await loadEncrypted<RatchetState>(sessionKey) || undefined;
}
const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey);
const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
let headerData: any = null;
const publicKey = bufferToBase64(publicKeyBuffer);
const privateKey = bufferToBase64(privateKeyBuffer);
if (!state) {
// 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;
console.log('[GenerateKeys] Generated keys:', {
publicKeyLength: publicKey.length,
privateKeyLength: privateKey.length,
publicKeyBytes: publicKeyBuffer.byteLength,
privateKeyBytes: privateKeyBuffer.byteLength
});
const { sk, ephemeralKey } = await x3dhSender(
localKeys.identity,
{ identityKey: remoteIdentityKey, signedPreKey: remoteSignedPreKey, oneTimeKey: remoteOtk },
`SynapsisV2${[identity.did, recipientDid].sort().join('')}${[localDeviceId, bundle.deviceId].sort().join('')}`
);
// Encrypt private key with password for server backup
const encryptedPrivateKey = await encryptPrivateKeyWithPassword(privateKey, password);
state = await initSender(sk, remoteSignedPreKey);
console.log('[GenerateKeys] Encrypted private key length:', encryptedPrivateKey.length);
headerData = {
ik: await exportKey(localKeys.identity.publicKey),
ek: await exportKey(ephemeralKey.publicKey),
spkId: bundle.signedPreKey.id,
opkId: otk?.id
};
}
// Register public key + encrypted private key backup with server FIRST
const response = await fetch('/api/chat/keys', {
const { ciphertext, newState } = await ratchetEncrypt(state, content);
sessionsRef.current.set(sessionKey, newState);
await storeEncrypted(sessionKey, newState);
// 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
};
const fullData = {
recipientDid,
recipientDeviceId: bundle.deviceId,
ciphertext: JSON.stringify(payload)
};
const action = await signUserAction('chat.deliver', fullData);
await fetch('/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatPublicKey: publicKey,
chatPrivateKeyEncrypted: encryptedPrivateKey,
}),
body: JSON.stringify(action)
});
if (!response.ok) {
const error = await response.json();
console.error('[GenerateKeys] Server registration failed:', error);
throw new Error(error.error || 'Failed to register chat keys');
}
// Only save to localStorage AFTER server confirms
localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey);
localStorage.setItem(PUBLIC_KEY_STORAGE, publicKey);
setKeys({ publicKey, privateKey });
setNeedsPasswordToRestore(false);
console.log('[GenerateKeys] Keys generated and registered successfully');
return { publicKey, privateKey };
} catch (error) {
console.error('[GenerateKeys] Failed:', error);
throw error;
} finally {
setIsRegistering(false);
}
}, []);
// Encrypt a message for a recipient
const encryptMessage = useCallback(async (
message: string,
recipientPublicKey: string
): Promise<string> => {
if (typeof window === 'undefined') {
throw new Error('Encryption can only be performed in the browser');
}
if (!keys?.privateKey) {
throw new Error('No chat keys available');
}
const myPrivateKey = await importPrivateKey(keys.privateKey);
const theirPublicKey = await importPublicKey(recipientPublicKey);
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
}, [isReady, identity, signUserAction]);
const encoder = new TextEncoder();
const messageBytes = encoder.encode(message);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
/**
* Decrypt and verify an incoming envelope
*/
const decryptMessage = useCallback(async (envelope: any) => {
if (!isReady || !identity) return '[Chat not ready]';
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);
}, [keys]);
// Decrypt a message from a sender
const decryptMessage = useCallback(async (
encryptedMessage: string,
senderPublicKey: string
): Promise<string> => {
// Early browser check before any operations
if (typeof window === 'undefined') {
return '[Decryption only available in browser]';
}
try {
if (!keys?.privateKey) {
console.error('[Decrypt] No private key available');
return '[No decryption key available]';
}
if (!senderPublicKey) {
console.error('[Decrypt] No sender public key provided');
return '[Sender key missing]';
// 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 } = payload;
const localDeviceId = localStorage.getItem('synapsis_device_id');
if (recipientDeviceId !== localDeviceId) return `[Message for device ${recipientDeviceId.slice(0, 4)}...]`;
// 2. Load Session
const sessionKey = `session:${senderDid}:${senderDeviceId}`;
let state = sessionsRef.current.get(sessionKey);
if (!state) {
state = await loadEncrypted<RatchetState>(sessionKey) || undefined;
}
const myPrivateKey = await importPrivateKey(keys.privateKey);
const theirPublicKey = await importPublicKey(senderPublicKey);
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
// 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 combined = base64ToBuffer(encryptedMessage);
const localKeys = await loadDeviceKeys();
if (!localKeys) return '[Keys locked]';
if (combined.byteLength < 12) {
throw new Error('Message too short (invalid ciphertext)');
}
// Recover keys
const senderIdentityKey = await importX25519PublicKey(header.ik);
const senderEphemeralKey = await importX25519PublicKey(header.ek);
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
// 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.
const decrypted = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
sharedKey,
ciphertext
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (error) {
console.warn('[Decrypt] Failed:', error instanceof Error ? error.message : error);
// Return a descriptive placeholder based on the error
if (error instanceof Error) {
if (error.message.includes('public key') || error.message.includes('import key')) {
return '[Incompatible encryption format]';
}
if (error.message.includes('private key')) {
return '[Invalid private key]';
}
if (error.message.includes('base64') || error.message.includes('decode')) {
return '[Corrupted message data]';
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
}
return '[Cannot decrypt message]';
// 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}]`;
}
}, [keys]);
// Clear keys (on logout)
const clearKeys = useCallback(() => {
localStorage.removeItem(PRIVATE_KEY_STORAGE);
localStorage.removeItem(PUBLIC_KEY_STORAGE);
setKeys(null);
}, []);
}, [isReady, identity]);
return {
keys,
isReady,
isRegistering,
hasKeys: !!keys,
needsPasswordToRestore,
generateAndRegisterKeys,
restoreKeysWithPassword,
encryptMessage,
decryptMessage,
clearKeys,
status,
ensureReady,
sendMessage,
decryptMessage
};
}
// ============================================
// Password-based encryption for private key backup
// ============================================
async function encryptPrivateKeyWithPassword(privateKey: string, password: string): Promise<string> {
if (typeof window === 'undefined') {
throw new Error('Encryption can only be performed in the browser');
}
const encoder = new TextEncoder();
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
// 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,
iterations: 100000,
hash: 'SHA-256',
},
passwordKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
// Encrypt the private key
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
encoder.encode(privateKey)
);
// Return as JSON with all components
return JSON.stringify({
salt: bufferToBase64(salt.buffer),
iv: bufferToBase64(iv.buffer),
ciphertext: bufferToBase64(ciphertext),
});
}
async function decryptPrivateKeyWithPassword(encryptedData: string, password: string): Promise<string> {
if (typeof window === 'undefined') {
throw new Error('Decryption can only be performed in the browser');
}
const { salt, iv, ciphertext } = JSON.parse(encryptedData);
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// Derive key from password
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(salt),
iterations: 100000,
hash: 'SHA-256',
},
passwordKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
// Decrypt
const decrypted = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBuffer(iv) },
aesKey,
base64ToBuffer(ciphertext)
);
return decoder.decode(decrypted);
}
// ============================================
// ECDH Key helpers
// ============================================
async function importPublicKey(publicKeyBase64: string): Promise<CryptoKey> {
if (typeof window === 'undefined') {
throw new Error('Crypto operations can only be performed in the browser');
}
// Validate the key format
if (!publicKeyBase64 || typeof publicKeyBase64 !== 'string') {
throw new Error('Invalid public key: must be a non-empty string');
}
try {
const keyBuffer = base64ToBuffer(publicKeyBase64);
// Try SPKI format first (standard format, typically ~91 bytes for P-256)
try {
return await window.crypto.subtle.importKey(
'spki',
keyBuffer,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
} catch (spkiError) {
// Try raw format (65 bytes for uncompressed P-256 public key)
// Raw format is: 0x04 + X coordinate (32 bytes) + Y coordinate (32 bytes)
if (keyBuffer.byteLength === 65) {
try {
return await window.crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
} catch (rawError) {
// Both formats failed
}
}
// If neither worked, throw a descriptive error
throw new Error(`Cannot import key (${keyBuffer.byteLength} bytes): incompatible format`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.warn('[ImportPublicKey] Failed:', errorMsg);
throw new Error(`Failed to import public key: ${errorMsg}`);
}
}
async function importPrivateKey(privateKeyBase64: string): Promise<CryptoKey> {
if (typeof window === 'undefined') {
throw new Error('Crypto operations can only be performed in the browser');
}
const keyBuffer = base64ToBuffer(privateKeyBase64);
return window.crypto.subtle.importKey(
'pkcs8',
keyBuffer,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
['deriveKey']
);
}
async function deriveSharedKey(
myPrivateKey: CryptoKey,
theirPublicKey: CryptoKey
): Promise<CryptoKey> {
if (typeof window === 'undefined') {
throw new Error('Key derivation can only be performed in the browser');
}
return window.crypto.subtle.deriveKey(
{ name: 'ECDH', public: theirPublicKey },
myPrivateKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
// ============================================
// Buffer utilities
// ============================================
function bufferToBase64(buffer: ArrayBuffer): string {
// btoa is available in both browser and Node 16+, but let's be safe
if (typeof btoa === 'undefined') {
throw new Error('Base64 encoding not available in this environment');
}
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 {
// Gracefully 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 {
// atob is available in both browser and Node 16+, but let's be safe
if (typeof atob === 'undefined') {
throw new Error('Base64 decoding not available in this environment');
}
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)}`);
}
}
+13 -1
View File
@@ -13,7 +13,8 @@ import {
generateKeyPair,
exportPrivateKey,
exportPublicKey,
base64UrlToBase64
base64UrlToBase64,
createSignedAction
} from '@/lib/crypto/user-signing';
export interface UserIdentity {
@@ -117,6 +118,16 @@ export function useUserIdentity() {
setIsUnlocked(false);
};
/**
* Sign a user action
*/
const signUserAction = async (action: string, data: any) => {
if (!identity || !isUnlocked) {
throw new Error('Identity locked');
}
return await createSignedAction(action, data, identity.did, identity.handle);
};
return {
identity,
isUnlocked,
@@ -124,5 +135,6 @@ export function useUserIdentity() {
unlockIdentity,
lockIdentity,
clearIdentity,
signUserAction
};
}