Reconcile the accepted safe federation helper with E2EE integration hardening and complete PIN-based encrypted DMs

Hop-State: A_06FPC0CGS3F7SJG3BGW0YF0
Hop-Proposal: R_06FPC0BK6YEV7XASNM7C908
Hop-Task: T_06FPAWA3279RBMJAR0BHM10
Hop-Attempt: AT_06FPBZWSBFTB9B5YH9JY9QR
This commit is contained in:
2026-07-15 06:40:06 -07:00
committed by Hop
parent 219a40bea4
commit b46be5c076
54 changed files with 14409 additions and 1192 deletions
+439
View File
@@ -0,0 +1,439 @@
import {
createHash,
createSign,
createVerify,
generateKeyPairSync,
randomBytes,
randomUUID,
} from 'node:crypto';
import { NextRequest } from 'next/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { generateDID } from '@/lib/crypto/did-key';
import { canonicalize } from '@/lib/crypto/user-signing';
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
import {
E2EE_CIPHER_SUITE,
E2EE_KEY_BUNDLE_ACTION,
E2EE_PROTOCOL,
} from '@/lib/e2ee/protocol';
const mocks = vi.hoisted(() => ({
tables: {
users: { table: 'users' },
posts: { table: 'posts' },
media: { table: 'media' },
follows: { table: 'follows' },
remoteFollows: { table: 'remoteFollows' },
chatConversations: { table: 'chatConversations' },
chatMessages: { table: 'chatMessages' },
e2eeKeyBundles: { table: 'e2eeKeyBundles' },
e2eeMessageReceipts: { table: 'e2eeMessageReceipts' },
bots: { table: 'bots' },
botContentSources: { table: 'botContentSources' },
botActivityLogs: { table: 'botActivityLogs' },
},
requireAuth: vi.fn(),
verifyPassword: vi.fn(),
hashPassword: vi.fn(),
createSession: vi.fn(),
findPosts: vi.fn(),
findFollows: vi.fn(),
findRemoteFollows: vi.fn(),
findConversations: vi.fn(),
findE2EEKeyBundle: vi.fn(),
findBots: vi.fn(),
findUsers: vi.fn(),
findNodes: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
transaction: vi.fn(),
insertedRows: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
safeFederationRequest: vi.fn(),
}));
vi.mock('@/lib/auth', () => ({
requireAuth: mocks.requireAuth,
verifyPassword: mocks.verifyPassword,
hashPassword: mocks.hashPassword,
createSession: mocks.createSession,
}));
vi.mock('@/db', () => ({
...mocks.tables,
db: {
insert: mocks.insert,
update: mocks.update,
transaction: mocks.transaction,
query: {
users: { findFirst: mocks.findUsers },
nodes: { findFirst: mocks.findNodes },
posts: { findMany: mocks.findPosts },
follows: { findMany: mocks.findFollows },
remoteFollows: { findMany: mocks.findRemoteFollows },
chatConversations: { findMany: mocks.findConversations },
e2eeKeyBundles: { findFirst: mocks.findE2EEKeyBundle },
bots: { findMany: mocks.findBots },
},
},
}));
vi.mock('drizzle-orm', async (importOriginal) => ({
...await importOriginal<typeof import('drizzle-orm')>(),
eq: vi.fn(() => ({})),
}));
vi.mock('@/lib/bots/encryption', () => ({
decryptApiKey: vi.fn(),
deserializeEncryptedData: vi.fn(),
encryptApiKey: vi.fn(),
serializeEncryptedData: vi.fn(),
}));
vi.mock('@/lib/federation/handles', () => ({ upsertHandleEntries: vi.fn() }));
vi.mock('@/lib/swarm/safe-federation-http', () => ({
safeFederationRequest: mocks.safeFederationRequest,
}));
import { POST } from './route';
import { POST as importAccount } from '../import/route';
interface ExportResponseBody {
export: {
manifest: {
version: '1.1';
did: string;
handle: string;
sourceNode: string;
exportedAt: string;
expiresAt: string;
publicKey: string;
privateKeyEncrypted: string;
payloadDigestAlgorithm: 'sha256';
payloadDigest: string;
signature: string;
};
profile: unknown;
posts: unknown[];
following: unknown[];
dms: unknown[];
bots: unknown[];
e2eeKeyBundle: unknown | null;
};
}
describe('account export manifest integrity', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.insertedRows.length = 0;
vi.stubEnv('NEXT_PUBLIC_NODE_DOMAIN', 'export.synapsis.social');
mocks.verifyPassword.mockResolvedValue(true);
mocks.hashPassword.mockResolvedValue('imported-password-hash');
mocks.createSession.mockResolvedValue('imported-session-token');
mocks.findPosts.mockResolvedValue([]);
mocks.findFollows.mockResolvedValue([]);
mocks.findRemoteFollows.mockResolvedValue([]);
mocks.findConversations.mockResolvedValue([]);
mocks.findE2EEKeyBundle.mockResolvedValue(undefined);
mocks.findBots.mockResolvedValue([]);
mocks.findUsers.mockResolvedValue(undefined);
mocks.findNodes.mockResolvedValue(undefined);
mocks.safeFederationRequest.mockResolvedValue({ status: 204 });
mocks.update.mockImplementation(() => ({
set: () => ({
where: () => Promise.resolve(),
}),
}));
mocks.insert.mockImplementation((table: unknown) => ({
values: (values: Record<string, unknown>) => {
mocks.insertedRows.push({ table, values });
const returnedRows = table === mocks.tables.users
? [{ id: 'imported-user-1', ...values }]
: table === mocks.tables.chatConversations
? [{ id: 'imported-conversation-1', ...values }]
: [];
return Object.assign(Promise.resolve(), {
returning: vi.fn(async () => returnedRows),
onConflictDoNothing: vi.fn(async () => undefined),
});
},
}));
mocks.transaction.mockImplementation(async (
callback: (tx: { insert: typeof mocks.insert }) => Promise<unknown>,
) => callback({ insert: mocks.insert }));
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('decrypts the stored key to sign v1.1 while exporting its encrypted blob unchanged', async () => {
const password = 'correct horse battery staple';
const { privateKey, publicKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
const storedPrivateKey = serializeEncryptedKey(encryptPrivateKey(privateKey, password));
const ownerDid = generateDID(publicKey);
const peerDid = 'did:synapsis:export-peer';
const envelopeCreatedAt = Math.floor(Date.now() / 1_000) * 1_000 + 537;
const storedCreatedAt = new Date(Math.floor(envelopeCreatedAt / 1_000) * 1_000);
const messageId = randomUUID();
const senderKeyId = `k1_${randomBytes(12).toString('base64url')}`;
const recipientKeyId = `k1_${randomBytes(12).toString('base64url')}`;
const envelope = {
protocol: E2EE_PROTOCOL,
cipherSuite: E2EE_CIPHER_SUITE,
messageId,
conversationId: `dm1_${randomBytes(12).toString('base64url')}`,
senderDid: ownerDid,
senderHandle: 'alice',
recipientDid: peerDid,
recipientHandle: 'bob',
createdAt: envelopeCreatedAt,
senderKeyId,
senderKeyVersion: 1,
recipientKeyId,
recipientKeyVersion: 1,
nonce: randomBytes(24).toString('base64url'),
ciphertext: randomBytes(17).toString('base64url'),
keyCommitment: randomBytes(32).toString('base64url'),
keyEnvelopes: [
{
did: ownerDid,
keyId: senderKeyId,
keyVersion: 1,
sealedKey: randomBytes(112).toString('base64url'),
},
{
did: peerDid,
keyId: recipientKeyId,
keyVersion: 1,
sealedKey: randomBytes(112).toString('base64url'),
},
],
};
const actionNonce = randomBytes(16).toString('base64url');
const actionPayload = {
action: 'chat_e2ee',
data: envelope,
did: ownerDid,
handle: 'alice',
nonce: actionNonce,
ts: envelopeCreatedAt,
};
const actionSigner = createSign('sha256');
actionSigner.update(canonicalize(actionPayload));
const actionSignature = actionSigner.sign({
key: privateKey,
dsaEncoding: 'ieee-p1363',
}).toString('base64url');
const encryptionPublicKey = randomBytes(32).toString('base64url');
const continuityKeyId = await encryptionKeyIdFromPublicKey(encryptionPublicKey);
if (!continuityKeyId) throw new Error('Could not derive test encryption key ID');
const continuityCreatedAt = Date.now();
const continuityBundle = {
protocol: E2EE_PROTOCOL,
keyId: continuityKeyId,
version: 1,
publicKey: encryptionPublicKey,
createdAt: continuityCreatedAt,
recoveryCommitment: randomBytes(32).toString('base64url'),
};
const continuityProofPayload = {
action: E2EE_KEY_BUNDLE_ACTION,
data: continuityBundle,
did: ownerDid,
handle: 'alice',
nonce: randomBytes(16).toString('base64url'),
ts: continuityCreatedAt,
};
const continuitySigner = createSign('sha256');
continuitySigner.update(canonicalize(continuityProofPayload));
const continuityProof = {
...continuityProofPayload,
sig: continuitySigner.sign({
key: privateKey,
dsaEncoding: 'ieee-p1363',
}).toString('base64url'),
};
mocks.findConversations.mockResolvedValue([{
id: 'conversation-1',
type: 'direct',
participant2Handle: 'bob',
lastMessageAt: storedCreatedAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: storedCreatedAt,
messages: [{
senderHandle: 'alice',
senderDisplayName: 'Alice',
senderAvatarUrl: null,
senderNodeDomain: null,
senderDid: ownerDid,
content: null,
protocolVersion: 1,
clientMessageId: messageId,
encryptedEnvelope: JSON.stringify(envelope),
e2eeSignature: actionSignature,
e2eeActionNonce: actionNonce,
e2eeActionTs: envelopeCreatedAt,
deliveredAt: null,
readAt: null,
createdAt: storedCreatedAt,
}],
}]);
mocks.requireAuth.mockResolvedValue({
id: 'user-1',
did: ownerDid,
handle: 'alice',
displayName: 'Alice',
bio: null,
avatarUrl: null,
headerUrl: null,
passwordHash: 'password-hash',
publicKey,
privateKeyEncrypted: storedPrivateKey,
movedTo: null,
});
mocks.findE2EEKeyBundle.mockResolvedValue({
did: ownerDid,
keyId: continuityKeyId,
keyVersion: 1,
publicKey: encryptionPublicKey,
proofAction: JSON.stringify(continuityProof),
});
const response = await POST(new NextRequest('https://export.synapsis.social/api/account/export', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password }),
}));
const body = await response.json() as ExportResponseBody;
expect(response.status).toBe(200);
expect(body.export.manifest.version).toBe('1.1');
expect(body.export.manifest.privateKeyEncrypted).toBe(storedPrivateKey);
expect(body.export.manifest).not.toHaveProperty('salt');
expect(body.export.manifest).not.toHaveProperty('iv');
expect(body.export.e2eeKeyBundle).toEqual({
did: ownerDid,
keyId: continuityKeyId,
keyVersion: 1,
publicKey: encryptionPublicKey,
proofAction: continuityProof,
});
const { signature, ...manifestData } = body.export.manifest;
const verifier = createVerify('sha256');
verifier.update(canonicalize(manifestData));
expect(verifier.verify(publicKey, signature, 'base64')).toBe(true);
const payload = {
profile: body.export.profile,
posts: body.export.posts,
following: body.export.following,
dms: body.export.dms,
bots: body.export.bots,
e2eeKeyBundle: body.export.e2eeKeyBundle,
};
expect(body.export.manifest.payloadDigest).toBe(
createHash('sha256').update(canonicalize(payload)).digest('hex'),
);
// The exported message carries millisecond-authenticated envelope
// time while its DB timestamp is second-precision. A fresh importer
// must accept the round-trip and preserve account login plus the
// signed public continuity anchor (but never a PIN vault).
const importResponse = await importAccount(new NextRequest('https://new.example/api/account/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
exportData: body.export,
password,
destinationEmail: ' Alice@New.Example ',
newHandle: 'alice_new',
acceptedCompliance: true,
}),
}));
expect(importResponse.status).toBe(200);
await expect(importResponse.json()).resolves.toMatchObject({
success: true,
stats: { dmsImported: 1 },
warnings: expect.arrayContaining([
expect.stringContaining('decryption key is not portable yet'),
]),
});
expect(mocks.hashPassword).toHaveBeenCalledWith(password);
const importedUser = mocks.insertedRows.find(({ table }) => table === mocks.tables.users);
expect(importedUser?.values).toMatchObject({
did: ownerDid,
email: 'alice@new.example',
passwordHash: 'imported-password-hash',
privateKeyEncrypted: storedPrivateKey,
});
expect(mocks.createSession).toHaveBeenCalledOnce();
expect(mocks.createSession).toHaveBeenCalledWith('imported-user-1');
const importedContinuity = mocks.insertedRows.find(
({ table }) => table === mocks.tables.e2eeKeyBundles,
);
expect(importedContinuity?.values).toMatchObject({
userId: 'imported-user-1',
did: ownerDid,
keyId: continuityKeyId,
keyVersion: 1,
publicKey: encryptionPublicKey,
});
expect(JSON.parse(String(importedContinuity?.values.proofAction))).toEqual(continuityProof);
expect(importedContinuity?.values).not.toHaveProperty('ciphertext');
expect(importedContinuity?.values).not.toHaveProperty('pinVerifierMac');
expect(mocks.safeFederationRequest).toHaveBeenCalledWith(
'https://export.synapsis.social/api/account/moved',
expect.objectContaining({ method: 'POST', timeoutMs: 5_000 }),
);
mocks.findUsers
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({ id: 'existing-email-user' });
const duplicateEmailResponse = await importAccount(new NextRequest('https://new.example/api/account/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
exportData: body.export,
password,
destinationEmail: 'alice@new.example',
newHandle: 'alice_other',
acceptedCompliance: true,
}),
}));
expect(duplicateEmailResponse.status).toBe(409);
await expect(duplicateEmailResponse.json()).resolves.toMatchObject({
error: 'Email is already registered on this node',
});
expect(mocks.createSession).toHaveBeenCalledOnce();
mocks.findUsers.mockResolvedValue(undefined);
mocks.createSession.mockRejectedValueOnce(new Error('session store unavailable'));
const sessionFailureResponse = await importAccount(new NextRequest('https://new.example/api/account/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
exportData: body.export,
password,
destinationEmail: 'alice-session-fallback@new.example',
newHandle: 'alice_retry',
acceptedCompliance: true,
}),
}));
expect(sessionFailureResponse.status).toBe(200);
await expect(sessionFailureResponse.json()).resolves.toMatchObject({
success: true,
warnings: expect.arrayContaining([
expect.stringContaining('automatic sign-in failed'),
]),
});
expect(mocks.createSession).toHaveBeenCalledTimes(2);
});
});
+145 -50
View File
@@ -7,25 +7,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, verifyPassword } from '@/lib/auth';
import { db, posts, media, follows, users, remoteFollows, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import * as crypto from 'crypto';
import { decryptApiKey, deserializeEncryptedData } from '@/lib/bots/encryption';
import { canonicalize, verifySignedActionSignature } from '@/lib/crypto/user-signing';
import {
decryptPrivateKey as decryptStoredPrivateKey,
deserializeEncryptedKey,
} from '@/lib/crypto/private-key';
import {
E2EE_KEY_BUNDLE_ACTION,
e2eeKeyBundleSchema,
signedUserActionSchema,
type SignedUserAction,
} from '@/lib/e2ee/protocol';
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
// We'll use a simple in-memory zip approach
// For production, consider using a streaming zip library
interface ExportManifest {
version: string;
version: '1.1';
did: string;
handle: string;
sourceNode: string;
exportedAt: string;
expiresAt: string; // Export expiration timestamp
publicKey: string;
privateKeyEncrypted: string; // Encrypted with user's password
salt: string; // For key derivation
iv: string; // For AES encryption
privateKeyEncrypted: string; // Original serialized AES-GCM key blob
payloadDigestAlgorithm: 'sha256';
payloadDigest: string; // Canonical digest of every non-manifest export field
signature: string; // Proof of ownership
}
@@ -59,6 +70,8 @@ interface ExportDMConversation {
participant2Handle: string;
lastMessageAt: string | null;
lastMessagePreview: string | null;
encryptionMode: string;
e2eeActivatedAt: string | null;
messages: ExportDMMessage[];
}
@@ -69,11 +82,25 @@ interface ExportDMMessage {
senderNodeDomain: string | null;
senderDid: string | null;
content: string | null;
protocolVersion: number;
clientMessageId: string | null;
encryptedEnvelope: string | null;
e2eeSignature: string | null;
e2eeActionNonce: string | null;
e2eeActionTs: number | null;
deliveredAt: string | null;
readAt: string | null;
createdAt: string;
}
interface ExportE2EEContinuityAnchor {
did: string;
keyId: string;
keyVersion: number;
publicKey: string;
proofAction: SignedUserAction;
}
interface ExportBot {
id: string;
name: string;
@@ -81,55 +108,91 @@ interface ExportBot {
bio: string | null;
avatarUrl: string | null;
headerUrl: string | null;
personalityConfig: any;
personalityConfig: unknown;
llmProvider: string;
llmModel: string;
llmApiKey: string; // Decrypted
botPrivateKey: string; // Decrypted
publicKey: string;
scheduleConfig: any;
scheduleConfig: unknown;
autonomousMode: boolean;
isActive: boolean;
sources: any[];
activityLogs: any[];
sources: unknown[];
activityLogs: unknown[];
}
/**
* Encrypt the private key with user's password using AES-256-GCM
*/
function encryptPrivateKey(privateKey: string, password: string): { encrypted: string; salt: string; iv: string } {
const salt = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);
// Derive key from password
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
// Encrypt
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(privateKey, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
// Combine encrypted data with auth tag
const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64');
return {
encrypted: combined,
salt: salt.toString('base64'),
iv: iv.toString('base64'),
};
interface ExportPayload {
profile: ExportProfile;
posts: ExportPost[];
following: ExportFollowing[];
dms: ExportDMConversation[];
bots: ExportBot[];
e2eeKeyBundle: ExportE2EEContinuityAnchor | null;
}
/**
* Sign the manifest to prove ownership
*/
function signManifest(manifest: Omit<ExportManifest, 'signature'>, privateKey: string): string {
const data = JSON.stringify(manifest);
const data = canonicalize(manifest);
const sign = crypto.createSign('sha256');
sign.update(data);
return sign.sign(privateKey, 'base64');
}
function digestExportPayload(payload: ExportPayload): string {
return crypto.createHash('sha256').update(canonicalize(payload)).digest('hex');
}
function signingKeyMatchesPublicKey(privateKey: string, publicKey: string): boolean {
try {
const derivedPublicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' });
const expectedPublicKey = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
return derivedPublicKey.length === expectedPublicKey.length
&& crypto.timingSafeEqual(derivedPublicKey, expectedPublicKey);
} catch {
return false;
}
}
async function exportE2EEContinuityAnchor(
row: {
did: string;
keyId: string;
keyVersion: number;
publicKey: string;
proofAction: string;
} | undefined,
user: { did: string; handle: string; publicKey: string },
): Promise<ExportE2EEContinuityAnchor | null> {
if (!row) return null;
const proof = signedUserActionSchema.parse(JSON.parse(row.proofAction));
const bundle = e2eeKeyBundleSchema.parse(proof.data);
if (proof.action !== E2EE_KEY_BUNDLE_ACTION
|| proof.did !== user.did
|| proof.handle.toLowerCase() !== user.handle.toLowerCase()
|| row.did !== proof.did
|| row.keyId !== bundle.keyId
|| row.keyVersion !== bundle.version
|| row.publicKey !== bundle.publicKey
|| Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1_000
|| Buffer.from(bundle.publicKey, 'base64url').length !== 32
|| Buffer.from(bundle.recoveryCommitment, 'base64url').length !== 32
|| await encryptionKeyIdFromPublicKey(bundle.publicKey) !== bundle.keyId
|| !await verifySignedActionSignature(proof, user.publicKey)) {
throw new Error('Stored E2EE continuity proof is invalid');
}
return {
did: row.did,
keyId: row.keyId,
keyVersion: row.keyVersion,
publicKey: row.publicKey,
proofAction: proof,
};
}
export async function POST(req: NextRequest) {
try {
const user = await requireAuth();
@@ -156,6 +219,23 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'This account has already been migrated' }, { status: 400 });
}
if (!user.privateKeyEncrypted) {
return NextResponse.json({ error: 'Account signing key is unavailable' }, { status: 500 });
}
let signingPrivateKey: string;
try {
signingPrivateKey = decryptStoredPrivateKey(
deserializeEncryptedKey(user.privateKeyEncrypted),
password,
);
} catch {
return NextResponse.json({ error: 'Account signing key could not be unlocked' }, { status: 500 });
}
if (!signingKeyMatchesPublicKey(signingPrivateKey, user.publicKey)) {
return NextResponse.json({ error: 'Account signing key does not match this account' }, { status: 500 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
// Fetch user's posts
@@ -187,6 +267,11 @@ export async function POST(req: NextRequest) {
}
});
const currentE2EEKeyBundle = await db.query.e2eeKeyBundles.findFirst({
where: { userId: user.id },
});
const e2eeKeyBundle = await exportE2EEContinuityAnchor(currentE2EEKeyBundle, user);
// Fetch Bots
const userBots = await db.query.bots.findMany({
where: { ownerId: user.id },
@@ -248,13 +333,21 @@ export async function POST(req: NextRequest) {
participant2Handle: conv.participant2Handle,
lastMessageAt: conv.lastMessageAt?.toISOString() || null,
lastMessagePreview: conv.lastMessagePreview,
encryptionMode: conv.encryptionMode,
e2eeActivatedAt: conv.e2eeActivatedAt?.toISOString() || null,
messages: conv.messages.map(msg => ({
senderHandle: msg.senderHandle,
senderDisplayName: msg.senderDisplayName,
senderAvatarUrl: msg.senderAvatarUrl,
senderNodeDomain: msg.senderNodeDomain,
senderDid: msg.senderDid,
content: msg.content,
content: msg.protocolVersion === 0 ? msg.content : null,
protocolVersion: msg.protocolVersion,
clientMessageId: msg.clientMessageId,
encryptedEnvelope: msg.encryptedEnvelope,
e2eeSignature: msg.e2eeSignature,
e2eeActionNonce: msg.e2eeActionNonce,
e2eeActionTs: msg.e2eeActionTs,
deliveredAt: msg.deliveredAt?.toISOString() || null,
readAt: msg.readAt?.toISOString() || null,
createdAt: msg.createdAt.toISOString()
@@ -311,39 +404,41 @@ export async function POST(req: NextRequest) {
};
});
// Encrypt private key
const privateKey = user.privateKeyEncrypted || '';
const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password);
const exportPayload: ExportPayload = {
profile,
posts: exportPosts,
following: exportFollowing,
dms: exportDMs,
bots: exportBots,
e2eeKeyBundle,
};
// Build manifest (without signature first)
// Version 1.1 fails closed on older importers and binds a canonical
// digest of every non-manifest field into the signed manifest.
const exportedAt = new Date();
const expiresAt = new Date(exportedAt.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
const manifestData: Omit<ExportManifest, 'signature'> = {
version: '1.0',
version: '1.1',
did: user.did,
handle: user.handle,
sourceNode: nodeDomain,
exportedAt: exportedAt.toISOString(),
expiresAt: expiresAt.toISOString(),
publicKey: user.publicKey,
privateKeyEncrypted: encrypted,
salt,
iv,
privateKeyEncrypted: user.privateKeyEncrypted,
payloadDigestAlgorithm: 'sha256',
payloadDigest: digestExportPayload(exportPayload),
};
// Sign the manifest
const signature = signManifest(manifestData, privateKey);
const signature = signManifest(manifestData, signingPrivateKey);
const manifest: ExportManifest = { ...manifestData, signature };
// Build the export package as JSON (ZIP would require additional library)
// For MVP, we'll use a JSON format that can be easily converted to ZIP later
const exportPackage = {
manifest,
profile,
posts: exportPosts,
following: exportFollowing,
dms: exportDMs,
bots: exportBots,
...exportPayload,
};
return NextResponse.json({
+529
View File
@@ -0,0 +1,529 @@
import { generateKeyPairSync, createHash, createSign, randomBytes, randomUUID } from 'node:crypto';
import { NextRequest } from 'next/server';
import { describe, expect, it } from 'vitest';
import { canonicalize } from '@/lib/crypto/user-signing';
import { generateDID } from '@/lib/crypto/did-key';
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
import {
E2EE_CIPHER_SUITE,
E2EE_KEY_BUNDLE_ACTION,
E2EE_PROTOCOL,
} from '@/lib/e2ee/protocol';
import { POST } from './route';
const peerDid = 'did:synapsis:account-import-peer';
const { privateKey, publicKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
const ownerDid = generateDID(publicKey);
const storedPrivateKey = serializeEncryptedKey(encryptPrivateKey(privateKey, 'correct-password'));
function basePayload(dms: unknown[]) {
return {
profile: {
displayName: 'Alice',
bio: null,
avatarUrl: null,
headerUrl: null,
},
posts: [],
following: [],
dms,
bots: [],
};
}
function signedExport(payload: ReturnType<typeof basePayload>, integrityProtected = true) {
const manifestData = {
version: '1.0' as const,
did: ownerDid,
handle: 'alice',
sourceNode: 'old.synapsis.social',
exportedAt: new Date(Date.now() - 1_000).toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
publicKey,
privateKeyEncrypted: 'AA==',
salt: randomBytes(32).toString('base64'),
iv: randomBytes(16).toString('base64'),
...(integrityProtected ? {
payloadDigestAlgorithm: 'sha256' as const,
payloadDigest: createHash('sha256').update(canonicalize(payload)).digest('hex'),
} : {}),
};
const signer = createSign('sha256');
signer.update(JSON.stringify(manifestData));
return {
manifest: {
...manifestData,
signature: signer.sign(privateKey, 'base64'),
},
...payload,
};
}
function signedV11Export(
payload: ReturnType<typeof basePayload>,
options: {
did?: string;
sourceNode?: string;
e2eeKeyBundle?: unknown;
} = {},
) {
const signedPayload = {
...payload,
e2eeKeyBundle: options.e2eeKeyBundle ?? null,
};
const manifestData = {
version: '1.1' as const,
did: options.did ?? ownerDid,
handle: 'alice',
sourceNode: options.sourceNode ?? 'old.synapsis.social',
exportedAt: new Date(Date.now() - 1_000).toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
publicKey,
privateKeyEncrypted: storedPrivateKey,
payloadDigestAlgorithm: 'sha256' as const,
payloadDigest: createHash('sha256').update(canonicalize(signedPayload)).digest('hex'),
};
const signer = createSign('sha256');
signer.update(canonicalize(manifestData));
return {
manifest: {
...manifestData,
signature: signer.sign(privateKey, 'base64'),
},
...signedPayload,
};
}
async function continuityAnchor() {
const encryptionPublicKey = randomBytes(32).toString('base64url');
const keyId = await encryptionKeyIdFromPublicKey(encryptionPublicKey);
if (!keyId) throw new Error('Could not derive test encryption key ID');
const createdAt = Date.now();
const bundle = {
protocol: E2EE_PROTOCOL,
keyId,
version: 1,
publicKey: encryptionPublicKey,
createdAt,
recoveryCommitment: randomBytes(32).toString('base64url'),
};
const proofPayload = {
action: E2EE_KEY_BUNDLE_ACTION,
data: bundle,
did: ownerDid,
handle: 'alice',
nonce: randomBytes(16).toString('base64url'),
ts: createdAt,
};
const proofSigner = createSign('sha256');
proofSigner.update(canonicalize(proofPayload));
return {
did: ownerDid,
keyId,
keyVersion: 1,
publicKey: encryptionPublicKey,
proofAction: {
...proofPayload,
sig: proofSigner.sign({
key: privateKey,
dsaEncoding: 'ieee-p1363',
}).toString('base64url'),
},
};
}
async function importResponse(
exportData: unknown,
requestOverrides: Record<string, unknown> = {},
) {
return POST(new NextRequest('http://localhost/api/account/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
exportData,
password: 'wrong-on-purpose',
destinationEmail: 'alice@new.example',
newHandle: 'alice_new',
acceptedCompliance: true,
...requestOverrides,
}),
}));
}
function commonMessage(createdAt: string) {
return {
senderHandle: 'alice',
senderDisplayName: 'Alice',
senderAvatarUrl: null,
senderNodeDomain: null,
senderDid: ownerDid,
deliveredAt: null,
readAt: null,
createdAt,
};
}
function modernConversation(messages: unknown[], overrides: Record<string, unknown> = {}) {
return {
id: randomUUID(),
type: 'direct',
participant2Handle: 'bob',
lastMessageAt: null,
lastMessagePreview: null,
encryptionMode: 'legacy',
e2eeActivatedAt: null,
messages,
...overrides,
};
}
function encryptedMessage(createdAtMs: number) {
const messageId = randomUUID();
const senderKeyId = `k1_${randomBytes(12).toString('base64url')}`;
const recipientKeyId = `k1_${randomBytes(12).toString('base64url')}`;
const envelope = {
protocol: E2EE_PROTOCOL,
cipherSuite: E2EE_CIPHER_SUITE,
messageId,
conversationId: `dm1_${randomBytes(12).toString('base64url')}`,
senderDid: ownerDid,
senderHandle: 'alice',
recipientDid: peerDid,
recipientHandle: 'bob',
createdAt: createdAtMs,
senderKeyId,
senderKeyVersion: 1,
recipientKeyId,
recipientKeyVersion: 1,
nonce: randomBytes(24).toString('base64url'),
ciphertext: randomBytes(17).toString('base64url'),
keyCommitment: randomBytes(32).toString('base64url'),
keyEnvelopes: [
{
did: ownerDid,
keyId: senderKeyId,
keyVersion: 1,
sealedKey: randomBytes(112).toString('base64url'),
},
{
did: peerDid,
keyId: recipientKeyId,
keyVersion: 1,
sealedKey: randomBytes(112).toString('base64url'),
},
],
};
const actionNonce = randomBytes(16).toString('base64url');
const actionPayload = {
action: 'chat_e2ee',
data: envelope,
did: ownerDid,
handle: 'alice',
nonce: actionNonce,
ts: createdAtMs,
};
const actionSigner = createSign('sha256');
actionSigner.update(canonicalize(actionPayload));
return {
// SQLite timestamp columns retain seconds, while the authenticated
// envelope retains the original millisecond value.
...commonMessage(new Date(Math.floor(createdAtMs / 1_000) * 1_000).toISOString()),
content: null,
protocolVersion: 1,
clientMessageId: messageId,
encryptedEnvelope: JSON.stringify(envelope),
e2eeSignature: actionSigner.sign({
key: privateKey,
dsaEncoding: 'ieee-p1363',
}).toString('base64url'),
e2eeActionNonce: actionNonce,
e2eeActionTs: createdAtMs,
};
}
describe('account import DM integrity', () => {
it('requires a valid destination email before importing', async () => {
const response = await importResponse(
signedV11Export(basePayload([])),
{ destinationEmail: 'not-an-email' },
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Enter a valid destination email address',
});
});
it('accepts a v1.1 export with no E2EE continuity anchor', async () => {
const response = await importResponse(signedV11Export(basePayload([])));
expect(response.status).toBe(401);
await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' });
});
it('accepts a valid account-signed E2EE continuity anchor', async () => {
const anchor = await continuityAnchor();
const response = await importResponse(signedV11Export(basePayload([]), {
e2eeKeyBundle: anchor,
}));
expect(response.status).toBe(401);
await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' });
});
it('rejects a continuity anchor with a forged proof signature', async () => {
const anchor = await continuityAnchor();
anchor.proofAction.sig = randomBytes(64).toString('base64url');
const response = await importResponse(signedV11Export(basePayload([]), {
e2eeKeyBundle: anchor,
}));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining('continuity anchor signature is invalid'),
});
});
it('rejects continuity row fields that do not match the signed bundle', async () => {
const anchor = await continuityAnchor();
anchor.keyVersion = 2;
const response = await importResponse(signedV11Export(basePayload([]), {
e2eeKeyBundle: anchor,
}));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining('does not match its signed proof'),
});
});
it('rejects a self-certifying DID claimed by a different signing key', async () => {
const otherKeys = generateKeyPairSync('ec', {
namedCurve: 'P-256',
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
const squattedDid = generateDID(otherKeys.publicKey);
const response = await importResponse(signedV11Export(basePayload([]), {
did: squattedDid,
}));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Export identity must be a self-certifying did:key that matches its signing key',
});
});
it('rejects legacy identity methods that are not self-certifying', async () => {
const response = await importResponse(signedV11Export(basePayload([]), {
did: 'did:synapsis:legacy-account',
}));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Export identity must be a self-certifying did:key that matches its signing key',
});
});
it.each([
'127.0.0.1',
'localhost:43821',
'10.0.0.8',
'https://old.synapsis.social',
'old.synapsis.social/api/account/moved',
'user@old.synapsis.social',
])('rejects unsafe migration source node %s', async (sourceNode) => {
const response = await importResponse(signedV11Export(basePayload([]), {
sourceNode,
}));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Export source node is unsafe or invalid',
});
});
it('rejects edits to any signed v1.1 export payload field', async () => {
const exportData = signedV11Export(basePayload([]));
exportData.profile.displayName = 'Mallory';
const response = await importResponse(exportData);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Export payload integrity check failed',
});
});
it('also verifies the digest when a legacy v1.0 manifest carries one', async () => {
const exportData = signedExport(basePayload([]));
exportData.profile.displayName = 'Mallory';
const response = await importResponse(exportData);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Export payload integrity check failed',
});
});
it('rejects unknown protocol versions instead of treating them as plaintext', async () => {
const createdAt = new Date().toISOString();
const unknownMessage = {
...commonMessage(createdAt),
content: 'must not be imported',
protocolVersion: 2,
clientMessageId: null,
encryptedEnvelope: null,
e2eeSignature: null,
e2eeActionNonce: null,
e2eeActionTs: null,
};
const response = await importResponse(signedV11Export(basePayload([
modernConversation([unknownMessage]),
])));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining('Unsupported DM protocol version'),
});
});
it('rejects plaintext at or after a conversation E2EE cutover', async () => {
const cutoverMs = Math.floor(Date.now() / 1_000) * 1_000 + 789;
const cutover = new Date(cutoverMs);
const legacyMessage = {
...commonMessage(new Date(Math.floor(cutoverMs / 1_000) * 1_000).toISOString()),
content: 'late plaintext',
protocolVersion: 0,
clientMessageId: null,
encryptedEnvelope: null,
e2eeSignature: null,
e2eeActionNonce: null,
e2eeActionTs: null,
};
const response = await importResponse(signedV11Export(basePayload([
modernConversation([legacyMessage], {
encryptionMode: 'e2ee',
e2eeActivatedAt: cutover.toISOString(),
}),
])));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining('plaintext at or after its E2EE cutover'),
});
});
it('allows legacy plaintext from the second before the E2EE cutover', async () => {
const cutoverMs = Math.floor(Date.now() / 1_000) * 1_000 + 789;
const legacyMessage = {
...commonMessage(new Date(Math.floor(cutoverMs / 1_000) * 1_000 - 1_000).toISOString()),
content: 'plaintext before cutover',
protocolVersion: 0,
clientMessageId: null,
encryptedEnvelope: null,
e2eeSignature: null,
e2eeActionNonce: null,
e2eeActionTs: null,
};
const response = await importResponse(signedV11Export(basePayload([
modernConversation([legacyMessage], {
encryptionMode: 'e2ee',
e2eeActivatedAt: new Date(cutoverMs).toISOString(),
}),
])));
expect(response.status).toBe(401);
await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' });
});
it('strictly validates an encrypted envelope and its stored signature metadata', async () => {
const createdAt = Math.floor(Date.now() / 1_000) * 1_000 + 537;
const validMessage = encryptedMessage(createdAt);
const validResponse = await importResponse(signedV11Export(basePayload([
modernConversation([validMessage], {
encryptionMode: 'e2ee',
e2eeActivatedAt: new Date(createdAt).toISOString(),
}),
])));
expect(validResponse.status).toBe(401);
const invalidMessage = { ...validMessage, senderHandle: 'mallory' };
const invalidResponse = await importResponse(signedV11Export(basePayload([
modernConversation([invalidMessage], {
encryptionMode: 'e2ee',
e2eeActivatedAt: new Date(createdAt).toISOString(),
}),
])));
expect(invalidResponse.status).toBe(400);
await expect(invalidResponse.json()).resolves.toMatchObject({
error: expect.stringContaining('sender handle does not match'),
});
const invalidSignature = {
...validMessage,
e2eeSignature: randomBytes(64).toString('base64url'),
};
const invalidSignatureResponse = await importResponse(signedV11Export(basePayload([
modernConversation([invalidSignature], {
encryptionMode: 'e2ee',
e2eeActivatedAt: new Date(createdAt).toISOString(),
}),
])));
expect(invalidSignatureResponse.status).toBe(400);
await expect(invalidSignatureResponse.json()).resolves.toMatchObject({
error: expect.stringContaining('signature is invalid'),
});
});
it('rejects digest-less historical exports before importing unauthenticated payloads', async () => {
const createdAt = new Date().toISOString();
const historicalMessage = {
...commonMessage(createdAt),
content: 'historical plaintext',
};
const historicalConversation = {
id: randomUUID(),
type: 'direct',
participant2Handle: 'bob',
lastMessageAt: createdAt,
lastMessagePreview: 'historical plaintext',
messages: [historicalMessage],
};
const historical = await importResponse(signedExport(
basePayload([historicalConversation]),
false,
));
expect(historical.status).toBe(400);
await expect(historical.json()).resolves.toMatchObject({
error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.',
});
const injectedConversation = {
...historicalConversation,
messages: [{ ...historicalMessage, protocolVersion: 2 }],
};
const injected = await importResponse(signedExport(
basePayload([injectedConversation]),
false,
));
expect(injected.status).toBe(400);
await expect(injected.json()).resolves.toMatchObject({
error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.',
});
});
});
+691 -91
View File
@@ -6,25 +6,101 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, posts, media, follows, remoteFollows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
import { db, users, posts, media, follows, remoteFollows, chatConversations, chatMessages, e2eeKeyBundles, e2eeMessageReceipts, bots, botContentSources, botActivityLogs } from '@/db';
import { eq, sql } from 'drizzle-orm';
import * as crypto from 'crypto';
import { z } from 'zod';
import { encryptApiKey, serializeEncryptedData } from '@/lib/bots/encryption';
import { v4 as uuid } from 'uuid';
import { upsertHandleEntries } from '@/lib/federation/handles';
import { canonicalize, verifySignedActionSignature } from '@/lib/crypto/user-signing';
import {
decryptPrivateKey as decryptStoredPrivateKey,
deserializeEncryptedKey,
serializeEncryptedKey,
} from '@/lib/crypto/private-key';
import { didKeyMatchesPublicKey } from '@/lib/crypto/did-key';
import { createSession, hashPassword } from '@/lib/auth';
import {
E2EE_CHAT_ACTION,
E2EE_KEY_BUNDLE_ACTION,
e2eeKeyBundleSchema,
e2eeMessageEnvelopeSchema,
signedUserActionSchema,
validateMessageBindings,
} from '@/lib/e2ee/protocol';
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
import { getPublicSwarmDomain, normalizeNodeDomain } from '@/lib/swarm/node-domain';
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
interface ImportManifest {
version: string;
did: string;
handle: string;
sourceNode: string;
exportedAt: string;
expiresAt?: string; // Optional for backward compatibility
publicKey: string;
privateKeyEncrypted: string;
salt: string;
iv: string;
signature: string;
const isoTimestampSchema = z.iso.datetime({ offset: true });
const didSchema = z.string().min(8).max(2_048).regex(/^did:/);
const base64Schema = z.string().min(1).max(32_768).regex(/^[A-Za-z0-9+/=_-]+$/);
const destinationEmailSchema = z.string().trim().email().max(320);
const serializedEncryptedKeySchema = z.string().min(2).max(65_536).refine((value) => {
try {
const parsed = JSON.parse(value) as Record<string, unknown>;
return Object.keys(parsed).length === 3
&& typeof parsed.encrypted === 'string' && base64Schema.safeParse(parsed.encrypted).success
&& typeof parsed.salt === 'string' && base64Schema.max(256).safeParse(parsed.salt).success
&& typeof parsed.iv === 'string' && base64Schema.max(256).safeParse(parsed.iv).success;
} catch {
return false;
}
}, { message: 'Invalid encrypted private key' });
// The property order of the 1.0 schema intentionally matches the historical
// exporter because those manifests were signed with JSON.stringify().
const importManifestV10Schema = z.strictObject({
version: z.literal('1.0'),
did: didSchema,
handle: z.string().min(1).max(320),
sourceNode: z.string().min(1).max(320),
exportedAt: isoTimestampSchema,
expiresAt: isoTimestampSchema.optional(),
publicKey: z.string().min(1).max(16_384),
privateKeyEncrypted: base64Schema,
salt: base64Schema.max(256),
iv: base64Schema.max(256),
payloadDigestAlgorithm: z.literal('sha256').optional(),
payloadDigest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
signature: base64Schema,
}).superRefine((manifest, context) => {
if ((manifest.payloadDigestAlgorithm === undefined) !== (manifest.payloadDigest === undefined)) {
context.addIssue({
code: 'custom',
message: 'Payload digest fields must be provided together',
});
}
});
const importManifestV11Schema = z.strictObject({
version: z.literal('1.1'),
did: didSchema,
handle: z.string().min(1).max(320),
sourceNode: z.string().min(1).max(320),
exportedAt: isoTimestampSchema,
expiresAt: isoTimestampSchema,
publicKey: z.string().min(1).max(16_384),
privateKeyEncrypted: serializedEncryptedKeySchema,
payloadDigestAlgorithm: z.literal('sha256'),
payloadDigest: z.string().regex(/^[a-f0-9]{64}$/),
signature: base64Schema,
});
const importManifestSchema = z.union([
importManifestV10Schema,
importManifestV11Schema,
]);
type ImportManifest = z.infer<typeof importManifestSchema>;
function hasPayloadDigest(manifest: ImportManifest): manifest is ImportManifest & {
payloadDigestAlgorithm: 'sha256';
payloadDigest: string;
} {
return manifest.payloadDigestAlgorithm === 'sha256'
&& typeof manifest.payloadDigest === 'string';
}
interface ImportProfile {
@@ -56,6 +132,8 @@ interface ImportDMConversation {
participant2Handle: string;
lastMessageAt: string | null;
lastMessagePreview: string | null;
encryptionMode: 'legacy' | 'e2ee';
e2eeActivatedAt: string | null;
messages: ImportDMMessage[];
}
@@ -66,6 +144,12 @@ interface ImportDMMessage {
senderNodeDomain: string | null;
senderDid: string | null;
content: string | null;
protocolVersion: 0 | 1;
clientMessageId: string | null;
encryptedEnvelope: string | null;
e2eeSignature: string | null;
e2eeActionNonce: string | null;
e2eeActionTs: number | null;
deliveredAt: string | null;
readAt: string | null;
createdAt: string;
@@ -78,32 +162,379 @@ interface ImportBot {
bio: string | null;
avatarUrl: string | null;
headerUrl: string | null;
personalityConfig: any;
personalityConfig: unknown;
llmProvider: string;
llmModel: string;
llmApiKey: string;
botPrivateKey: string;
publicKey: string;
scheduleConfig: any;
scheduleConfig: unknown;
autonomousMode: boolean;
isActive: boolean;
sources: any[];
activityLogs: any[];
sources: ImportBotSource[];
activityLogs: ImportBotActivityLog[];
}
interface ImportPackage {
manifest: ImportManifest;
profile: ImportProfile;
posts: ImportPost[];
following: ImportFollowing[];
dms: ImportDMConversation[];
bots: ImportBot[];
interface ImportBotSource {
type: string;
url: string;
subreddit?: string | null;
apiKeyEncrypted?: string | null;
sourceConfig?: unknown;
keywords?: unknown;
isActive: boolean;
}
interface ImportBotActivityLog {
action: string;
details: unknown;
success: boolean;
errorMessage?: string | null;
createdAt: string;
}
const dmMessageCommonSchema = {
senderHandle: z.string().min(1).max(640),
senderDisplayName: z.string().max(1_000).nullable(),
senderAvatarUrl: z.string().max(16_384).nullable(),
senderNodeDomain: z.string().max(320).nullable(),
deliveredAt: isoTimestampSchema.nullable(),
readAt: isoTimestampSchema.nullable(),
createdAt: isoTimestampSchema,
};
const historicalLegacyDMMessageSchema = z.strictObject({
...dmMessageCommonSchema,
senderDid: didSchema.nullable(),
content: z.string().max(100_000).nullable(),
});
const legacyDMMessageSchema = z.strictObject({
...dmMessageCommonSchema,
senderDid: didSchema.nullable(),
content: z.string().max(100_000).nullable(),
protocolVersion: z.literal(0),
clientMessageId: z.null(),
encryptedEnvelope: z.null(),
e2eeSignature: z.null(),
e2eeActionNonce: z.null(),
e2eeActionTs: z.null(),
});
const encryptedDMMessageSchema = z.strictObject({
...dmMessageCommonSchema,
senderDid: didSchema,
content: z.null(),
protocolVersion: z.literal(1),
clientMessageId: z.string().uuid(),
encryptedEnvelope: z.string().min(2).max(32_768),
e2eeSignature: z.string().min(1).max(512).regex(/^[A-Za-z0-9_-]+$/),
e2eeActionNonce: z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/),
e2eeActionTs: z.number().int().positive(),
});
const e2eeContinuityAnchorSchema = z.strictObject({
did: didSchema,
keyId: z.string().min(12).max(96).regex(/^k1_[A-Za-z0-9_-]+$/),
keyVersion: z.number().int().positive().max(1_000_000),
publicKey: z.string().min(1).max(64).regex(/^[A-Za-z0-9_-]+$/),
proofAction: signedUserActionSchema,
});
interface ImportedE2EEContinuityAnchor {
did: string;
keyId: string;
keyVersion: number;
publicKey: string;
proofAction: z.infer<typeof signedUserActionSchema>;
createdAt: number;
}
const dmConversationCommonSchema = {
id: z.string().min(1).max(128),
type: z.string().min(1).max(32),
participant2Handle: z.string().min(1).max(640),
lastMessageAt: isoTimestampSchema.nullable(),
lastMessagePreview: z.string().max(100_000).nullable(),
messages: z.array(z.unknown()).max(100_000),
};
const dmConversationV10Schema = z.strictObject({
...dmConversationCommonSchema,
});
const dmConversationV11Schema = z.strictObject({
...dmConversationCommonSchema,
encryptionMode: z.enum(['legacy', 'e2ee']),
e2eeActivatedAt: isoTimestampSchema.nullable(),
});
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isExactDevelopmentLoopbackNode(value: string): boolean {
const match = value.match(/^(?:localhost|127\.0\.0\.1|\[::1\])(?::(\d{1,5}))?$/);
if (!match) return false;
if (!match[1]) return true;
const port = Number(match[1]);
return Number.isInteger(port) && port >= 1 && port <= 65_535;
}
function validatedSourceNode(value: string): string | null {
if (value !== value.trim()) return null;
const normalized = value.toLowerCase();
if (!normalized || normalizeNodeDomain(normalized) !== normalized) return null;
if (process.env.NODE_ENV === 'development' && isExactDevelopmentLoopbackNode(normalized)) {
return normalized;
}
const publicDomain = getPublicSwarmDomain(normalized);
return publicDomain === normalized ? publicDomain : null;
}
function sourceNodeProtocol(sourceNode: string): 'http' | 'https' {
return process.env.NODE_ENV === 'development'
&& isExactDevelopmentLoopbackNode(sourceNode)
? 'http'
: 'https';
}
async function validateE2EEContinuityAnchor(
rawAnchor: unknown,
manifest: ImportManifest,
): Promise<ImportedE2EEContinuityAnchor> {
const anchor = e2eeContinuityAnchorSchema.parse(rawAnchor);
const proof = anchor.proofAction;
const bundle = e2eeKeyBundleSchema.parse(proof.data);
if (proof.action !== E2EE_KEY_BUNDLE_ACTION
|| proof.did !== manifest.did
|| proof.handle.toLowerCase() !== manifest.handle.toLowerCase()
|| anchor.did !== manifest.did
|| anchor.did !== proof.did
|| anchor.keyId !== bundle.keyId
|| anchor.keyVersion !== bundle.version
|| anchor.publicKey !== bundle.publicKey
|| Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1_000) {
throw new Error('E2EE continuity anchor does not match its signed proof');
}
if (Buffer.from(bundle.publicKey, 'base64url').length !== 32
|| Buffer.from(bundle.recoveryCommitment, 'base64url').length !== 32
|| await encryptionKeyIdFromPublicKey(bundle.publicKey) !== bundle.keyId) {
throw new Error('E2EE continuity anchor key is invalid');
}
if (!await verifySignedActionSignature(proof, manifest.publicKey)) {
throw new Error('E2EE continuity anchor signature is invalid');
}
return {
did: anchor.did,
keyId: anchor.keyId,
keyVersion: anchor.keyVersion,
publicKey: anchor.publicKey,
proofAction: proof,
createdAt: bundle.createdAt,
};
}
function digestImportPayload(exportData: Record<string, unknown>): string {
const payload = Object.fromEntries(
Object.entries(exportData).filter(([key]) => key !== 'manifest'),
);
return crypto.createHash('sha256').update(canonicalize(payload)).digest('hex');
}
function payloadDigestMatches(expected: string, actual: string): boolean {
const expectedBytes = Buffer.from(expected, 'hex');
const actualBytes = Buffer.from(actual, 'hex');
return expectedBytes.length === actualBytes.length
&& expectedBytes.length === 32
&& crypto.timingSafeEqual(expectedBytes, actualBytes);
}
function sqliteTimestampSecond(timestamp: number): number {
return Math.floor(timestamp / 1_000);
}
function validateCiphertextSizes(envelope: z.infer<typeof e2eeMessageEnvelopeSchema>): void {
if (Buffer.from(envelope.nonce, 'base64url').length !== 24
|| Buffer.from(envelope.ciphertext, 'base64url').length < 17
|| Buffer.from(envelope.ciphertext, 'base64url').length > 8_192
|| Buffer.from(envelope.keyCommitment, 'base64url').length !== 32
|| envelope.keyEnvelopes.some((item) => Buffer.from(item.sealedKey, 'base64url').length !== 112)) {
throw new Error('Encrypted DM has invalid field sizes');
}
}
async function normalizeEncryptedDMMessage(
rawMessage: unknown,
ownerDid: string,
ownerPublicKey: string,
): Promise<ImportDMMessage> {
const message = encryptedDMMessageSchema.parse(rawMessage);
let rawEnvelope: unknown;
try {
rawEnvelope = JSON.parse(message.encryptedEnvelope);
} catch {
throw new Error('Encrypted DM envelope is not valid JSON');
}
const envelope = e2eeMessageEnvelopeSchema.parse(rawEnvelope);
const storedSenderHandle = message.senderHandle.toLowerCase().replace(/^@/, '');
const signedSenderHandle = envelope.senderHandle.toLowerCase().replace(/^@/, '');
if (storedSenderHandle !== signedSenderHandle
&& !storedSenderHandle.startsWith(`${signedSenderHandle}@`)) {
throw new Error('Encrypted DM sender handle does not match its envelope');
}
const signedAction = signedUserActionSchema.parse({
action: E2EE_CHAT_ACTION,
data: envelope,
did: message.senderDid,
handle: envelope.senderHandle,
ts: message.e2eeActionTs,
nonce: message.e2eeActionNonce,
sig: message.e2eeSignature,
});
validateMessageBindings(envelope, signedAction);
validateCiphertextSizes(envelope);
// The export carries the imported account's signing key, so messages sent
// by that account can and must retain a valid original action signature.
// Incoming peer signatures remain structurally validated here and are
// verified against resolved peer keys when displayed by the chat client.
if (envelope.senderDid === ownerDid
&& !await verifySignedActionSignature(signedAction, ownerPublicKey)) {
throw new Error('Encrypted DM signature is invalid');
}
if (message.clientMessageId !== envelope.messageId) {
throw new Error('Encrypted DM message ID does not match its envelope');
}
if (sqliteTimestampSecond(Date.parse(message.createdAt))
!== sqliteTimestampSecond(envelope.createdAt)) {
throw new Error('Encrypted DM timestamp does not match its envelope');
}
if (envelope.senderDid !== ownerDid && envelope.recipientDid !== ownerDid) {
throw new Error('Encrypted DM does not include the imported account');
}
if (envelope.senderDid === envelope.recipientDid || envelope.keyEnvelopes.length !== 2) {
throw new Error('Encrypted DM participants are invalid');
}
return {
...message,
encryptedEnvelope: JSON.stringify(envelope),
};
}
function normalizeLegacyDMMessage(
rawMessage: unknown,
integrityProtected: boolean,
): ImportDMMessage {
const message = integrityProtected
? legacyDMMessageSchema.parse(rawMessage)
: historicalLegacyDMMessageSchema.parse(rawMessage);
return {
...message,
protocolVersion: 0,
clientMessageId: null,
encryptedEnvelope: null,
e2eeSignature: null,
e2eeActionNonce: null,
e2eeActionTs: null,
};
}
async function normalizeImportedDMs(
rawConversations: unknown[],
manifest: ImportManifest,
payloadIntegrityVerified: boolean,
): Promise<ImportDMConversation[]> {
const integrityProtected = payloadIntegrityVerified;
return Promise.all(rawConversations.map(async (rawConversation, conversationIndex) => {
const protectedConversation = integrityProtected
? dmConversationV11Schema.parse(rawConversation)
: null;
const conversation = protectedConversation
?? dmConversationV10Schema.parse(rawConversation);
const messages: ImportDMMessage[] = [];
let sawEncrypted = false;
for (const [messageIndex, rawMessage] of conversation.messages.entries()) {
const rawVersion = isRecord(rawMessage) ? rawMessage.protocolVersion : undefined;
try {
if (!integrityProtected) {
// Historical 1.0 exports predate encrypted DM fields. The
// strict historical schema rejects injected protocol
// markers instead of guessing that they are plaintext.
messages.push(normalizeLegacyDMMessage(rawMessage, false));
} else if (rawVersion === 0) {
messages.push(normalizeLegacyDMMessage(rawMessage, true));
} else if (rawVersion === 1) {
messages.push(await normalizeEncryptedDMMessage(
rawMessage,
manifest.did,
manifest.publicKey,
));
sawEncrypted = true;
} else {
throw new Error('Unsupported DM protocol version');
}
} catch (error) {
const reason = error instanceof Error ? error.message : 'invalid message';
throw new Error(`Invalid DM ${conversationIndex + 1}.${messageIndex + 1}: ${reason}`);
}
}
let encryptionMode: 'legacy' | 'e2ee' = 'legacy';
let activatedAt: number | null = null;
if (protectedConversation) {
encryptionMode = protectedConversation.encryptionMode;
activatedAt = protectedConversation.e2eeActivatedAt
? Date.parse(protectedConversation.e2eeActivatedAt)
: null;
if (encryptionMode === 'legacy' && activatedAt !== null) {
throw new Error(`Legacy DM conversation ${conversationIndex + 1} has an E2EE activation time`);
}
if (encryptionMode === 'legacy' && sawEncrypted) {
throw new Error(`Legacy DM conversation ${conversationIndex + 1} contains encrypted messages`);
}
if (encryptionMode === 'e2ee' && activatedAt === null) {
throw new Error(`Encrypted DM conversation ${conversationIndex + 1} has no activation time`);
}
}
if (activatedAt !== null) {
const legacyAfterCutover = messages.some((message) => (
message.protocolVersion === 0
&& sqliteTimestampSecond(Date.parse(message.createdAt))
>= sqliteTimestampSecond(activatedAt!)
));
if (legacyAfterCutover) {
throw new Error(`DM conversation ${conversationIndex + 1} contains plaintext at or after its E2EE cutover`);
}
}
return {
id: conversation.id,
type: conversation.type,
participant2Handle: conversation.participant2Handle,
lastMessageAt: conversation.lastMessageAt,
lastMessagePreview: conversation.lastMessagePreview,
encryptionMode,
e2eeActivatedAt: activatedAt === null ? null : new Date(activatedAt).toISOString(),
messages,
};
}));
}
/**
* Decrypt the private key using the user's password
*/
function decryptPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
function decryptLegacyExportPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
const saltBuffer = Buffer.from(salt, 'base64');
const ivBuffer = Buffer.from(iv, 'base64');
const encryptedBuffer = Buffer.from(encrypted, 'base64');
@@ -125,13 +556,26 @@ function decryptPrivateKey(encrypted: string, password: string, salt: string, iv
return decrypted.toString('utf8');
}
function signingKeyMatchesPublicKey(privateKey: string, publicKey: string): boolean {
try {
const derivedPublicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' });
const expectedPublicKey = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
return derivedPublicKey.length === expectedPublicKey.length
&& crypto.timingSafeEqual(derivedPublicKey, expectedPublicKey);
} catch {
return false;
}
}
/**
* Verify the manifest signature
*/
function verifyManifestSignature(manifest: ImportManifest): boolean {
try {
const { signature, ...manifestData } = manifest;
const data = JSON.stringify(manifestData);
const data = manifest.version === '1.1'
? canonicalize(manifestData)
: JSON.stringify(manifestData);
const verify = crypto.createVerify('sha256');
verify.update(data);
@@ -145,31 +589,39 @@ function verifyManifestSignature(manifest: ImportManifest): boolean {
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { exportData, password, newHandle, acceptedCompliance } = body as {
exportData: ImportPackage;
password: string;
newHandle: string;
acceptedCompliance: boolean;
};
const body: unknown = await req.json();
if (!isRecord(body)) {
return NextResponse.json({ error: 'Invalid import request' }, { status: 400 });
}
const { exportData, password, newHandle, destinationEmail, acceptedCompliance } = body;
// Validate required fields
if (!exportData || !password || !newHandle) {
if (!isRecord(exportData)
|| typeof password !== 'string' || password.length === 0
|| typeof newHandle !== 'string' || newHandle.length === 0
|| typeof destinationEmail !== 'string' || destinationEmail.length === 0) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (!acceptedCompliance) {
const destinationEmailResult = destinationEmailSchema.safeParse(destinationEmail);
if (!destinationEmailResult.success) {
return NextResponse.json({
error: 'Enter a valid destination email address',
}, { status: 400 });
}
const destinationEmailClean = destinationEmailResult.data.toLowerCase();
if (acceptedCompliance !== true) {
return NextResponse.json({
error: 'You must accept the content compliance agreement'
}, { status: 400 });
}
const { manifest, profile, posts: importPosts, following } = exportData;
// Validate manifest version
if (manifest.version !== '1.0') {
return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 });
const manifestResult = importManifestSchema.safeParse(exportData.manifest);
if (!manifestResult.success) {
return NextResponse.json({ error: 'Invalid or unsupported export manifest' }, { status: 400 });
}
const manifest = manifestResult.data;
// Check if export has expired
if (manifest.expiresAt) {
@@ -181,25 +633,103 @@ export async function POST(req: NextRequest) {
}
}
const { dms: importDMs = [], bots: importBots = [] } = exportData;
// Verify signature
if (!verifyManifestSignature(manifest)) {
return NextResponse.json({ error: 'Invalid export signature' }, { status: 400 });
}
if (!didKeyMatchesPublicKey(manifest.did, manifest.publicKey)) {
return NextResponse.json({
error: 'Export identity must be a self-certifying did:key that matches its signing key',
}, { status: 400 });
}
const sourceNode = validatedSourceNode(manifest.sourceNode);
if (!sourceNode) {
return NextResponse.json({ error: 'Export source node is unsafe or invalid' }, { status: 400 });
}
if (!hasPayloadDigest(manifest)) {
return NextResponse.json({
error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.',
}, { status: 400 });
}
const actualDigest = digestImportPayload(exportData);
if (!payloadDigestMatches(manifest.payloadDigest, actualDigest)) {
return NextResponse.json({ error: 'Export payload integrity check failed' }, { status: 400 });
}
const { profile: rawProfile, posts: rawPosts, following: rawFollowing } = exportData;
if (!isRecord(rawProfile) || !Array.isArray(rawPosts) || !Array.isArray(rawFollowing)) {
return NextResponse.json({ error: 'Invalid export payload' }, { status: 400 });
}
const rawDMs = exportData.dms === undefined ? [] : exportData.dms;
const rawBots = exportData.bots === undefined ? [] : exportData.bots;
if (!Array.isArray(rawDMs) || !Array.isArray(rawBots)) {
return NextResponse.json({ error: 'Invalid export payload' }, { status: 400 });
}
let importedE2EEKeyBundle: ImportedE2EEContinuityAnchor | null = null;
if (manifest.version === '1.1' && exportData.e2eeKeyBundle != null) {
try {
importedE2EEKeyBundle = await validateE2EEContinuityAnchor(
exportData.e2eeKeyBundle,
manifest,
);
} catch (error) {
const reason = error instanceof Error ? error.message : 'invalid E2EE continuity anchor';
return NextResponse.json({ error: `Invalid E2EE continuity anchor: ${reason}` }, { status: 400 });
}
}
let importDMs: ImportDMConversation[];
try {
importDMs = await normalizeImportedDMs(rawDMs, manifest, true);
} catch (error) {
const reason = error instanceof Error ? error.message : 'invalid direct-message history';
return NextResponse.json({ error: `Invalid direct-message history: ${reason}` }, { status: 400 });
}
const encryptedDMImportWarning = importDMs.some((conversation) => (
conversation.messages.some((message) => message.protocolVersion === 1)
))
? 'Encrypted DM records were preserved, but their decryption key is not portable yet. This history cannot be opened on this node.'
: null;
const federatedMoveWarning = importedE2EEKeyBundle
? 'Federated DM relationships do not automatically follow a home-node move. Peers that cached your old full handle may reject the new handle until signed account-move support is implemented.'
: null;
const profile = rawProfile as unknown as ImportProfile;
const importPosts = rawPosts as ImportPost[];
const following = rawFollowing as ImportFollowing[];
const importBots = rawBots as ImportBot[];
// Decrypt private key to verify password is correct
let privateKey: string;
let privateKeyEncryptedForStorage: string;
try {
privateKey = decryptPrivateKey(
manifest.privateKeyEncrypted,
password,
manifest.salt,
manifest.iv
);
} catch (error) {
if (manifest.version === '1.1') {
privateKey = decryptStoredPrivateKey(
deserializeEncryptedKey(manifest.privateKeyEncrypted),
password,
);
privateKeyEncryptedForStorage = manifest.privateKeyEncrypted;
} else {
privateKey = decryptLegacyExportPrivateKey(
manifest.privateKeyEncrypted,
password,
manifest.salt,
manifest.iv,
);
privateKeyEncryptedForStorage = serializeEncryptedKey({
encrypted: manifest.privateKeyEncrypted,
salt: manifest.salt,
iv: manifest.iv,
});
}
} catch {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
if (!signingKeyMatchesPublicKey(privateKey, manifest.publicKey)) {
return NextResponse.json({ error: 'Export private key does not match its signing key' }, { status: 400 });
}
const importedPasswordHash = await hashPassword(password);
// Check if DID already exists on this node
const existingDid = await db.query.users.findFirst({
@@ -212,6 +742,16 @@ export async function POST(req: NextRequest) {
}, { status: 409 });
}
const existingEmail = await db.query.users.findFirst({
where: { email: destinationEmailClean },
});
if (existingEmail) {
return NextResponse.json({
error: 'Email is already registered on this node',
}, { status: 409 });
}
// Validate handle format
const handleClean = newHandle.toLowerCase().replace(/^@/, '').trim();
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handleClean)) {
@@ -233,23 +773,43 @@ export async function POST(req: NextRequest) {
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const oldActorUrl = `https://${manifest.sourceNode}/users/${manifest.handle}`;
const oldActorUrl = `${sourceNodeProtocol(sourceNode)}://${sourceNode}/users/${manifest.handle}`;
const newActorUrl = `https://${nodeDomain}/users/${handleClean}`;
// Create the user with the same DID
const [newUser] = await db.insert(users).values({
did: manifest.did,
handle: handleClean,
displayName: profile.displayName,
bio: profile.bio,
avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading
headerUrl: profile.headerUrl,
publicKey: manifest.publicKey,
privateKeyEncrypted: privateKey,
movedFrom: oldActorUrl,
migratedAt: new Date(),
postsCount: importPosts.length,
}).returning();
// The identity and its public continuity anchor are one unit. A key-ID
// conflict must not leave behind an account that cannot be retried.
const newUser = await db.transaction(async (tx) => {
const [createdUser] = await tx.insert(users).values({
did: manifest.did,
handle: handleClean,
email: destinationEmailClean,
displayName: profile.displayName,
bio: profile.bio,
avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading
headerUrl: profile.headerUrl,
publicKey: manifest.publicKey,
privateKeyEncrypted: privateKeyEncryptedForStorage,
passwordHash: importedPasswordHash,
movedFrom: oldActorUrl,
migratedAt: new Date(),
postsCount: importPosts.length,
}).returning();
if (importedE2EEKeyBundle) {
await tx.insert(e2eeKeyBundles).values({
userId: createdUser.id,
did: importedE2EEKeyBundle.did,
keyId: importedE2EEKeyBundle.keyId,
keyVersion: importedE2EEKeyBundle.keyVersion,
publicKey: importedE2EEKeyBundle.publicKey,
proofAction: JSON.stringify(importedE2EEKeyBundle.proofAction),
createdAt: new Date(importedE2EEKeyBundle.createdAt),
updatedAt: new Date(),
});
}
return createdUser;
});
// Check if this is an NSFW node and auto-enable NSFW settings
const node = await db.query.nodes.findFirst({
@@ -346,31 +906,53 @@ export async function POST(req: NextRequest) {
.set({ followingCount: importedFollowing })
.where(eq(users.id, newUser.id));
// Import DMs
// Import each conversation and all of its messages atomically. A
// failed message cannot leave a partial conversation behind.
let importedDMs = 0;
for (const conv of importDMs) {
try {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: newUser.id,
participant2Handle: conv.participant2Handle,
type: conv.type,
lastMessageAt: conv.lastMessageAt ? new Date(conv.lastMessageAt) : null,
lastMessagePreview: conv.lastMessagePreview
}).returning();
await db.transaction(async (tx) => {
const [newConv] = await tx.insert(chatConversations).values({
participant1Id: newUser.id,
participant2Handle: conv.participant2Handle,
type: conv.type,
lastMessageAt: conv.lastMessageAt ? new Date(conv.lastMessageAt) : null,
lastMessagePreview: conv.encryptionMode === 'e2ee'
? 'Encrypted message'
: conv.lastMessagePreview,
encryptionMode: conv.encryptionMode,
e2eeActivatedAt: conv.e2eeActivatedAt ? new Date(conv.e2eeActivatedAt) : null,
}).returning();
for (const msg of conv.messages) {
await db.insert(chatMessages).values({
conversationId: newConv.id,
senderHandle: msg.senderHandle,
senderDisplayName: msg.senderDisplayName,
senderAvatarUrl: msg.senderAvatarUrl,
senderNodeDomain: msg.senderNodeDomain,
senderDid: msg.senderDid,
content: msg.content,
deliveredAt: msg.deliveredAt ? new Date(msg.deliveredAt) : null,
readAt: msg.readAt ? new Date(msg.readAt) : null,
createdAt: new Date(msg.createdAt)
});
}
for (const msg of conv.messages) {
await tx.insert(chatMessages).values({
conversationId: newConv.id,
senderHandle: msg.senderHandle,
senderDisplayName: msg.senderDisplayName,
senderAvatarUrl: msg.senderAvatarUrl,
senderNodeDomain: msg.senderNodeDomain,
senderDid: msg.senderDid,
content: msg.protocolVersion === 0 ? msg.content : null,
protocolVersion: msg.protocolVersion,
clientMessageId: msg.clientMessageId,
encryptedEnvelope: msg.encryptedEnvelope,
e2eeSignature: msg.e2eeSignature,
e2eeActionNonce: msg.e2eeActionNonce,
e2eeActionTs: msg.e2eeActionTs,
deliveredAt: msg.deliveredAt ? new Date(msg.deliveredAt) : null,
readAt: msg.readAt ? new Date(msg.readAt) : null,
createdAt: new Date(msg.createdAt),
});
if (msg.protocolVersion === 1 && msg.clientMessageId && msg.senderDid) {
await tx.insert(e2eeMessageReceipts).values({
ownerUserId: newUser.id,
senderDid: msg.senderDid,
messageId: msg.clientMessageId,
}).onConflictDoNothing();
}
}
});
importedDMs += 1;
} catch (error) {
console.error('Failed to import DM conversation:', error);
}
@@ -447,12 +1029,23 @@ export async function POST(req: NextRequest) {
// Notify old node about the migration
try {
await notifyOldNode(manifest.sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey);
await notifyOldNode(sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey);
} catch (error) {
console.error('Failed to notify old node:', error);
// Don't fail the import if notification fails
}
// Match registration/login behavior so the successful import can
// redirect directly into the authenticated app. The imported email
// and password hash also make ordinary email login work thereafter.
let sessionWarning: string | null = null;
try {
await createSession(newUser.id);
} catch (error) {
console.error('Account imported but automatic sign-in failed:', error);
sessionWarning = 'The account was imported, but automatic sign-in failed. Sign in with the destination email and import password.';
}
return NextResponse.json({
success: true,
user: {
@@ -464,10 +1057,12 @@ export async function POST(req: NextRequest) {
stats: {
postsImported: importedPosts,
followingImported: importedFollowing,
dmsImported: importDMs.length,
dmsImported: importedDMs,
botsImported: importBots.length,
},
message: 'Account imported successfully. Your followers on other Synapsis nodes will be automatically migrated.',
warnings: [encryptedDMImportWarning, federatedMoveWarning, sessionWarning]
.filter((warning): warning is string => warning !== null),
message: 'Account imported successfully. The old node was notified of the move when reachable.',
});
} catch (error) {
@@ -498,7 +1093,8 @@ async function notifyOldNode(
sign.update(JSON.stringify(payload));
const signature = sign.sign(privateKey, 'base64');
const response = await fetch(`https://${sourceNode}/api/account/moved`, {
const response = await safeFederationRequest(
`${sourceNodeProtocol(sourceNode)}://${sourceNode}/api/account/moved`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -507,9 +1103,13 @@ async function notifyOldNode(
...payload,
signature,
}),
timeoutMs: 5_000,
maxResponseBytes: 64 * 1024,
});
if (!response.ok) {
// The safe requester never follows redirects. Treat every non-2xx,
// including redirects, as a failed best-effort notification.
if (response.status < 200 || response.status >= 300) {
throw new Error(`Old node returned ${response.status}`);
}
}
+349 -301
View File
@@ -1,313 +1,361 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import {
chatConversations,
chatMessages,
db,
e2eeMessageReceipts,
handleRegistry,
} from '@/db';
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
import { normalizeSigningPublicKey } from '@/lib/crypto/did-key';
import { signingPublicKeyFromDid } from '@/lib/e2ee/bundle-proof';
import {
E2EE_PROTOCOL_VERSION,
e2eeMessageEnvelopeSchema,
signedUserActionSchema,
validateMessageBindings,
} from '@/lib/e2ee/protocol';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { isRateLimited } from '@/lib/rate-limit';
const signedChatActionSchema = z.object({
action: z.string().min(1),
did: z.string().regex(/^did:/, 'Must be a valid DID'),
handle: z.string().min(3).max(30),
ts: z.number(),
nonce: z.string().min(1),
sig: z.string().min(1),
data: z.object({
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
content: z.string().min(1).max(5000),
}),
const federatedEnvelopeSchema = z.strictObject({
userAction: signedUserActionSchema,
fullSenderHandle: z.string().min(3).max(640),
sourceDomain: z.string().min(1).max(253),
destinationDomain: z.string().min(1).max(253),
route: z.literal('/api/chat/receive'),
deliveryId: z.string().min(12).max(512),
ts: z.number().int().positive(),
expiresAt: z.number().int().positive(),
});
// Backward compatibility for older nodes that sent legacy field names.
const legacyChatActionSchema = z.object({
did: z.string().regex(/^did:/, 'Must be a valid DID'),
handle: z.string().min(3).max(30),
data: z.object({
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
content: z.string().min(1).max(5000),
}),
signature: z.string().min(1),
timestamp: z.number(),
});
const remoteProfileResponseSchema = z.object({
user: z.object({
did: z.string(),
publicKey: z.string(),
displayName: z.string().nullish(),
avatarUrl: z.string().nullish(),
isBot: z.boolean().optional(),
}).passthrough(),
}).passthrough();
const incomingChatActionSchema = z.union([signedChatActionSchema, legacyChatActionSchema]);
const federatedEnvelopeSchema = z.object({
userAction: incomingChatActionSchema,
fullSenderHandle: z.string().min(3).max(60),
sourceDomain: z.string().min(1),
ts: z.number(),
});
function normalizeSignedAction(action: z.infer<typeof incomingChatActionSchema>): SignedAction {
if ('sig' in action) {
return action;
}
return {
action: 'chat',
data: action.data,
did: action.did,
handle: action.handle,
ts: action.timestamp,
nonce: `legacy:${action.did}:${action.timestamp}`,
sig: action.signature,
};
class E2EEIdentityContinuityError extends Error {
constructor() {
super('Sender identity continuity check failed');
this.name = 'E2EEIdentityContinuityError';
}
}
/**
* POST /api/chat/receive
* Endpoint for receiving federated chat messages from other nodes.
* Expects either:
* 1. A SignedAction payload from the sender (legacy, for backward compatibility)
* 2. A federated envelope with node's signature and user's signed action
*/
const MAX_CONCURRENT_NODE_VERIFICATIONS = 32;
let activeNodeVerifications = 0;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Check if this is a federated envelope (node-signed)
const swarmSignature = request.headers.get('X-Swarm-Signature');
const sourceDomain = request.headers.get('X-Swarm-Source-Domain');
let signedAction: SignedAction;
let fullSenderHandle: string | null = null;
if (swarmSignature && sourceDomain && body.userAction) {
const normalizedSourceDomain = normalizeNodeDomain(sourceDomain);
if (await isNodeBlocked(normalizedSourceDomain)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
// Federated envelope format - validate and verify node signature
const envelopeValidation = federatedEnvelopeSchema.safeParse(body);
if (!envelopeValidation.success) {
return NextResponse.json(
{ error: 'Invalid envelope payload', details: envelopeValidation.error.issues },
{ status: 400 }
);
}
const isValidNodeSig = await verifySwarmRequest(
{ userAction: body.userAction, fullSenderHandle: body.fullSenderHandle, sourceDomain: body.sourceDomain, ts: body.ts },
swarmSignature,
sourceDomain
);
if (!isValidNodeSig) {
console.error('[Chat Receive] Invalid node signature from:', sourceDomain);
return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 });
}
// Extract user's signed action and full handle from envelope
signedAction = normalizeSignedAction(body.userAction);
fullSenderHandle = body.fullSenderHandle;
console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`);
} else {
// Legacy format - direct user signed action
const actionValidation = incomingChatActionSchema.safeParse(body);
if (!actionValidation.success) {
return NextResponse.json(
{ error: 'Invalid action payload', details: actionValidation.error.issues },
{ status: 400 }
);
}
signedAction = normalizeSignedAction(actionValidation.data);
}
const { did, handle, data } = signedAction;
const { recipientDid, content } = data || {};
// Use full handle if provided in envelope, otherwise fall back to signed handle
const senderHandle = fullSenderHandle || handle;
const senderDomainFromHandle = senderHandle.includes('@')
? normalizeNodeDomain(senderHandle.split('@').pop() || '')
: null;
if (senderDomainFromHandle && await isNodeBlocked(senderDomainFromHandle)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`);
// 1. Resolve Sender Public Key
let senderUser = await db.query.users.findFirst({
where: { did: did }
});
let publicKey = senderUser?.publicKey;
let senderDisplayName = senderUser?.displayName || senderHandle;
let senderAvatarUrl = senderUser?.avatarUrl;
let senderNodeDomain: string | null = null;
if (!senderUser) {
// Unknown user - likely remote. We need to fetch their profile to get the public key.
// Derive domain from full sender handle if possible
if (senderHandle.includes('@')) {
const parts = senderHandle.split('@');
senderNodeDomain = normalizeNodeDomain(parts[parts.length - 1]);
} else {
// Try to get from header first
const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain');
if (sourceDomainHeader) {
senderNodeDomain = normalizeNodeDomain(sourceDomainHeader);
} else {
// Try handle registry (though we likely don't have it if we don't have the user)
const registryEntry = await db.query.handleRegistry.findFirst({
where: { did: did }
});
if (registryEntry) senderNodeDomain = normalizeNodeDomain(registryEntry.nodeDomain);
}
}
if (senderNodeDomain && await isNodeBlocked(senderNodeDomain)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
if (senderNodeDomain) {
try {
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
const remoteHandle = senderHandle.includes('@') ? senderHandle.split('@')[0] : senderHandle;
// Fetch public key with TOFU validation
const { publicKey: cachedOrFreshKey, fromCache, keyChanged } = await fetchAndCacheRemoteKey(
did,
senderHandle,
senderNodeDomain,
async () => {
// Fetch profile from remote node
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
if (!res.ok) return null;
const profileData = await res.json();
const remoteProfile = profileData.user;
if (remoteProfile?.did !== did) {
console.error('[Chat Receive] DID mismatch for remote user');
return null;
}
return remoteProfile?.publicKey || null;
}
);
if (cachedOrFreshKey) {
publicKey = cachedOrFreshKey;
console.log(`[Chat Receive] Using ${fromCache ? 'cached' : 'fetched'} public key for ${senderHandle}${keyChanged ? ' (KEY CHANGED!)' : ''}`);
// Also fetch display name/avatar if not from cache (or if we need fresh data)
if (!fromCache || keyChanged) {
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
if (res.ok) {
const profileData = await res.json();
const remoteProfile = profileData.user;
senderDisplayName = remoteProfile?.displayName || senderHandle;
senderAvatarUrl = remoteProfile?.avatarUrl;
// CACHE: Upsert the remote user into our local database
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
await upsertRemoteUser({
handle: senderHandle, // use full handle (user@domain)
displayName: senderDisplayName,
avatarUrl: senderAvatarUrl || null,
did: did || '',
isBot: remoteProfile.isBot || false
});
}
}
} else {
console.error('Failed to fetch remote profile: no key returned');
}
} catch (e) {
console.error('Remote profile fetch failed:', e);
}
}
}
if (!publicKey) {
return NextResponse.json({ error: 'Could not resolve sender public key' }, { status: 401 });
}
// 2. Verify Signature
const isValid = await verifyActionSignature(signedAction, publicKey);
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
}
// 3. Find Local Recipient
const recipientUser = await db.query.users.findFirst({
where: { did: recipientDid }
});
if (!recipientUser) {
return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 });
}
// 4. Find or Create Conversation
// For the RECIPIENT, the conversation is with the SENDER
// Use full handle from envelope if available, otherwise construct from handle + domain
const computedFullSenderHandle = senderHandle.includes('@') ? senderHandle : (senderNodeDomain ? `${senderHandle}@${senderNodeDomain}` : senderHandle);
let conversation = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: computedFullSenderHandle }] }
});
if (!conversation) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipientUser.id,
participant2Handle: computedFullSenderHandle,
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50)
}).returning();
conversation = newConv;
} else {
// Update preview
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, conversation.id));
}
// 5. Store Message
await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: computedFullSenderHandle,
senderDisplayName: senderDisplayName,
senderAvatarUrl: senderAvatarUrl,
senderNodeDomain: senderNodeDomain,
senderDid: did,
content: content,
deliveredAt: new Date(),
});
// 6. Update Registry (to ensure we can reply efficiently)
if (senderNodeDomain) {
await db.insert(handleRegistry).values({
handle: computedFullSenderHandle, // user@domain
did: did,
nodeDomain: senderNodeDomain
}).onConflictDoUpdate({
target: handleRegistry.handle,
set: {
did: did,
nodeDomain: senderNodeDomain
}
});
}
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Federated Receive Failed:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.issues },
{ status: 400 }
);
}
return NextResponse.json({ error: error.message }, { status: 500 });
try {
const swarmSignature = request.headers.get('X-Swarm-Signature');
const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain');
if (!swarmSignature || !sourceDomainHeader) {
return NextResponse.json({
error: 'Encrypted federated messages require a node-signed envelope',
code: 'E2EE_REQUIRED',
}, { status: 426 });
}
const body = federatedEnvelopeSchema.parse(await request.json());
const sourceDomain = normalizeNodeDomain(sourceDomainHeader);
if (normalizeNodeDomain(body.sourceDomain) !== sourceDomain) {
return NextResponse.json({ error: 'Source node mismatch' }, { status: 403 });
}
const localDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || '');
if (!localDomain || normalizeNodeDomain(body.destinationDomain) !== localDomain) {
return NextResponse.json({ error: 'Destination node mismatch' }, { status: 403 });
}
if (await isNodeBlocked(sourceDomain)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
if (Math.abs(Date.now() - body.ts) > 5 * 60 * 1000 || body.expiresAt < Date.now()
|| body.expiresAt - body.ts > 5 * 60 * 1000) {
return NextResponse.json({ error: 'Federated envelope is stale' }, { status: 400 });
}
if (isRateLimited('e2ee-federation-preauth-global', 1_200, 60 * 1000)
|| activeNodeVerifications >= MAX_CONCURRENT_NODE_VERIFICATIONS) {
return NextResponse.json({
error: 'Federated encrypted-message verification is busy',
code: 'E2EE_REMOTE_RATE_LIMITED',
}, { status: 429, headers: { 'Retry-After': '60' } });
}
activeNodeVerifications += 1;
let nodeSignatureValid = false;
try {
nodeSignatureValid = await verifySwarmRequest(body, swarmSignature, sourceDomain);
} finally {
activeNodeVerifications -= 1;
}
if (!nodeSignatureValid) {
return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 });
}
if (isRateLimited(`e2ee-federation-node:${sourceDomain}`, 600, 60 * 1000)) {
return NextResponse.json({
error: 'This node is sending encrypted messages too quickly',
code: 'E2EE_REMOTE_RATE_LIMITED',
}, { status: 429 });
}
const signedAction = body.userAction as SignedAction;
const envelope = e2eeMessageEnvelopeSchema.parse(signedAction.data);
validateMessageBindings(envelope, signedAction);
if (Math.abs(Date.now() - signedAction.ts) > 5 * 60 * 1000) {
return NextResponse.json({ error: 'Encrypted message action is stale' }, { status: 400 });
}
if (body.deliveryId !== `${envelope.messageId}:${normalizeNodeDomain(body.destinationDomain)}`) {
return NextResponse.json({ error: 'Federated delivery identity mismatch' }, { status: 403 });
}
const senderHandle = body.fullSenderHandle.toLowerCase().replace(/^@/, '');
const senderParts = senderHandle.split('@');
if (senderParts.length !== 2
|| senderParts[0] !== signedAction.handle.toLowerCase()
|| normalizeNodeDomain(senderParts[1]) !== sourceDomain) {
return NextResponse.json({ error: 'Federated sender handle mismatch' }, { status: 403 });
}
if (Buffer.from(envelope.nonce, 'base64url').length !== 24
|| Buffer.from(envelope.ciphertext, 'base64url').length < 17
|| Buffer.from(envelope.ciphertext, 'base64url').length > 8_192
|| Buffer.from(envelope.keyCommitment, 'base64url').length !== 32
|| envelope.keyEnvelopes.some((item) => Buffer.from(item.sealedKey, 'base64url').length !== 112)) {
return NextResponse.json({ error: 'Invalid encrypted message sizes' }, { status: 400 });
}
// Reject nonexistent, unwilling, or stale-key recipients before resolving
// or persisting any attacker-controlled remote user profile.
const recipient = await db.query.users.findFirst({ where: { did: envelope.recipientDid } });
if (!recipient || recipient.handle.includes('@') || recipient.id.startsWith('swarm:')) {
return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 });
}
const envelopeRecipientHandle = envelope.recipientHandle.toLowerCase().replace(/^@/, '');
const recipientHandleParts = envelopeRecipientHandle.split('@');
const recipientHandleMatches = recipientHandleParts.length === 1
? recipientHandleParts[0] === recipient.handle.toLowerCase()
: recipientHandleParts.length === 2
&& recipientHandleParts[0] === recipient.handle.toLowerCase()
&& normalizeNodeDomain(recipientHandleParts[1]) === localDomain;
if (!recipientHandleMatches) {
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 });
}
if (recipient.isBot || recipient.dmPrivacy === 'none') {
return NextResponse.json({ error: 'Recipient does not accept direct messages' }, { status: 403 });
}
if (recipient.dmPrivacy === 'following') {
const followsSender = await db.query.remoteFollows.findFirst({
where: { AND: [{ followerId: recipient.id }, { targetHandle: senderHandle }] },
});
if (!followsSender) {
return NextResponse.json({ error: 'Recipient only accepts messages from accounts they follow' }, { status: 403 });
}
}
const [recipientKey, recipientVault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: recipient.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: recipient.id } }),
]);
if (!recipientKey || !recipientVault) {
return NextResponse.json({
error: recipientKey
? 'Recipient needs to finish encrypted message setup on this node'
: 'Recipient has not set up encrypted messages',
code: 'E2EE_NOT_CONFIGURED',
}, { status: 409 });
}
if (recipientKey.keyId !== recipientVault.keyId
|| recipientKey.keyVersion !== recipientVault.keyVersion
|| recipientKey.publicKey !== recipientVault.publicKey
|| recipientVault.ownerDid !== recipient.did
|| recipientKey.keyId !== envelope.recipientKeyId
|| recipientKey.keyVersion !== envelope.recipientKeyVersion) {
return NextResponse.json({
error: 'Recipient encryption key changed',
code: 'E2EE_RECIPIENT_KEY_STALE',
}, { status: 409 });
}
let senderUser = await db.query.users.findFirst({ where: { did: signedAction.did } });
if (senderUser && !senderUser.handle.includes('@') && !senderUser.id.startsWith('swarm:')) {
return NextResponse.json({ error: 'A remote node cannot claim a local identity' }, { status: 403 });
}
if (senderUser && senderUser.handle !== senderHandle) {
return NextResponse.json({ error: 'Sender identity continuity check failed' }, { status: 403 });
}
const didPublicKey = signingPublicKeyFromDid(signedAction.did);
let signingPublicKey = didPublicKey || senderUser?.publicKey || null;
let senderDisplayName = senderUser?.displayName || signedAction.handle;
let senderAvatarUrl = senderUser?.avatarUrl || null;
let resolvedProfile: z.infer<typeof remoteProfileResponseSchema>['user'] | null = null;
let signatureVerified = !!signingPublicKey
&& await verifyActionSignature(signedAction, signingPublicKey);
if (didPublicKey && !signatureVerified) {
return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 });
}
if (!senderUser || !signatureVerified) {
const isDevelopmentLoopback = process.env.NODE_ENV === 'development'
&& /^localhost(?::\d{1,5})?$/i.test(sourceDomain);
const protocol = isDevelopmentLoopback ? 'http' : 'https';
const profileResponse = await safeFederationRequest(`${protocol}://${sourceDomain}/api/users/${encodeURIComponent(signedAction.handle)}`, {
headers: { Accept: 'application/json' },
maxResponseBytes: 64 * 1024,
});
if (profileResponse.status < 200 || profileResponse.status >= 300) {
return NextResponse.json({ error: 'Could not resolve sender identity' }, { status: 401 });
}
const profileData = remoteProfileResponseSchema.parse(profileResponse.json());
const profile = profileData.user;
if (profile?.did !== signedAction.did || !profile.publicKey) {
return NextResponse.json({ error: 'Sender identity does not match their node profile' }, { status: 403 });
}
const profileSigningPublicKey = normalizeSigningPublicKey(profile.publicKey);
if (!profileSigningPublicKey) {
return NextResponse.json({ error: 'Sender profile contains an invalid signing key' }, { status: 403 });
}
if (didPublicKey && didPublicKey !== profileSigningPublicKey) {
return NextResponse.json({ error: 'Sender signing key does not match their DID' }, { status: 403 });
}
const resolvedSigningPublicKey = didPublicKey || profileSigningPublicKey;
signingPublicKey = resolvedSigningPublicKey;
signatureVerified = await verifyActionSignature(signedAction, resolvedSigningPublicKey);
if (!signatureVerified) {
return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 });
}
senderDisplayName = profile.displayName || signedAction.handle;
senderAvatarUrl = profile.avatarUrl || null;
resolvedProfile = profile;
}
if (!signingPublicKey || !signatureVerified) {
return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 });
}
if (isRateLimited(`e2ee-federation-sender:${sourceDomain}:${signedAction.did}`, 120, 60 * 1000)) {
return NextResponse.json({
error: 'This sender is sending encrypted messages too quickly',
code: 'E2EE_REMOTE_RATE_LIMITED',
}, { status: 429 });
}
if (senderUser) {
const blocked = await db.query.blocks.findFirst({
where: { AND: [{ userId: recipient.id }, { blockedUserId: senderUser.id }] },
});
if (blocked) return NextResponse.json({ error: 'Message not permitted' }, { status: 403 });
}
if (resolvedProfile) {
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
await upsertRemoteUser({
handle: senderHandle,
displayName: senderDisplayName,
avatarUrl: senderAvatarUrl,
did: signedAction.did,
isBot: resolvedProfile.isBot || false,
publicKey: normalizeSigningPublicKey(resolvedProfile.publicKey)!,
});
senderUser = await db.query.users.findFirst({ where: { did: signedAction.did } });
}
const createdAt = new Date(envelope.createdAt);
await db.transaction(async (tx) => {
const [receipt] = await tx.insert(e2eeMessageReceipts).values({
ownerUserId: recipient.id,
senderDid: signedAction.did,
messageId: envelope.messageId,
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
if (!receipt) return;
const [insertedRegistryEntry] = await tx.insert(handleRegistry).values({
handle: senderHandle,
did: signedAction.did,
nodeDomain: sourceDomain,
}).onConflictDoNothing().returning({
did: handleRegistry.did,
nodeDomain: handleRegistry.nodeDomain,
});
const [registryEntry] = insertedRegistryEntry
? [insertedRegistryEntry]
: await tx.select({
did: handleRegistry.did,
nodeDomain: handleRegistry.nodeDomain,
}).from(handleRegistry).where(eq(handleRegistry.handle, senderHandle)).limit(1);
if (!registryEntry
|| registryEntry.did !== signedAction.did
|| normalizeNodeDomain(registryEntry.nodeDomain) !== sourceDomain) {
throw new E2EEIdentityContinuityError();
}
let [conversation] = await tx.select().from(chatConversations).where(and(
eq(chatConversations.participant1Id, recipient.id),
eq(chatConversations.participant2Handle, senderHandle),
)).limit(1);
if (!conversation) {
[conversation] = await tx.insert(chatConversations).values({
participant1Id: recipient.id,
participant2Handle: senderHandle,
lastMessageAt: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: createdAt,
}).returning();
} else {
await tx.update(chatConversations).set({
lastMessageAt: conversation.lastMessageAt && conversation.lastMessageAt > createdAt
? conversation.lastMessageAt
: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: conversation.e2eeActivatedAt ?? createdAt,
updatedAt: new Date(),
}).where(eq(chatConversations.id, conversation.id));
}
if (!conversation) throw new Error('Failed to create encrypted conversation');
await tx.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle,
senderDisplayName,
senderAvatarUrl,
senderNodeDomain: sourceDomain,
senderDid: signedAction.did,
content: null,
protocolVersion: E2EE_PROTOCOL_VERSION,
clientMessageId: envelope.messageId,
encryptedEnvelope: JSON.stringify(envelope),
e2eeSignature: signedAction.sig,
e2eeActionNonce: signedAction.nonce,
e2eeActionTs: signedAction.ts,
deliveredAt: new Date(),
createdAt,
});
});
return NextResponse.json({ success: true, messageId: envelope.messageId });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({
error: 'This node only accepts encrypted message envelopes',
code: 'E2EE_REQUIRED',
details: error.issues,
}, { status: 426 });
}
if (error instanceof E2EEIdentityContinuityError) {
return NextResponse.json({
error: error.message,
code: 'E2EE_IDENTITY_KEY_CHANGED',
}, { status: 409 });
}
console.error('[E2EE Chat] Federated receive failed:', error);
return NextResponse.json({ error: error instanceof Error ? error.message : 'Receive failed' }, { status: 500 });
}
}
+408 -256
View File
@@ -1,263 +1,415 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatConversations, chatMessages, users, handleRegistry, follows } from '@/db/schema';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { eq, and } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import {
chatConversations,
chatMessages,
db,
e2eeMessageReceipts,
} from '@/db';
import { requireSignedAction, SignedActionError, type SignedAction } from '@/lib/auth/verify-signature';
import {
E2EE_PROTOCOL_VERSION,
e2eeMessageEnvelopeSchema,
signedUserActionSchema,
validateMessageBindings,
} from '@/lib/e2ee/protocol';
import { createSignedPayload } from '@/lib/swarm/signature';
import { federatedHandleSchema } from '@/lib/utils/federation';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { getPublicSwarmDomain } from '@/lib/swarm/node-domain';
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
const chatSendSchema = z.object({
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
recipientHandle: federatedHandleSchema,
content: z.string().min(1).max(5000),
});
/**
* POST /api/chat/send
* Send a signed chat message (verified with DID)
* Stores plain text content.
*/
export async function POST(request: NextRequest) {
try {
// Parse the signed action from the request body
const signedAction = await request.json();
// Strictly verify the signature and get the user
const user = await requireSignedAction(signedAction);
// Extract message data
const data = chatSendSchema.parse(signedAction.data);
const { recipientDid, recipientHandle, content } = data;
// Check if recipient is local
const recipientUser = await db.query.users.findFirst({
where: { did: recipientDid }
});
// Check if recipient is a local user (not remote/swarm cached)
// Remote users have handles with @domain or IDs starting with "swarm:"
const isRemoteUser = recipientUser && (
recipientUser.handle.includes('@') ||
recipientUser.id.startsWith('swarm:')
);
if (recipientUser && !isRemoteUser) {
// Reject if recipient is a bot
if (recipientUser.isBot) {
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
}
// Check DM privacy settings
if (recipientUser.dmPrivacy === 'none') {
return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 });
} else if (recipientUser.dmPrivacy === 'following') {
// Check if recipient follows the sender
const isFollowingSender = await db.query.follows.findFirst({
where: { AND: [{ followerId: recipientUser.id }, { followingId: user.id }] }
});
if (!isFollowingSender) {
return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 });
}
}
// LOCAL RECIPIENT
// Ensure conversations exist for both sides
// 1. Recipient's Inbox (Recipient -> User)
let recipientConv = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: user.handle }] }
});
if (!recipientConv) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipientUser.id,
participant2Handle: user.handle,
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50)
}).returning();
recipientConv = newConv;
} else {
// Update preview
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, recipientConv.id));
}
// 2. Sender's Sent Box (User -> Recipient)
let senderConv = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: user.id }, { participant2Handle: recipientUser.handle }] }
});
if (!senderConv) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: recipientUser.handle,
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50)
}).returning();
senderConv = newConv;
} else {
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, senderConv.id));
}
// Create message for recipient (Inbox)
await db.insert(chatMessages).values({
conversationId: recipientConv.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
content: content,
deliveredAt: new Date(),
});
// Create message for sender (Sent)
await db.insert(chatMessages).values({
conversationId: senderConv.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
content: content,
deliveredAt: new Date(),
readAt: new Date() // Sender has read their own message
});
return NextResponse.json({ success: true });
} else {
// REMOTE RECIPIENT
// 1. Resolve recipient node
const registryEntry = await db.query.handleRegistry.findFirst({
where: { did: recipientDid }
});
// If not in registry, try to parse from handle if it has domain
let targetDomain: string | null = registryEntry?.nodeDomain || null;
let targetHandle = recipientHandle;
if (!targetDomain && recipientHandle.includes('@')) {
const parts = recipientHandle.split('@');
targetDomain = parts[parts.length - 1];
}
if (!targetDomain) {
console.error('Recipient node domain not found for:', recipientHandle);
return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
}
targetDomain = normalizeNodeDomain(targetDomain);
if (await isNodeBlocked(targetDomain)) {
return NextResponse.json({ error: 'This node is blocked by the server administrator' }, { status: 403 });
}
console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`);
// 2. Send to Remote Node (Forward the Signed Action)
try {
const protocol = targetDomain.includes('localhost') ? 'http' : 'https';
const sourceDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || '';
// Construct full sender handle for federation
const fullSenderHandle = `${user.handle}@${sourceDomain}`;
console.log(`[Remote Send] Debug:`, {
targetDomain,
targetUrl: `${protocol}://${targetDomain}/api/chat/receive`,
sourceDomainEnv: sourceDomain,
fullSenderHandle,
});
// Create a federated envelope with node's signature
// This wraps the user's signed action with the full handle
const federatedPayload = {
userAction: signedAction,
fullSenderHandle,
sourceDomain,
ts: Date.now(),
};
const { payload, signature } = await createSignedPayload(federatedPayload);
const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Swarm-Source-Domain': sourceDomain,
'X-Swarm-Signature': signature,
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const errText = await res.text();
console.error(`[Remote Send] Remote node rejected chat (${res.status}):`, errText);
return NextResponse.json({ error: `Remote delivery failed: ${res.statusText} - ${errText}` }, { status: 502 });
}
} catch (err: any) {
console.error('[Remote Send] Failed to contact remote node:', err);
return NextResponse.json({ error: 'Failed to contact remote node' }, { status: 504 });
}
// 3. Store "Sent" copy locally
// Ensure conversation exists locally
let senderConv = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: user.id }, { participant2Handle: recipientHandle }] }
});
if (!senderConv) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: recipientHandle,
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50)
}).returning();
senderConv = newConv;
} else {
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, senderConv.id));
}
await db.insert(chatMessages).values({
conversationId: senderConv.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
content: content,
deliveredAt: new Date(),
readAt: new Date()
});
return NextResponse.json({ success: true });
}
} catch (error: any) {
console.error('Send chat failed:', error);
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
return NextResponse.json({ error: error.message || 'Failed to send message' }, { status: 500 });
function validateCiphertextLengths(envelope: z.infer<typeof e2eeMessageEnvelopeSchema>): void {
if (Buffer.from(envelope.nonce, 'base64url').length !== 24) throw new Error('Invalid message nonce');
const ciphertextLength = Buffer.from(envelope.ciphertext, 'base64url').length;
if (ciphertextLength < 17 || ciphertextLength > 8_192) throw new Error('Invalid encrypted message');
if (Buffer.from(envelope.keyCommitment, 'base64url').length !== 32) {
throw new Error('Invalid encrypted message key commitment');
}
for (const wrappedKey of envelope.keyEnvelopes) {
if (Buffer.from(wrappedKey.sealedKey, 'base64url').length !== 112) {
throw new Error('Invalid encrypted message key');
}
}
}
function messageValues(input: {
conversationId: string;
senderHandle: string;
senderDisplayName: string | null;
senderAvatarUrl: string | null;
senderDid: string;
envelope: z.infer<typeof e2eeMessageEnvelopeSchema>;
signedAction: SignedAction;
createdAt: Date;
readAt?: Date;
}) {
return {
conversationId: input.conversationId,
senderHandle: input.senderHandle,
senderDisplayName: input.senderDisplayName,
senderAvatarUrl: input.senderAvatarUrl,
senderNodeDomain: null,
senderDid: input.senderDid,
content: null,
protocolVersion: E2EE_PROTOCOL_VERSION,
clientMessageId: input.envelope.messageId,
encryptedEnvelope: JSON.stringify(input.envelope),
e2eeSignature: input.signedAction.sig,
e2eeActionNonce: input.signedAction.nonce,
e2eeActionTs: input.signedAction.ts,
deliveredAt: new Date(),
readAt: input.readAt,
createdAt: input.createdAt,
};
}
export async function POST(request: NextRequest) {
try {
const signedAction = signedUserActionSchema.parse(await request.json()) as SignedAction;
const user = await requireSignedAction(signedAction);
const envelope = e2eeMessageEnvelopeSchema.parse(signedAction.data);
validateMessageBindings(envelope, signedAction);
validateCiphertextLengths(envelope);
if (envelope.senderDid !== user.did || envelope.senderHandle !== user.handle) {
return NextResponse.json({ error: 'Sender identity mismatch' }, { status: 403 });
}
const [senderKey, senderVault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }),
]);
if (!senderKey || !senderVault
|| senderKey.keyId !== senderVault.keyId
|| senderKey.keyVersion !== senderVault.keyVersion
|| senderKey.publicKey !== senderVault.publicKey
|| senderVault.ownerDid !== user.did
|| senderKey.keyId !== envelope.senderKeyId
|| senderKey.keyVersion !== envelope.senderKeyVersion) {
return NextResponse.json({
error: senderKey && !senderVault
? 'Finish encrypted message setup on this node before sending'
: 'Your encrypted message key changed. Reload Chat and try again.',
code: 'E2EE_SENDER_KEY_STALE',
}, { status: 409 });
}
const recipientUser = await db.query.users.findFirst({ where: { did: envelope.recipientDid } });
const isRemoteRecipient = !recipientUser
|| recipientUser.handle.includes('@')
|| recipientUser.id.startsWith('swarm:');
const createdAt = new Date(envelope.createdAt);
if (recipientUser && !isRemoteRecipient) {
if (recipientUser.id === user.id) {
return NextResponse.json({ error: 'Cannot send an encrypted DM to yourself' }, { status: 400 });
}
if (envelope.recipientHandle.toLowerCase() !== recipientUser.handle.toLowerCase()) {
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 });
}
if (recipientUser.isBot) {
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
}
if (recipientUser.dmPrivacy === 'none') {
return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 });
}
if (recipientUser.dmPrivacy === 'following') {
const followsSender = await db.query.follows.findFirst({
where: { AND: [{ followerId: recipientUser.id }, { followingId: user.id }] },
});
if (!followsSender) {
return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 });
}
}
const [recipientBlockedSender, senderBlockedRecipient] = await Promise.all([
db.query.blocks.findFirst({
where: { AND: [{ userId: recipientUser.id }, { blockedUserId: user.id }] },
}),
db.query.blocks.findFirst({
where: { AND: [{ userId: user.id }, { blockedUserId: recipientUser.id }] },
}),
]);
if (recipientBlockedSender || senderBlockedRecipient) {
return NextResponse.json({ error: 'Message not permitted' }, { status: 403 });
}
const [recipientKey, recipientVault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: recipientUser.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: recipientUser.id } }),
]);
if (!recipientKey || !recipientVault) {
return NextResponse.json({
error: recipientKey
? 'Recipient needs to finish encrypted message setup on this node'
: 'Recipient has not set up encrypted messages',
code: 'E2EE_NOT_CONFIGURED',
}, { status: 409 });
}
if (recipientKey.keyId !== recipientVault.keyId
|| recipientKey.keyVersion !== recipientVault.keyVersion
|| recipientKey.publicKey !== recipientVault.publicKey
|| recipientVault.ownerDid !== recipientUser.did
|| recipientKey.keyId !== envelope.recipientKeyId
|| recipientKey.keyVersion !== envelope.recipientKeyVersion) {
return NextResponse.json({
error: 'Recipient encryption key changed. Reload Chat and try again.',
code: 'E2EE_RECIPIENT_KEY_STALE',
}, { status: 409 });
}
await db.transaction(async (tx) => {
const [recipientReceipt] = await tx.insert(e2eeMessageReceipts).values({
ownerUserId: recipientUser.id,
senderDid: user.did,
messageId: envelope.messageId,
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
if (!recipientReceipt) return;
const [senderReceipt] = await tx.insert(e2eeMessageReceipts).values({
ownerUserId: user.id,
senderDid: user.did,
messageId: envelope.messageId,
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
if (!senderReceipt) throw new Error('Encrypted message receipt state is inconsistent');
let [recipientConversation] = await tx.select().from(chatConversations).where(and(
eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, user.handle),
)).limit(1);
if (!recipientConversation) {
[recipientConversation] = await tx.insert(chatConversations).values({
participant1Id: recipientUser.id,
participant2Handle: user.handle,
lastMessageAt: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: createdAt,
}).returning();
} else {
await tx.update(chatConversations).set({
lastMessageAt: recipientConversation.lastMessageAt && recipientConversation.lastMessageAt > createdAt
? recipientConversation.lastMessageAt
: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: recipientConversation.e2eeActivatedAt ?? createdAt,
updatedAt: new Date(),
}).where(eq(chatConversations.id, recipientConversation.id));
}
let [senderConversation] = await tx.select().from(chatConversations).where(and(
eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, recipientUser.handle),
)).limit(1);
if (!senderConversation) {
[senderConversation] = await tx.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: recipientUser.handle,
lastMessageAt: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: createdAt,
}).returning();
} else {
await tx.update(chatConversations).set({
lastMessageAt: senderConversation.lastMessageAt && senderConversation.lastMessageAt > createdAt
? senderConversation.lastMessageAt
: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: senderConversation.e2eeActivatedAt ?? createdAt,
updatedAt: new Date(),
}).where(eq(chatConversations.id, senderConversation.id));
}
if (!recipientConversation || !senderConversation) {
throw new Error('Failed to create encrypted conversations');
}
await tx.insert(chatMessages).values([
messageValues({
conversationId: recipientConversation.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderDid: user.did,
envelope,
signedAction,
createdAt,
}),
messageValues({
conversationId: senderConversation.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderDid: user.did,
envelope,
signedAction,
createdAt,
readAt: new Date(),
}),
]);
});
return NextResponse.json({ success: true, messageId: envelope.messageId });
}
const cachedRecipientKey = await db.query.e2eeRemoteKeyBundles.findFirst({
where: { did: envelope.recipientDid },
});
if (!cachedRecipientKey || cachedRecipientKey.keyId !== envelope.recipientKeyId
|| cachedRecipientKey.keyVersion !== envelope.recipientKeyVersion) {
return NextResponse.json({
error: 'Recipient encryption key must be verified again',
code: 'E2EE_RECIPIENT_KEY_STALE',
}, { status: 409 });
}
if (cachedRecipientKey.handle.toLowerCase().replace(/^@/, '')
!== envelope.recipientHandle.toLowerCase().replace(/^@/, '')) {
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 });
}
if (recipientUser) {
const senderBlockedRecipient = await db.query.blocks.findFirst({
where: { AND: [{ userId: user.id }, { blockedUserId: recipientUser.id }] },
});
if (senderBlockedRecipient) {
return NextResponse.json({ error: 'Message not permitted' }, { status: 403 });
}
}
const fullRecipientHandle = envelope.recipientHandle.toLowerCase().replace(/^@/, '');
const recipientHandleParts = fullRecipientHandle.split('@');
if (recipientHandleParts.length !== 2 || !recipientHandleParts[0] || !recipientHandleParts[1]) {
return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
}
let targetDomain: string | null = normalizeNodeDomain(recipientHandleParts[1]);
const registryEntry = await db.query.handleRegistry.findFirst({
where: { handle: fullRecipientHandle },
});
if (registryEntry && normalizeNodeDomain(registryEntry.nodeDomain) !== targetDomain) {
return NextResponse.json({
error: 'Recipient node identity does not match the verified handle',
code: 'E2EE_IDENTITY_KEY_CHANGED',
}, { status: 409 });
}
const normalizedTargetDomain = normalizeNodeDomain(targetDomain);
const isDevelopmentLoopback = process.env.NODE_ENV === 'development'
&& /^localhost(?::\d{1,5})?$/i.test(normalizedTargetDomain);
targetDomain = isDevelopmentLoopback
? normalizedTargetDomain
: getPublicSwarmDomain(normalizedTargetDomain);
if (!targetDomain) {
return NextResponse.json({ error: 'Recipient node domain is invalid' }, { status: 400 });
}
if (await isNodeBlocked(targetDomain)) {
return NextResponse.json({ error: 'Recipient node is blocked' }, { status: 403 });
}
const protocol = isDevelopmentLoopback ? 'http' : 'https';
const sourceDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || '');
if (!sourceDomain) {
return NextResponse.json({ error: 'This node is not configured for federated messages' }, { status: 503 });
}
const deliveredAt = Date.now();
const federatedPayload = {
userAction: signedAction,
fullSenderHandle: `${user.handle}@${sourceDomain}`,
sourceDomain,
destinationDomain: targetDomain,
route: '/api/chat/receive' as const,
deliveryId: `${envelope.messageId}:${targetDomain}`,
ts: deliveredAt,
expiresAt: deliveredAt + 5 * 60 * 1000,
};
const { payload, signature } = await createSignedPayload(federatedPayload);
const remoteResponse = await safeFederationRequest(`${protocol}://${targetDomain}/api/chat/receive`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Swarm-Source-Domain': sourceDomain,
'X-Swarm-Signature': signature,
},
body: JSON.stringify(payload),
maxResponseBytes: 64 * 1024,
});
if (remoteResponse.status < 200 || remoteResponse.status >= 300) {
let remoteBody: { error?: string; code?: string } | null = null;
try {
remoteBody = remoteResponse.json() as { error?: string; code?: string };
} catch {
// Preserve a generic delivery error for non-JSON remote failures.
}
return NextResponse.json({
error: remoteBody?.error || 'Recipient node rejected the encrypted message',
code: remoteBody?.code || 'E2EE_REMOTE_DELIVERY_FAILED',
}, { status: remoteResponse.status === 426 ? 409 : 502 });
}
await db.transaction(async (tx) => {
const [receipt] = await tx.insert(e2eeMessageReceipts).values({
ownerUserId: user.id,
senderDid: user.did,
messageId: envelope.messageId,
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
if (!receipt) return;
let [senderConversation] = await tx.select().from(chatConversations).where(and(
eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, envelope.recipientHandle),
)).limit(1);
if (!senderConversation) {
[senderConversation] = await tx.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: envelope.recipientHandle,
lastMessageAt: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: createdAt,
}).returning();
} else {
await tx.update(chatConversations).set({
lastMessageAt: senderConversation.lastMessageAt && senderConversation.lastMessageAt > createdAt
? senderConversation.lastMessageAt
: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: senderConversation.e2eeActivatedAt ?? createdAt,
updatedAt: new Date(),
}).where(eq(chatConversations.id, senderConversation.id));
}
if (!senderConversation) throw new Error('Failed to create encrypted conversation');
await tx.insert(chatMessages).values(messageValues({
conversationId: senderConversation.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderDid: user.did,
envelope,
signedAction,
createdAt,
readAt: new Date(),
}));
});
return NextResponse.json({ success: true, messageId: envelope.messageId });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({
error: 'This client must send an encrypted message envelope',
code: 'E2EE_REQUIRED',
details: error.issues,
}, { status: 426 });
}
if (error instanceof SignedActionError) {
const rateLimited = error.message === 'RATE_LIMITED';
return NextResponse.json({
error: rateLimited ? 'Too many encrypted messages; try again shortly' : 'Message signature was rejected',
code: rateLimited ? 'E2EE_RATE_LIMITED' : 'E2EE_SIGNATURE_REJECTED',
}, { status: rateLimited ? 429 : 403 });
}
console.error('[E2EE Chat] Send failed:', error);
return NextResponse.json({ error: error instanceof Error ? error.message : 'Send failed' }, { status: 500 });
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { e2eeKeyBundleSchema, signedUserActionSchema } from '@/lib/e2ee/protocol';
type RouteContext = { params: Promise<{ did: string }> };
export async function GET(_request: Request, context: RouteContext) {
const { did } = await context.params;
const user = await db.query.users.findFirst({ where: { did } });
if (!user || user.handle.includes('@') || user.id.startsWith('swarm:')) {
return NextResponse.json({ error: 'Encryption key not found' }, { status: 404 });
}
const [row, vault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }),
]);
if (!row && !vault) {
return NextResponse.json({
error: 'Encrypted messages are not configured for this account',
code: 'E2EE_NOT_CONFIGURED',
}, { status: 404 });
}
if (!row || !vault || row.keyId !== vault.keyId || row.keyVersion !== vault.keyVersion
|| row.publicKey !== vault.publicKey || vault.ownerDid !== user.did) {
return NextResponse.json({
error: vault ? 'Encrypted message key state is inconsistent' : 'Encrypted messages need setup on this node',
code: vault ? 'E2EE_KEY_STATE_INVALID' : 'E2EE_NOT_CONFIGURED',
}, { status: vault ? 500 : 404 });
}
try {
const proof = signedUserActionSchema.parse(JSON.parse(row.proofAction));
const bundle = e2eeKeyBundleSchema.parse(proof.data);
return NextResponse.json({
bundle,
proof,
signingPublicKey: user.publicKey,
}, { headers: { 'Cache-Control': 'public, max-age=60, must-revalidate' } });
} catch (error) {
console.error('[E2EE Keys] Stored key proof is invalid:', error);
return NextResponse.json({ error: 'Encryption key proof is invalid' }, { status: 500 });
}
}
+186
View File
@@ -0,0 +1,186 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { db, e2eeRemoteKeyBundles } from '@/db';
import { getSession } from '@/lib/auth';
import { normalizeSigningPublicKey } from '@/lib/crypto/did-key';
import { signingPublicKeyFromDid, verifyE2EEPublicBundle } from '@/lib/e2ee/bundle-proof';
import { e2eePublicBundleResponseSchema } from '@/lib/e2ee/protocol';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { getPublicSwarmDomain } from '@/lib/swarm/node-domain';
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
const querySchema = z.object({
did: z.string().min(8).max(2_048).regex(/^did:/),
handle: z.string().min(1).max(320),
});
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const query = querySchema.parse({
did: request.nextUrl.searchParams.get('did'),
handle: request.nextUrl.searchParams.get('handle'),
});
const localUser = await db.query.users.findFirst({ where: { did: query.did } });
const isLocal = localUser && !localUser.handle.includes('@') && !localUser.id.startsWith('swarm:');
if (isLocal) {
const requestedHandle = query.handle.toLowerCase().replace(/^@/, '').split('@')[0];
if (requestedHandle !== localUser.handle.toLowerCase()) {
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 409 });
}
const [bundle, vault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: localUser.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: localUser.id } }),
]);
if (!bundle && !vault) {
return NextResponse.json({ code: 'E2EE_NOT_CONFIGURED', error: 'Recipient has not set up encrypted messages' }, { status: 404 });
}
if (!bundle || !vault || bundle.keyId !== vault.keyId || bundle.keyVersion !== vault.keyVersion
|| bundle.publicKey !== vault.publicKey || vault.ownerDid !== localUser.did) {
return NextResponse.json({
code: vault ? 'E2EE_KEY_STATE_INVALID' : 'E2EE_NOT_CONFIGURED',
error: vault
? 'Recipient encryption key state is inconsistent'
: 'Recipient needs to finish encrypted message setup on this node',
}, { status: vault ? 500 : 404 });
}
const response = e2eePublicBundleResponseSchema.parse({
bundle: JSON.parse(bundle.proofAction).data,
proof: JSON.parse(bundle.proofAction),
signingPublicKey: localUser.publicKey,
});
return NextResponse.json(response, { headers: { 'Cache-Control': 'no-store' } });
}
const handleParts = query.handle.toLowerCase().replace(/^@/, '').split('@');
if (handleParts.length < 2) {
return NextResponse.json({ error: 'Remote recipient domain is missing' }, { status: 400 });
}
const remoteHandle = handleParts[0];
const normalizedDomain = normalizeNodeDomain(handleParts.at(-1) || '');
const isDevelopmentLoopback = process.env.NODE_ENV === 'development'
&& /^localhost(?::\d{1,5})?$/i.test(normalizedDomain);
const domain = isDevelopmentLoopback
? normalizedDomain
: getPublicSwarmDomain(normalizedDomain);
if (!domain) {
return NextResponse.json({ error: 'Recipient node domain is invalid' }, { status: 400 });
}
if (await isNodeBlocked(domain)) {
return NextResponse.json({ error: 'Recipient node is blocked' }, { status: 403 });
}
const protocol = isDevelopmentLoopback ? 'http' : 'https';
const remote = await safeFederationRequest(`${protocol}://${domain}/api/e2ee/keys/${encodeURIComponent(query.did)}`, {
headers: { Accept: 'application/json' },
maxResponseBytes: 64 * 1024,
});
if (remote.status < 200 || remote.status >= 300) {
let remoteBody: { code?: string } | null = null;
try {
remoteBody = remote.json() as { code?: string };
} catch {
// A non-JSON error response still maps to a bounded lookup failure.
}
const code = remoteBody?.code === 'E2EE_NOT_CONFIGURED' ? 'E2EE_NOT_CONFIGURED' : 'E2EE_KEY_LOOKUP_FAILED';
return NextResponse.json({
error: code === 'E2EE_NOT_CONFIGURED'
? 'Recipient has not set up encrypted messages'
: 'Recipient encryption key could not be verified',
code,
}, { status: code === 'E2EE_NOT_CONFIGURED' ? 404 : 502 });
}
const response = e2eePublicBundleResponseSchema.parse(remote.json());
if (response.proof.handle.toLowerCase() !== remoteHandle || !await verifyE2EEPublicBundle(response, query.did)) {
return NextResponse.json({ error: 'Recipient encryption key proof is invalid' }, { status: 502 });
}
const resolvedSigningPublicKey = normalizeSigningPublicKey(response.signingPublicKey);
if (!resolvedSigningPublicKey) {
return NextResponse.json({ error: 'Recipient signing key is invalid' }, { status: 502 });
}
const canonicalSigningPublicKey = signingPublicKeyFromDid(query.did)
|| resolvedSigningPublicKey;
const now = new Date();
const persistedValues = {
did: query.did,
handle: query.handle,
keyId: response.bundle.keyId,
keyVersion: response.bundle.version,
publicKey: response.bundle.publicKey,
proofAction: JSON.stringify(response.proof),
signingPublicKey: canonicalSigningPublicKey,
updatedAt: now,
};
let persisted = false;
for (let attempt = 0; attempt < 16; attempt += 1) {
const cached = await db.query.e2eeRemoteKeyBundles.findFirst({ where: { did: query.did } });
const cachedSigningPublicKey = cached
? normalizeSigningPublicKey(cached.signingPublicKey)
: null;
if (cached && cachedSigningPublicKey !== canonicalSigningPublicKey) {
return NextResponse.json({
error: 'Recipient signing identity changed unexpectedly',
code: 'E2EE_IDENTITY_KEY_CHANGED',
}, { status: 409 });
}
if (cached && (
response.bundle.version < cached.keyVersion
|| (response.bundle.version === cached.keyVersion && response.bundle.keyId !== cached.keyId)
|| (response.bundle.version === cached.keyVersion && response.bundle.publicKey !== cached.publicKey)
|| (response.bundle.version > cached.keyVersion && !response.bundle.replacesKeyId)
)) {
return NextResponse.json({
error: 'Recipient encryption key continuity check failed',
code: 'E2EE_KEY_ROLLBACK',
}, { status: 409 });
}
if (!cached) {
const [inserted] = await db.insert(e2eeRemoteKeyBundles)
.values(persistedValues)
.onConflictDoNothing()
.returning({ did: e2eeRemoteKeyBundles.did });
if (inserted) {
persisted = true;
break;
}
continue;
}
const [updated] = await db.update(e2eeRemoteKeyBundles).set(persistedValues).where(and(
eq(e2eeRemoteKeyBundles.did, cached.did),
eq(e2eeRemoteKeyBundles.signingPublicKey, cached.signingPublicKey),
eq(e2eeRemoteKeyBundles.keyId, cached.keyId),
eq(e2eeRemoteKeyBundles.keyVersion, cached.keyVersion),
eq(e2eeRemoteKeyBundles.publicKey, cached.publicKey),
)).returning({ did: e2eeRemoteKeyBundles.did });
if (updated) {
persisted = true;
break;
}
}
if (!persisted) {
return NextResponse.json({
error: 'Recipient encryption key changed during verification; try again',
code: 'E2EE_KEY_CONFLICT',
}, { status: 409 });
}
return NextResponse.json({ ...response, signingPublicKey: canonicalSigningPublicKey }, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid key lookup request' }, { status: 400 });
}
console.error('[E2EE Keys] Resolution failed:', error);
return NextResponse.json({ error: 'Recipient encryption key lookup failed', code: 'E2EE_KEY_LOOKUP_FAILED' }, { status: 502 });
}
}
+248
View File
@@ -0,0 +1,248 @@
import crypto from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { db, e2eeKeyBundles, e2eeKeyVaults } from '@/db';
import { requireSignedAction, SignedActionError, type SignedAction } from '@/lib/auth/verify-signature';
import {
E2EE_KEY_BUNDLE_ACTION,
E2EE_MAX_UNLOCK_ATTEMPTS,
e2eeKeyBundleSchema,
e2eeVaultSetupSchema,
signedUserActionSchema,
} from '@/lib/e2ee/protocol';
import { createPinVerifierMac, sealServerShare } from '@/lib/e2ee/server-secrets';
import { getSession, verifyPassword } from '@/lib/auth';
import { canonicalize } from '@/lib/crypto/user-signing';
const setupRequestSchema = z.strictObject({
proof: signedUserActionSchema,
recovery: e2eeVaultSetupSchema,
currentPassword: z.string().min(8).max(256).optional(),
});
class E2EEKeyConflictError extends Error {}
function byteLength(value: string): number {
return Buffer.from(value, 'base64url').length;
}
function validateEncodedLengths(recovery: z.infer<typeof e2eeVaultSetupSchema>): void {
if (byteLength(recovery.vault.publicKey) !== 32) throw new Error('Invalid encryption public key');
if (byteLength(recovery.serverShare) !== 32) throw new Error('Invalid recovery share');
if (byteLength(recovery.pinVerifier) !== 32) throw new Error('Invalid PIN verifier');
if (byteLength(recovery.vault.salt) !== 16) throw new Error('Invalid PIN salt');
if (byteLength(recovery.vault.nonce) !== 24) throw new Error('Invalid vault nonce');
const ciphertextLength = byteLength(recovery.vault.ciphertext);
if (ciphertextLength < 64 || ciphertextLength > 3_072) throw new Error('Invalid encrypted vault');
}
export async function GET() {
const session = await getSession();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const [bundle, vault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: session.user.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: session.user.id } }),
]);
if (!bundle && !vault) {
return NextResponse.json(
{ ownerDid: session.user.did, configured: false },
{ headers: { 'Cache-Control': 'no-store' } },
);
}
if (bundle && !vault && bundle.did === session.user.did) {
return NextResponse.json({
ownerDid: session.user.did,
configured: false,
previousKey: {
keyId: bundle.keyId,
keyVersion: bundle.keyVersion,
},
}, { headers: { 'Cache-Control': 'no-store' } });
}
if (!bundle || !vault
|| bundle.did !== session.user.did
|| vault.ownerDid !== session.user.did
|| bundle.keyId !== vault.keyId
|| bundle.keyVersion !== vault.keyVersion
|| bundle.publicKey !== vault.publicKey) {
return NextResponse.json({ error: 'Encrypted message recovery is inconsistent' }, { status: 500 });
}
return NextResponse.json({
ownerDid: session.user.did,
configured: true,
keyId: bundle.keyId,
keyVersion: bundle.keyVersion,
publicKey: bundle.publicKey,
salt: vault.salt,
kdfAlgorithm: vault.kdfAlgorithm,
kdfOpsLimit: vault.kdfOpsLimit,
kdfMemLimit: vault.kdfMemLimit,
failedAttempts: vault.failedAttempts,
attemptsRemaining: vault.lockedUntil && vault.lockedUntil > new Date()
? 0
: Math.max(0, E2EE_MAX_UNLOCK_ATTEMPTS - vault.failedAttempts),
lockedUntil: vault.lockedUntil?.toISOString() ?? null,
}, { headers: { 'Cache-Control': 'no-store' } });
}
export async function POST(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = setupRequestSchema.parse(await request.json());
const proof = body.proof;
if (proof.action !== E2EE_KEY_BUNDLE_ACTION) {
return NextResponse.json({ error: 'Invalid encryption key proof' }, { status: 400 });
}
if (proof.did !== session.user.did || proof.handle !== session.user.handle) {
return NextResponse.json({ error: 'Active account does not match encryption setup' }, { status: 403 });
}
const user = await requireSignedAction(proof as SignedAction);
if (user.id !== session.user.id) {
return NextResponse.json({ error: 'Active account does not match encryption setup' }, { status: 403 });
}
const bundle = e2eeKeyBundleSchema.parse(proof.data);
if (byteLength(bundle.recoveryCommitment) !== 32) {
return NextResponse.json({ error: 'Invalid recovery commitment' }, { status: 400 });
}
const recovery = body.recovery;
validateEncodedLengths(recovery);
const recoveryCommitment = crypto.createHash('sha256')
.update(canonicalize(recovery))
.digest('base64url');
if (bundle.recoveryCommitment !== recoveryCommitment) {
return NextResponse.json({ error: 'Recovery vault does not match the signed key proof' }, { status: 400 });
}
if (recovery.vault.ownerDid !== proof.did
|| recovery.vault.keyId !== bundle.keyId
|| recovery.vault.keyVersion !== bundle.version
|| recovery.vault.publicKey !== bundle.publicKey) {
return NextResponse.json({ error: 'Recovery vault does not match the signed key' }, { status: 400 });
}
if (Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1000) {
return NextResponse.json({ error: 'Encryption key timestamp is invalid' }, { status: 400 });
}
const [existing, existingVault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }),
]);
if (existing && existing.did !== user.did) {
return NextResponse.json({ error: 'Encryption key identity is inconsistent' }, { status: 409 });
}
if (!existing && bundle.replacesKeyId) {
return NextResponse.json({ error: 'There is no encryption key to replace' }, { status: 409 });
}
if (existing && bundle.replacesKeyId !== existing.keyId) {
return NextResponse.json({
error: 'Encrypted messages are already configured',
code: 'E2EE_ALREADY_CONFIGURED',
}, { status: 409 });
}
if ((!existing && bundle.version !== 1)
|| (existing && bundle.version !== existing.keyVersion + 1)) {
return NextResponse.json({ error: 'Encryption key version is invalid' }, { status: 409 });
}
if (existingVault) {
if (!body.currentPassword || !user.passwordHash
|| !await verifyPassword(body.currentPassword, user.passwordHash)) {
return NextResponse.json({ error: 'Current password is required to reset encrypted messages' }, { status: 403 });
}
}
const now = new Date();
const verifierMac = createPinVerifierMac(recovery.pinVerifier, user.id, bundle.keyId);
const sealedShare = sealServerShare(recovery.serverShare, user.id, bundle.keyId);
const vaultValues = {
keyId: bundle.keyId,
keyVersion: bundle.version,
ownerDid: proof.did,
publicKey: bundle.publicKey,
ciphertext: recovery.vault.ciphertext,
nonce: recovery.vault.nonce,
salt: recovery.vault.salt,
kdfAlgorithm: recovery.vault.kdfAlgorithm,
kdfOpsLimit: recovery.vault.kdfOpsLimit,
kdfMemLimit: recovery.vault.kdfMemLimit,
pinVerifierMac: verifierMac,
serverShareEncrypted: sealedShare,
failedAttempts: 0,
lockedUntil: null,
updatedAt: now,
};
await db.transaction(async (tx) => {
if (existing) {
const [updatedBundle] = await tx.update(e2eeKeyBundles).set({
did: user.did,
keyId: bundle.keyId,
keyVersion: bundle.version,
publicKey: bundle.publicKey,
proofAction: JSON.stringify(proof),
updatedAt: now,
}).where(and(
eq(e2eeKeyBundles.userId, user.id),
eq(e2eeKeyBundles.keyId, existing.keyId),
eq(e2eeKeyBundles.keyVersion, existing.keyVersion),
)).returning({ userId: e2eeKeyBundles.userId });
if (!updatedBundle) throw new E2EEKeyConflictError('Encryption key changed during reset');
if (existingVault) {
const [updatedVault] = await tx.update(e2eeKeyVaults).set(vaultValues).where(and(
eq(e2eeKeyVaults.userId, user.id),
eq(e2eeKeyVaults.keyId, existing.keyId),
eq(e2eeKeyVaults.keyVersion, existing.keyVersion),
)).returning({ userId: e2eeKeyVaults.userId });
if (!updatedVault) throw new E2EEKeyConflictError('Recovery vault changed during reset');
} else {
await tx.insert(e2eeKeyVaults).values({ userId: user.id, ...vaultValues });
}
return;
}
await tx.insert(e2eeKeyBundles).values({
userId: user.id,
did: user.did,
keyId: bundle.keyId,
keyVersion: bundle.version,
publicKey: bundle.publicKey,
proofAction: JSON.stringify(proof),
updatedAt: now,
});
await tx.insert(e2eeKeyVaults).values({ userId: user.id, ...vaultValues });
});
return NextResponse.json({ success: true, keyId: bundle.keyId });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid encrypted message setup', details: error.issues }, { status: 400 });
}
if (error instanceof SignedActionError) {
return NextResponse.json({
error: error.message === 'RATE_LIMITED'
? 'Too many encryption setup attempts; try again shortly'
: 'Encryption key proof was rejected',
code: error.message === 'RATE_LIMITED' ? 'E2EE_RATE_LIMITED' : 'E2EE_SIGNATURE_REJECTED',
}, { status: error.message === 'RATE_LIMITED' ? 429 : 403 });
}
if (error instanceof E2EEKeyConflictError
|| (error instanceof Error && /unique|constraint/i.test(error.message))) {
return NextResponse.json({
error: 'Encrypted messages changed in another tab. Reload Chat and try again.',
code: 'E2EE_KEY_CONFLICT',
}, { status: 409 });
}
console.error('[E2EE Vault] Setup failed:', error);
return NextResponse.json({ error: error instanceof Error ? error.message : 'Setup failed' }, { status: 500 });
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db, e2eeKeyVaults } from '@/db';
import { getSession } from '@/lib/auth';
import {
E2EE_LOCKOUT_MS,
E2EE_MAX_UNLOCK_ATTEMPTS,
E2EE_PROTOCOL,
} from '@/lib/e2ee/protocol';
import { openServerShare, pinVerifierMatches } from '@/lib/e2ee/server-secrets';
const unlockSchema = z.strictObject({
ownerDid: z.string().min(8).max(2_048).regex(/^did:/),
keyId: z.string().min(12).max(96).regex(/^k1_[A-Za-z0-9_-]+$/),
keyVersion: z.number().int().positive().max(1_000_000),
pinVerifier: z.string().min(40).max(64).regex(/^[A-Za-z0-9_-]+$/),
});
const MAX_CAS_RETRIES = 16;
export async function POST(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { ownerDid, keyId, keyVersion, pinVerifier } = unlockSchema.parse(await request.json());
let observedKey: { keyId: string; keyVersion: number } | null = null;
for (let retry = 0; retry < MAX_CAS_RETRIES; retry += 1) {
const vault = await db.query.e2eeKeyVaults.findFirst({ where: { userId: session.user.id } });
if (!vault) return NextResponse.json({ error: 'Encrypted messages are not configured' }, { status: 404 });
if (ownerDid !== session.user.did || vault.ownerDid !== ownerDid
|| vault.keyId !== keyId || vault.keyVersion !== keyVersion) {
return NextResponse.json({
error: 'The active account or encryption key changed during unlock',
code: 'E2EE_ACCOUNT_CHANGED',
}, { status: 409 });
}
if (observedKey && (vault.keyId !== observedKey.keyId || vault.keyVersion !== observedKey.keyVersion)) {
return NextResponse.json({
error: 'Encryption key changed during unlock',
code: 'E2EE_KEY_CONFLICT',
}, { status: 409 });
}
observedKey ??= { keyId: vault.keyId, keyVersion: vault.keyVersion };
const now = new Date();
if (vault.lockedUntil && vault.lockedUntil > now) {
return NextResponse.json({
error: 'Too many PIN attempts',
code: 'E2EE_PIN_LOCKED',
lockedUntil: vault.lockedUntil.toISOString(),
}, { status: 429 });
}
const unchangedVault = and(
eq(e2eeKeyVaults.userId, session.user.id),
eq(e2eeKeyVaults.keyId, vault.keyId),
eq(e2eeKeyVaults.keyVersion, vault.keyVersion),
eq(e2eeKeyVaults.pinVerifierMac, vault.pinVerifierMac),
eq(e2eeKeyVaults.failedAttempts, vault.failedAttempts),
vault.lockedUntil
? eq(e2eeKeyVaults.lockedUntil, vault.lockedUntil)
: isNull(e2eeKeyVaults.lockedUntil),
);
if (!pinVerifierMatches(pinVerifier, vault.pinVerifierMac, session.user.id, vault.keyId)) {
const priorAttempts = vault.lockedUntil && vault.lockedUntil <= now ? 0 : vault.failedAttempts;
const failedAttempts = priorAttempts + 1;
const shouldLock = failedAttempts >= E2EE_MAX_UNLOCK_ATTEMPTS;
const lockedUntil = shouldLock ? new Date(Date.now() + E2EE_LOCKOUT_MS) : null;
const [updated] = await db.update(e2eeKeyVaults).set({
failedAttempts: shouldLock ? 0 : failedAttempts,
lockedUntil,
updatedAt: now,
}).where(unchangedVault).returning({ userId: e2eeKeyVaults.userId });
if (!updated) continue;
return NextResponse.json({
error: shouldLock ? 'Too many PIN attempts' : 'Incorrect PIN',
code: shouldLock ? 'E2EE_PIN_LOCKED' : 'E2EE_PIN_INCORRECT',
attemptsRemaining: shouldLock ? 0 : E2EE_MAX_UNLOCK_ATTEMPTS - failedAttempts,
lockedUntil: lockedUntil?.toISOString() ?? null,
}, { status: shouldLock ? 429 : 403 });
}
const [updated] = await db.update(e2eeKeyVaults).set({
failedAttempts: 0,
lockedUntil: null,
updatedAt: now,
}).where(unchangedVault).returning({ userId: e2eeKeyVaults.userId });
if (!updated) continue;
return NextResponse.json({
serverShare: openServerShare(vault.serverShareEncrypted, session.user.id, vault.keyId),
vault: {
protocol: E2EE_PROTOCOL,
ownerDid: session.user.did,
keyId: vault.keyId,
keyVersion: vault.keyVersion,
publicKey: vault.publicKey,
ciphertext: vault.ciphertext,
nonce: vault.nonce,
salt: vault.salt,
kdfAlgorithm: vault.kdfAlgorithm,
kdfOpsLimit: vault.kdfOpsLimit,
kdfMemLimit: vault.kdfMemLimit,
},
}, { headers: { 'Cache-Control': 'no-store' } });
}
return NextResponse.json({
error: 'Encrypted message unlock was busy; retry',
code: 'E2EE_UNLOCK_CONFLICT',
}, { status: 409 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid unlock request' }, { status: 400 });
}
console.error('[E2EE Vault] Unlock failed:', error);
return NextResponse.json({ error: 'Failed to unlock encrypted messages' }, { status: 500 });
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ const announcementSchema = z.object({
userCount: z.number().optional(),
postCount: z.number().optional(),
isNsfw: z.boolean().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
timestamp: z.string().optional(),
});
@@ -5,10 +5,9 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, and } from 'drizzle-orm';
import { db, chatConversations } from '@/db';
import { eq } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { z } from 'zod';
// Schema for conversation ID parameter
@@ -62,67 +61,31 @@ export async function DELETE(
}
if (deleteFor === 'both') {
const participant2Handle = conversation.participant2Handle;
if (participant2Handle.includes('@')) {
return NextResponse.json({
error: 'Delete for everyone is not supported across nodes. You can still delete this conversation for yourself.',
code: 'REMOTE_DELETE_FOR_EVERYONE_UNSUPPORTED',
}, { status: 409 });
}
// Delete the entire conversation and all messages (cascade will handle messages)
await db.delete(chatConversations).where(eq(chatConversations.id, id));
// Send deletion request to the other party
const participant2Handle = conversation.participant2Handle;
const isRemote = participant2Handle.includes('@');
// Local user - find and delete their conversation too.
const recipientUser = await db.query.users.findFirst({
where: { handle: participant2Handle },
});
if (isRemote) {
// Extract domain from handle (format: handle@domain)
const domain = normalizeNodeDomain(participant2Handle.split('@')[1]);
const handle = participant2Handle.split('@')[0];
try {
if (await isNodeBlocked(domain)) {
return NextResponse.json({ success: true });
}
const protocol = domain.includes('localhost') ? 'http' : 'https';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// SECURITY: Sign the deletion request
const { signPayload, getNodePrivateKey } = await import('@/lib/swarm/signature');
const privateKey = await getNodePrivateKey();
const payload = {
senderHandle: session.user.handle,
senderNodeDomain: nodeDomain,
recipientHandle: handle,
conversationId: id,
timestamp: new Date().toISOString(),
};
const signature = signPayload(payload, privateKey);
await fetch(`${protocol}://${domain}/api/swarm/chat/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, signature }),
});
console.log(`[Chat Delete] Sent deletion request to ${domain}`);
} catch (error) {
console.error('[Chat Delete] Failed to notify remote node:', error);
// Continue anyway - local deletion succeeded
}
} else {
// Local user - find and delete their conversation too
const recipientUser = await db.query.users.findFirst({
where: { handle: participant2Handle },
if (recipientUser) {
// Find their conversation with us
const recipientConversation = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] },
});
if (recipientUser) {
// Find their conversation with us
const recipientConversation = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] },
});
if (recipientConversation) {
await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id));
console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`);
}
if (recipientConversation) {
await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id));
console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`);
}
}
+61 -11
View File
@@ -4,12 +4,13 @@
* GET: List all conversations for the current user
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, desc, and, isNull, sql } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db, chatMessages } from '@/db';
import { eq, and, isNull, sql } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol';
export async function GET(request: NextRequest) {
export async function GET() {
try {
if (!db) {
return NextResponse.json({ conversations: [] });
@@ -87,12 +88,13 @@ export async function GET(request: NextRequest) {
avatarUrl: profileData.profile.avatarUrl || null,
did: profileData.profile.did || '',
isBot: profileData.profile.isBot || false,
publicKey: profileData.profile.publicKey,
});
// Re-query to get the new cached user
cachedUser = await db.query.users.findFirst({
where: { handle: participant2Handle },
}) as any;
});
}
} catch (e) {
console.error(`[Lazy Load] Failed for ${participant2Handle}:`, e);
@@ -102,18 +104,66 @@ export async function GET(request: NextRequest) {
if (cachedUser) {
participant2Info = {
handle: cachedUser.handle,
displayName: (cachedUser as any).displayName || cachedUser.handle,
avatarUrl: (cachedUser as any).avatarUrl || null,
did: (cachedUser as any).did || '',
displayName: cachedUser.displayName || cachedUser.handle,
avatarUrl: cachedUser.avatarUrl || null,
did: cachedUser.did || '',
};
}
const latest = conv.messages[0] || null;
let lastMessage: {
protocolVersion: number;
content: string | null;
encryptedEnvelope: unknown;
signedAction: unknown;
senderPublicKey: string | null;
} | null = latest ? {
protocolVersion: 0,
content: latest.content,
encryptedEnvelope: null,
signedAction: null,
senderPublicKey: null as string | null,
} : null;
if (latest?.protocolVersion === E2EE_PROTOCOL_VERSION && latest.encryptedEnvelope
&& latest.senderDid && latest.e2eeSignature && latest.e2eeActionNonce && latest.e2eeActionTs) {
try {
const encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(latest.encryptedEnvelope));
const senderPublicKey = latest.senderDid === session.user.did
? session.user.publicKey
: cachedUser?.did === latest.senderDid
? cachedUser.publicKey
: null;
lastMessage = {
protocolVersion: E2EE_PROTOCOL_VERSION,
content: null,
encryptedEnvelope,
signedAction: {
action: E2EE_CHAT_ACTION,
data: encryptedEnvelope,
did: latest.senderDid,
handle: encryptedEnvelope.senderHandle,
ts: latest.e2eeActionTs,
nonce: latest.e2eeActionNonce,
sig: latest.e2eeSignature,
},
senderPublicKey,
};
} catch (error) {
console.error(`[E2EE Chat] Invalid conversation preview ${latest.id}:`, error);
lastMessage = null;
}
}
const { messages: _rawMessages, ...conversation } = conv;
void _rawMessages;
return {
...conv,
...conversation,
participant2: {
...participant2Info,
isBot: (cachedUser as any)?.isBot || false,
isBot: cachedUser?.isBot || false,
},
lastMessage,
unreadCount: Number(unreadCount[0]?.count || 0),
};
})
@@ -121,7 +171,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
conversations: conversationsWithUnread.filter(c => !c.participant2.isBot),
});
}, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
console.error('List conversations error:', error);
return NextResponse.json({ error: 'Failed to list conversations' }, { status: 500 });
+36 -7
View File
@@ -6,10 +6,11 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm';
import { db, chatMessages, users } from '@/db';
import { eq, and, isNull } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { z } from 'zod';
import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol';
// Schema for query parameters
const messagesQuerySchema = z.object({
@@ -23,6 +24,8 @@ const markReadSchema = z.object({
conversationId: z.string().uuid(),
});
type ChatUser = typeof users.$inferSelect;
export async function GET(request: NextRequest) {
try {
@@ -83,8 +86,8 @@ export async function GET(request: NextRequest) {
});
// Fetch users
const usersByDid: Record<string, any> = {};
const usersByHandle: Record<string, any> = {};
const usersByDid: Record<string, ChatUser> = {};
const usersByHandle: Record<string, ChatUser> = {};
if (senderDids.size > 0) {
const found = await db.query.users.findMany({
@@ -102,7 +105,7 @@ export async function GET(request: NextRequest) {
}
const messagesMapped = messages.map((msg) => {
const isSentByMe = msg.senderHandle === session.user.handle;
const isSentByMe = msg.senderDid === session.user.did || msg.senderHandle === session.user.handle;
// Resolve fresh user data
const user = msg.senderDid ? usersByDid[msg.senderDid] : usersByHandle[msg.senderHandle];
@@ -110,13 +113,39 @@ export async function GET(request: NextRequest) {
const displayName = user?.displayName || msg.senderDisplayName || msg.senderHandle;
const avatarUrl = user?.avatarUrl || msg.senderAvatarUrl;
let encryptedEnvelope = null;
let signedAction = null;
if (msg.protocolVersion === E2EE_PROTOCOL_VERSION && msg.encryptedEnvelope) {
try {
encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(msg.encryptedEnvelope));
if (!msg.senderDid || !msg.e2eeSignature || !msg.e2eeActionNonce || !msg.e2eeActionTs) {
throw new Error('Encrypted message signature metadata is incomplete');
}
signedAction = {
action: E2EE_CHAT_ACTION,
data: encryptedEnvelope,
did: msg.senderDid,
handle: encryptedEnvelope.senderHandle,
ts: msg.e2eeActionTs,
nonce: msg.e2eeActionNonce,
sig: msg.e2eeSignature,
};
} catch (error) {
console.error(`[E2EE Chat] Invalid stored envelope ${msg.id}:`, error);
}
}
return {
id: msg.id,
senderHandle: msg.senderHandle,
senderDisplayName: displayName,
senderAvatarUrl: avatarUrl,
senderDid: msg.senderDid,
content: msg.content,
content: msg.protocolVersion === 0 ? msg.content : null,
protocolVersion: msg.protocolVersion,
encryptedEnvelope,
signedAction,
senderPublicKey: user?.publicKey || null,
deliveredAt: msg.deliveredAt,
readAt: msg.readAt,
createdAt: msg.createdAt,
@@ -127,7 +156,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
messages: messagesMapped.reverse(), // Oldest first for display
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
});
}, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
+1 -1
View File
@@ -31,7 +31,7 @@ const nodeInfoSchema = z.object({
userCount: z.number().optional(),
postCount: z.number().optional(),
isNsfw: z.boolean().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
lastSeenAt: z.string().optional(),
});