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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+73
-23
@@ -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'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'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>
|
||||
|
||||
@@ -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 "Import" 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
|
||||
|
||||
Reference in New Issue
Block a user