Refactor chat encryption to use sodium, update API

Replaces legacy chat encryption modules with new sodium-based implementation. Adds and updates API routes for chat key management and debugging, introduces new hooks and scripts for sodium chat, and updates related database schema and configuration. Removes old crypto modules and updates dependencies accordingly.
This commit is contained in:
Christopher
2026-01-28 06:07:03 -08:00
parent 222f1c6d8a
commit a87977241c
28 changed files with 10701 additions and 2052 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -78,6 +78,20 @@
"when": 1769579537338,
"tag": "0010_add_sender_did_to_chat_messages",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769586658154,
"tag": "0011_elite_kat_farrell",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769586699065,
"tag": "0012_quick_mockingbird",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -1,7 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// Empty turbopack config to silence the warning
turbopack: {},
};
export default nextConfig;
+35
View File
@@ -15,6 +15,7 @@
"crypto-js": "^4.2.0",
"drizzle-orm": "^0.44.1",
"jose": "^6.0.11",
"libsodium-wrappers-sumo": "^0.8.2",
"lucide-react": "^0.562.0",
"next": "16.1.4",
"next-auth": "^5.0.0-beta.25",
@@ -1014,6 +1015,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -4516,6 +4518,7 @@
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
@@ -4588,6 +4591,7 @@
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4654,6 +4658,7 @@
"integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.53.1",
"@typescript-eslint/types": "8.53.1",
@@ -5152,6 +5157,7 @@
"resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.1.tgz",
"integrity": "sha512-N6SjDcgXdOcTAF+7uNoY69o7hCspe9BcA7YjQdxVu5d25avljTwyLaHBW3krWjrP0FfocgMk94qyVtQbeDp39A==",
"license": "MIT",
"peer": true,
"dependencies": {
"uncrypto": "^0.1.3"
}
@@ -5273,6 +5279,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5635,6 +5642,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6364,6 +6372,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -6441,6 +6450,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6626,6 +6636,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -8026,6 +8037,21 @@
"node": ">= 0.8.0"
}
},
"node_modules/libsodium-sumo": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz",
"integrity": "sha512-uMgnjphJ717jLN+jFG1HUgNrK/gOVVfaO1DGZ1Ig/fKLKLVhvaH/sM1I1v784JFvmkJDaczDpi7xSYC4Jvdo1Q==",
"license": "ISC"
},
"node_modules/libsodium-wrappers-sumo": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.2.tgz",
"integrity": "sha512-wd1xAY++Kr6VMikSaa4EPRAHJmFvNlGWiiwU3Jh3GR1zRYF3/I3vy/wYsr4k3LVsNzwb9sqfEQ4LdVQ6zEebyQ==",
"license": "ISC",
"dependencies": {
"libsodium-sumo": "^0.8.0"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -8835,6 +8861,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz",
"integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.10.1",
"pg-pool": "^3.11.0",
@@ -9021,6 +9048,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -9110,6 +9138,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9119,6 +9148,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -9932,6 +9962,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10608,6 +10639,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10767,6 +10799,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -11344,6 +11377,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11608,6 +11642,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+1
View File
@@ -22,6 +22,7 @@
"crypto-js": "^4.2.0",
"drizzle-orm": "^0.44.1",
"jose": "^6.0.11",
"libsodium-wrappers-sumo": "^0.8.2",
"lucide-react": "^0.562.0",
"next": "16.1.4",
"next-auth": "^5.0.0-beta.25",
+16
View File
@@ -0,0 +1,16 @@
import { db } from '../src/db';
import { chatDeviceBundles } from '../src/db/schema';
async function clearChatKeys() {
console.log('Clearing all chat device bundles...');
const result = await db.delete(chatDeviceBundles);
console.log('Cleared chat device bundles');
process.exit(0);
}
clearChatKeys().catch((error) => {
console.error('Failed to clear keys:', error);
process.exit(1);
});
@@ -10,33 +10,24 @@ export async function GET(
) {
const { did } = await params;
// 1. Fetch all devices for this DID
const bundles = await db.query.chatDeviceBundles.findMany({
// Fetch device bundle for this DID
const bundle = await db.query.chatDeviceBundles.findFirst({
where: eq(chatDeviceBundles.did, did),
with: {
oneTimeKeys: {
limit: 5, // Return a few keys; client picks one
// Ideally we pick non-conflicted ones or random ones
}
}
});
if (!bundles || bundles.length === 0) {
return NextResponse.json([], { status: 404 });
if (!bundle) {
return NextResponse.json({ error: 'No keys found' }, { status: 404 });
}
// 2. Format Response
const response = bundles.map(b => ({
did: b.did,
deviceId: b.deviceId,
identityKey: b.identityKey, // Base64 X25519
signedPreKey: JSON.parse(b.signedPreKey),
oneTimeKeys: b.oneTimeKeys.map(k => ({
id: k.keyId,
key: k.publicKey
})),
signature: b.signature // ECDSA signature of this bundle
}));
const signedPreKey = JSON.parse(bundle.signedPreKey);
const kyberPreKey = bundle.kyberPreKey ? JSON.parse(bundle.kyberPreKey) : null;
// Format Response for Olm
const response = {
identityKey: bundle.identityKey, // curve25519
signingKey: signedPreKey.signingKey, // ed25519
oneTimeKeys: kyberPreKey?.oneTimeKeys || [],
};
return NextResponse.json(response, {
headers: {
+101 -60
View File
@@ -1,79 +1,120 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatDeviceBundles, chatOneTimeKeys } from '@/db/schema';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { chatDeviceBundles } from '@/db/schema';
import { requireAuth } from '@/lib/auth';
import { eq, and } from 'drizzle-orm';
/**
* GET /api/chat/keys?did=<did>
* Fetch public key for a user
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const did = searchParams.get('did');
if (!did) {
return NextResponse.json({ error: 'Missing did parameter' }, { status: 400 });
}
console.log('[Chat Keys GET] Looking for DID:', did);
const bundle = await db.query.chatDeviceBundles.findFirst({
where: eq(chatDeviceBundles.did, did),
});
console.log('[Chat Keys GET] Found bundle:', bundle ? 'YES' : 'NO');
if (bundle) {
console.log('[Chat Keys GET] Bundle data:', {
userId: bundle.userId,
did: bundle.did,
deviceId: bundle.deviceId,
hasIdentityKey: !!bundle.identityKey,
});
}
if (!bundle) {
return NextResponse.json({ error: 'No keys found' }, { status: 404 });
}
// For libsodium, we just need the public key
return NextResponse.json({
publicKey: bundle.identityKey, // Reusing identityKey field for libsodium public key
});
} catch (error: any) {
console.error('[Chat Keys] Failed to fetch keys:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
/**
* POST /api/chat/keys
* Publish a new Device Bundle and OTKs.
* Publish public key
*
* Payload: SignedAction < {
* deviceId: string,
* identityKey: string,
* signedPreKey: { id, key, sig },
* signature: string, (The ECDSA signature of the bundle itself)
* oneTimeKeys: { id, key }[]
* } >
* Body: {
* publicKey: string (base64)
* }
*/
export async function POST(request: NextRequest) {
try {
const user = await requireAuth();
console.log('[Chat Keys POST] User:', user.handle, 'DID:', user.did, 'UserID:', user.id);
const body = await request.json();
const { publicKey } = body;
// 1. Verify User Identity (ECDSA Root)
// The wrapper itself is a SignedAction with action="chat.keys.publish"
// This proves the user (DID) is authorizing this device registration.
const user = await requireSignedAction(body);
console.log('[Chat Keys POST] Received publicKey:', publicKey ? publicKey.substring(0, 20) + '...' : 'MISSING');
const { deviceId, identityKey, signedPreKey, signature, oneTimeKeys } = body.data;
if (!publicKey) {
return NextResponse.json({ error: 'Missing publicKey' }, { status: 400 });
}
// 2. Validate Bundle Signature (The bundle.signature must cover the fields)
// Actually, requireSignedAction already verified the payload signature.
// The "signature" field inside data is redundant if the whole thing is signed by DID?
// Or is "signature" the signature of the bundle bytes?
// In our design, the SignedAction *is* the signature.
// So "signature" inside might be the "Self-Signature" of the X25519 Identity Key signing the structure?
// No, standard Signal: The Bundle is signed by Identity Key.
// Here, Identity Key is ECDSA. The SignedAction covers it.
// So we just trust the SignedAction.
// 3. Upsert Bundle
await db.transaction(async (tx) => {
// Upsert device bundle
await tx.delete(chatDeviceBundles).where(
and(
eq(chatDeviceBundles.userId, user.id),
eq(chatDeviceBundles.deviceId, deviceId)
)
);
const [bundle] = await tx.insert(chatDeviceBundles).values({
userId: user.id,
did: user.did,
deviceId,
identityKey,
signedPreKey: JSON.stringify(signedPreKey),
signature, // We store the action signature or the explicit inner signature provided
}).returning();
// 4. Insert OTKs
if (oneTimeKeys && oneTimeKeys.length > 0) {
await tx.insert(chatOneTimeKeys).values(
oneTimeKeys.map((k: any) => ({
userId: user.id,
bundleId: bundle.id,
keyId: k.id,
publicKey: k.key
}))
).onConflictDoNothing();
}
// Check if bundle exists
const existing = await db.query.chatDeviceBundles.findFirst({
where: and(
eq(chatDeviceBundles.userId, user.id),
eq(chatDeviceBundles.deviceId, '1')
)
});
return NextResponse.json({ success: true, count: oneTimeKeys?.length || 0 });
console.log('[Chat Keys POST] Existing bundle found:', existing ? 'YES' : 'NO');
if (existing) {
// Update existing bundle
console.log('[Chat Keys POST] Updating existing bundle...');
await db.update(chatDeviceBundles)
.set({
identityKey: publicKey,
did: user.did, // Update DID too in case it changed
lastSeenAt: new Date(),
})
.where(
and(
eq(chatDeviceBundles.userId, user.id),
eq(chatDeviceBundles.deviceId, '1')
)
);
console.log('[Chat Keys POST] Updated existing key');
} else {
// Insert new bundle
console.log('[Chat Keys POST] Inserting new bundle...');
await db.insert(chatDeviceBundles).values({
userId: user.id,
did: user.did,
deviceId: '1',
identityKey: publicKey,
signedPreKey: 'libsodium', // Placeholder
registrationId: 1,
signature: 'libsodium',
});
console.log('[Chat Keys POST] Inserted new key');
}
console.log('[Chat Keys POST] Key published successfully for', user.handle);
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Failed to publish keys:', error);
return NextResponse.json({ error: error.message }, { status: 400 });
console.error('[Chat Keys] Failed to publish key:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
}
+64 -160
View File
@@ -1,212 +1,116 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatInbox, chatConversations, chatMessages, users } from '@/db/schema';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { chatConversations, chatMessages, users } from '@/db/schema';
import { requireAuth } from '@/lib/auth';
import { eq, and } from 'drizzle-orm';
import { signedFetch } from '@/lib/api/signed-fetch';
/**
* POST /api/chat/send
* Deliver an encrypted envelope to a local or remote user's inbox.
* Store encrypted message (server never decrypts)
*
* Body: {
* recipientDid: string,
* senderPublicKey: string (base64),
* ciphertext: string (base64),
* nonce: string (base64),
* recipientHandle?: string
* }
*/
export async function POST(request: NextRequest) {
try {
const user = await requireAuth();
const body = await request.json();
const { recipientDid, senderPublicKey, ciphertext, nonce, recipientHandle } = body;
// 1. Verify Envelope Signature (Anti-Spoofing & Replay)
const user = await requireSignedAction(body);
const { action, data } = body;
if (action !== 'chat.deliver') {
return NextResponse.json({ error: 'Invalid action type' }, { status: 400 });
if (!recipientDid || !senderPublicKey || !ciphertext || !nonce) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const { recipientDid, recipientDeviceId, ciphertext, nodeDomain, recipientHandle } = data;
if (!recipientDid || !ciphertext) {
return NextResponse.json({ error: 'Missing required delivery fields' }, { status: 400 });
}
// 2. Check if recipient is local or remote
// Check if recipient is local
const recipientUser = await db.query.users.findFirst({
where: eq(users.did, recipientDid)
});
if (recipientUser) {
// LOCAL RECIPIENT - Store in local inbox
// LOCAL RECIPIENT
// Ensure conversation exists for recipient
let conversation = await db.query.chatConversations.findFirst({
// Ensure conversations exist
let recipientConv = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, user.handle)
)
});
if (!conversation) {
if (!recipientConv) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipientUser.id,
participant2Handle: user.handle,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted Message]'
lastMessagePreview: '[Encrypted]'
}).returning();
conversation = newConv;
recipientConv = newConv;
}
// Ensure conversation exists for sender
let senderConversation = await db.query.chatConversations.findFirst({
let senderConv = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, recipientUser.handle)
)
});
if (!senderConversation) {
await db.insert(chatConversations).values({
if (!senderConv) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: recipientUser.handle,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted Message]'
});
lastMessagePreview: '[Encrypted]'
}).returning();
senderConv = newConv;
}
// Insert into local inbox
await db.insert(chatInbox).values({
// Store encrypted message (libsodium format)
// Include recipientDid so sender can decrypt their own sent messages
const messageData = {
senderPublicKey,
recipientDid, // Add this so sender knows who to decrypt with
ciphertext,
nonce,
};
// Create message for recipient
await db.insert(chatMessages).values({
conversationId: recipientConv.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
recipientDid,
recipientDeviceId: recipientDeviceId || null,
envelope: JSON.stringify(body),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
encryptedContent: JSON.stringify(messageData),
deliveredAt: new Date(),
});
return NextResponse.json({ success: true, local: true });
// Create message for sender
await db.insert(chatMessages).values({
conversationId: senderConv.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
encryptedContent: JSON.stringify(messageData),
deliveredAt: new Date(),
});
return NextResponse.json({ success: true });
} else {
// REMOTE RECIPIENT - Forward to their node
let targetNodeDomain = nodeDomain;
if (!targetNodeDomain) {
// Try to extract from DID
if (recipientDid.startsWith('did:web:')) {
targetNodeDomain = recipientDid.replace('did:web:', '');
} else {
return NextResponse.json({
error: 'Remote delivery requires node domain'
}, { status: 400 });
}
}
// Forward the signed envelope to the remote node
const remoteUrl = `https://${targetNodeDomain}/api/swarm/chat/inbox`;
try {
console.log('[Chat Send] Forwarding to remote:', remoteUrl);
// Get or create conversation for sender
let conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, recipientHandle || recipientDid)
)
});
if (!conversation) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: recipientHandle || recipientDid,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted Message]'
}).returning();
conversation = newConv;
console.log('[Chat Send] Created conversation:', conversation.id);
}
// Store message locally so sender can see it
const messageId = crypto.randomUUID();
const localDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
const swarmMessageId = `swarm:${localDomain}:${messageId}`;
// Store full envelope data for decryption
const envelopeData = {
did: user.did,
handle: user.handle,
ciphertext: ciphertext
};
const [newMessage] = await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null, // Local sender
encryptedContent: JSON.stringify(envelopeData), // Full envelope
senderChatPublicKey: null, // V2 E2E - keys are in the envelope
swarmMessageId,
deliveredAt: null, // Will update when remote confirms
readAt: null,
}).returning();
console.log('[Chat Send] Stored local message:', newMessage.id);
const response = await fetch(remoteUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
let errorData;
try {
errorData = JSON.parse(errorText);
} catch {
errorData = { raw: errorText };
}
console.error('[Chat Send] Remote delivery failed:', response.status, errorData);
// Check if remote node doesn't support V2 (returns V1 validation errors)
const isV1ValidationError = errorData.details &&
Array.isArray(errorData.details) &&
errorData.details.some((d: any) =>
d.path && ['messageId', 'senderHandle', 'senderNodeDomain', 'recipientHandle', 'encryptedContent', 'timestamp'].includes(d.path[0])
);
if (isV1ValidationError) {
return NextResponse.json({
error: 'Remote node needs update',
details: 'The recipient node is running an older version that does not support the V2 chat protocol. Please ask the node administrator to update to the latest version.',
remoteError: errorData
}, { status: 400 });
}
return NextResponse.json({
error: 'Remote delivery failed',
details: errorData
}, { status: response.status });
}
// Mark message as delivered since remote accepted it
await db.update(chatMessages)
.set({ deliveredAt: new Date() })
.where(eq(chatMessages.id, newMessage.id));
console.log('[Chat Send] Message marked as delivered');
return NextResponse.json({ success: true, remote: true, messageId: newMessage.id });
} catch (error: any) {
console.error('[Chat Send] Remote delivery error:', error);
return NextResponse.json({
error: 'Failed to reach remote node',
details: error.message
}, { status: 500 });
}
// REMOTE RECIPIENT - not implemented yet
return NextResponse.json({ error: 'Remote delivery not yet implemented' }, { status: 501 });
}
} catch (error: any) {
console.error('Delivery failed:', error);
return NextResponse.json({ error: error.message }, { status: 400 });
console.error('Send failed:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
}
@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { db, users, chatDeviceBundles } from '@/db';
import { eq } from 'drizzle-orm';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const handle = searchParams.get('handle');
if (!handle) {
return NextResponse.json({ error: 'Missing handle parameter' }, { status: 400 });
}
// Find user
const user = await db.query.users.findFirst({
where: eq(users.handle, handle.toLowerCase()),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check for device bundles
const bundles = await db.query.chatDeviceBundles.findMany({
where: eq(chatDeviceBundles.userId, user.id),
with: {
oneTimeKeys: true,
},
});
return NextResponse.json({
user: {
id: user.id,
handle: user.handle,
did: user.did,
hasChatPublicKey: !!user.chatPublicKey,
},
bundles: bundles.map(b => ({
deviceId: b.deviceId,
identityKey: b.identityKey?.substring(0, 20) + '...',
hasSignedPreKey: !!b.signedPreKey,
oneTimeKeysCount: b.oneTimeKeys.length,
})),
bundleCount: bundles.length,
});
} catch (error) {
console.error('Check chat keys error:', error);
return NextResponse.json({ error: 'Failed to check keys' }, { status: 500 });
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { chatDeviceBundles } from '@/db/schema';
export async function GET() {
try {
const bundles = await db.select({
did: chatDeviceBundles.did,
userId: chatDeviceBundles.userId,
deviceId: chatDeviceBundles.deviceId,
identityKey: chatDeviceBundles.identityKey,
createdAt: chatDeviceBundles.createdAt,
}).from(chatDeviceBundles).orderBy(chatDeviceBundles.createdAt);
return NextResponse.json({ bundles, count: bundles.length });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { chatDeviceBundles } from '@/db/schema';
export async function POST() {
try {
console.log('[Debug] Clearing all chat device bundles...');
await db.delete(chatDeviceBundles);
console.log('[Debug] Cleared all chat device bundles');
return NextResponse.json({ success: true, message: 'Cleared all chat keys' });
} catch (error: any) {
console.error('[Debug] Failed to clear keys:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+17 -2
View File
@@ -33,7 +33,7 @@ const parseRemoteHandleQuery = (query: string): { handle: string; domain: string
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q') || '';
let query = searchParams.get('q') || '';
const type = searchParams.get('type') || 'all'; // all, users, posts
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
@@ -50,7 +50,22 @@ export async function GET(request: Request) {
});
}
const searchPattern = `%${query}%`;
// Normalize query for local user search
// Strip leading @ and local domain if present
let localSearchQuery = query.trim();
if (localSearchQuery.startsWith('@')) {
localSearchQuery = localSearchQuery.slice(1);
}
// Remove local domain if searching like "admin2@dev.syn.quest"
const localDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN;
if (localDomain && localSearchQuery.includes('@')) {
const parts = localSearchQuery.split('@');
if (parts[1] === localDomain) {
localSearchQuery = parts[0];
}
}
const searchPattern = `%${localSearchQuery}%`;
let searchUsers: SearchUser[] = [];
let searchPosts: typeof posts.$inferSelect[] = [];
+8 -3
View File
@@ -64,8 +64,13 @@ export async function POST(request: NextRequest) {
// Also create conversation and message for recipient UI
// Extract sender info from envelope
const senderHandle = body.handle || body.did; // Fallback to DID if no handle
const senderFullHandle = `${senderHandle}@${body.did?.split(':')[2] || 'unknown'}`; // Extract domain from DID
const senderHandle = body.handle || 'unknown';
const senderNodeDomain = body.data?.senderNodeDomain || 'unknown';
const senderFullHandle = senderNodeDomain !== 'unknown'
? `${senderHandle}@${senderNodeDomain}`
: senderHandle;
console.log('[Swarm Chat V2] Sender info:', { senderHandle, senderNodeDomain, senderFullHandle });
// Get or create conversation for recipient
let conversation = await db.query.chatConversations.findFirst({
@@ -108,7 +113,7 @@ export async function POST(request: NextRequest) {
senderHandle: senderFullHandle,
senderDisplayName: null, // Unknown until decrypted
senderAvatarUrl: null,
senderNodeDomain: body.did?.split(':')[2] || null,
senderNodeDomain: senderNodeDomain !== 'unknown' ? senderNodeDomain : null,
encryptedContent: JSON.stringify(envelopeData), // Full envelope for decryption
senderChatPublicKey: null,
swarmMessageId: `swarm:v2:${messageId}`,
+231 -179
View File
@@ -3,43 +3,11 @@
import { useState, useEffect, useRef } from 'react';
import { useAuth } from '@/lib/contexts/AuthContext';
import { useChatEncryption } from '@/lib/hooks/useChatEncryption';
import { loadDeviceKeys } from '@/lib/crypto/chat-storage';
import { useSodiumChat } from '@/lib/hooks/useSodiumChat';
import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react';
import { formatFullHandle } from '@/lib/utils/handle';
import { useRouter, useSearchParams } from 'next/navigation';
// Helper to decrypt self-encrypted message (copied from useChatEncryption)
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);
}
interface Conversation {
id: string;
participant2: {
@@ -69,15 +37,13 @@ interface Message {
}
export default function ChatPage() {
const { user, setShowUnlockPrompt } = useAuth();
const { user, isIdentityUnlocked, setShowUnlockPrompt } = useAuth();
const router = useRouter();
// V2 Hook Destructuring
const { isReady, isLocked, status, ensureReady, sendMessage, decryptMessage } = useChatEncryption();
// Libsodium E2EE Hook
const { isReady, status, sendMessage, decryptMessage } = useSodiumChat();
const searchParams = useSearchParams();
const composeHandle = searchParams.get('compose');
// Chat Data State
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
@@ -88,15 +54,71 @@ export default function ChatPage() {
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [loadingMessages, setLoadingMessages] = useState(false);
// Cache for decrypted messages to avoid re-decrypting on every poll
const decryptedCacheRef = useRef<Map<string, string>>(new Map());
// Track which messages we've attempted to decrypt (even if they failed)
const attemptedDecryptionRef = useRef<Set<string>>(new Set());
// Track the current conversation ID to prevent race conditions
const currentConversationIdRef = useRef<string | null>(null);
// Load conversations
useEffect(() => {
if (user && isReady) {
loadConversations(true); // Initial load with spinner
// Poll for new conversations every 5 seconds (no spinner)
const pollInterval = setInterval(() => {
loadConversations(false);
}, 5000);
return () => clearInterval(pollInterval);
}
}, [user, isReady]);
// Handle Compose Intent
useEffect(() => {
if (composeHandle && isReady && !selectedConversation && !showNewChat) {
setNewChatHandle(composeHandle);
setShowNewChat(true);
// We could auto-submit here if we refactored startNewChat to be separate from event hnadler
if (composeHandle && isReady && !selectedConversation && conversations.length >= 0) {
// Check if we already have a conversation with this user
const existing = conversations.find(c =>
c.participant2.handle.toLowerCase() === composeHandle.toLowerCase()
);
if (existing) {
setSelectedConversation(existing);
// Clear the query param so refresh doesn't keep resetting state
router.replace('/chat', { scroll: false });
} else if (!loading) {
// Fetch user details to create a draft conversation
const fetchUserAndInitDraft = async () => {
try {
const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`);
const data = await res.json();
if (data.user) {
const draftConv: Conversation = {
id: 'new',
participant2: {
handle: data.user.handle,
displayName: data.user.displayName || data.user.handle,
avatarUrl: data.user.avatarUrl,
did: data.user.did
},
lastMessageAt: new Date().toISOString(),
lastMessagePreview: 'New Conversation',
unreadCount: 0
};
setSelectedConversation(draftConv);
router.replace('/chat', { scroll: false });
}
} catch (e) {
console.error("Failed to load user for compose", e);
}
};
fetchUserAndInitDraft();
}
}
}, [composeHandle, isReady, selectedConversation, showNewChat]);
}, [composeHandle, isReady, selectedConversation, conversations, loading, router]);
// Legacy / V2 Hybrid State
@@ -135,33 +157,39 @@ export default function ChatPage() {
}
}, [user, router]);
// Load conversations
useEffect(() => {
if (user && isReady) {
// ... existing loadConversations code ...
loadConversations(true); // Initial load with spinner
// Poll for new conversations every 5 seconds (no spinner)
const pollInterval = setInterval(() => {
loadConversations(false);
}, 5000);
return () => clearInterval(pollInterval);
}
}, [user, isReady]);
// Load messages when conversation is selected
useEffect(() => {
if (selectedConversation && isReady) {
// Update current conversation ref
currentConversationIdRef.current = selectedConversation.id;
// Clear messages immediately to prevent flash
setMessages([]);
if (selectedConversation.id === 'new') {
setLoadingMessages(false);
return; // Don't load messages for new/draft conversation
}
setLoadingMessages(true);
loadMessages(selectedConversation.id);
markAsRead(selectedConversation.id);
// Poll for new messages every 3 seconds
const pollInterval = setInterval(() => {
loadMessages(selectedConversation.id);
// Only load if still the same conversation
if (currentConversationIdRef.current === selectedConversation.id && selectedConversation.id !== 'new') {
loadMessages(selectedConversation.id);
}
}, 3000);
return () => clearInterval(pollInterval);
} else if (!selectedConversation) {
// Clear messages when no conversation selected
currentConversationIdRef.current = null;
setMessages([]);
setLoadingMessages(false);
}
}, [selectedConversation, isReady]);
@@ -192,97 +220,77 @@ export default function ChatPage() {
const decrypted = await Promise.all((data.messages || []).map(async (msg: any) => {
try {
console.log('[Chat UI] Processing message:', {
id: msg.id,
isSentByMe: msg.isSentByMe,
hasEncryptedContent: !!msg.encryptedContent,
contentPreview: msg.encryptedContent?.substring(0, 100)
});
// Check cache first
const cacheKey = `${msg.id}`;
const cached = decryptedCacheRef.current.get(cacheKey);
if (cached) {
return { ...msg, decryptedContent: cached };
}
// Check if this is a V2 encrypted message (JSON envelope)
// Check if already attempted
if (attemptedDecryptionRef.current.has(cacheKey)) {
const fallback = decryptedCacheRef.current.get(cacheKey) || '🔒 [Encrypted]';
return { ...msg, decryptedContent: fallback };
}
// Mark as attempted
attemptedDecryptionRef.current.add(cacheKey);
// Parse libsodium message format
if (msg.encryptedContent && msg.encryptedContent.startsWith('{')) {
try {
// First layer: { did, handle, ciphertext }
const envelope = JSON.parse(msg.encryptedContent);
console.log('[Chat UI] Parsed envelope:', {
hasDid: !!envelope.did,
hasHandle: !!envelope.handle,
hasCiphertext: !!envelope.ciphertext,
ciphertextPreview: envelope.ciphertext?.substring(0, 100)
});
// Second layer: the actual payload with selfEncrypted
let payload;
if (envelope.ciphertext && typeof envelope.ciphertext === 'string' && envelope.ciphertext.startsWith('{')) {
payload = JSON.parse(envelope.ciphertext);
console.log('[Chat UI] Parsed V2 payload:', {
hasSelfEncrypted: !!payload.selfEncrypted,
hasCiphertext: !!payload.ciphertext,
recipientDeviceId: payload.recipientDeviceId,
senderDeviceId: payload.senderDeviceId
});
} else {
// Old format or malformed
payload = envelope;
}
// Libsodium format: {senderPublicKey, recipientDid, ciphertext, nonce}
if (envelope.senderPublicKey && envelope.ciphertext && envelope.nonce) {
// For decryption with crypto_box_open_easy:
// - We need the OTHER party's public key
// - We use OUR private key
// For messages we sent, try to decrypt the selfEncrypted copy
if (msg.isSentByMe && payload.selfEncrypted && user?.did) {
console.log('[Chat UI] Attempting to decrypt self-encrypted message');
try {
const localKeys = await loadDeviceKeys();
if (localKeys) {
const plaintext = await decryptForSelf(payload.selfEncrypted, localKeys.identity);
console.log('[Chat UI] Self-decrypt successful:', plaintext.substring(0, 50));
return { ...msg, decryptedContent: plaintext };
}
} catch (e) {
console.error('[Chat UI] Self-decrypt failed:', e);
}
}
// console.log('[Chat UI] Decrypting message:', {
// isSentByMe: msg.isSentByMe,
// recipientDid: envelope.recipientDid,
// senderPublicKey: envelope.senderPublicKey?.substring(0, 20) + '...'
// });
// For messages we received, decrypt normally
if (!msg.isSentByMe) {
console.log('[Chat UI] Attempting to decrypt received message');
// Need to get the sender's DID
let senderDid = msg.senderDid || envelope.did;
if (!senderDid && msg.senderHandle) {
// Try to resolve DID from handle
// If I sent this message, the "other party" is the recipient
// If I received this message, the "other party" is the sender
let otherPartyPublicKey = envelope.senderPublicKey;
if (msg.isSentByMe && envelope.recipientDid) {
// I'm the sender, so I need the recipient's public key to decrypt my own message
try {
const userRes = await fetch(`/api/users/${encodeURIComponent(msg.senderHandle)}`);
if (userRes.ok) {
const userData = await userRes.json();
senderDid = userData.user?.did;
const keyRes = await fetch(`/api/chat/keys?did=${encodeURIComponent(envelope.recipientDid)}`);
if (keyRes.ok) {
const keyData = await keyRes.json();
otherPartyPublicKey = keyData.publicKey;
// console.log('[Chat UI] Fetched recipient public key:', otherPartyPublicKey?.substring(0, 20) + '...');
}
} catch (e) {
console.error('[Chat UI] Failed to resolve sender DID:', e);
console.error('[Chat UI] Failed to fetch recipient key:', e);
}
} else {
// console.log('[Chat UI] Using sender public key from envelope');
}
if (senderDid) {
// Create the envelope structure that decryptMessage expects
const decryptEnvelope = {
did: senderDid,
data: {
ciphertext: envelope.ciphertext // Pass the inner payload JSON string
}
};
const dec = await decryptMessage(decryptEnvelope);
console.log('[Chat UI] Received decrypt result:', dec.substring(0, 50));
if (!dec.startsWith('[')) {
return { ...msg, decryptedContent: dec };
}
}
const plaintext = await decryptMessage(
envelope.ciphertext,
envelope.nonce,
otherPartyPublicKey
);
decryptedCacheRef.current.set(cacheKey, plaintext);
return { ...msg, decryptedContent: plaintext };
}
} catch (e) {
console.error('[Chat UI] V2 decryption failed:', e);
console.error('[Chat UI] Libsodium decryption failed:', e);
}
}
// Fallback: show encrypted indicator
console.log('[Chat UI] Could not decrypt message, showing encrypted');
return { ...msg, decryptedContent: '[Encrypted]' };
// Fallback
const fallback = '🔒 [Encrypted - refresh page]';
decryptedCacheRef.current.set(cacheKey, fallback);
return { ...msg, decryptedContent: fallback };
} catch (err) {
console.error('[Chat UI] Message processing error:', err);
return { ...msg, decryptedContent: '[Error]' };
@@ -290,8 +298,8 @@ export default function ChatPage() {
}));
setMessages(decrypted);
} catch (err) {
console.error('[Chat UI] Load messages error:', err);
} catch (err) {
console.error('[Chat UI] Load messages error:', err);
}
};
@@ -311,36 +319,42 @@ export default function ChatPage() {
if (!newMessage.trim() || !selectedConversation) return;
setSending(true);
try {
// Need Recipient DID.
// conversation.participant2 might have valid handle.
// We resolve DID first.
// Get recipient DID
let did = selectedConversation.participant2.did;
// We need to support nodeDomain for existing chats too.
// But Conversation interface might lack it.
// We can try to resolve it from handle if needed, or if we stored it?
// "participant2" comes from API.
// Let's assume we re-fetch to be safe if it's remote?
// Or just check handle structure?
let nodeDomain = undefined;
if (selectedConversation.participant2.handle.includes('@')) {
const parts = selectedConversation.participant2.handle.split('@');
if (parts.length === 2) nodeDomain = parts[1];
}
if (!did) {
const res = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`);
const data = await res.json();
did = data.user?.did;
nodeDomain = data.user?.nodeDomain || nodeDomain; // API is authoritative
if (!did) throw new Error('User not found');
}
await sendMessage(did, newMessage, nodeDomain, selectedConversation.participant2.handle);
// Send using Signal Protocol
await sendMessage(did, newMessage, selectedConversation.participant2.handle);
// Legacy UI expects message reload.
setNewMessage('');
await loadMessages(selectedConversation.id);
loadConversations(false);
// If this was a new conversation, we need to refresh the conversation list and select the real one
if (selectedConversation.id === 'new') {
// Refresh conversations to get the new ID
const res = await fetch('/api/swarm/chat/conversations');
const data = await res.json();
const updatedConversations = data.conversations || [];
setConversations(updatedConversations);
// Find the real conversation
const realConv = updatedConversations.find((c: Conversation) =>
c.participant2.handle === selectedConversation.participant2.handle
);
if (realConv) {
setSelectedConversation(realConv);
loadMessages(realConv.id);
}
} else {
await loadMessages(selectedConversation.id);
loadConversations(false);
}
} catch (err: any) {
console.error('[Send] Error:', err);
alert(`Failed: ${err.message}`);
@@ -354,36 +368,66 @@ export default function ChatPage() {
if (!newChatHandle.trim()) return;
setSending(true);
try {
const cleanHandle = newChatHandle.replace(/^@/, '');
let cleanHandle = newChatHandle.replace(/^@/, '');
// If the handle includes a domain, check if it's our local domain
if (cleanHandle.includes('@')) {
const [handle, domain] = cleanHandle.split('@');
const localDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || window.location.host;
// If it's our local domain, strip it for the API call
if (domain === localDomain) {
cleanHandle = handle;
}
}
const res = await fetch(`/api/users/${encodeURIComponent(cleanHandle)}`);
const data = await res.json();
if (!data.user?.did) {
alert('User not found or V2 not enabled.');
alert('User not found or Olm encryption not enabled.');
setSending(false);
return;
}
console.log('[Chat UI] Starting chat with:', data.user);
// Previously we auto-sent "👋" here.
// Now we just setup the draft conversation.
// Send "Hello" to init session
await sendMessage(data.user.did, '👋', data.user.nodeDomain, data.user.handle);
// Check if existing conversation
const existing = conversations.find(c =>
c.participant2.handle.toLowerCase() === data.user.handle.toLowerCase()
);
console.log('[Chat UI] Message sent, reloading conversations');
if (existing) {
setSelectedConversation(existing);
} else {
// Setup draft
const draftConv: Conversation = {
id: 'new',
participant2: {
handle: data.user.handle,
displayName: data.user.displayName || data.user.handle,
avatarUrl: data.user.avatarUrl,
did: data.user.did
},
lastMessageAt: new Date().toISOString(),
lastMessagePreview: 'New Conversation',
unreadCount: 0
};
setSelectedConversation(draftConv);
}
setShowNewChat(false);
setNewChatHandle('');
await loadConversations(false);
// Select the new conversation (we might need to find it)
// For now just reload list.
} catch (e: any) {
console.error('[Chat UI] Start chat failed:', e);
if (e.message.includes('Recipient keys not found')) {
if (e.message.includes('Recipient keys not found') || e.message.includes('Failed to fetch recipient keys')) {
alert('This user has not set up secure chat yet. They need to log in to enable end-to-end encryption.');
} else {
alert('Failed to start chat: ' + e.message);
}
} finally {
setSending(false);
} finally {
setSending(false);
}
};
@@ -410,6 +454,7 @@ export default function ChatPage() {
}
};
const filteredConversations = conversations.filter((conv) =>
conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase())
@@ -417,16 +462,21 @@ export default function ChatPage() {
if (user === null) return null;
// Locked State
if (isLocked) {
// Identity Locked State
if (!isIdentityUnlocked) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center', padding: '16px', textAlign: 'center' }}>
<Lock size={48} style={{ color: 'var(--accent)', marginBottom: '16px' }} />
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>Chat Locked</h2>
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '300px', marginBottom: '24px' }}>
Your end-to-end encrypted identity is locked. Please unlock it to view your messages.
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center', gap: '16px', padding: '24px' }}>
<Lock size={48} style={{ color: 'rgb(251, 191, 36)' }} />
<h2 style={{ fontSize: '20px', fontWeight: 600 }}>Identity Locked</h2>
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '400px', textAlign: 'center' }}>
End-to-end encrypted chat requires your identity to be unlocked. Your private keys are needed to encrypt and decrypt messages.
</p>
<button onClick={() => setShowUnlockPrompt(true)} className="btn btn-primary">
<button
onClick={() => setShowUnlockPrompt(true)}
className="btn btn-primary"
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<Shield size={16} />
Unlock Identity
</button>
</div>
@@ -440,24 +490,23 @@ export default function ChatPage() {
<Shield size={48} style={{ color: 'var(--destructive)' }} />
<h2 style={{ fontSize: '20px', fontWeight: 600 }}>Connection Failed</h2>
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '300px', textAlign: 'center' }}>
Unable to initialize secure chat. This might be a network issue or missing keys.
Unable to initialize secure chat. Please refresh the page to try again.
</p>
<button
onClick={() => ensureReady('RETRY', user?.id || 'retry')}
onClick={() => window.location.reload()}
className="btn btn-primary"
>
Retry Connection
Refresh Page
</button>
</div>
);
}
// Loading State
if ((!isReady && status !== 'error') || status === 'initializing' || status === 'generating_keys') {
if (!isReady || status === 'initializing') {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center' }}>
<Loader2 className="animate-spin" size={32} />
<p style={{ marginTop: 16 }}>Initializing Secure Encrypted Chat...</p>
</div>
);
}
@@ -673,7 +722,10 @@ export default function ChatPage() {
<div
key={conv.id}
className="post"
onClick={() => setSelectedConversation(conv)}
onClick={() => {
setMessages([]);
setSelectedConversation(conv);
}}
style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px' }}
>
<div className="avatar">
+7
View File
@@ -925,6 +925,7 @@ export const chatMessages = pgTable('chat_messages', {
senderDisplayName: text('sender_display_name'),
senderAvatarUrl: text('sender_avatar_url'),
senderNodeDomain: text('sender_node_domain'), // null if local
senderDid: text('sender_did'), // DID for Signal Protocol
// Message content (encrypted for recipient with their public key)
encryptedContent: text('encrypted_content').notNull(),
@@ -968,11 +969,17 @@ export const chatDeviceBundles = pgTable('chat_device_bundles', {
// The device identifier (UUID generated by client)
deviceId: text('device_id').notNull(),
// Signal Protocol fields
registrationId: integer('registration_id'),
// X25519 Identity Key (Base64)
identityKey: text('identity_key').notNull(),
// Signed PreKey (JSON: { id, key, sig })
signedPreKey: text('signed_pre_key').notNull(),
// Kyber PreKey for post-quantum security (JSON: { id, key, sig })
kyberPreKey: text('kyber_pre_key'),
// One-Time Keys (JSON array of { id, key }) - cached/uploaded batch
// Note: Individual keys are usually stored in a separate table for atomic consumption,
+9 -12
View File
@@ -2,7 +2,6 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { useUserIdentity } from '@/lib/hooks/useUserIdentity';
import { useChatEncryption } from '@/lib/hooks/useChatEncryption';
export interface User {
id: string;
@@ -68,9 +67,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
// Integrate chat encryption hook
const { ensureReady } = useChatEncryption();
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
/**
@@ -83,15 +79,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
throw new Error('No encrypted private key available');
}
await unlockIdentityHook(targetUser.privateKeyEncrypted, password);
// Initialize Chat Keys (Async, don't block UI but start it)
if (targetUser.id) {
ensureReady(password, targetUser.id).catch(err => {
console.error('Failed to initialize chat keys:', err);
});
}
await unlockIdentityHook(
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
};
-236
View File
@@ -1,236 +0,0 @@
/**
* 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);
});
}
async function deleteItem(key: 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.delete(key);
req.onsuccess = () => resolve();
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;
}
}
/**
* Deletes an encrypted item from storage.
*/
export async function deleteEncrypted(key: string): Promise<void> {
await deleteItem(key);
}
/**
* Clears all session data (useful for recovery from corruption).
*/
export async function clearAllSessions(): 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.openCursor();
req.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
// Only delete session keys, not device keys
if (cursor.key.toString().startsWith('session:')) {
cursor.delete();
}
cursor.continue();
} else {
resolve();
}
};
req.onerror = () => reject(req.error);
});
}
// ----------------------------------------------------------------------------
// 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';
-104
View File
@@ -1,104 +0,0 @@
/**
* End-to-End Encrypted Chat Cryptography
*
* Uses ECDH (Elliptic Curve Diffie-Hellman) for key exchange
* and AES-GCM for message encryption.
*
* This is a simplified version of the Signal Protocol approach.
* Private keys NEVER leave the client.
*/
import * as crypto from 'crypto';
/**
* Generate an ECDH key pair for chat encryption
* The private key should be stored client-side only
*/
export function generateChatKeyPair(): { publicKey: string; privateKey: string } {
const ecdh = crypto.createECDH('prime256v1');
ecdh.generateKeys();
return {
publicKey: ecdh.getPublicKey('base64'),
privateKey: ecdh.getPrivateKey('base64'),
};
}
/**
* Derive a shared secret from your private key and their public key
* This is the magic of ECDH - both parties derive the same secret
*/
export function deriveSharedSecret(myPrivateKey: string, theirPublicKey: string): Buffer {
const ecdh = crypto.createECDH('prime256v1');
ecdh.setPrivateKey(Buffer.from(myPrivateKey, 'base64'));
const sharedSecret = ecdh.computeSecret(Buffer.from(theirPublicKey, 'base64'));
// Derive a proper AES key from the shared secret using HKDF
return crypto.createHash('sha256').update(sharedSecret).digest();
}
/**
* Encrypt a message using the shared secret
*/
export function encryptWithSharedSecret(message: string, sharedSecret: Buffer): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', sharedSecret, iv);
const encrypted = Buffer.concat([
cipher.update(message, 'utf8'),
cipher.final()
]);
const authTag = cipher.getAuthTag();
// Combine: iv (12) + authTag (16) + ciphertext
const combined = Buffer.concat([iv, authTag, encrypted]);
return combined.toString('base64');
}
/**
* Decrypt a message using the shared secret
*/
export function decryptWithSharedSecret(encryptedMessage: string, sharedSecret: Buffer): string {
const combined = Buffer.from(encryptedMessage, 'base64');
const iv = combined.subarray(0, 12);
const authTag = combined.subarray(12, 28);
const ciphertext = combined.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', sharedSecret, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]);
return decrypted.toString('utf8');
}
/**
* High-level: Encrypt a message for a recipient
* Uses sender's private key + recipient's public key
*/
export function encryptMessage(
message: string,
senderPrivateKey: string,
recipientPublicKey: string
): string {
const sharedSecret = deriveSharedSecret(senderPrivateKey, recipientPublicKey);
return encryptWithSharedSecret(message, sharedSecret);
}
/**
* High-level: Decrypt a message from a sender
* Uses recipient's private key + sender's public key
*/
export function decryptMessage(
encryptedMessage: string,
recipientPrivateKey: string,
senderPublicKey: string
): string {
const sharedSecret = deriveSharedSecret(recipientPrivateKey, senderPublicKey);
return decryptWithSharedSecret(encryptedMessage, sharedSecret);
}
-247
View File
@@ -1,247 +0,0 @@
/**
* 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' },
true, // Must be extractable for serialization
['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 algorithm: any = {
name: 'AES-GCM',
iv: iv
};
if (associatedData) {
algorithm.additionalData = associatedData;
}
const encrypted = await cryptoSubtle.encrypt(
algorithm,
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 algorithm: any = {
name: 'AES-GCM',
iv: iv
};
if (associatedData) {
algorithm.additionalData = associatedData;
}
const decrypted = await cryptoSubtle.decrypt(
algorithm,
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 {
if (!base64 || typeof base64 !== 'string') {
throw new Error('Invalid base64 input: expected non-empty string');
}
// 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;
}
View File
-390
View File
@@ -1,390 +0,0 @@
/**
* 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,
importX25519PrivateKey,
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 };
}
// ----------------------------------------------------------------------------
// 6. Serialization Helpers (CRITICAL: CryptoKeys and Buffers don't JSON stringify)
// ----------------------------------------------------------------------------
export interface SerializedRatchetState {
dhPair: { pub: string, priv: string };
remoteDhPub: string;
rootKey: string;
chainKeySend: string;
chainKeyRecv: string;
ns: number;
nr: number;
pn: number;
}
export async function serializeRatchetState(state: RatchetState): Promise<SerializedRatchetState> {
return {
dhPair: {
pub: await exportKey(state.dhPair.publicKey),
priv: await exportKey(state.dhPair.privateKey)
},
remoteDhPub: await exportKey(state.remoteDhPub),
rootKey: arrayBufferToBase64(state.rootKey),
chainKeySend: arrayBufferToBase64(state.chainKeySend),
chainKeyRecv: arrayBufferToBase64(state.chainKeyRecv),
ns: state.ns,
nr: state.nr,
pn: state.pn
};
}
export async function deserializeRatchetState(data: SerializedRatchetState): Promise<RatchetState> {
// Validate integrity - check all required fields exist (but allow empty strings for buffers that can be empty)
if (!data ||
!data.rootKey ||
!data.dhPair ||
!data.dhPair.pub ||
!data.dhPair.priv ||
!data.remoteDhPub ||
data.chainKeySend === undefined || data.chainKeySend === null ||
data.chainKeyRecv === undefined || data.chainKeyRecv === null) {
throw new Error('Invalid serialized state: missing required fields');
}
return {
dhPair: {
publicKey: await importX25519PublicKey(data.dhPair.pub),
privateKey: await importX25519PrivateKey(data.dhPair.priv)
},
remoteDhPub: await importX25519PublicKey(data.remoteDhPub),
rootKey: base64ToArrayBuffer(data.rootKey),
chainKeySend: data.chainKeySend ? base64ToArrayBuffer(data.chainKeySend) : new Uint8Array(0).buffer,
chainKeyRecv: data.chainKeyRecv ? base64ToArrayBuffer(data.chainKeyRecv) : new Uint8Array(0).buffer,
ns: data.ns,
nr: data.nr,
pn: data.pn
};
}
+228
View File
@@ -0,0 +1,228 @@
/**
* 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),
};
}
/**
* 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();
const messageBytes = sodium.from_string(message);
const recipientPubKey = sodium.from_base64(recipientPublicKey);
const senderPrivKey = sodium.from_base64(senderPrivateKey);
// 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),
};
}
/**
* 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 = sodium.from_base64(ciphertext);
const nonceBytes = sodium.from_base64(nonce);
const senderPubKey = sodium.from_base64(senderPublicKey);
const recipientPrivKey = sodium.from_base64(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);
const privateKey = sodium.to_string(decrypted);
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);
}
}
+10
View File
@@ -27,6 +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() { }
@@ -56,9 +57,18 @@ 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;
}
}
-634
View File
@@ -1,634 +0,0 @@
'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);
const serialized = await serializeRatchetState(newState);
await storeEncrypted(sessionKey, serialized);
return plaintext;
} catch (e: any) {
console.error('Decryption failed:', e);
return `[Decryption Error: ${e.message}]`;
}
}, [isReady, identity]);
return {
isReady,
isLocked,
status,
ensureReady,
sendMessage,
decryptMessage
};
}
+204
View File
@@ -0,0 +1,204 @@
/**
* 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...');
const checkResponse = await fetch(`/api/chat/keys?did=${encodeURIComponent(user.did)}`);
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,
};
}
+50 -2
View File
@@ -89,8 +89,22 @@ export function useUserIdentity() {
/**
* Unlock the identity with a password
*/
const unlockIdentity = async (privateKeyEncrypted: string, password: string) => {
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({
did: userDid,
handle: userHandle,
publicKey: userPublicKey
});
console.log('[Identity] Identity set in keyStore');
} else {
console.warn('[Identity] Missing user info for identity setup');
}
// 1. Decrypt the PEM/String from server (which is actually a base64 encoded PKCS8 export usually?)
// Wait, existing implementation returns a string.
// We need to verify what `decryptPrivateKey` returns.
@@ -114,8 +128,42 @@ export function useUserIdentity() {
// 3. Store in Memory
keyStore.setPrivateKey(cryptoKey);
console.log('[Identity] Private key stored in memory');
// 4. Update State
// 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);
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
setIdentity(prev => prev ? { ...prev, isUnlocked: true } : null); // We need the other data...
setIsUnlocked(true);