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(),
});
+825 -222
View File
File diff suppressed because it is too large Load Diff
+73 -23
View File
@@ -2,7 +2,6 @@
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { TriangleAlert, X } from 'lucide-react';
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
@@ -31,7 +30,6 @@ interface AuthScreenProps {
}
export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProps) {
const router = useRouter();
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -53,10 +51,12 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
const [importFile, setImportFile] = useState<File | null>(null);
const [importPassword, setImportPassword] = useState('');
const [importEmail, setImportEmail] = useState('');
const [importHandle, setImportHandle] = useState('');
const [acceptedCompliance, setAcceptedCompliance] = useState(false);
const [importAgeVerified, setImportAgeVerified] = useState(false);
const [importSuccess, setImportSuccess] = useState<string | null>(null);
const [importWarnings, setImportWarnings] = useState<string[]>([]);
// Fetch node info
useEffect(() => {
@@ -105,7 +105,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
if (turnstileWidgetId.current && window.turnstile) {
try {
window.turnstile.remove(turnstileWidgetId.current);
} catch (e) {
} catch {
// Ignore errors
}
}
@@ -130,7 +130,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
if (turnstileWidgetId.current && window.turnstile) {
try {
window.turnstile.remove(turnstileWidgetId.current);
} catch (e) {
} catch {
// Ignore errors
}
}
@@ -165,7 +165,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
const handleImport = async (e: React.FormEvent) => {
e.preventDefault();
if (!importFile || !importPassword || !importHandle || !acceptedCompliance) {
if (!importFile || !importPassword || !importEmail || !importHandle || !acceptedCompliance) {
setError('Please fill in all fields and accept the compliance agreement');
return;
}
@@ -178,6 +178,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
setLoading(true);
setError('');
setImportSuccess(null);
setImportWarnings([]);
try {
const fileContent = await importFile.text();
@@ -189,6 +190,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
body: JSON.stringify({
exportData,
password: importPassword,
destinationEmail: importEmail,
newHandle: importHandle,
acceptedCompliance,
}),
@@ -200,16 +202,21 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
throw new Error(data.error || 'Import failed');
}
setImportSuccess(data.message);
// Soft navigation to preserve AuthContext/KeyStore state
setTimeout(() => {
router.refresh();
if (onSuccess) {
onSuccess();
} else {
router.push('/');
}
}, 2000);
const warnings = Array.isArray(data.warnings)
? data.warnings.filter((warning: unknown): warning is string => typeof warning === 'string')
: [];
setImportSuccess(data.message || 'Account imported successfully.');
setImportWarnings(warnings);
if (warnings.length === 0) {
setTimeout(() => {
if (onSuccess) {
onSuccess();
window.location.reload();
} else {
window.location.assign('/');
}
}, 2000);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed');
@@ -280,7 +287,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
// Import and set in memory store
// Remove PEM headers if present and clean whitespace
let cleanKey = privateKeyDecrypted
const cleanKey = privateKeyDecrypted
.replace(/-----BEGIN [A-Z ]+-----/, '')
.replace(/-----END [A-Z ]+-----/, '')
.replace(/\s/g, '');
@@ -306,7 +313,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
if (data.user?.privateKeyEncrypted) {
try {
// Update AuthContext first so it has the user and key
login(data.user);
await login(data.user);
// Now unlock (passing user explicitly to avoid async state delay)
await unlockIdentity(password, data.user);
@@ -315,12 +322,14 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
}
}
// Soft navigation to preserve AuthContext/KeyStore state
router.refresh();
// Start Chat in a fresh JavaScript realm before it creates or loads
// the E2EE account key. Turnstile-enabled credential handling remains
// part of the login trust boundary and is documented accordingly.
if (onSuccess) {
onSuccess();
window.location.reload();
} else {
router.push('/');
window.location.assign('/');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
@@ -688,7 +697,29 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
color: '#000',
fontSize: '14px',
}}>
{importSuccess} Redirecting...
<div>{importSuccess}</div>
{importWarnings.length > 0 ? (
<>
<ul style={{ margin: '8px 0 10px', paddingLeft: 20 }}>
{importWarnings.map((warning) => <li key={warning}>{warning}</li>)}
</ul>
<button
type="button"
className="btn"
onClick={() => {
if (onSuccess) {
onSuccess();
window.location.reload();
} else {
window.location.assign('/');
}
}}
style={{ width: '100%', justifyContent: 'center' }}
>
Continue
</button>
</>
) : ' Redirecting…'}
</div>
)}
@@ -738,6 +769,25 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Email on this node
</label>
<input
type="email"
className="input"
value={importEmail}
onChange={(e) => setImportEmail(e.target.value)}
placeholder="you@example.com"
autoComplete="email"
required
maxLength={320}
/>
<span style={{ display: 'block', color: 'var(--foreground-tertiary)', fontSize: '12px', marginTop: '4px' }}>
You&apos;ll use this email to sign in after the import.
</span>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Handle on this node
@@ -801,7 +851,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
<span style={{ fontSize: '12px', color: 'var(--foreground-secondary)', lineHeight: 1.4 }}>
<strong style={{ color: 'var(--warning)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<TriangleAlert size={12} /> Compliance:
</strong> I agree to comply with this node's rules and take responsibility for my migrated content.
</strong> I agree to comply with this node&apos;s rules and take responsibility for my migrated content.
</span>
</label>
</div>
@@ -834,7 +884,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
type="submit"
className="btn btn-primary btn-lg"
style={{ width: '100%' }}
disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)}
disabled={loading || !importFile || !importPassword || !importEmail || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)}
>
{loading ? 'Importing...' : 'Import Account'}
</button>
+21 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { useAuth } from '@/lib/contexts/AuthContext';
@@ -114,8 +114,8 @@ export default function MigrationPage() {
</h2>
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
Download a complete backup of your account including your identity, posts, and media.
You can use this file to migrate to another Synapsis node by selecting "Import" on the login page of that node.
Download a signed backup of your account identity, posts, and media.
You can use this file to migrate to another Synapsis node by selecting &quot;Import&quot; on the login page of that node.
</p>
<div style={{
@@ -139,11 +139,28 @@ export default function MigrationPage() {
<li>Your profile information</li>
<li>All your posts</li>
<li>Your following list</li>
<li>All DMs and conversation history</li>
<li>DM conversation records and encrypted message envelopes</li>
<li>Your automated bots and their configuration</li>
</ul>
</div>
<div style={{
display: 'flex',
gap: '12px',
padding: '16px',
marginBottom: '20px',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.35)',
borderRadius: '8px',
}}>
<TriangleAlert size={20} style={{ flexShrink: 0, color: '#f59e0b' }} />
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', margin: 0, lineHeight: 1.5 }}>
Encrypted DM keys are not portable yet. This export preserves encrypted message records,
but E2EE history will not open after importing it on another node. Keep access to this
browser and node if you need to read that history.
</p>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
Confirm your password