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
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Eye, EyeOff, Loader2, LockKeyhole } from 'lucide-react';
|
||||
|
||||
import type { E2EEIdentityState } from '@/lib/e2ee/use-e2ee-identity';
|
||||
|
||||
interface E2EEChatGateProps {
|
||||
state: E2EEIdentityState;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
identityUnlocked: boolean;
|
||||
onSetup: (pin: string) => Promise<void>;
|
||||
onUnlock: (pin: string) => Promise<void>;
|
||||
onReset: (pin: string, currentPassword: string) => Promise<void>;
|
||||
onRetry: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatCountdown(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes}m ${seconds.toString().padStart(2, '0')}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
export function E2EEChatGate(props: E2EEChatGateProps) {
|
||||
const { busy, onCancel } = props;
|
||||
const [pin, setPin] = useState('');
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [showPin, setShowPin] = useState(false);
|
||||
const [resetMode, setResetMode] = useState(false);
|
||||
const [understandsReset, setUnderstandsReset] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const pinRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const lockedVault = props.state.status === 'locked' && props.state.vault.configured
|
||||
? props.state.vault
|
||||
: null;
|
||||
const lockedUntilMs = lockedVault?.lockedUntil
|
||||
? new Date(lockedVault.lockedUntil).getTime()
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.state.status === 'setup_required' || props.state.status === 'locked') {
|
||||
pinRef.current?.focus();
|
||||
}
|
||||
}, [props.state.status, resetMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lockedUntilMs || lockedUntilMs <= Date.now()) return;
|
||||
const timer = window.setInterval(() => {
|
||||
const next = Date.now();
|
||||
setNow(next);
|
||||
if (next >= lockedUntilMs) window.clearInterval(timer);
|
||||
}, 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [lockedUntilMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || busy) return;
|
||||
if (resetMode) {
|
||||
setResetMode(false);
|
||||
setLocalError(null);
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [busy, onCancel, resetMode]);
|
||||
|
||||
if (props.state.status === 'loading') {
|
||||
return (
|
||||
<main aria-busy="true" style={{ minHeight: '100vh', display: 'grid', placeItems: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: 'var(--foreground-secondary)' }}>
|
||||
<Loader2 className="animate-spin" size={28} style={{ margin: '0 auto 12px' }} aria-hidden="true" />
|
||||
<p>Opening encrypted messages…</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.state.status === 'error') {
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24 }}>
|
||||
<section style={{ maxWidth: 420, textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: 20 }}>Encrypted messages unavailable</h1>
|
||||
<p style={{ color: 'var(--foreground-secondary)' }}>{props.state.message}</p>
|
||||
<button className="btn btn-primary" onClick={() => void props.onRetry()}>Try again</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.state.status === 'ready') return null;
|
||||
|
||||
const isSetup = props.state.status === 'setup_required';
|
||||
const isMigrationSetup = props.state.status === 'setup_required' && !!props.state.previousKey;
|
||||
const lockedUntil = lockedVault?.lockedUntil
|
||||
? new Date(lockedVault.lockedUntil)
|
||||
: null;
|
||||
const remainingLockSeconds = lockedUntilMs ? Math.max(0, Math.ceil((lockedUntilMs - now) / 1_000)) : 0;
|
||||
// A recovery lock blocks further PIN guesses, but password-authorized key
|
||||
// reset remains available to someone who genuinely forgot the PIN.
|
||||
const isRateLimited = !resetMode && remainingLockSeconds > 0;
|
||||
const attemptsRemaining = lockedVault?.attemptsRemaining;
|
||||
const title = resetMode
|
||||
? 'Reset encrypted messages?'
|
||||
: isSetup
|
||||
? 'Set up encrypted messages'
|
||||
: 'Unlock encrypted messages';
|
||||
const description = resetMode
|
||||
? 'Resetting starts a new encryption key. Messages encrypted with your old key will no longer open. This cannot be undone.'
|
||||
: isSetup
|
||||
? isMigrationSetup
|
||||
? 'Create a PIN for this node. A new encryption key will safely replace your previous node’s key; old encrypted history will remain unavailable here.'
|
||||
: 'Create a PIN to protect and restore your encrypted message history. You’ll enter it only on a new or cleared device. This PIN is separate from your login password.'
|
||||
: 'Enter your encrypted messages PIN to restore your history on this device. This is not your login password.';
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setLocalError(null);
|
||||
if (!/^\d{6,12}$/.test(pin)) {
|
||||
setLocalError('PIN must contain 6–12 digits.');
|
||||
return;
|
||||
}
|
||||
if ((isSetup || resetMode) && pin !== confirmation) {
|
||||
setLocalError('PINs don’t match. Try again.');
|
||||
return;
|
||||
}
|
||||
if (!props.identityUnlocked) {
|
||||
setLocalError('Your identity is locked. Sign in again before setting up encrypted messages.');
|
||||
return;
|
||||
}
|
||||
if (resetMode) {
|
||||
if (!understandsReset || !currentPassword) {
|
||||
setLocalError('Confirm the warning and enter your current login password.');
|
||||
return;
|
||||
}
|
||||
await props.onReset(pin, currentPassword).catch(() => undefined);
|
||||
} else if (isSetup) {
|
||||
await props.onSetup(pin).catch(() => undefined);
|
||||
} else {
|
||||
await props.onUnlock(pin).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24 }}>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="e2ee-gate-title"
|
||||
aria-describedby="e2ee-gate-description"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 440,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 20,
|
||||
background: 'var(--background)',
|
||||
padding: 24,
|
||||
boxShadow: '0 18px 60px rgba(0,0,0,.28)',
|
||||
}}
|
||||
>
|
||||
<LockKeyhole size={30} aria-hidden="true" style={{ color: 'var(--accent)', marginBottom: 16 }} />
|
||||
<h1 id="e2ee-gate-title" style={{ fontSize: 22, margin: '0 0 10px' }}>{title}</h1>
|
||||
<p id="e2ee-gate-description" style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<form onSubmit={submit} aria-busy={props.busy} style={{ display: 'grid', gap: 14, marginTop: 20 }}>
|
||||
{resetMode && (
|
||||
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: 14 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={understandsReset}
|
||||
onChange={(event) => setUnderstandsReset(event.target.checked)}
|
||||
style={{ marginTop: 3 }}
|
||||
/>
|
||||
<span>I understand that my old encrypted message history will be unavailable.</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{resetMode ? 'New encrypted messages PIN' : 'Encrypted messages PIN'}</span>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
ref={pinRef}
|
||||
className="input"
|
||||
type={showPin ? 'text' : 'password'}
|
||||
inputMode="numeric"
|
||||
autoComplete={isSetup || resetMode ? 'new-password' : 'off'}
|
||||
minLength={6}
|
||||
maxLength={12}
|
||||
value={pin}
|
||||
onChange={(event) => setPin(event.target.value.replace(/\D/g, '').slice(0, 12))}
|
||||
aria-invalid={!!(localError || props.error)}
|
||||
aria-describedby="e2ee-pin-hint e2ee-gate-error"
|
||||
disabled={props.busy || isRateLimited}
|
||||
style={{ width: '100%', paddingRight: 48, fontSize: 16 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showPin ? 'Hide PIN' : 'Show PIN'}
|
||||
onClick={() => setShowPin((value) => !value)}
|
||||
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 0, color: 'var(--foreground-secondary)', minWidth: 36, minHeight: 36 }}
|
||||
>
|
||||
{showPin ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<span id="e2ee-pin-hint" style={{ fontSize: 12, color: 'var(--foreground-tertiary)' }}>6–12 digits; avoid birthdays and repeated or sequential numbers.</span>
|
||||
</label>
|
||||
|
||||
{(isSetup || resetMode) && (
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>Confirm PIN</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
maxLength={12}
|
||||
value={confirmation}
|
||||
onChange={(event) => setConfirmation(event.target.value.replace(/\D/g, '').slice(0, 12))}
|
||||
disabled={props.busy}
|
||||
style={{ fontSize: 16 }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{resetMode && (
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>Current login password</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
disabled={props.busy}
|
||||
style={{ fontSize: 16 }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{isRateLimited && (
|
||||
<p role="status" aria-live="polite" style={{ color: 'var(--destructive)', fontSize: 14, margin: 0 }}>
|
||||
Too many attempts. Try again in {formatCountdown(remainingLockSeconds)}
|
||||
{lockedUntil ? ` (${lockedUntil.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})` : ''}.
|
||||
</p>
|
||||
)}
|
||||
{!isSetup && !resetMode && !isRateLimited && typeof attemptsRemaining === 'number' && attemptsRemaining > 0 && (
|
||||
<p role="status" aria-live="polite" style={{ color: 'var(--foreground-secondary)', fontSize: 13, margin: 0 }}>
|
||||
{attemptsRemaining} {attemptsRemaining === 1 ? 'attempt' : 'attempts'} remaining before a temporary lock.
|
||||
</p>
|
||||
)}
|
||||
{(localError || props.error) && (
|
||||
<p id="e2ee-gate-error" role="alert" style={{ color: 'var(--destructive)', fontSize: 14, margin: 0 }}>
|
||||
{localError || props.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={resetMode ? 'btn btn-danger' : 'btn btn-primary'}
|
||||
disabled={props.busy || isRateLimited}
|
||||
style={{ justifyContent: 'center', minHeight: 44 }}
|
||||
>
|
||||
{props.busy ? <Loader2 size={18} className="animate-spin" aria-hidden="true" /> : null}
|
||||
{props.busy
|
||||
? resetMode ? 'Resetting…' : isSetup ? 'Setting up…' : 'Unlocking…'
|
||||
: resetMode ? 'Reset encrypted messages' : isSetup ? 'Set up encrypted messages' : 'Unlock'}
|
||||
</button>
|
||||
|
||||
{!isSetup && !resetMode && (
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setResetMode(true)}>
|
||||
Forgot PIN?
|
||||
</button>
|
||||
)}
|
||||
{resetMode && (
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setResetMode(false)}>
|
||||
Back to PIN
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost" onClick={props.onCancel}>Not now</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+99
-2
@@ -85,6 +85,8 @@ export const users = sqliteTable('users', {
|
||||
}, (table) => [
|
||||
index('users_handle_idx').on(table.handle),
|
||||
index('users_did_idx').on(table.did),
|
||||
uniqueIndex('users_handle_unique_idx').on(table.handle),
|
||||
uniqueIndex('users_did_unique_idx').on(table.did),
|
||||
index('users_suspended_idx').on(table.isSuspended),
|
||||
index('users_silenced_idx').on(table.isSilenced),
|
||||
index('users_is_bot_idx').on(table.isBot),
|
||||
@@ -807,6 +809,11 @@ export const chatConversations = sqliteTable('chat_conversations', {
|
||||
lastMessageAt: integer('last_message_at', { mode: 'timestamp' }),
|
||||
lastMessagePreview: text('last_message_preview'),
|
||||
|
||||
// Existing conversations begin as legacy. The first encrypted message marks
|
||||
// the cutover without pretending older plaintext history was retroactively protected.
|
||||
encryptionMode: text('encryption_mode').default('legacy').notNull(),
|
||||
e2eeActivatedAt: integer('e2ee_activated_at', { mode: 'timestamp' }),
|
||||
|
||||
// Metadata
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
@@ -820,8 +827,8 @@ export const chatConversations = sqliteTable('chat_conversations', {
|
||||
|
||||
/**
|
||||
* Individual chat messages within conversations.
|
||||
* Messages are stored as plain text on the server.
|
||||
* Both sender and recipient can view the message content.
|
||||
* Legacy messages can contain plaintext. E2EE v1 messages store only a signed,
|
||||
* opaque encrypted envelope and leave `content` null.
|
||||
*/
|
||||
export const chatMessages = sqliteTable('chat_messages', {
|
||||
id: text('id').primaryKey().$defaultFn(() => randomUUID()),
|
||||
@@ -839,6 +846,14 @@ export const chatMessages = sqliteTable('chat_messages', {
|
||||
// Message content (plain text for verified chat)
|
||||
content: text('content'),
|
||||
|
||||
// End-to-end encrypted message fields. Protocol version 0 means legacy plaintext.
|
||||
protocolVersion: integer('protocol_version').default(0).notNull(),
|
||||
clientMessageId: text('client_message_id'),
|
||||
encryptedEnvelope: text('encrypted_envelope'),
|
||||
e2eeSignature: text('e2ee_signature'),
|
||||
e2eeActionNonce: text('e2ee_action_nonce'),
|
||||
e2eeActionTs: integer('e2ee_action_ts'),
|
||||
|
||||
// Swarm sync info
|
||||
swarmMessageId: text('swarm_message_id').unique(), // Format: swarm:domain:uuid
|
||||
|
||||
@@ -852,6 +867,7 @@ export const chatMessages = sqliteTable('chat_messages', {
|
||||
index('chat_messages_conversation_idx').on(table.conversationId),
|
||||
index('chat_messages_created_idx').on(table.createdAt),
|
||||
index('chat_messages_swarm_id_idx').on(table.swarmMessageId),
|
||||
uniqueIndex('chat_messages_conversation_client_id_unique').on(table.conversationId, table.clientMessageId),
|
||||
]);
|
||||
|
||||
|
||||
@@ -879,6 +895,87 @@ export const chatTypingIndicators = sqliteTable('chat_typing_indicators', {
|
||||
// CRYPTO & SECURITY
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Current account encryption key, certified by the account's existing DID key.
|
||||
* Private material is never stored here.
|
||||
*/
|
||||
export const e2eeKeyBundles = sqliteTable('e2ee_key_bundles', {
|
||||
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||
did: text('did').notNull(),
|
||||
keyId: text('key_id').notNull(),
|
||||
keyVersion: integer('key_version').notNull(),
|
||||
publicKey: text('public_key').notNull(),
|
||||
proofAction: text('proof_action').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex('e2ee_key_bundles_did_unique').on(table.did),
|
||||
uniqueIndex('e2ee_key_bundles_key_id_unique').on(table.keyId),
|
||||
]);
|
||||
|
||||
/**
|
||||
* PIN recovery vault. The PIN verifier is HMACed with a node secret and the
|
||||
* server share is separately encrypted, so a database-only leak is not an
|
||||
* offline PIN oracle. This is not a substitute for threshold/HSM recovery.
|
||||
*/
|
||||
export const e2eeKeyVaults = sqliteTable('e2ee_key_vaults', {
|
||||
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||
keyId: text('key_id').notNull(),
|
||||
keyVersion: integer('key_version').notNull(),
|
||||
ownerDid: text('owner_did').notNull(),
|
||||
publicKey: text('public_key').notNull(),
|
||||
ciphertext: text('ciphertext').notNull(),
|
||||
nonce: text('nonce').notNull(),
|
||||
salt: text('salt').notNull(),
|
||||
kdfAlgorithm: text('kdf_algorithm').notNull(),
|
||||
kdfOpsLimit: integer('kdf_ops_limit').notNull(),
|
||||
kdfMemLimit: integer('kdf_mem_limit').notNull(),
|
||||
pinVerifierMac: text('pin_verifier_mac').notNull(),
|
||||
serverShareEncrypted: text('server_share_encrypted').notNull(),
|
||||
failedAttempts: integer('failed_attempts').default(0).notNull(),
|
||||
lockedUntil: integer('locked_until', { mode: 'timestamp' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex('e2ee_key_vaults_key_id_unique').on(table.keyId),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Verified cache of remote users' DID-certified encryption keys. Key changes
|
||||
* must carry a fresh signature by the same account signing identity.
|
||||
*/
|
||||
export const e2eeRemoteKeyBundles = sqliteTable('e2ee_remote_key_bundles', {
|
||||
did: text('did').primaryKey(),
|
||||
handle: text('handle').notNull(),
|
||||
keyId: text('key_id').notNull(),
|
||||
keyVersion: integer('key_version').notNull(),
|
||||
publicKey: text('public_key').notNull(),
|
||||
proofAction: text('proof_action').notNull(),
|
||||
signingPublicKey: text('signing_public_key').notNull(),
|
||||
firstSeenAt: integer('first_seen_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
}, (table) => [
|
||||
index('e2ee_remote_key_bundles_key_id_idx').on(table.keyId),
|
||||
index('e2ee_remote_key_bundles_handle_idx').on(table.handle),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Durable replay tombstones survive conversation deletion, preventing a valid
|
||||
* old signed envelope from recreating a message after the user removed it.
|
||||
*/
|
||||
export const e2eeMessageReceipts = sqliteTable('e2ee_message_receipts', {
|
||||
id: text('id').primaryKey().$defaultFn(() => randomUUID()),
|
||||
ownerUserId: text('owner_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
senderDid: text('sender_did').notNull(),
|
||||
messageId: text('message_id').notNull(),
|
||||
protocolVersion: integer('protocol_version').default(1).notNull(),
|
||||
receivedAt: integer('received_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex('e2ee_message_receipts_owner_sender_message_unique')
|
||||
.on(table.ownerUserId, table.senderDid, table.messageId),
|
||||
index('e2ee_message_receipts_received_idx').on(table.receivedAt),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Replay protection for signed user actions.
|
||||
* Enforces uniqueness of (did, nonce) within the valid timeframe.
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
*/
|
||||
|
||||
import { createSignedAction, hasUserPrivateKey } from '@/lib/crypto/user-signing';
|
||||
import type { E2EEMessageEnvelope } from '@/lib/e2ee/protocol';
|
||||
|
||||
export interface SignedFetchOptions {
|
||||
method?: string;
|
||||
body?: any;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -26,7 +27,7 @@ export interface SignedFetchOptions {
|
||||
export async function signedFetch(
|
||||
url: string,
|
||||
action: string,
|
||||
data: any,
|
||||
data: unknown,
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
options: SignedFetchOptions = {}
|
||||
@@ -115,9 +116,9 @@ export const signedAPI = {
|
||||
async createPost(
|
||||
content: string,
|
||||
mediaIds: string[],
|
||||
linkPreview: any,
|
||||
linkPreview: unknown,
|
||||
replyToId: string | undefined,
|
||||
swarmReplyTo: any | undefined,
|
||||
swarmReplyTo: unknown | undefined,
|
||||
isNsfw: boolean,
|
||||
userDid: string,
|
||||
userHandle: string
|
||||
@@ -213,11 +214,11 @@ export const signedAPI = {
|
||||
/**
|
||||
* Send a chat message
|
||||
*/
|
||||
async sendChat(recipientDid: string, recipientHandle: string, content: string, userDid: string, userHandle: string) {
|
||||
async sendChat(envelope: E2EEMessageEnvelope, userDid: string, userHandle: string) {
|
||||
return signedFetch(
|
||||
'/api/chat/send',
|
||||
'chat',
|
||||
{ recipientDid, recipientHandle, content },
|
||||
'chat_e2ee',
|
||||
envelope,
|
||||
userDid,
|
||||
userHandle
|
||||
);
|
||||
|
||||
+22
-13
@@ -7,8 +7,8 @@ import { eq, inArray } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||
import { didKeyMatchesPublicKey, generateDID } from '@/lib/crypto/did-key';
|
||||
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
|
||||
import { base58btc } from 'multiformats/bases/base58';
|
||||
import { cookies } from 'next/headers';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
import { registrationDisplayName } from '@/lib/auth/display-name';
|
||||
@@ -133,18 +133,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a DID for a new user
|
||||
* Uses did:key format (W3C standard) - the DID contains the public key itself
|
||||
*/
|
||||
export function generateDID(publicKey: string): string {
|
||||
// Encode the SPKI public key in base58btc (multibase)
|
||||
const publicKeyBytes = Buffer.from(publicKey, 'base64');
|
||||
const encoded = base58btc.encode(new Uint8Array(publicKeyBytes));
|
||||
|
||||
// Create did:key - the 'z' prefix indicates base58btc encoding
|
||||
return `did:key:${encoded}`;
|
||||
}
|
||||
export { generateDID } from '@/lib/crypto/did-key';
|
||||
|
||||
/**
|
||||
* Generate legacy DID format (for backward compatibility)
|
||||
@@ -399,6 +388,26 @@ export async function authenticateUser(
|
||||
user.privateKeyEncrypted = serializeEncryptedKey(encryptedPrivateKey);
|
||||
}
|
||||
|
||||
// Older login migrations rotated an RSA signing key without rotating the
|
||||
// self-certifying did:key identifier. Repair that mismatch transparently so
|
||||
// clients can verify the current account key from the DID itself.
|
||||
if (user.did.startsWith('did:key:') && !didKeyMatchesPublicKey(user.did, user.publicKey)) {
|
||||
const expectedDid = generateDID(user.publicKey);
|
||||
if (user.did !== expectedDid) {
|
||||
await db.update(users)
|
||||
.set({ did: expectedDid, updatedAt: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
user.did = expectedDid;
|
||||
|
||||
await upsertHandleEntries([{
|
||||
handle: user.handle,
|
||||
did: expectedDid,
|
||||
nodeDomain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,18 +10,36 @@
|
||||
|
||||
import { db } from '@/db';
|
||||
import { users, signedActionDedupe } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, lt } from 'drizzle-orm';
|
||||
import { canonicalize, importPublicKey, base64UrlToBase64 } from '@/lib/crypto/user-signing';
|
||||
// Note: user-signing helpers are isomorphic (work in Node via webcrypto polyfill/availability)
|
||||
import crypto from 'crypto';
|
||||
import { isRateLimited } from '@/lib/rate-limit';
|
||||
|
||||
// Use Node's webcrypto for server-side if not global
|
||||
const cryptoSubtle = globalThis.crypto?.subtle || require('crypto').webcrypto.subtle;
|
||||
const cryptoSubtle = globalThis.crypto?.subtle || crypto.webcrypto.subtle;
|
||||
const DEDUPE_RETENTION_MS = 10 * 60 * 1000;
|
||||
const DEDUPE_CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
let nextDedupeCleanupAt = 0;
|
||||
|
||||
export interface SignedAction {
|
||||
async function pruneExpiredSignedActions(now: number): Promise<void> {
|
||||
if (now < nextDedupeCleanupAt) return;
|
||||
nextDedupeCleanupAt = now + DEDUPE_CLEANUP_INTERVAL_MS;
|
||||
|
||||
try {
|
||||
await db.delete(signedActionDedupe).where(
|
||||
lt(signedActionDedupe.createdAt, new Date(now - DEDUPE_RETENTION_MS)),
|
||||
);
|
||||
} catch (error) {
|
||||
// Replay verification must not become unavailable because maintenance
|
||||
// failed; the next process or interval will retry the bounded cleanup.
|
||||
console.error('[Verify] Signed-action cleanup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SignedAction<TData = Record<string, string>> {
|
||||
action: string;
|
||||
data: any;
|
||||
data: TData;
|
||||
did: string;
|
||||
handle: string;
|
||||
ts: number;
|
||||
@@ -39,7 +57,7 @@ export class SignedActionError extends Error {
|
||||
/**
|
||||
* Verify a signed action against a specific public key
|
||||
*/
|
||||
export async function verifyActionSignature(signedAction: SignedAction, publicKeyStr: string): Promise<boolean> {
|
||||
export async function verifyActionSignature(signedAction: SignedAction<unknown>, publicKeyStr: string): Promise<boolean> {
|
||||
try {
|
||||
const { sig, ...payload } = signedAction;
|
||||
const canonicalString = canonicalize(payload);
|
||||
@@ -74,7 +92,7 @@ export async function verifyActionSignature(signedAction: SignedAction, publicKe
|
||||
* @param signedAction - The signed action payload
|
||||
* @returns The user if signature is valid and not replayed
|
||||
*/
|
||||
export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
export async function verifyUserAction(signedAction: SignedAction<unknown>): Promise<{
|
||||
valid: boolean;
|
||||
user?: typeof users.$inferSelect;
|
||||
error?: string;
|
||||
@@ -83,15 +101,16 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
return { valid: false, error: 'Database not available' };
|
||||
}
|
||||
|
||||
const { sig, ...payload } = signedAction;
|
||||
const payload = {
|
||||
action: signedAction.action,
|
||||
data: signedAction.data,
|
||||
did: signedAction.did,
|
||||
handle: signedAction.handle,
|
||||
nonce: signedAction.nonce,
|
||||
ts: signedAction.ts,
|
||||
};
|
||||
|
||||
// 1. RATE LIMIT CHECK (Fail fast before heavy operations)
|
||||
// 5 requests per minute per DID
|
||||
if (isRateLimited(payload.did, 5, 60 * 1000)) {
|
||||
return { valid: false, error: 'RATE_LIMITED' };
|
||||
}
|
||||
|
||||
// 2. FRESHNESS CHECK (Fail fast before DB/Crypto)
|
||||
// 1. FRESHNESS CHECK (Fail fast before DB/Crypto)
|
||||
const now = Date.now();
|
||||
const diff = Math.abs(now - payload.ts);
|
||||
// Allow 5 minutes clock skew
|
||||
@@ -101,7 +120,7 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
return { valid: false, error: 'INVALID_TIMESTAMP: Request too old or in future' };
|
||||
}
|
||||
|
||||
// 3. FETCH USER & KEY
|
||||
// 2. FETCH USER & KEY
|
||||
const user = await db.query.users.findFirst({
|
||||
where: { did: payload.did },
|
||||
});
|
||||
@@ -114,18 +133,43 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
return { valid: false, error: 'Handle mismatch' };
|
||||
}
|
||||
|
||||
// 4. CRYPTOGRAPHIC VERIFICATION
|
||||
// 3. CRYPTOGRAPHIC VERIFICATION
|
||||
const isValid = await verifyActionSignature(signedAction, user.publicKey);
|
||||
|
||||
if (!isValid) {
|
||||
return { valid: false, error: 'INVALID_SIGNATURE' };
|
||||
}
|
||||
|
||||
// 5. ACTION ID HASH COMPUTATION
|
||||
await pruneExpiredSignedActions(now);
|
||||
|
||||
// 4. ACTION ID HASH COMPUTATION
|
||||
const canonicalString = canonicalize(payload);
|
||||
const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex');
|
||||
|
||||
// 6. REPLAY PROTECTION (DB)
|
||||
// 5. EXISTING REPLAY CHECK. Avoid charging the authenticated account bucket
|
||||
// for an action already recorded in durable replay storage. The unique
|
||||
// insert below remains the authoritative guard for concurrent requests.
|
||||
const existingReplay = await db
|
||||
.select({ actionId: signedActionDedupe.actionId })
|
||||
.from(signedActionDedupe)
|
||||
.where(eq(signedActionDedupe.actionId, actionIdHash))
|
||||
.limit(1);
|
||||
|
||||
if (existingReplay.length > 0) {
|
||||
return { valid: false, error: 'REPLAYED_NONCE' };
|
||||
}
|
||||
|
||||
// 6. AUTHENTICATED RATE LIMIT. Charge quota before creating durable replay
|
||||
// state so a fresh, unique action rejected for excess volume leaves no row.
|
||||
// Invalid signatures and attacker-controlled public DIDs never create
|
||||
// per-account limiter entries or consume quota.
|
||||
const requestsPerMinute = payload.action === 'chat_e2ee' ? 120 : 5;
|
||||
if (isRateLimited(`${user.id}:${payload.action}`, requestsPerMinute, 60 * 1000)) {
|
||||
return { valid: false, error: 'RATE_LIMITED' };
|
||||
}
|
||||
|
||||
// 7. AUTHORITATIVE REPLAY INSERT. Another request can race the read above,
|
||||
// so rely on the primary-key constraint to reject concurrent duplicates.
|
||||
try {
|
||||
await db.insert(signedActionDedupe).values({
|
||||
actionId: actionIdHash,
|
||||
@@ -133,9 +177,13 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
nonce: payload.nonce,
|
||||
ts: payload.ts,
|
||||
});
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
// Check for unique constraint violation (duplicate key)
|
||||
if (err.code === '23505') { // Postgres unique_violation code
|
||||
const errorCode = typeof err === 'object' && err !== null && 'code' in err
|
||||
? (err as { code?: unknown }).code
|
||||
: undefined;
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
if (errorCode === '23505' || /unique|constraint/i.test(errorMessage)) {
|
||||
return { valid: false, error: 'REPLAYED_NONCE' };
|
||||
}
|
||||
console.error('[Verify] Dedupe error:', err);
|
||||
@@ -149,7 +197,7 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
* Middleware to require a signed action
|
||||
* Throws an error if signature is invalid
|
||||
*/
|
||||
export async function requireSignedAction(signedAction: SignedAction): Promise<typeof users.$inferSelect> {
|
||||
export async function requireSignedAction(signedAction: SignedAction<unknown>): Promise<typeof users.$inferSelect> {
|
||||
const result = await verifyUserAction(signedAction);
|
||||
|
||||
if (!result.valid) {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { base58btc } from 'multiformats/bases/base58';
|
||||
|
||||
// DER SubjectPublicKeyInfo prefix for an uncompressed ECDSA P-256 public key.
|
||||
// The remaining 64 bytes are the affine X and Y coordinates.
|
||||
const P256_SPKI_PREFIX = Uint8Array.from([
|
||||
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
|
||||
0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48,
|
||||
0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, 0x04,
|
||||
]);
|
||||
|
||||
// Historical generateDID() passed the complete PEM string to
|
||||
// Buffer.from(value, 'base64'). Node treats the five '-' characters on each
|
||||
// side of BEGIN PUBLIC KEY as base64url data, producing this exact 18-byte
|
||||
// prefix before the otherwise valid SPKI DER bytes.
|
||||
const LEGACY_PEM_AS_BASE64_PREFIX = Uint8Array.from([
|
||||
0xfb, 0xef, 0xbe, 0xf8, 0x11, 0x06, 0x20, 0xd3, 0xd4,
|
||||
0x04, 0xb2, 0x02, 0x28, 0x46, 0x3e, 0xfb, 0xef, 0xbe,
|
||||
]);
|
||||
|
||||
function startsWith(bytes: Uint8Array, prefix: Uint8Array): boolean {
|
||||
if (bytes.length < prefix.length) return false;
|
||||
for (let index = 0; index < prefix.length; index += 1) {
|
||||
if (bytes[index] !== prefix[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function strictBase64ToBytes(value: string): Uint8Array | null {
|
||||
const unpadded = value.replace(/=+$/, '');
|
||||
if (
|
||||
!unpadded
|
||||
|| unpadded.length % 4 === 1
|
||||
|| !/^[A-Za-z0-9+/]+$/.test(unpadded)
|
||||
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(value)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const padded = unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
return bytesToBase64(bytes).replace(/=+$/, '') === unpadded ? bytes : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isP256SpkiDer(bytes: Uint8Array): boolean {
|
||||
return bytes.length === P256_SPKI_PREFIX.length + 64
|
||||
&& startsWith(bytes, P256_SPKI_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a PEM or base64 SPKI ECDSA P-256 public key to canonical DER.
|
||||
*/
|
||||
export function signingPublicKeyDer(publicKey: string): Uint8Array | null {
|
||||
const trimmed = publicKey.trim();
|
||||
let encoded = trimmed;
|
||||
|
||||
if (trimmed.includes('-----BEGIN')) {
|
||||
const match = trimmed.match(
|
||||
/^-----BEGIN PUBLIC KEY-----\s+([A-Za-z0-9+/=\s]+?)\s+-----END PUBLIC KEY-----$/,
|
||||
);
|
||||
if (!match) return null;
|
||||
encoded = match[1].replace(/\s/g, '');
|
||||
} else if (/\s/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = strictBase64ToBytes(encoded);
|
||||
return bytes && isP256SpkiDer(bytes) ? bytes : null;
|
||||
}
|
||||
|
||||
export function normalizeSigningPublicKey(publicKey: string): string | null {
|
||||
const bytes = signingPublicKeyDer(publicKey);
|
||||
return bytes ? bytesToBase64(bytes) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the project's did:key representation from canonical SPKI DER.
|
||||
*/
|
||||
export function generateDID(publicKey: string): string {
|
||||
const bytes = signingPublicKeyDer(publicKey);
|
||||
if (!bytes) throw new Error('Invalid ECDSA P-256 public key');
|
||||
return `did:key:${base58btc.encode(bytes)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the canonical signing key from a project did:key. Historical DIDs
|
||||
* are accepted only when they have the exact prefix emitted by the old PEM
|
||||
* decoding bug and a valid P-256 SPKI DER suffix.
|
||||
*/
|
||||
export function signingPublicKeyFromDid(did: string): string | null {
|
||||
if (!did.startsWith('did:key:')) return null;
|
||||
|
||||
try {
|
||||
const encoded = base58btc.decode(did.slice('did:key:'.length));
|
||||
if (isP256SpkiDer(encoded)) return bytesToBase64(encoded);
|
||||
|
||||
if (
|
||||
encoded.length === LEGACY_PEM_AS_BASE64_PREFIX.length + P256_SPKI_PREFIX.length + 64
|
||||
&& startsWith(encoded, LEGACY_PEM_AS_BASE64_PREFIX)
|
||||
) {
|
||||
const der = encoded.slice(LEGACY_PEM_AS_BASE64_PREFIX.length);
|
||||
if (isP256SpkiDer(der)) return bytesToBase64(der);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function didKeyMatchesPublicKey(did: string, publicKey: string): boolean {
|
||||
const didPublicKey = signingPublicKeyFromDid(did);
|
||||
const normalizedPublicKey = normalizeSigningPublicKey(publicKey);
|
||||
return didPublicKey !== null && didPublicKey === normalizedPublicKey;
|
||||
}
|
||||
@@ -15,18 +15,17 @@ import {
|
||||
keyStore,
|
||||
createSignedAction,
|
||||
canonicalize,
|
||||
exportPublicKey,
|
||||
importPublicKey,
|
||||
base64UrlToBase64
|
||||
exportPublicKey
|
||||
} from './user-signing';
|
||||
import { verifyUserAction, type SignedAction } from '../auth/verify-signature';
|
||||
import { verifyUserAction } from '../auth/verify-signature';
|
||||
|
||||
// Mock DB interactions
|
||||
const mockDbMethods = {
|
||||
findFirst: vi.fn(),
|
||||
values: vi.fn(() => ({ onConflictDoUpdate: vi.fn() })),
|
||||
insert: vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate: vi.fn() })) }))
|
||||
};
|
||||
const { mockIsRateLimited } = vi.hoisted(() => ({
|
||||
mockIsRateLimited: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
isRateLimited: mockIsRateLimited,
|
||||
}));
|
||||
|
||||
// We need to hoist the variable if we use it in vi.mock
|
||||
// Or simpler for this case, simply define it inline or use a factory that doesn't capture outer scope incorrectly.
|
||||
@@ -36,7 +35,15 @@ vi.mock('@/db', () => ({
|
||||
users: { findFirst: vi.fn() },
|
||||
remoteIdentityCache: { findFirst: vi.fn() }
|
||||
},
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({ values: vi.fn() })),
|
||||
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
||||
},
|
||||
users: { did: 'did', publicKey: 'publicKey' },
|
||||
signedActionDedupe: { actionId: 'actionId' },
|
||||
@@ -46,10 +53,6 @@ vi.mock('@/db', () => ({
|
||||
// Access the mocked module to manipulate it in tests
|
||||
import { db } from '@/db';
|
||||
|
||||
// Mock Database unique constraint error for replay test
|
||||
const duplicateKeyError = new Error('Duplicate key');
|
||||
(duplicateKeyError as any).code = '23505';
|
||||
|
||||
describe('Cryptographic User Signing', () => {
|
||||
let userKeyPair: CryptoKeyPair;
|
||||
let userPublicKeyBase64: string;
|
||||
@@ -63,6 +66,7 @@ describe('Cryptographic User Signing', () => {
|
||||
userPublicKeyBase64 = await exportPublicKey(userKeyPair.publicKey);
|
||||
|
||||
vi.clearAllMocks();
|
||||
mockIsRateLimited.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should canonicalize objects strictly', () => {
|
||||
@@ -97,17 +101,17 @@ describe('Cryptographic User Signing', () => {
|
||||
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
|
||||
|
||||
// Mock DB finding the user
|
||||
(db.query.users.findFirst as any).mockResolvedValue({
|
||||
vi.mocked(db.query.users.findFirst).mockResolvedValue({
|
||||
id: 'uuid-123',
|
||||
did: testDid,
|
||||
handle: testHandle,
|
||||
publicKey: userPublicKeyBase64,
|
||||
});
|
||||
} as never);
|
||||
|
||||
// Mock DB insert (dedupe) success
|
||||
(db.insert as any).mockReturnValue({
|
||||
vi.mocked(db.insert).mockReturnValue({
|
||||
values: vi.fn().mockResolvedValue(true)
|
||||
});
|
||||
} as never);
|
||||
|
||||
const result = await verifyUserAction(signed);
|
||||
|
||||
@@ -124,12 +128,12 @@ describe('Cryptographic User Signing', () => {
|
||||
// Tamper with data
|
||||
signed.data.content = 'Hacked';
|
||||
|
||||
(db.query.users.findFirst as any).mockResolvedValue({
|
||||
vi.mocked(db.query.users.findFirst).mockResolvedValue({
|
||||
id: 'uuid-123',
|
||||
did: testDid,
|
||||
handle: testHandle,
|
||||
publicKey: userPublicKeyBase64,
|
||||
});
|
||||
} as never);
|
||||
|
||||
const result = await verifyUserAction(signed);
|
||||
|
||||
@@ -141,25 +145,77 @@ describe('Cryptographic User Signing', () => {
|
||||
const payload = { content: 'Replay Me' };
|
||||
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
|
||||
|
||||
(db.query.users.findFirst as any).mockResolvedValue({
|
||||
vi.mocked(db.query.users.findFirst).mockResolvedValue({
|
||||
id: 'uuid-123',
|
||||
did: testDid,
|
||||
handle: testHandle,
|
||||
publicKey: userPublicKeyBase64,
|
||||
});
|
||||
} as never);
|
||||
|
||||
// Mock Duplicate Key Error
|
||||
const duplicateKeyError = new Error('Duplicate key');
|
||||
(duplicateKeyError as any).code = '23505';
|
||||
const duplicateKeyError = Object.assign(new Error('Duplicate key'), { code: '23505' });
|
||||
|
||||
// Second attempt fails with unique violation
|
||||
(db.insert as any).mockReturnValue({
|
||||
vi.mocked(db.insert).mockReturnValue({
|
||||
values: vi.fn().mockRejectedValue(duplicateKeyError)
|
||||
});
|
||||
} as never);
|
||||
|
||||
// Verify failure path
|
||||
const result = await verifyUserAction(signed);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('REPLAYED_NONCE');
|
||||
});
|
||||
|
||||
it('should not persist a unique action rejected by the authenticated rate limit', async () => {
|
||||
const signed = await createSignedAction(
|
||||
'create_post',
|
||||
{ content: 'Over limit' },
|
||||
testDid,
|
||||
testHandle,
|
||||
);
|
||||
|
||||
vi.mocked(db.query.users.findFirst).mockResolvedValue({
|
||||
id: 'uuid-123',
|
||||
did: testDid,
|
||||
handle: testHandle,
|
||||
publicKey: userPublicKeyBase64,
|
||||
} as never);
|
||||
mockIsRateLimited.mockReturnValue(true);
|
||||
|
||||
const result = await verifyUserAction(signed);
|
||||
|
||||
expect(result).toEqual({ valid: false, error: 'RATE_LIMITED' });
|
||||
expect(db.select).toHaveBeenCalledOnce();
|
||||
expect(mockIsRateLimited).toHaveBeenCalledOnce();
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an existing replay without charging quota or inserting again', async () => {
|
||||
const signed = await createSignedAction(
|
||||
'create_post',
|
||||
{ content: 'Already accepted' },
|
||||
testDid,
|
||||
testHandle,
|
||||
);
|
||||
|
||||
vi.mocked(db.query.users.findFirst).mockResolvedValue({
|
||||
id: 'uuid-123',
|
||||
did: testDid,
|
||||
handle: testHandle,
|
||||
publicKey: userPublicKeyBase64,
|
||||
} as never);
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: vi.fn().mockResolvedValue([{ actionId: 'already-stored' }]),
|
||||
})),
|
||||
})),
|
||||
} as never);
|
||||
|
||||
const result = await verifyUserAction(signed);
|
||||
|
||||
expect(result).toEqual({ valid: false, error: 'REPLAYED_NONCE' });
|
||||
expect(mockIsRateLimited).not.toHaveBeenCalled();
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
* - Nonce: 16+ bytes random base64url.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// ============================================
|
||||
// KEY STORAGE (In-Memory Only)
|
||||
// ============================================
|
||||
@@ -79,10 +77,8 @@ export function clearUserPrivateKey(): void {
|
||||
// CRYPTO HELPERS (WebCrypto / Node)
|
||||
// ============================================
|
||||
|
||||
// Detect environment for Crypto
|
||||
const cryptoSubtle = typeof window !== 'undefined'
|
||||
? window.crypto.subtle // Browser
|
||||
: (globalThis.crypto as any)?.subtle || require('crypto').webcrypto?.subtle; // Node
|
||||
// Modern browsers and the supported Node.js runtime both expose WebCrypto.
|
||||
const cryptoSubtle = globalThis.crypto?.subtle;
|
||||
|
||||
if (!cryptoSubtle) {
|
||||
throw new Error('WebCrypto is not supported in this environment');
|
||||
@@ -115,9 +111,12 @@ export async function exportPrivateKey(key: CryptoKey): Promise<ArrayBuffer> {
|
||||
* Import Private Key from PKCS8 (after decryption)
|
||||
*/
|
||||
export async function importPrivateKey(keyData: ArrayBuffer | Uint8Array): Promise<CryptoKey> {
|
||||
const normalizedKeyData = keyData instanceof ArrayBuffer
|
||||
? keyData
|
||||
: Uint8Array.from(keyData).buffer;
|
||||
return await cryptoSubtle.importKey(
|
||||
'pkcs8',
|
||||
keyData,
|
||||
normalizedKeyData,
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
@@ -170,7 +169,7 @@ export async function importPublicKey(base64Key: string): Promise<CryptoKey> {
|
||||
* - No Dates, Maps, Sets, Functions
|
||||
* - No NaN, Infinity
|
||||
*/
|
||||
export function canonicalize(obj: any): string {
|
||||
export function canonicalize(obj: unknown): string {
|
||||
if (obj === undefined) return ''; // Should not happen for valid inputs
|
||||
if (obj === null) return 'null';
|
||||
|
||||
@@ -199,12 +198,13 @@ export function canonicalize(obj: any): string {
|
||||
if (obj instanceof Date) throw new Error('Serialization failed: Date objects not allowed');
|
||||
if (obj instanceof RegExp) throw new Error('Serialization failed: RegExp objects not allowed');
|
||||
|
||||
const keys = Object.keys(obj).sort();
|
||||
const record = obj as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
const pairs: string[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
if (obj[key] === undefined) continue;
|
||||
const val = canonicalize(obj[key]);
|
||||
if (record[key] === undefined) continue;
|
||||
const val = canonicalize(record[key]);
|
||||
pairs.push(`${JSON.stringify(key)}:${val}`);
|
||||
}
|
||||
|
||||
@@ -218,14 +218,14 @@ export function canonicalize(obj: any): string {
|
||||
* Create a Signed Action
|
||||
* @returns { SignedAction } Includes ts, nonce, and strict signature
|
||||
*/
|
||||
export async function createSignedAction(
|
||||
export async function createSignedAction<TData>(
|
||||
action: string,
|
||||
data: any,
|
||||
data: TData,
|
||||
userDid: string,
|
||||
userHandle: string
|
||||
): Promise<{
|
||||
action: string;
|
||||
data: any;
|
||||
data: TData;
|
||||
did: string;
|
||||
handle: string;
|
||||
ts: number;
|
||||
@@ -275,6 +275,34 @@ export async function createSignedAction(
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySignedActionSignature(
|
||||
signedAction: {
|
||||
action: string;
|
||||
data: unknown;
|
||||
did: string;
|
||||
handle: string;
|
||||
ts: number;
|
||||
nonce: string;
|
||||
sig: string;
|
||||
},
|
||||
publicKeyString: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { sig, ...payload } = signedAction;
|
||||
const canonicalString = canonicalize(payload);
|
||||
const signature = base64ToArrayBuffer(base64UrlToBase64(sig));
|
||||
const publicKey = await importPublicKey(publicKeyString);
|
||||
return cryptoSubtle.verify(
|
||||
{ name: 'ECDSA', hash: { name: 'SHA-256' } },
|
||||
publicKey,
|
||||
signature,
|
||||
new TextEncoder().encode(canonicalString),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// UTILS
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { base58btc } from 'multiformats/bases/base58';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { generateDID } from '@/lib/auth';
|
||||
import { generateKeyPair as generatePemKeyPair } from '@/lib/crypto/keys';
|
||||
import {
|
||||
didKeyMatchesPublicKey,
|
||||
normalizeSigningPublicKey,
|
||||
signingPublicKeyFromDid,
|
||||
} from '@/lib/crypto/did-key';
|
||||
import {
|
||||
clearUserPrivateKey,
|
||||
createSignedAction,
|
||||
importPrivateKey,
|
||||
keyStore,
|
||||
} from '@/lib/crypto/user-signing';
|
||||
import { verifyE2EEPublicBundle } from './bundle-proof';
|
||||
import { generateE2EEKeyMaterial } from './client-crypto';
|
||||
import { E2EE_PROTOCOL, type E2EEKeyBundle } from './protocol';
|
||||
|
||||
function pemBody(pem: string): Uint8Array {
|
||||
return Uint8Array.from(Buffer.from(
|
||||
pem
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/g, '')
|
||||
.replace(/-----END PRIVATE KEY-----/g, '')
|
||||
.replace(/\s/g, ''),
|
||||
'base64',
|
||||
));
|
||||
}
|
||||
|
||||
function bundle(
|
||||
material: Awaited<ReturnType<typeof generateE2EEKeyMaterial>>,
|
||||
): E2EEKeyBundle {
|
||||
return {
|
||||
protocol: E2EE_PROTOCOL,
|
||||
keyId: material.keyId,
|
||||
version: 1,
|
||||
publicKey: material.publicKey,
|
||||
createdAt: Date.now(),
|
||||
recoveryCommitment: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
};
|
||||
}
|
||||
|
||||
async function signedBundleResponse(did: string) {
|
||||
const signingKeys = await generatePemKeyPair();
|
||||
const signingPrivateKey = await importPrivateKey(pemBody(signingKeys.privateKey));
|
||||
keyStore.setPrivateKey(signingPrivateKey);
|
||||
|
||||
const encryptionKeys = await generateE2EEKeyMaterial();
|
||||
const publicBundle = bundle(encryptionKeys);
|
||||
const proof = await createSignedAction('e2ee_key_bundle', publicBundle, did, 'alice');
|
||||
|
||||
return {
|
||||
response: {
|
||||
bundle: publicBundle,
|
||||
proof,
|
||||
signingPublicKey: signingKeys.publicKey,
|
||||
},
|
||||
signingPublicKey: signingKeys.publicKey,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => clearUserPrivateKey());
|
||||
|
||||
describe('did:key E2EE bundle binding', () => {
|
||||
it('binds an actual generated PEM key through a canonical DER DID', async () => {
|
||||
const signingKeys = await generatePemKeyPair();
|
||||
const did = generateDID(signingKeys.publicKey);
|
||||
const signingPrivateKey = await importPrivateKey(pemBody(signingKeys.privateKey));
|
||||
keyStore.setPrivateKey(signingPrivateKey);
|
||||
|
||||
const encryptionKeys = await generateE2EEKeyMaterial();
|
||||
const publicBundle = bundle(encryptionKeys);
|
||||
const proof = await createSignedAction('e2ee_key_bundle', publicBundle, did, 'alice');
|
||||
const response = { bundle: publicBundle, proof, signingPublicKey: signingKeys.publicKey };
|
||||
|
||||
const didBytes = base58btc.decode(did.slice('did:key:'.length));
|
||||
expect(didBytes).toHaveLength(91);
|
||||
expect(Buffer.from(didBytes).toString('base64'))
|
||||
.toBe(normalizeSigningPublicKey(signingKeys.publicKey));
|
||||
expect(signingPublicKeyFromDid(did)).toBe(normalizeSigningPublicKey(signingKeys.publicKey));
|
||||
expect(didKeyMatchesPublicKey(did, signingKeys.publicKey)).toBe(true);
|
||||
await expect(verifyE2EEPublicBundle(response, did)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('verifies the exact historical PEM-as-base64 DID without accepting arbitrary prefixes', async () => {
|
||||
const signingKeys = await generatePemKeyPair();
|
||||
const legacyDid = `did:key:${base58btc.encode(
|
||||
new Uint8Array(Buffer.from(signingKeys.publicKey, 'base64')),
|
||||
)}`;
|
||||
const signingPrivateKey = await importPrivateKey(pemBody(signingKeys.privateKey));
|
||||
keyStore.setPrivateKey(signingPrivateKey);
|
||||
|
||||
const encryptionKeys = await generateE2EEKeyMaterial();
|
||||
const publicBundle = bundle(encryptionKeys);
|
||||
const proof = await createSignedAction('e2ee_key_bundle', publicBundle, legacyDid, 'alice');
|
||||
const response = { bundle: publicBundle, proof, signingPublicKey: signingKeys.publicKey };
|
||||
|
||||
expect(signingPublicKeyFromDid(legacyDid)).toBe(normalizeSigningPublicKey(signingKeys.publicKey));
|
||||
expect(didKeyMatchesPublicKey(legacyDid, signingKeys.publicKey)).toBe(true);
|
||||
await expect(verifyE2EEPublicBundle(response, legacyDid)).resolves.toBe(true);
|
||||
|
||||
const legacyBytes = base58btc.decode(legacyDid.slice('did:key:'.length));
|
||||
const wrongPrefix = legacyBytes.slice();
|
||||
wrongPrefix[0] ^= 0x01;
|
||||
const malformedDid = `did:key:${base58btc.encode(wrongPrefix)}`;
|
||||
const malformedProof = await createSignedAction(
|
||||
'e2ee_key_bundle',
|
||||
publicBundle,
|
||||
malformedDid,
|
||||
'alice',
|
||||
);
|
||||
|
||||
expect(signingPublicKeyFromDid(malformedDid)).toBeNull();
|
||||
await expect(verifyE2EEPublicBundle({ ...response, proof: malformedProof }, malformedDid))
|
||||
.resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a different signing key even when it signs a proof naming the expected DID', async () => {
|
||||
const owner = await generatePemKeyPair();
|
||||
const ownerDid = generateDID(owner.publicKey);
|
||||
const attacker = await signedBundleResponse(ownerDid);
|
||||
|
||||
await expect(verifyE2EEPublicBundle(attacker.response, ownerDid)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { canonicalize, verifySignedActionSignature } from '@/lib/crypto/user-signing';
|
||||
import {
|
||||
normalizeSigningPublicKey,
|
||||
signingPublicKeyFromDid,
|
||||
} from '@/lib/crypto/did-key';
|
||||
import {
|
||||
E2EE_KEY_BUNDLE_ACTION,
|
||||
e2eeKeyBundleSchema,
|
||||
signedUserActionSchema,
|
||||
type E2EEPublicBundleResponse,
|
||||
} from './protocol';
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string): Uint8Array {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
export async function encryptionKeyIdFromPublicKey(publicKey: string): Promise<string | null> {
|
||||
try {
|
||||
await sodium.ready;
|
||||
const key = base64UrlToBytes(publicKey);
|
||||
if (key.length !== sodium.crypto_box_PUBLICKEYBYTES) return null;
|
||||
return `k1_${bytesToBase64Url(sodium.crypto_generichash(16, key, null))}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { signingPublicKeyFromDid } from '@/lib/crypto/did-key';
|
||||
|
||||
export async function verifyE2EEPublicBundle(
|
||||
response: E2EEPublicBundleResponse,
|
||||
expectedDid: string,
|
||||
): Promise<boolean> {
|
||||
const proofResult = signedUserActionSchema.safeParse(response.proof);
|
||||
const bundleResult = e2eeKeyBundleSchema.safeParse(response.bundle);
|
||||
if (!proofResult.success || !bundleResult.success) return false;
|
||||
|
||||
const proof = proofResult.data;
|
||||
if (proof.action !== E2EE_KEY_BUNDLE_ACTION || proof.did !== expectedDid) return false;
|
||||
|
||||
const proofBundle = e2eeKeyBundleSchema.safeParse(proof.data);
|
||||
if (!proofBundle.success || canonicalize(proofBundle.data) !== canonicalize(bundleResult.data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await encryptionKeyIdFromPublicKey(bundleResult.data.publicKey) !== bundleResult.data.keyId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const responseSigningPublicKey = normalizeSigningPublicKey(response.signingPublicKey);
|
||||
if (!responseSigningPublicKey) return false;
|
||||
|
||||
const didSigningPublicKey = signingPublicKeyFromDid(expectedDid);
|
||||
if (expectedDid.startsWith('did:key:') && !didSigningPublicKey) return false;
|
||||
if (didSigningPublicKey && didSigningPublicKey !== responseSigningPublicKey) return false;
|
||||
const signingPublicKey = didSigningPublicKey || responseSigningPublicKey;
|
||||
if (!signingPublicKey) return false;
|
||||
return verifySignedActionSignature(proof, signingPublicKey);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createE2EEVault,
|
||||
decryptE2EEMessage,
|
||||
encryptE2EEMessage,
|
||||
fromBase64Url,
|
||||
generateE2EEKeyMaterial,
|
||||
openE2EEVault,
|
||||
prepareE2EEVaultUnlock,
|
||||
toBase64Url,
|
||||
} from './client-crypto';
|
||||
import {
|
||||
E2EE_PROTOCOL,
|
||||
e2eeMessageEnvelopeSchema,
|
||||
validateMessageBindings,
|
||||
type E2EEKeyBundle,
|
||||
type E2EEMessageEnvelope,
|
||||
} from './protocol';
|
||||
import {
|
||||
createPinVerifierMac,
|
||||
openServerShare,
|
||||
pinVerifierMatches,
|
||||
sealServerShare,
|
||||
} from './server-secrets';
|
||||
|
||||
const aliceDid = 'did:key:alice-test-identity';
|
||||
const bobDid = 'did:key:bob-test-identity';
|
||||
|
||||
function bundle(
|
||||
material: Awaited<ReturnType<typeof generateE2EEKeyMaterial>>,
|
||||
version = 1,
|
||||
): E2EEKeyBundle {
|
||||
return {
|
||||
protocol: E2EE_PROTOCOL,
|
||||
keyId: material.keyId,
|
||||
version,
|
||||
publicKey: material.publicKey,
|
||||
createdAt: 1_700_000_000_000,
|
||||
recoveryCommitment: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
};
|
||||
}
|
||||
|
||||
function flipEncodedByte(value: string): string {
|
||||
const bytes = fromBase64Url(value);
|
||||
bytes[Math.floor(bytes.length / 2)] ^= 1;
|
||||
return toBase64Url(bytes);
|
||||
}
|
||||
|
||||
async function messageFixture() {
|
||||
const alice = await generateE2EEKeyMaterial();
|
||||
const bob = await generateE2EEKeyMaterial();
|
||||
const envelope = await encryptE2EEMessage({
|
||||
plaintext: 'the server should never receive this text',
|
||||
senderDid: aliceDid,
|
||||
senderHandle: 'alice',
|
||||
senderBundle: bundle(alice),
|
||||
recipientDid: bobDid,
|
||||
recipientHandle: 'bob',
|
||||
recipientBundle: bundle(bob),
|
||||
});
|
||||
return { alice, bob, envelope };
|
||||
}
|
||||
|
||||
describe('E2EE message crypto', () => {
|
||||
it('decrypts the same envelope for both sender and recipient', async () => {
|
||||
const { alice, bob, envelope } = await messageFixture();
|
||||
|
||||
await expect(decryptE2EEMessage(envelope, aliceDid, alice))
|
||||
.resolves.toBe('the server should never receive this text');
|
||||
await expect(decryptE2EEMessage(envelope, bobDid, bob))
|
||||
.resolves.toBe('the server should never receive this text');
|
||||
});
|
||||
|
||||
it('rejects an unrelated private key', async () => {
|
||||
const { envelope } = await messageFixture();
|
||||
const mallory = await generateE2EEKeyMaterial();
|
||||
await expect(decryptE2EEMessage(envelope, bobDid, mallory)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each(['ciphertext', 'nonce', 'keyCommitment'] as const)(
|
||||
'rejects a modified %s',
|
||||
async (field) => {
|
||||
const { bob, envelope } = await messageFixture();
|
||||
const tampered = { ...envelope, [field]: flipEncodedByte(envelope[field]) };
|
||||
await expect(decryptE2EEMessage(tampered, bobDid, bob)).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects authenticated-header tampering', async () => {
|
||||
const { bob, envelope } = await messageFixture();
|
||||
const tampered = { ...envelope, conversationId: `${envelope.conversationId}x` };
|
||||
await expect(decryptE2EEMessage(tampered, bobDid, bob)).rejects.toThrow(/transplanted/);
|
||||
});
|
||||
|
||||
it('rejects sealed-key transplantation between messages', async () => {
|
||||
const { alice, bob, envelope } = await messageFixture();
|
||||
const second = await encryptE2EEMessage({
|
||||
plaintext: 'second message',
|
||||
senderDid: aliceDid,
|
||||
senderHandle: 'alice',
|
||||
senderBundle: bundle(alice),
|
||||
recipientDid: bobDid,
|
||||
recipientHandle: 'bob',
|
||||
recipientBundle: bundle(bob),
|
||||
});
|
||||
const transplanted = { ...second, keyEnvelopes: envelope.keyEnvelopes };
|
||||
await expect(decryptE2EEMessage(transplanted, bobDid, bob)).rejects.toThrow(/transplanted/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('E2EE PIN vault', () => {
|
||||
it('round-trips only with the correct PIN and server share', async () => {
|
||||
const material = await generateE2EEKeyMaterial();
|
||||
const setup = await createE2EEVault('739184', material, aliceDid, 1);
|
||||
const prepared = await prepareE2EEVaultUnlock('739184', setup.vault);
|
||||
|
||||
await expect(openE2EEVault(prepared, setup.vault, setup.serverShare)).resolves.toEqual(material);
|
||||
|
||||
const wrong = await prepareE2EEVaultUnlock('739185', setup.vault);
|
||||
await expect(openE2EEVault(wrong, setup.vault, setup.serverShare)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('binds a vault to its owner and key metadata', async () => {
|
||||
const material = await generateE2EEKeyMaterial();
|
||||
const setup = await createE2EEVault('739184', material, aliceDid, 1);
|
||||
const prepared = await prepareE2EEVaultUnlock('739184', setup.vault);
|
||||
const transplanted = { ...setup.vault, ownerDid: bobDid };
|
||||
|
||||
await expect(openE2EEVault(prepared, transplanted, setup.serverShare)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('E2EE protocol validation', () => {
|
||||
it('rejects unknown fields and unsupported suites', async () => {
|
||||
const { envelope } = await messageFixture();
|
||||
expect(e2eeMessageEnvelopeSchema.safeParse({ ...envelope, plaintext: 'leak' }).success).toBe(false);
|
||||
expect(e2eeMessageEnvelopeSchema.safeParse({ ...envelope, cipherSuite: 'unknown' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects duplicate and mismatched key-envelope bindings', async () => {
|
||||
const { envelope } = await messageFixture();
|
||||
const duplicate: E2EEMessageEnvelope = {
|
||||
...envelope,
|
||||
keyEnvelopes: [envelope.keyEnvelopes[0], envelope.keyEnvelopes[0]],
|
||||
};
|
||||
expect(() => validateMessageBindings(duplicate, {
|
||||
action: 'chat_e2ee',
|
||||
did: envelope.senderDid,
|
||||
handle: envelope.senderHandle,
|
||||
ts: envelope.createdAt,
|
||||
})).toThrow(/duplicate/);
|
||||
});
|
||||
|
||||
it('allows a stable old envelope to be re-signed for idempotent retry but rejects future dating', async () => {
|
||||
const { envelope } = await messageFixture();
|
||||
const action = {
|
||||
action: 'chat_e2ee',
|
||||
did: envelope.senderDid,
|
||||
handle: envelope.senderHandle,
|
||||
ts: envelope.createdAt + 24 * 60 * 60 * 1000,
|
||||
};
|
||||
expect(() => validateMessageBindings(envelope, action)).not.toThrow();
|
||||
expect(() => validateMessageBindings({
|
||||
...envelope,
|
||||
createdAt: action.ts + 5 * 60 * 1000 + 1,
|
||||
}, action)).toThrow(/future/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('server recovery-secret protection', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('E2EE_RECOVERY_SECRET', 'test-only-recovery-secret-with-entropy');
|
||||
});
|
||||
|
||||
it('seals server shares to account and key context', () => {
|
||||
const sealed = sealServerShare('server-share', 'user-1', 'key-1');
|
||||
expect(openServerShare(sealed, 'user-1', 'key-1')).toBe('server-share');
|
||||
expect(() => openServerShare(sealed, 'user-2', 'key-1')).toThrow();
|
||||
expect(() => openServerShare(sealed, 'user-1', 'key-2')).toThrow();
|
||||
});
|
||||
|
||||
it('compares PIN verifiers without accepting another context', () => {
|
||||
const mac = createPinVerifierMac('pin-proof', 'user-1', 'key-1');
|
||||
expect(pinVerifierMatches('pin-proof', mac, 'user-1', 'key-1')).toBe(true);
|
||||
expect(pinVerifierMatches('pin-proof', mac, 'user-2', 'key-1')).toBe(false);
|
||||
expect(pinVerifierMatches('other-proof', mac, 'user-1', 'key-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing, placeholder, or reused deployment secret', () => {
|
||||
vi.stubEnv('E2EE_RECOVERY_SECRET', 'replace-with-a-separate-long-random-secret');
|
||||
expect(() => sealServerShare('share', 'user-1', 'key-1')).toThrow(/high-entropy/);
|
||||
|
||||
vi.stubEnv('E2EE_RECOVERY_SECRET', 'same-secret-that-is-long-enough-for-both-values');
|
||||
vi.stubEnv('AUTH_SECRET', 'same-secret-that-is-long-enough-for-both-values');
|
||||
expect(() => sealServerShare('share', 'user-1', 'key-1')).toThrow(/independent/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
E2EE_KDF,
|
||||
E2EE_CIPHER_SUITE,
|
||||
E2EE_PROTOCOL,
|
||||
type E2EEKeyBundle,
|
||||
type E2EEKeyMaterial,
|
||||
type E2EEMessageEnvelope,
|
||||
type E2EEVaultRecord,
|
||||
type E2EEVaultSetup,
|
||||
messageAuthenticatedData,
|
||||
validatePin,
|
||||
vaultAuthenticatedData,
|
||||
} from './protocol';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
const PIN_VERIFIER_CONTEXT = encoder.encode('synapsis-e2ee-pin-verifier-v1');
|
||||
const VAULT_KEY_CONTEXT = encoder.encode('synapsis-e2ee-vault-key-v1');
|
||||
|
||||
async function ready(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
export function toBase64Url(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function fromBase64Url(value: string): Uint8Array {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
||||
const length = parts.reduce((total, part) => total + part.length, 0);
|
||||
const result = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function derivePinKey(
|
||||
pin: string,
|
||||
salt: Uint8Array,
|
||||
opsLimit: number,
|
||||
memLimit: number,
|
||||
): Promise<Uint8Array> {
|
||||
validatePin(pin);
|
||||
const crypto = await ready();
|
||||
return crypto.crypto_pwhash(
|
||||
32,
|
||||
pin,
|
||||
salt,
|
||||
opsLimit,
|
||||
memLimit,
|
||||
crypto.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
function deriveVerifier(crypto: typeof sodium, pinKey: Uint8Array): Uint8Array {
|
||||
return crypto.crypto_generichash(32, PIN_VERIFIER_CONTEXT, pinKey);
|
||||
}
|
||||
|
||||
function deriveVaultKey(
|
||||
crypto: typeof sodium,
|
||||
pinKey: Uint8Array,
|
||||
serverShare: Uint8Array,
|
||||
): Uint8Array {
|
||||
return crypto.crypto_generichash(32, concatBytes(VAULT_KEY_CONTEXT, serverShare), pinKey);
|
||||
}
|
||||
|
||||
export async function generateE2EEKeyMaterial(): Promise<E2EEKeyMaterial> {
|
||||
const crypto = await ready();
|
||||
const pair = crypto.crypto_box_keypair();
|
||||
const fingerprint = crypto.crypto_generichash(16, pair.publicKey, null);
|
||||
return {
|
||||
keyId: `k1_${toBase64Url(fingerprint)}`,
|
||||
publicKey: toBase64Url(pair.publicKey),
|
||||
privateKey: toBase64Url(pair.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertKeyMaterialMatchesPublicKey(material: E2EEKeyMaterial): Promise<void> {
|
||||
const crypto = await ready();
|
||||
const publicKey = fromBase64Url(material.publicKey);
|
||||
const privateKey = fromBase64Url(material.privateKey);
|
||||
try {
|
||||
if (publicKey.length !== crypto.crypto_box_PUBLICKEYBYTES
|
||||
|| privateKey.length !== crypto.crypto_box_SECRETKEYBYTES) {
|
||||
throw new Error('Encrypted message key material has an invalid length');
|
||||
}
|
||||
const derivedPublicKey = crypto.crypto_scalarmult_base(privateKey);
|
||||
const fingerprint = crypto.crypto_generichash(16, publicKey, null);
|
||||
if (!crypto.memcmp(derivedPublicKey, publicKey)
|
||||
|| material.keyId !== `k1_${toBase64Url(fingerprint)}`) {
|
||||
throw new Error('Encrypted message private key does not match its public key');
|
||||
}
|
||||
} finally {
|
||||
crypto.memzero(privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createE2EEVault(
|
||||
pin: string,
|
||||
material: E2EEKeyMaterial,
|
||||
ownerDid: string,
|
||||
keyVersion: number,
|
||||
): Promise<E2EEVaultSetup> {
|
||||
validatePin(pin);
|
||||
const crypto = await ready();
|
||||
await assertKeyMaterialMatchesPublicKey(material);
|
||||
const salt = crypto.randombytes_buf(crypto.crypto_pwhash_SALTBYTES);
|
||||
const serverShare = crypto.randombytes_buf(32);
|
||||
const nonce = crypto.randombytes_buf(crypto.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||
let pinKey: Uint8Array | null = null;
|
||||
let vaultKey: Uint8Array | null = null;
|
||||
let plaintext: Uint8Array | null = null;
|
||||
|
||||
try {
|
||||
pinKey = await derivePinKey(pin, salt, E2EE_KDF.opsLimit, E2EE_KDF.memLimit);
|
||||
vaultKey = deriveVaultKey(crypto, pinKey, serverShare);
|
||||
const vaultWithoutCiphertext = {
|
||||
protocol: E2EE_PROTOCOL,
|
||||
ownerDid,
|
||||
keyId: material.keyId,
|
||||
keyVersion,
|
||||
publicKey: material.publicKey,
|
||||
salt: toBase64Url(salt),
|
||||
kdfAlgorithm: E2EE_KDF.algorithm,
|
||||
kdfOpsLimit: E2EE_KDF.opsLimit,
|
||||
kdfMemLimit: E2EE_KDF.memLimit,
|
||||
} as const;
|
||||
plaintext = encoder.encode(JSON.stringify(material));
|
||||
const ciphertext = crypto.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
plaintext,
|
||||
encoder.encode(vaultAuthenticatedData(vaultWithoutCiphertext)),
|
||||
null,
|
||||
nonce,
|
||||
vaultKey,
|
||||
);
|
||||
|
||||
return {
|
||||
vault: {
|
||||
...vaultWithoutCiphertext,
|
||||
ciphertext: toBase64Url(ciphertext),
|
||||
nonce: toBase64Url(nonce),
|
||||
},
|
||||
serverShare: toBase64Url(serverShare),
|
||||
pinVerifier: toBase64Url(deriveVerifier(crypto, pinKey)),
|
||||
} satisfies E2EEVaultSetup;
|
||||
} finally {
|
||||
if (pinKey) crypto.memzero(pinKey);
|
||||
if (vaultKey) crypto.memzero(vaultKey);
|
||||
if (plaintext) crypto.memzero(plaintext);
|
||||
crypto.memzero(serverShare);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreparedVaultUnlock {
|
||||
pinKey: Uint8Array;
|
||||
pinVerifier: string;
|
||||
}
|
||||
|
||||
export async function prepareE2EEVaultUnlock(
|
||||
pin: string,
|
||||
vault: Pick<E2EEVaultRecord, 'salt' | 'kdfOpsLimit' | 'kdfMemLimit'>,
|
||||
): Promise<PreparedVaultUnlock> {
|
||||
const crypto = await ready();
|
||||
const pinKey = await derivePinKey(pin, fromBase64Url(vault.salt), vault.kdfOpsLimit, vault.kdfMemLimit);
|
||||
return {
|
||||
pinKey,
|
||||
pinVerifier: toBase64Url(deriveVerifier(crypto, pinKey)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function openE2EEVault(
|
||||
prepared: PreparedVaultUnlock,
|
||||
vault: E2EEVaultRecord,
|
||||
serverShareValue: string,
|
||||
): Promise<E2EEKeyMaterial> {
|
||||
const crypto = await ready();
|
||||
let serverShare: Uint8Array | null = null;
|
||||
let vaultKey: Uint8Array | null = null;
|
||||
let plaintext: Uint8Array | null = null;
|
||||
|
||||
try {
|
||||
serverShare = fromBase64Url(serverShareValue);
|
||||
vaultKey = deriveVaultKey(crypto, prepared.pinKey, serverShare);
|
||||
plaintext = crypto.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
null,
|
||||
fromBase64Url(vault.ciphertext),
|
||||
encoder.encode(vaultAuthenticatedData(vault)),
|
||||
fromBase64Url(vault.nonce),
|
||||
vaultKey,
|
||||
);
|
||||
const material = JSON.parse(decoder.decode(plaintext)) as E2EEKeyMaterial;
|
||||
if (material.keyId !== vault.keyId || material.publicKey !== vault.publicKey) {
|
||||
throw new Error('Encrypted message vault does not match its public key');
|
||||
}
|
||||
await assertKeyMaterialMatchesPublicKey(material);
|
||||
return material;
|
||||
} finally {
|
||||
crypto.memzero(prepared.pinKey);
|
||||
if (vaultKey) crypto.memzero(vaultKey);
|
||||
if (serverShare) crypto.memzero(serverShare);
|
||||
if (plaintext) crypto.memzero(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptE2EEMessage(input: {
|
||||
plaintext: string;
|
||||
senderDid: string;
|
||||
senderHandle: string;
|
||||
senderBundle: E2EEKeyBundle;
|
||||
recipientDid: string;
|
||||
recipientHandle: string;
|
||||
recipientBundle: E2EEKeyBundle;
|
||||
}): Promise<E2EEMessageEnvelope> {
|
||||
const crypto = await ready();
|
||||
const contentKey = crypto.randombytes_buf(crypto.crypto_aead_xchacha20poly1305_ietf_KEYBYTES);
|
||||
const nonce = crypto.randombytes_buf(crypto.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||
const conversationParticipants = [input.senderDid, input.recipientDid].sort();
|
||||
const conversationId = `dm1_${toBase64Url(crypto.crypto_generichash(
|
||||
16,
|
||||
encoder.encode(JSON.stringify(conversationParticipants)),
|
||||
null,
|
||||
))}`;
|
||||
const header = {
|
||||
protocol: E2EE_PROTOCOL,
|
||||
cipherSuite: E2EE_CIPHER_SUITE,
|
||||
messageId: uuidv4(),
|
||||
conversationId,
|
||||
senderDid: input.senderDid,
|
||||
senderHandle: input.senderHandle,
|
||||
recipientDid: input.recipientDid,
|
||||
recipientHandle: input.recipientHandle,
|
||||
createdAt: Date.now(),
|
||||
senderKeyId: input.senderBundle.keyId,
|
||||
senderKeyVersion: input.senderBundle.version,
|
||||
recipientKeyId: input.recipientBundle.keyId,
|
||||
recipientKeyVersion: input.recipientBundle.version,
|
||||
} as const;
|
||||
|
||||
const recipients = new Map<string, E2EEKeyBundle>();
|
||||
recipients.set(input.senderDid, input.senderBundle);
|
||||
recipients.set(input.recipientDid, input.recipientBundle);
|
||||
let sealedPayload: Uint8Array | null = null;
|
||||
|
||||
try {
|
||||
const authenticatedData = encoder.encode(messageAuthenticatedData(header));
|
||||
const ciphertext = crypto.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
encoder.encode(input.plaintext),
|
||||
authenticatedData,
|
||||
null,
|
||||
nonce,
|
||||
contentKey,
|
||||
);
|
||||
|
||||
const transcriptHash = crypto.crypto_hash_sha256(authenticatedData);
|
||||
sealedPayload = concatBytes(contentKey, transcriptHash);
|
||||
const keyCommitment = crypto.crypto_generichash(
|
||||
32,
|
||||
concatBytes(authenticatedData, nonce, ciphertext),
|
||||
contentKey,
|
||||
);
|
||||
|
||||
return {
|
||||
...header,
|
||||
nonce: toBase64Url(nonce),
|
||||
ciphertext: toBase64Url(ciphertext),
|
||||
keyCommitment: toBase64Url(keyCommitment),
|
||||
keyEnvelopes: Array.from(recipients.entries()).map(([did, bundle]) => ({
|
||||
did,
|
||||
keyId: bundle.keyId,
|
||||
keyVersion: bundle.version,
|
||||
sealedKey: toBase64Url(
|
||||
crypto.crypto_box_seal(sealedPayload!, fromBase64Url(bundle.publicKey)),
|
||||
),
|
||||
})),
|
||||
};
|
||||
} finally {
|
||||
crypto.memzero(contentKey);
|
||||
if (sealedPayload) crypto.memzero(sealedPayload);
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptE2EEMessage(
|
||||
envelope: E2EEMessageEnvelope,
|
||||
userDid: string,
|
||||
material: E2EEKeyMaterial,
|
||||
): Promise<string> {
|
||||
const crypto = await ready();
|
||||
const wrappedKey = envelope.keyEnvelopes.find(
|
||||
(candidate) => candidate.did === userDid && candidate.keyId === material.keyId,
|
||||
);
|
||||
if (!wrappedKey) {
|
||||
throw new Error('Message was encrypted for a different encryption key');
|
||||
}
|
||||
|
||||
const sealedPayload = crypto.crypto_box_seal_open(
|
||||
fromBase64Url(wrappedKey.sealedKey),
|
||||
fromBase64Url(material.publicKey),
|
||||
fromBase64Url(material.privateKey),
|
||||
);
|
||||
let contentKey: Uint8Array | null = null;
|
||||
|
||||
try {
|
||||
if (sealedPayload.length !== 64) throw new Error('Encrypted message key payload is invalid');
|
||||
contentKey = sealedPayload.slice(0, 32);
|
||||
const expectedTranscriptHash = sealedPayload.slice(32);
|
||||
const authenticatedData = encoder.encode(messageAuthenticatedData(envelope));
|
||||
const transcriptHash = crypto.crypto_hash_sha256(authenticatedData);
|
||||
if (!crypto.memcmp(transcriptHash, expectedTranscriptHash)) {
|
||||
throw new Error('Encrypted message key was transplanted from another message');
|
||||
}
|
||||
const commitment = crypto.crypto_generichash(
|
||||
32,
|
||||
concatBytes(authenticatedData, fromBase64Url(envelope.nonce), fromBase64Url(envelope.ciphertext)),
|
||||
contentKey,
|
||||
);
|
||||
if (!crypto.memcmp(commitment, fromBase64Url(envelope.keyCommitment))) {
|
||||
throw new Error('Encrypted message key commitment is invalid');
|
||||
}
|
||||
const plaintext = crypto.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
null,
|
||||
fromBase64Url(envelope.ciphertext),
|
||||
authenticatedData,
|
||||
fromBase64Url(envelope.nonce),
|
||||
contentKey,
|
||||
);
|
||||
return decoder.decode(plaintext);
|
||||
} finally {
|
||||
if (contentKey) crypto.memzero(contentKey);
|
||||
crypto.memzero(sealedPayload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
clearUserPrivateKey,
|
||||
createSignedAction,
|
||||
exportPublicKey,
|
||||
generateKeyPair,
|
||||
keyStore,
|
||||
} from '@/lib/crypto/user-signing';
|
||||
import { decryptStoredChatMessage } from './client';
|
||||
import { verifyE2EEPublicBundle } from './bundle-proof';
|
||||
import { encryptE2EEMessage, generateE2EEKeyMaterial } from './client-crypto';
|
||||
import { E2EE_PROTOCOL, type E2EEKeyBundle } from './protocol';
|
||||
|
||||
const senderDid = 'did:synapsis:alice-signature-test';
|
||||
const recipientDid = 'did:synapsis:bob-signature-test';
|
||||
|
||||
function bundle(
|
||||
material: Awaited<ReturnType<typeof generateE2EEKeyMaterial>>,
|
||||
): E2EEKeyBundle {
|
||||
return {
|
||||
protocol: E2EE_PROTOCOL,
|
||||
keyId: material.keyId,
|
||||
version: 1,
|
||||
publicKey: material.publicKey,
|
||||
createdAt: Date.now(),
|
||||
recoveryCommitment: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => clearUserPrivateKey());
|
||||
|
||||
describe('stored encrypted messages', () => {
|
||||
it('verifies the outer account signature before decrypting', async () => {
|
||||
const signingKeys = await generateKeyPair();
|
||||
const signingPublicKey = await exportPublicKey(signingKeys.publicKey);
|
||||
keyStore.setPrivateKey(signingKeys.privateKey);
|
||||
|
||||
const sender = await generateE2EEKeyMaterial();
|
||||
const recipient = await generateE2EEKeyMaterial();
|
||||
const envelope = await encryptE2EEMessage({
|
||||
plaintext: 'signed and encrypted',
|
||||
senderDid,
|
||||
senderHandle: 'alice',
|
||||
senderBundle: bundle(sender),
|
||||
recipientDid,
|
||||
recipientHandle: 'bob',
|
||||
recipientBundle: bundle(recipient),
|
||||
});
|
||||
const signedAction = await createSignedAction(
|
||||
'chat_e2ee',
|
||||
envelope,
|
||||
senderDid,
|
||||
'alice',
|
||||
);
|
||||
|
||||
await expect(decryptStoredChatMessage({
|
||||
protocolVersion: 1,
|
||||
encryptedEnvelope: envelope,
|
||||
signedAction,
|
||||
senderPublicKey: signingPublicKey,
|
||||
}, recipientDid, recipient)).resolves.toEqual({
|
||||
content: 'signed and encrypted',
|
||||
legacy: false,
|
||||
});
|
||||
|
||||
const invalidSignature = {
|
||||
...signedAction,
|
||||
sig: `${signedAction.sig.startsWith('A') ? 'B' : 'A'}${signedAction.sig.slice(1)}`,
|
||||
};
|
||||
await expect(decryptStoredChatMessage({
|
||||
protocolVersion: 1,
|
||||
encryptedEnvelope: envelope,
|
||||
signedAction: invalidSignature,
|
||||
senderPublicKey: signingPublicKey,
|
||||
}, recipientDid, recipient)).rejects.toThrow(/signature/);
|
||||
});
|
||||
|
||||
it('rejects a stored envelope that differs from the signed envelope', async () => {
|
||||
const signingKeys = await generateKeyPair();
|
||||
const signingPublicKey = await exportPublicKey(signingKeys.publicKey);
|
||||
keyStore.setPrivateKey(signingKeys.privateKey);
|
||||
const sender = await generateE2EEKeyMaterial();
|
||||
const recipient = await generateE2EEKeyMaterial();
|
||||
const envelope = await encryptE2EEMessage({
|
||||
plaintext: 'original',
|
||||
senderDid,
|
||||
senderHandle: 'alice',
|
||||
senderBundle: bundle(sender),
|
||||
recipientDid,
|
||||
recipientHandle: 'bob',
|
||||
recipientBundle: bundle(recipient),
|
||||
});
|
||||
const signedAction = await createSignedAction('chat_e2ee', envelope, senderDid, 'alice');
|
||||
|
||||
await expect(decryptStoredChatMessage({
|
||||
protocolVersion: 1,
|
||||
encryptedEnvelope: { ...envelope, ciphertext: `${envelope.ciphertext}A` },
|
||||
signedAction,
|
||||
senderPublicKey: signingPublicKey,
|
||||
}, recipientDid, recipient)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('signed encryption key bundles', () => {
|
||||
it('accepts the DID-signing-key proof and rejects altered key material', async () => {
|
||||
const signingKeys = await generateKeyPair();
|
||||
const signingPublicKey = await exportPublicKey(signingKeys.publicKey);
|
||||
keyStore.setPrivateKey(signingKeys.privateKey);
|
||||
const material = await generateE2EEKeyMaterial();
|
||||
const publicBundle = bundle(material);
|
||||
const proof = await createSignedAction('e2ee_key_bundle', publicBundle, senderDid, 'alice');
|
||||
const response = { bundle: publicBundle, proof, signingPublicKey };
|
||||
|
||||
await expect(verifyE2EEPublicBundle(response, senderDid)).resolves.toBe(true);
|
||||
await expect(verifyE2EEPublicBundle({
|
||||
...response,
|
||||
bundle: { ...publicBundle, version: 2 },
|
||||
}, senderDid)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import { canonicalize, createSignedAction, verifySignedActionSignature } from '@/lib/crypto/user-signing';
|
||||
import { signingPublicKeyFromDid, verifyE2EEPublicBundle } from './bundle-proof';
|
||||
import {
|
||||
createE2EEVault,
|
||||
decryptE2EEMessage,
|
||||
generateE2EEKeyMaterial,
|
||||
openE2EEVault,
|
||||
prepareE2EEVaultUnlock,
|
||||
toBase64Url,
|
||||
} from './client-crypto';
|
||||
import { persistE2EEKeyMaterial } from './local-key-store';
|
||||
import {
|
||||
E2EE_KEY_BUNDLE_ACTION,
|
||||
E2EE_PROTOCOL,
|
||||
e2eeMessageEnvelopeSchema,
|
||||
e2eePublicBundleResponseSchema,
|
||||
e2eeVaultRecordSchema,
|
||||
e2eeVaultStatusSchema,
|
||||
signedUserActionSchema,
|
||||
validateMessageBindings,
|
||||
type E2EEKeyMaterial,
|
||||
type E2EEPublicBundleResponse,
|
||||
type E2EEVaultStatus,
|
||||
} from './protocol';
|
||||
|
||||
export class E2EEClientError extends Error {
|
||||
constructor(message: string, readonly code: string, readonly details?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = 'E2EEClientError';
|
||||
}
|
||||
}
|
||||
|
||||
async function responseError(response: Response, fallback: string): Promise<E2EEClientError> {
|
||||
const body = await response.json().catch(() => null) as Record<string, unknown> | null;
|
||||
return new E2EEClientError(
|
||||
typeof body?.error === 'string' ? body.error : fallback,
|
||||
typeof body?.code === 'string' ? body.code : 'E2EE_REQUEST_FAILED',
|
||||
body || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE2EEVaultStatus(expectedDid: string): Promise<E2EEVaultStatus> {
|
||||
const response = await fetch('/api/e2ee/vault', { cache: 'no-store' });
|
||||
if (!response.ok) throw await responseError(response, 'Encrypted message setup could not be loaded');
|
||||
const status = e2eeVaultStatusSchema.parse(await response.json());
|
||||
if (status.ownerDid !== expectedDid) {
|
||||
throw new E2EEClientError('The active account changed while encrypted messages were loading', 'E2EE_ACCOUNT_CHANGED');
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function provisionE2EEAccount(input: {
|
||||
did: string;
|
||||
handle: string;
|
||||
pin: string;
|
||||
replacesKeyId?: string;
|
||||
currentPassword?: string;
|
||||
}): Promise<E2EEKeyMaterial> {
|
||||
const material = await generateE2EEKeyMaterial();
|
||||
let version = 1;
|
||||
if (input.replacesKeyId) {
|
||||
const status = await fetchE2EEVaultStatus(input.did);
|
||||
const previousKey = status.configured
|
||||
? { keyId: status.keyId, keyVersion: status.keyVersion }
|
||||
: status.previousKey;
|
||||
if (!previousKey || previousKey.keyId !== input.replacesKeyId) {
|
||||
throw new E2EEClientError('The encryption key to replace changed', 'E2EE_KEY_CONFLICT');
|
||||
}
|
||||
version = previousKey.keyVersion + 1;
|
||||
}
|
||||
const recovery = await createE2EEVault(input.pin, material, input.did, version);
|
||||
const recoveryCommitment = toBase64Url(new Uint8Array(await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(canonicalize(recovery)),
|
||||
)));
|
||||
const bundle = {
|
||||
protocol: E2EE_PROTOCOL,
|
||||
keyId: material.keyId,
|
||||
version,
|
||||
publicKey: material.publicKey,
|
||||
createdAt: Date.now(),
|
||||
recoveryCommitment,
|
||||
...(input.replacesKeyId ? { replacesKeyId: input.replacesKeyId } : {}),
|
||||
} as const;
|
||||
const proof = await createSignedAction(
|
||||
E2EE_KEY_BUNDLE_ACTION,
|
||||
bundle,
|
||||
input.did,
|
||||
input.handle,
|
||||
);
|
||||
|
||||
const response = await fetch('/api/e2ee/vault', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
proof,
|
||||
recovery,
|
||||
...(input.currentPassword ? { currentPassword: input.currentPassword } : {}),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw await responseError(response, 'Encrypted messages were not set up');
|
||||
|
||||
try {
|
||||
await persistE2EEKeyMaterial(input.did, material);
|
||||
} catch (error) {
|
||||
// IndexedDB can be unavailable in private/restricted browser contexts. The
|
||||
// key remains usable in memory; this device will simply ask for the PIN again.
|
||||
console.warn('[E2EE] Could not remember this device:', error);
|
||||
}
|
||||
return material;
|
||||
}
|
||||
|
||||
export async function unlockE2EEAccount(
|
||||
did: string,
|
||||
pin: string,
|
||||
status: E2EEVaultStatus,
|
||||
): Promise<E2EEKeyMaterial> {
|
||||
if (!status.configured || !status.keyId || !status.keyVersion || !status.publicKey || !status.salt
|
||||
|| !status.kdfAlgorithm || !status.kdfOpsLimit || !status.kdfMemLimit) {
|
||||
throw new E2EEClientError('Encrypted message recovery is incomplete', 'E2EE_VAULT_INVALID');
|
||||
}
|
||||
|
||||
const prepared = await prepareE2EEVaultUnlock(pin, {
|
||||
salt: status.salt,
|
||||
kdfOpsLimit: status.kdfOpsLimit,
|
||||
kdfMemLimit: status.kdfMemLimit,
|
||||
});
|
||||
try {
|
||||
const response = await fetch('/api/e2ee/vault/unlock', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ownerDid: did,
|
||||
keyId: status.keyId,
|
||||
keyVersion: status.keyVersion,
|
||||
pinVerifier: prepared.pinVerifier,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await responseError(response, 'Encrypted messages could not be unlocked');
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
const vault = e2eeVaultRecordSchema.parse(body.vault);
|
||||
const material = await openE2EEVault(prepared, vault, body.serverShare);
|
||||
if (material.keyId !== status.keyId || material.publicKey !== status.publicKey) {
|
||||
throw new E2EEClientError('Recovered key does not match this account', 'E2EE_KEY_MISMATCH');
|
||||
}
|
||||
try {
|
||||
await persistE2EEKeyMaterial(did, material);
|
||||
} catch (error) {
|
||||
console.warn('[E2EE] Could not remember this device:', error);
|
||||
}
|
||||
return material;
|
||||
} finally {
|
||||
prepared.pinKey.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveE2EEPublicBundle(
|
||||
did: string,
|
||||
handle: string,
|
||||
): Promise<E2EEPublicBundleResponse> {
|
||||
const response = await fetch(
|
||||
`/api/e2ee/keys/resolve?did=${encodeURIComponent(did)}&handle=${encodeURIComponent(handle)}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!response.ok) throw await responseError(response, 'Recipient encryption key could not be verified');
|
||||
const result = e2eePublicBundleResponseSchema.parse(await response.json());
|
||||
if (!await verifyE2EEPublicBundle(result, did)) {
|
||||
throw new E2EEClientError('Recipient encryption key proof is invalid', 'E2EE_KEY_PROOF_INVALID');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface StoredChatMessage {
|
||||
protocolVersion: number;
|
||||
content?: string | null;
|
||||
encryptedEnvelope?: unknown;
|
||||
signedAction?: unknown;
|
||||
senderPublicKey?: string | null;
|
||||
}
|
||||
|
||||
export async function decryptStoredChatMessage(
|
||||
message: StoredChatMessage,
|
||||
userDid: string,
|
||||
material: E2EEKeyMaterial,
|
||||
): Promise<{ content: string; legacy: boolean }> {
|
||||
if (message.protocolVersion === 0) {
|
||||
return { content: message.content || '[Empty legacy message]', legacy: true };
|
||||
}
|
||||
|
||||
const envelope = e2eeMessageEnvelopeSchema.parse(message.encryptedEnvelope);
|
||||
const signedAction = signedUserActionSchema.parse(message.signedAction);
|
||||
validateMessageBindings(envelope, signedAction);
|
||||
if (canonicalize(signedAction.data) !== canonicalize(envelope)) {
|
||||
throw new E2EEClientError('Encrypted message envelope was altered', 'E2EE_MESSAGE_TAMPERED');
|
||||
}
|
||||
|
||||
const signingPublicKey = signingPublicKeyFromDid(signedAction.did) || message.senderPublicKey;
|
||||
if (!signingPublicKey || !await verifySignedActionSignature(signedAction, signingPublicKey)) {
|
||||
throw new E2EEClientError('Encrypted message signature is invalid', 'E2EE_MESSAGE_SIGNATURE_INVALID');
|
||||
}
|
||||
return {
|
||||
content: await decryptE2EEMessage(envelope, userDid, material),
|
||||
legacy: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import type { E2EEKeyMaterial } from './protocol';
|
||||
|
||||
const DB_NAME = 'synapsis-e2ee';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'account-keys';
|
||||
|
||||
interface PersistedKeyRecord {
|
||||
keyId: string;
|
||||
publicKey: string;
|
||||
wrappingKey: CryptoKey;
|
||||
ciphertext: ArrayBuffer;
|
||||
iv: ArrayBuffer;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readValue<T>(key: string): Promise<T | null> {
|
||||
const database = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, 'readonly');
|
||||
const request = transaction.objectStore(STORE_NAME).get(key);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve((request.result as T | undefined) ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
async function writeValue(key: string, value: unknown): Promise<void> {
|
||||
const database = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.objectStore(STORE_NAME).put(value, key);
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteValue(key: string): Promise<void> {
|
||||
const database = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.objectStore(STORE_NAME).delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
function wrappingKeyName(did: string): string {
|
||||
return `wrapping:${did}`;
|
||||
}
|
||||
|
||||
function materialName(did: string): string {
|
||||
return `material:${did}`;
|
||||
}
|
||||
|
||||
export async function persistE2EEKeyMaterial(did: string, material: E2EEKeyMaterial): Promise<void> {
|
||||
const existingRecord = await readValue<Partial<PersistedKeyRecord>>(materialName(did));
|
||||
const legacyWrappingKey = existingRecord?.wrappingKey
|
||||
? null
|
||||
: await readValue<CryptoKey>(wrappingKeyName(did));
|
||||
const wrappingKey = existingRecord?.wrappingKey
|
||||
|| legacyWrappingKey
|
||||
|| await crypto.subtle.generateKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(JSON.stringify(material));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(`${did}:${material.keyId}`) },
|
||||
wrappingKey,
|
||||
encoded,
|
||||
);
|
||||
|
||||
// The wrapping key and the payload it encrypts must be committed together.
|
||||
// If two first-time writes race, either complete record can win without
|
||||
// leaving one writer's ciphertext paired with the other writer's key.
|
||||
await writeValue(materialName(did), {
|
||||
keyId: material.keyId,
|
||||
publicKey: material.publicKey,
|
||||
wrappingKey,
|
||||
ciphertext,
|
||||
iv: iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer,
|
||||
updatedAt: Date.now(),
|
||||
} satisfies PersistedKeyRecord);
|
||||
}
|
||||
|
||||
export async function restoreE2EEKeyMaterial(did: string): Promise<E2EEKeyMaterial | null> {
|
||||
try {
|
||||
const record = await readValue<Partial<PersistedKeyRecord>>(materialName(did));
|
||||
const wrappingKey = record?.wrappingKey
|
||||
|| await readValue<CryptoKey>(wrappingKeyName(did));
|
||||
if (!wrappingKey || !record) return null;
|
||||
if (!record.keyId || !record.publicKey || !record.iv || !record.ciphertext) return null;
|
||||
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: new Uint8Array(record.iv),
|
||||
additionalData: new TextEncoder().encode(`${did}:${record.keyId}`),
|
||||
},
|
||||
wrappingKey,
|
||||
record.ciphertext,
|
||||
);
|
||||
const material = JSON.parse(new TextDecoder().decode(plaintext)) as E2EEKeyMaterial;
|
||||
if (material.keyId !== record.keyId || material.publicKey !== record.publicKey) return null;
|
||||
return material;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearE2EEKeyMaterial(did: string): Promise<void> {
|
||||
await Promise.all([
|
||||
deleteValue(materialName(did)),
|
||||
deleteValue(wrappingKeyName(did)),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { canonicalize } from '@/lib/crypto/user-signing';
|
||||
|
||||
export const E2EE_PROTOCOL = 'synapsis-e2ee-v1' as const;
|
||||
export const E2EE_PROTOCOL_VERSION = 1;
|
||||
export const E2EE_CIPHER_SUITE = 'x25519+xchacha20poly1305+blake2b-v1' as const;
|
||||
export const E2EE_KEY_BUNDLE_ACTION = 'e2ee_key_bundle' as const;
|
||||
export const E2EE_CHAT_ACTION = 'chat_e2ee' as const;
|
||||
export const E2EE_PIN_MIN_LENGTH = 6;
|
||||
export const E2EE_PIN_MAX_LENGTH = 12;
|
||||
export const E2EE_MAX_UNLOCK_ATTEMPTS = 10;
|
||||
export const E2EE_LOCKOUT_MS = 60 * 60 * 1000;
|
||||
|
||||
export const E2EE_KDF = {
|
||||
algorithm: 'argon2id13' as const,
|
||||
opsLimit: 2,
|
||||
memLimit: 64 * 1024 * 1024,
|
||||
};
|
||||
|
||||
const base64UrlSchema = z.string().min(1).max(24_000).regex(/^[A-Za-z0-9_-]+$/);
|
||||
const didSchema = z.string().min(8).max(2_048).regex(/^did:/);
|
||||
const handleSchema = z.string().min(1).max(320);
|
||||
const keyIdSchema = z.string().min(12).max(96).regex(/^k1_[A-Za-z0-9_-]+$/);
|
||||
|
||||
export const e2eeKeyBundleSchema = z.strictObject({
|
||||
protocol: z.literal(E2EE_PROTOCOL),
|
||||
keyId: keyIdSchema,
|
||||
version: z.number().int().positive().max(1_000_000),
|
||||
publicKey: base64UrlSchema.max(64),
|
||||
createdAt: z.number().int().positive(),
|
||||
recoveryCommitment: base64UrlSchema.max(64),
|
||||
replacesKeyId: keyIdSchema.optional(),
|
||||
}).superRefine((bundle, context) => {
|
||||
if (bundle.version === 1 && bundle.replacesKeyId) {
|
||||
context.addIssue({ code: 'custom', path: ['replacesKeyId'], message: 'Initial encryption keys cannot replace another key' });
|
||||
}
|
||||
if (bundle.version > 1 && !bundle.replacesKeyId) {
|
||||
context.addIssue({ code: 'custom', path: ['replacesKeyId'], message: 'Rotated encryption keys must name their predecessor' });
|
||||
}
|
||||
});
|
||||
|
||||
export type E2EEKeyBundle = z.infer<typeof e2eeKeyBundleSchema>;
|
||||
|
||||
export const signedUserActionSchema = z.strictObject({
|
||||
action: z.string().min(1).max(64),
|
||||
data: z.unknown(),
|
||||
did: didSchema,
|
||||
handle: handleSchema,
|
||||
ts: z.number().int().positive(),
|
||||
nonce: base64UrlSchema.max(128),
|
||||
sig: base64UrlSchema.max(256),
|
||||
});
|
||||
|
||||
export type SignedUserAction = z.infer<typeof signedUserActionSchema>;
|
||||
|
||||
export const e2eeKeyEnvelopeSchema = z.strictObject({
|
||||
did: didSchema,
|
||||
keyId: keyIdSchema,
|
||||
keyVersion: z.number().int().positive().max(1_000_000),
|
||||
sealedKey: base64UrlSchema.max(256),
|
||||
});
|
||||
|
||||
export const e2eeMessageEnvelopeSchema = z.strictObject({
|
||||
protocol: z.literal(E2EE_PROTOCOL),
|
||||
cipherSuite: z.literal(E2EE_CIPHER_SUITE),
|
||||
messageId: z.string().uuid(),
|
||||
conversationId: z.string().min(12).max(96).regex(/^dm1_[A-Za-z0-9_-]+$/),
|
||||
senderDid: didSchema,
|
||||
senderHandle: handleSchema,
|
||||
recipientDid: didSchema,
|
||||
recipientHandle: handleSchema,
|
||||
createdAt: z.number().int().positive(),
|
||||
senderKeyId: keyIdSchema,
|
||||
senderKeyVersion: z.number().int().positive().max(1_000_000),
|
||||
recipientKeyId: keyIdSchema,
|
||||
recipientKeyVersion: z.number().int().positive().max(1_000_000),
|
||||
nonce: base64UrlSchema.max(64),
|
||||
ciphertext: base64UrlSchema.max(16_000),
|
||||
keyCommitment: base64UrlSchema.max(64),
|
||||
keyEnvelopes: z.array(e2eeKeyEnvelopeSchema).min(1).max(2),
|
||||
});
|
||||
|
||||
export type E2EEMessageEnvelope = z.infer<typeof e2eeMessageEnvelopeSchema>;
|
||||
export type E2EEKeyEnvelope = z.infer<typeof e2eeKeyEnvelopeSchema>;
|
||||
|
||||
export const e2eeVaultRecordSchema = z.strictObject({
|
||||
protocol: z.literal(E2EE_PROTOCOL),
|
||||
ownerDid: didSchema,
|
||||
keyId: keyIdSchema,
|
||||
keyVersion: z.number().int().positive().max(1_000_000),
|
||||
publicKey: base64UrlSchema.max(64),
|
||||
ciphertext: base64UrlSchema.max(4_096),
|
||||
nonce: base64UrlSchema.max(64),
|
||||
salt: base64UrlSchema.max(64),
|
||||
kdfAlgorithm: z.literal(E2EE_KDF.algorithm),
|
||||
kdfOpsLimit: z.number().int().min(1).max(10),
|
||||
kdfMemLimit: z.number().int().min(8 * 1024 * 1024).max(256 * 1024 * 1024),
|
||||
});
|
||||
|
||||
export type E2EEVaultRecord = z.infer<typeof e2eeVaultRecordSchema>;
|
||||
|
||||
export const e2eeVaultSetupSchema = z.strictObject({
|
||||
vault: e2eeVaultRecordSchema,
|
||||
serverShare: base64UrlSchema.max(64),
|
||||
pinVerifier: base64UrlSchema.max(64),
|
||||
});
|
||||
|
||||
export type E2EEVaultSetup = z.infer<typeof e2eeVaultSetupSchema>;
|
||||
|
||||
export interface E2EEPublicBundleResponse {
|
||||
bundle: E2EEKeyBundle;
|
||||
proof: SignedUserAction;
|
||||
signingPublicKey: string;
|
||||
}
|
||||
|
||||
export const e2eePublicBundleResponseSchema = z.strictObject({
|
||||
bundle: e2eeKeyBundleSchema,
|
||||
proof: signedUserActionSchema,
|
||||
signingPublicKey: z.string().min(1).max(8_192),
|
||||
});
|
||||
|
||||
export interface E2EEKeyMaterial {
|
||||
keyId: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
export const e2eeVaultStatusSchema = z.discriminatedUnion('configured', [
|
||||
z.strictObject({
|
||||
ownerDid: didSchema,
|
||||
configured: z.literal(false),
|
||||
previousKey: z.strictObject({
|
||||
keyId: keyIdSchema,
|
||||
keyVersion: z.number().int().positive().max(1_000_000),
|
||||
}).optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
ownerDid: didSchema,
|
||||
configured: z.literal(true),
|
||||
keyId: keyIdSchema,
|
||||
keyVersion: z.number().int().positive().max(1_000_000),
|
||||
publicKey: base64UrlSchema.max(64),
|
||||
salt: base64UrlSchema.max(64),
|
||||
kdfAlgorithm: z.literal(E2EE_KDF.algorithm),
|
||||
kdfOpsLimit: z.number().int().min(1).max(10),
|
||||
kdfMemLimit: z.number().int().min(8 * 1024 * 1024).max(256 * 1024 * 1024),
|
||||
failedAttempts: z.number().int().min(0).max(E2EE_MAX_UNLOCK_ATTEMPTS),
|
||||
attemptsRemaining: z.number().int().min(0).max(E2EE_MAX_UNLOCK_ATTEMPTS),
|
||||
lockedUntil: z.string().datetime().nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type E2EEVaultStatus = z.infer<typeof e2eeVaultStatusSchema>;
|
||||
|
||||
export function messageAuthenticatedData(
|
||||
envelope: Pick<
|
||||
E2EEMessageEnvelope,
|
||||
| 'protocol'
|
||||
| 'cipherSuite'
|
||||
| 'messageId'
|
||||
| 'conversationId'
|
||||
| 'senderDid'
|
||||
| 'senderHandle'
|
||||
| 'recipientDid'
|
||||
| 'recipientHandle'
|
||||
| 'createdAt'
|
||||
| 'senderKeyId'
|
||||
| 'senderKeyVersion'
|
||||
| 'recipientKeyId'
|
||||
| 'recipientKeyVersion'
|
||||
>,
|
||||
): string {
|
||||
return canonicalize({
|
||||
protocol: envelope.protocol,
|
||||
cipherSuite: envelope.cipherSuite,
|
||||
messageId: envelope.messageId,
|
||||
conversationId: envelope.conversationId,
|
||||
senderDid: envelope.senderDid,
|
||||
senderHandle: envelope.senderHandle,
|
||||
recipientDid: envelope.recipientDid,
|
||||
recipientHandle: envelope.recipientHandle,
|
||||
createdAt: envelope.createdAt,
|
||||
senderKeyId: envelope.senderKeyId,
|
||||
senderKeyVersion: envelope.senderKeyVersion,
|
||||
recipientKeyId: envelope.recipientKeyId,
|
||||
recipientKeyVersion: envelope.recipientKeyVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export function vaultAuthenticatedData(
|
||||
record: Pick<
|
||||
E2EEVaultRecord,
|
||||
'protocol' | 'ownerDid' | 'keyId' | 'keyVersion' | 'publicKey' | 'salt' | 'kdfAlgorithm' | 'kdfOpsLimit' | 'kdfMemLimit'
|
||||
>,
|
||||
): string {
|
||||
return canonicalize({
|
||||
protocol: record.protocol,
|
||||
ownerDid: record.ownerDid,
|
||||
keyId: record.keyId,
|
||||
keyVersion: record.keyVersion,
|
||||
publicKey: record.publicKey,
|
||||
salt: record.salt,
|
||||
kdfAlgorithm: record.kdfAlgorithm,
|
||||
kdfOpsLimit: record.kdfOpsLimit,
|
||||
kdfMemLimit: record.kdfMemLimit,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateMessageBindings(
|
||||
envelope: E2EEMessageEnvelope,
|
||||
signedAction: Pick<SignedUserAction, 'action' | 'did' | 'handle' | 'ts'>,
|
||||
): void {
|
||||
if (signedAction.action !== E2EE_CHAT_ACTION) {
|
||||
throw new Error('Encrypted message action is invalid');
|
||||
}
|
||||
if (envelope.senderDid !== signedAction.did || envelope.senderHandle !== signedAction.handle) {
|
||||
throw new Error('Encrypted message sender does not match its signature');
|
||||
}
|
||||
// A sender may re-sign the same prepared envelope after an ambiguous network
|
||||
// failure so its stable messageId remains idempotent. Old envelope timestamps
|
||||
// are therefore valid; only future-dated envelopes are rejected.
|
||||
if (envelope.createdAt > signedAction.ts + 5 * 60 * 1000) {
|
||||
throw new Error('Encrypted message timestamp is too far in the future');
|
||||
}
|
||||
|
||||
const uniqueRecipients = new Set(envelope.keyEnvelopes.map((item) => item.did));
|
||||
if (uniqueRecipients.size !== envelope.keyEnvelopes.length) {
|
||||
throw new Error('Encrypted message contains duplicate key envelopes');
|
||||
}
|
||||
|
||||
const senderEnvelope = envelope.keyEnvelopes.find((item) => item.did === envelope.senderDid);
|
||||
const recipientEnvelope = envelope.keyEnvelopes.find((item) => item.did === envelope.recipientDid);
|
||||
if (!senderEnvelope || senderEnvelope.keyId !== envelope.senderKeyId
|
||||
|| senderEnvelope.keyVersion !== envelope.senderKeyVersion) {
|
||||
throw new Error('Encrypted message is missing the sender key envelope');
|
||||
}
|
||||
if (!recipientEnvelope || recipientEnvelope.keyId !== envelope.recipientKeyId
|
||||
|| recipientEnvelope.keyVersion !== envelope.recipientKeyVersion) {
|
||||
throw new Error('Encrypted message is missing the recipient key envelope');
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePin(pin: string): void {
|
||||
const pinPattern = new RegExp(`^\\d{${E2EE_PIN_MIN_LENGTH},${E2EE_PIN_MAX_LENGTH}}$`);
|
||||
if (!pinPattern.test(pin)) {
|
||||
throw new Error(`PIN must contain ${E2EE_PIN_MIN_LENGTH}-${E2EE_PIN_MAX_LENGTH} digits`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function recoverySecret(): string {
|
||||
const secret = process.env.E2EE_RECOVERY_SECRET;
|
||||
if (!secret || secret.length < 32 || secret === 'replace-with-a-separate-long-random-secret') {
|
||||
throw new Error('A high-entropy E2EE_RECOVERY_SECRET is required for encrypted message recovery');
|
||||
}
|
||||
if (secret === process.env.AUTH_SECRET) {
|
||||
throw new Error('E2EE_RECOVERY_SECRET must be independent of AUTH_SECRET');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function encryptionKey(): Buffer {
|
||||
return crypto.createHash('sha256').update(`synapsis:e2ee:server-share:v1:${recoverySecret()}`).digest();
|
||||
}
|
||||
|
||||
function verifierKey(): Buffer {
|
||||
return crypto.createHash('sha256').update(`synapsis:e2ee:pin-verifier:v1:${recoverySecret()}`).digest();
|
||||
}
|
||||
|
||||
export function sealServerShare(value: string, userId: string, keyId: string): string {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv);
|
||||
cipher.setAAD(Buffer.from(`${userId}:${keyId}`));
|
||||
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64url');
|
||||
}
|
||||
|
||||
export function openServerShare(value: string, userId: string, keyId: string): string {
|
||||
const raw = Buffer.from(value, 'base64url');
|
||||
if (raw.length < 29) throw new Error('Invalid encrypted recovery share');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), raw.subarray(0, 12));
|
||||
decipher.setAAD(Buffer.from(`${userId}:${keyId}`));
|
||||
decipher.setAuthTag(raw.subarray(12, 28));
|
||||
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
export function createPinVerifierMac(pinVerifier: string, userId: string, keyId: string): string {
|
||||
return crypto
|
||||
.createHmac('sha256', verifierKey())
|
||||
.update(`${userId}:${keyId}:`)
|
||||
.update(pinVerifier)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
export function pinVerifierMatches(
|
||||
candidate: string,
|
||||
expectedMac: string,
|
||||
userId: string,
|
||||
keyId: string,
|
||||
): boolean {
|
||||
const actual = Buffer.from(createPinVerifierMac(candidate, userId, keyId), 'base64url');
|
||||
const expected = Buffer.from(expectedMac, 'base64url');
|
||||
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
fetchE2EEVaultStatus,
|
||||
provisionE2EEAccount,
|
||||
unlockE2EEAccount,
|
||||
type E2EEClientError,
|
||||
} from './client';
|
||||
import { restoreE2EEKeyMaterial } from './local-key-store';
|
||||
import type { E2EEKeyMaterial, E2EEVaultStatus } from './protocol';
|
||||
|
||||
type ConfiguredVaultStatus = Extract<E2EEVaultStatus, { configured: true }>;
|
||||
|
||||
function requireConfiguredVault(status: E2EEVaultStatus): ConfiguredVaultStatus {
|
||||
if (!status.configured) {
|
||||
throw new Error('Encrypted message vault is not configured');
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
export type E2EEIdentityState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'setup_required'; previousKey?: { keyId: string; keyVersion: number } }
|
||||
| { status: 'locked'; vault: ConfiguredVaultStatus }
|
||||
| { status: 'ready'; material: E2EEKeyMaterial; vault: ConfiguredVaultStatus }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
export function useE2EEIdentity(did?: string | null, handle?: string | null) {
|
||||
const [state, setState] = useState<E2EEIdentityState>({ status: 'loading' });
|
||||
const [stateOwnerDid, setStateOwnerDid] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const channelSourceRef = useRef<string | null>(null);
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
const generation = ++generationRef.current;
|
||||
if (!did) {
|
||||
setState({ status: 'loading' });
|
||||
setStateOwnerDid(null);
|
||||
setBusy(false);
|
||||
setActionError(null);
|
||||
return;
|
||||
}
|
||||
setState({ status: 'loading' });
|
||||
setStateOwnerDid(null);
|
||||
setBusy(false);
|
||||
setActionError(null);
|
||||
try {
|
||||
const vault = await fetchE2EEVaultStatus(did);
|
||||
if (generation !== generationRef.current) return;
|
||||
if (!vault.configured) {
|
||||
setStateOwnerDid(did);
|
||||
setState({ status: 'setup_required', previousKey: vault.previousKey });
|
||||
return;
|
||||
}
|
||||
|
||||
const local = await restoreE2EEKeyMaterial(did);
|
||||
if (generation !== generationRef.current) return;
|
||||
if (local && local.keyId === vault.keyId && local.publicKey === vault.publicKey) {
|
||||
setStateOwnerDid(did);
|
||||
setState({ status: 'ready', material: local, vault });
|
||||
return;
|
||||
}
|
||||
setStateOwnerDid(did);
|
||||
setState({ status: 'locked', vault });
|
||||
} catch (error) {
|
||||
if (generation !== generationRef.current) return;
|
||||
setStateOwnerDid(did);
|
||||
setState({ status: 'error', message: error instanceof Error ? error.message : 'Encrypted messages could not be loaded' });
|
||||
}
|
||||
}, [did]);
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!did || typeof BroadcastChannel === 'undefined') return;
|
||||
channelSourceRef.current ??= crypto.randomUUID();
|
||||
const channel = new BroadcastChannel('synapsis-e2ee');
|
||||
channel.onmessage = (event) => {
|
||||
if (event.data?.did === did && event.data?.type === 'key-updated'
|
||||
&& event.data?.source !== channelSourceRef.current) {
|
||||
void bootstrap();
|
||||
}
|
||||
};
|
||||
return () => channel.close();
|
||||
}, [did, bootstrap]);
|
||||
|
||||
const broadcastUpdate = useCallback(() => {
|
||||
if (!did || typeof BroadcastChannel === 'undefined') return;
|
||||
const channel = new BroadcastChannel('synapsis-e2ee');
|
||||
channel.postMessage({ type: 'key-updated', did, source: channelSourceRef.current });
|
||||
channel.close();
|
||||
}, [did]);
|
||||
|
||||
const setup = useCallback(async (pin: string) => {
|
||||
if (!did || !handle) return;
|
||||
const generation = ++generationRef.current;
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const material = await provisionE2EEAccount({
|
||||
did,
|
||||
handle,
|
||||
pin,
|
||||
...(state.status === 'setup_required' && state.previousKey
|
||||
? { replacesKeyId: state.previousKey.keyId }
|
||||
: {}),
|
||||
});
|
||||
const vault = requireConfiguredVault(await fetchE2EEVaultStatus(did));
|
||||
broadcastUpdate();
|
||||
if (generation !== generationRef.current) return;
|
||||
setStateOwnerDid(did);
|
||||
setState({ status: 'ready', material, vault });
|
||||
} catch (error) {
|
||||
if (generation !== generationRef.current) return;
|
||||
setActionError(error instanceof Error ? error.message : 'Encrypted messages were not set up');
|
||||
throw error;
|
||||
} finally {
|
||||
if (generation === generationRef.current) setBusy(false);
|
||||
}
|
||||
}, [did, handle, state, broadcastUpdate]);
|
||||
|
||||
const unlock = useCallback(async (pin: string) => {
|
||||
if (!did || state.status !== 'locked') return;
|
||||
const generation = ++generationRef.current;
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const material = await unlockE2EEAccount(did, pin, state.vault);
|
||||
const vault = requireConfiguredVault(await fetchE2EEVaultStatus(did));
|
||||
broadcastUpdate();
|
||||
if (generation !== generationRef.current) return;
|
||||
setStateOwnerDid(did);
|
||||
setState({ status: 'ready', material, vault });
|
||||
} catch (error) {
|
||||
if (generation !== generationRef.current) return;
|
||||
const clientError = error as E2EEClientError;
|
||||
setActionError(clientError.message || 'Encrypted messages could not be unlocked');
|
||||
if (clientError.details?.lockedUntil || clientError.details?.attemptsRemaining !== undefined) {
|
||||
setState({
|
||||
status: 'locked',
|
||||
vault: {
|
||||
...state.vault,
|
||||
lockedUntil: typeof clientError.details.lockedUntil === 'string'
|
||||
? clientError.details.lockedUntil
|
||||
: state.vault.lockedUntil,
|
||||
attemptsRemaining: typeof clientError.details.attemptsRemaining === 'number'
|
||||
? clientError.details.attemptsRemaining
|
||||
: state.vault.attemptsRemaining,
|
||||
},
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (generation === generationRef.current) setBusy(false);
|
||||
}
|
||||
}, [did, state, broadcastUpdate]);
|
||||
|
||||
const reset = useCallback(async (pin: string, currentPassword: string) => {
|
||||
if (!did || !handle || state.status !== 'locked' || !state.vault.keyId) return;
|
||||
const generation = ++generationRef.current;
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const material = await provisionE2EEAccount({
|
||||
did,
|
||||
handle,
|
||||
pin,
|
||||
replacesKeyId: state.vault.keyId,
|
||||
currentPassword,
|
||||
});
|
||||
const vault = requireConfiguredVault(await fetchE2EEVaultStatus(did));
|
||||
broadcastUpdate();
|
||||
if (generation !== generationRef.current) return;
|
||||
setStateOwnerDid(did);
|
||||
setState({ status: 'ready', material, vault });
|
||||
} catch (error) {
|
||||
if (generation !== generationRef.current) return;
|
||||
setActionError(error instanceof Error ? error.message : 'Encrypted messages were not reset');
|
||||
throw error;
|
||||
} finally {
|
||||
if (generation === generationRef.current) setBusy(false);
|
||||
}
|
||||
}, [did, handle, state, broadcastUpdate]);
|
||||
|
||||
const visibleState: E2EEIdentityState = did && stateOwnerDid === did
|
||||
? state
|
||||
: { status: 'loading' };
|
||||
|
||||
return { state: visibleState, busy, actionError, setup, unlock, reset, retry: bootstrap };
|
||||
}
|
||||
@@ -6,21 +6,16 @@
|
||||
* across page refreshes and tabs.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
|
||||
import {
|
||||
persistUnlockedKey,
|
||||
tryRestoreKey,
|
||||
clearPersistentKey,
|
||||
hasPersistentKey,
|
||||
} from '@/lib/crypto/key-persistence';
|
||||
import {
|
||||
keyStore,
|
||||
importPrivateKey,
|
||||
generateKeyPair,
|
||||
exportPrivateKey,
|
||||
exportPublicKey,
|
||||
base64UrlToBase64,
|
||||
createSignedAction
|
||||
} from '@/lib/crypto/user-signing';
|
||||
|
||||
@@ -35,10 +30,12 @@ export function useUserIdentity() {
|
||||
const [identity, setIdentity] = useState<UserIdentity | null>(null);
|
||||
const [isUnlocked, setIsUnlocked] = useState(false);
|
||||
const [isRestoring, setIsRestoring] = useState(true);
|
||||
const generationRef = useRef(0);
|
||||
|
||||
// Check status on mount and try to restore from persistence
|
||||
useEffect(() => {
|
||||
const checkAndRestore = async () => {
|
||||
const generation = ++generationRef.current;
|
||||
setIsRestoring(true);
|
||||
try {
|
||||
// First check if already in memory (hot reload scenario)
|
||||
@@ -58,9 +55,13 @@ export function useUserIdentity() {
|
||||
|
||||
// Try to restore from persistent storage
|
||||
const restoredKeyBytes = await tryRestoreKey(globalIdentity.did);
|
||||
if (generation !== generationRef.current
|
||||
|| keyStore.getIdentity()?.did !== globalIdentity.did) return;
|
||||
if (restoredKeyBytes) {
|
||||
// Import the restored key as non-extractable
|
||||
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
||||
if (generation !== generationRef.current
|
||||
|| keyStore.getIdentity()?.did !== globalIdentity.did) return;
|
||||
keyStore.setPrivateKey(cryptoKey);
|
||||
setIdentity({ ...globalIdentity, isUnlocked: true });
|
||||
setIsUnlocked(true);
|
||||
@@ -73,7 +74,7 @@ export function useUserIdentity() {
|
||||
} catch (error) {
|
||||
console.error('[Identity] Error during restore:', error);
|
||||
} finally {
|
||||
setIsRestoring(false);
|
||||
if (generation === generationRef.current) setIsRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,6 +91,7 @@ export function useUserIdentity() {
|
||||
publicKey: string;
|
||||
privateKeyEncrypted?: string;
|
||||
}) => {
|
||||
const generation = ++generationRef.current;
|
||||
keyStore.clear();
|
||||
|
||||
const coreIdentity = {
|
||||
@@ -98,17 +100,26 @@ export function useUserIdentity() {
|
||||
publicKey: userData.publicKey
|
||||
};
|
||||
keyStore.setIdentity(coreIdentity);
|
||||
|
||||
// Try to auto-restore if we have persisted key
|
||||
const restoredKeyBytes = await tryRestoreKey(userData.did);
|
||||
if (restoredKeyBytes) {
|
||||
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
||||
keyStore.setPrivateKey(cryptoKey);
|
||||
setIdentity({ ...coreIdentity, isUnlocked: true });
|
||||
setIsUnlocked(true);
|
||||
} else {
|
||||
setIdentity({ ...coreIdentity, isUnlocked: false });
|
||||
setIsUnlocked(false);
|
||||
setIdentity({ ...coreIdentity, isUnlocked: false });
|
||||
setIsUnlocked(false);
|
||||
setIsRestoring(true);
|
||||
|
||||
try {
|
||||
// Try to auto-restore if we have persisted key. Every async boundary is
|
||||
// account-bound so an older refresh cannot overwrite a newer switch.
|
||||
const restoredKeyBytes = await tryRestoreKey(userData.did);
|
||||
if (generation !== generationRef.current
|
||||
|| keyStore.getIdentity()?.did !== userData.did) return;
|
||||
if (restoredKeyBytes) {
|
||||
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
||||
if (generation !== generationRef.current
|
||||
|| keyStore.getIdentity()?.did !== userData.did) return;
|
||||
keyStore.setPrivateKey(cryptoKey);
|
||||
setIdentity({ ...coreIdentity, isUnlocked: true });
|
||||
setIsUnlocked(true);
|
||||
}
|
||||
} finally {
|
||||
if (generation === generationRef.current) setIsRestoring(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -124,6 +135,8 @@ export function useUserIdentity() {
|
||||
userPublicKey?: string
|
||||
) => {
|
||||
try {
|
||||
const generation = ++generationRef.current;
|
||||
setIsRestoring(false);
|
||||
console.log('[Identity] Unlocking with DID:', userDid, 'Handle:', userHandle);
|
||||
|
||||
// Set identity first if provided
|
||||
@@ -151,6 +164,12 @@ export function useUserIdentity() {
|
||||
const binaryDer = Buffer.from(privateKeyBase64, 'base64');
|
||||
const cryptoKey = await importPrivateKey(binaryDer);
|
||||
|
||||
if (generation !== generationRef.current
|
||||
|| !userDid
|
||||
|| keyStore.getIdentity()?.did !== userDid) {
|
||||
throw new Error('Active account changed while the identity was unlocking');
|
||||
}
|
||||
|
||||
// Store in memory
|
||||
keyStore.setPrivateKey(cryptoKey);
|
||||
|
||||
@@ -161,6 +180,8 @@ export function useUserIdentity() {
|
||||
}
|
||||
|
||||
await persistUnlockedKey(privateKeyBase64, password, userDid);
|
||||
if (generation !== generationRef.current
|
||||
|| keyStore.getIdentity()?.did !== userDid) return;
|
||||
|
||||
console.log('[Identity] Private key stored in memory and persisted');
|
||||
|
||||
@@ -181,28 +202,34 @@ export function useUserIdentity() {
|
||||
* Lock the identity (manual lock, keeps identity info)
|
||||
*/
|
||||
const lockIdentity = useCallback(async () => {
|
||||
const generation = ++generationRef.current;
|
||||
const identifier = keyStore.getIdentity()?.did;
|
||||
keyStore.clear();
|
||||
await clearPersistentKey(identifier);
|
||||
setIsUnlocked(false);
|
||||
setIsRestoring(false);
|
||||
setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null);
|
||||
await clearPersistentKey(identifier);
|
||||
if (generation !== generationRef.current) return;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear the identity (logout)
|
||||
*/
|
||||
const clearIdentity = useCallback(async () => {
|
||||
const generation = ++generationRef.current;
|
||||
const identifier = keyStore.getIdentity()?.did;
|
||||
keyStore.clear();
|
||||
await clearPersistentKey(identifier);
|
||||
setIdentity(null);
|
||||
setIsUnlocked(false);
|
||||
setIsRestoring(false);
|
||||
await clearPersistentKey(identifier);
|
||||
if (generation !== generationRef.current) return;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Sign a user action
|
||||
*/
|
||||
const signUserAction = useCallback(async (action: string, data: any) => {
|
||||
const signUserAction = useCallback(async (action: string, data: unknown) => {
|
||||
const pk = keyStore.getPrivateKey();
|
||||
const id = keyStore.getIdentity();
|
||||
|
||||
|
||||
@@ -13,6 +13,20 @@ const rateLimits = new Map<string, number[]>();
|
||||
// Configuration for cleanup
|
||||
const CLEANUP_INTERVAL_MS = 60 * 1000; // Run cleanup every minute
|
||||
const MAX_IDLE_DID_MS = 10 * 60 * 1000; // Remove DIDs with no recent activity after 10 minutes
|
||||
const MAX_RATE_LIMIT_KEYS = 10_000;
|
||||
|
||||
function evictOldestKey(): void {
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTimestamp = Number.POSITIVE_INFINITY;
|
||||
for (const [key, timestamps] of rateLimits) {
|
||||
const newest = timestamps.at(-1) ?? 0;
|
||||
if (newest < oldestTimestamp) {
|
||||
oldestTimestamp = newest;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey) rateLimits.delete(oldestKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a DID is rate limited
|
||||
@@ -23,6 +37,7 @@ const MAX_IDLE_DID_MS = 10 * 60 * 1000; // Remove DIDs with no recent activity a
|
||||
*/
|
||||
export function isRateLimited(did: string, maxRequests = 5, windowMs = 60000): boolean {
|
||||
const now = Date.now();
|
||||
if (!rateLimits.has(did) && rateLimits.size >= MAX_RATE_LIMIT_KEYS) evictOldestKey();
|
||||
const timestamps = rateLimits.get(did) || [];
|
||||
|
||||
// Filter to only keep timestamps within the window (sliding window)
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* Handles node discovery and announcement in the swarm network.
|
||||
*/
|
||||
|
||||
import { db, nodes, users, posts } from '@/db';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { db, users, posts } from '@/db';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
|
||||
import { getCurrentBuildInfo } from '@/lib/version';
|
||||
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
|
||||
@@ -55,7 +55,7 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
|
||||
postCount = Number(postResult[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
const capabilities: SwarmCapability[] = ['handles', 'gossip', 'interactions'];
|
||||
const capabilities: SwarmCapability[] = ['handles', 'gossip', 'interactions', 'e2ee_dm_v1'];
|
||||
|
||||
return {
|
||||
domain,
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import { getActiveSwarmNodes } from './registry';
|
||||
import type { SwarmNodeInfo } from './types';
|
||||
import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
||||
import { getPublicSwarmDomain } from './node-domain';
|
||||
import { safeFederationRequest } from './safe-federation-http';
|
||||
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
||||
|
||||
// ============================================
|
||||
@@ -390,6 +392,49 @@ export interface SwarmProfileResponse {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const DEVELOPMENT_LOOPBACK_DOMAIN =
|
||||
/^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?$/i;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSwarmUserPost(value: unknown): value is SwarmUserPost {
|
||||
if (!isRecord(value)) return false;
|
||||
|
||||
return (
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.content === 'string' &&
|
||||
typeof value.createdAt === 'string' &&
|
||||
typeof value.isNsfw === 'boolean' &&
|
||||
typeof value.likesCount === 'number' &&
|
||||
typeof value.repostsCount === 'number' &&
|
||||
typeof value.repliesCount === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
function isSwarmProfileResponse(value: unknown): value is SwarmProfileResponse {
|
||||
if (!isRecord(value) || !isRecord(value.profile) || !Array.isArray(value.posts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const profile = value.profile;
|
||||
return (
|
||||
typeof profile.handle === 'string' &&
|
||||
typeof profile.displayName === 'string' &&
|
||||
typeof profile.followersCount === 'number' &&
|
||||
typeof profile.followingCount === 'number' &&
|
||||
typeof profile.postsCount === 'number' &&
|
||||
typeof profile.createdAt === 'string' &&
|
||||
typeof profile.isNsfw === 'boolean' &&
|
||||
typeof profile.nodeIsNsfw === 'boolean' &&
|
||||
typeof profile.nodeDomain === 'string' &&
|
||||
typeof value.nodeDomain === 'string' &&
|
||||
typeof value.timestamp === 'string' &&
|
||||
value.posts.every(isSwarmUserPost)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a user profile from a swarm node
|
||||
*/
|
||||
@@ -401,33 +446,51 @@ export async function fetchSwarmUserProfile(
|
||||
): Promise<SwarmProfileResponse | null> {
|
||||
try {
|
||||
const normalizedDomain = normalizeNodeDomain(domain);
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
const publicDomain = getPublicSwarmDomain(normalizedDomain);
|
||||
const developmentDomain =
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
DEVELOPMENT_LOOPBACK_DOMAIN.test(normalizedDomain)
|
||||
? normalizedDomain
|
||||
: null;
|
||||
const targetDomain = publicDomain ?? developmentDomain;
|
||||
const cleanHandle = handle.trim().replace(/^@/, '').toLowerCase();
|
||||
|
||||
if (
|
||||
!targetDomain ||
|
||||
!/^[a-z0-9_]{1,64}$/.test(cleanHandle) ||
|
||||
(await isNodeBlocked(targetDomain))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = domain.startsWith('http')
|
||||
? domain
|
||||
: normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')
|
||||
? `http://${normalizedDomain}`
|
||||
: `https://${normalizedDomain}`;
|
||||
const baseUrl = developmentDomain
|
||||
? `http://${targetDomain}`
|
||||
: `https://${targetDomain}`;
|
||||
const url = new URL(`/api/swarm/users/${encodeURIComponent(cleanHandle)}`, baseUrl);
|
||||
url.searchParams.set(
|
||||
'limit',
|
||||
String(Number.isSafeInteger(postsLimit) ? Math.min(Math.max(postsLimit, 0), 50) : 25)
|
||||
);
|
||||
if (cursor) url.searchParams.set('cursor', cursor.slice(0, 128));
|
||||
|
||||
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await safeFederationRequest(url.toString(), {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: controller.signal,
|
||||
maxResponseBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const payload = response.json();
|
||||
if (
|
||||
!isSwarmProfileResponse(payload) ||
|
||||
payload.profile.handle.toLowerCase() !== cleanHandle
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error(`[Swarm] Failed to fetch profile for ${handle}@${domain}:`, error);
|
||||
return null;
|
||||
@@ -452,7 +515,6 @@ export async function cacheSwarmUserPosts(
|
||||
}
|
||||
|
||||
const { db, remotePosts } = await import('@/db');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
|
||||
if (!db) {
|
||||
return { cached: 0, skipped: 0 };
|
||||
@@ -672,8 +734,7 @@ export async function deliverSwarmPost(
|
||||
*/
|
||||
export async function getSwarmFollowerDomains(userId: string): Promise<string[]> {
|
||||
try {
|
||||
const { db, remoteFollowers } = await import('@/db');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
const { db } = await import('@/db');
|
||||
|
||||
if (!db) return [];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
E2EE_FEDERATION_MAX_REQUEST_BYTES,
|
||||
SafeFederationError,
|
||||
createSafeFederationRequester,
|
||||
isPublicFederationAddress,
|
||||
@@ -200,6 +201,17 @@ describe('E2EE federation URL and DNS policy', () => {
|
||||
});
|
||||
|
||||
describe('E2EE federation HTTP behavior', () => {
|
||||
it('rejects an oversized request body before DNS or network activity', async () => {
|
||||
const dnsResolver = vi.fn(async () => [{ address: '8.8.8.8', family: 4 as const }]);
|
||||
const request = createSafeFederationRequester({ development: false, dnsResolver });
|
||||
|
||||
await expect(request('https://node.synapsis.social/api/chat/receive', {
|
||||
method: 'POST',
|
||||
body: 'x'.repeat(E2EE_FEDERATION_MAX_REQUEST_BYTES + 1),
|
||||
})).rejects.toSatisfy(expectCode('REQUEST_TOO_LARGE'));
|
||||
expect(dnsResolver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supports GET, bounded buffering, UTF-8 text, and JSON parsing', async () => {
|
||||
const server = await startServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
||||
@@ -338,5 +350,11 @@ describe('E2EE federation HTTP behavior', () => {
|
||||
await expect(
|
||||
request('http://127.0.0.1:1/', { headers: { 'content-length': '999' } })
|
||||
).rejects.toSatisfy(expectCode('INVALID_HEADER'));
|
||||
await expect(
|
||||
request('http://127.0.0.1:1/', { headers: { authorization: 'Bearer secret' } })
|
||||
).rejects.toSatisfy(expectCode('INVALID_HEADER'));
|
||||
await expect(
|
||||
request('http://127.0.0.1:1/', { headers: { cookie: 'session=secret' } })
|
||||
).rejects.toSatisfy(expectCode('INVALID_HEADER'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,10 +20,14 @@ const EXACT_DEVELOPMENT_LOOPBACK_URL =
|
||||
/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?(?:[/?#]|$)/i;
|
||||
const FORBIDDEN_REQUEST_HEADERS = new Set([
|
||||
'accept-encoding',
|
||||
'authorization',
|
||||
'connection',
|
||||
'content-length',
|
||||
'cookie',
|
||||
'expect',
|
||||
'host',
|
||||
'proxy-authorization',
|
||||
'proxy-connection',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
|
||||
+91
-29
@@ -10,13 +10,41 @@ import crypto from 'crypto';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { canonicalize } from '@/lib/crypto/user-signing';
|
||||
import { isNodeBlocked } from './node-blocklist';
|
||||
import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
||||
import { getPublicSwarmDomain } from './node-domain';
|
||||
import { safeFederationRequest } from './safe-federation-http';
|
||||
|
||||
const NODE_PUBLIC_KEY_CACHE_TTL_MS = 60_000;
|
||||
const MAX_NODE_PUBLIC_KEY_CACHE_ENTRIES = 1_000;
|
||||
const nodePublicKeyCache = new Map<string, { publicKey: string; expiresAt: number }>();
|
||||
const pendingNodePublicKeyRequests = new Map<string, Promise<string | null>>();
|
||||
|
||||
function cacheNodePublicKey(domain: string, publicKey: string): void {
|
||||
if (!nodePublicKeyCache.has(domain)
|
||||
&& nodePublicKeyCache.size >= MAX_NODE_PUBLIC_KEY_CACHE_ENTRIES) {
|
||||
const oldest = nodePublicKeyCache.keys().next().value as string | undefined;
|
||||
if (oldest) nodePublicKeyCache.delete(oldest);
|
||||
}
|
||||
nodePublicKeyCache.set(domain, {
|
||||
publicKey,
|
||||
expiresAt: Date.now() + NODE_PUBLIC_KEY_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveFederationDomain(domain: string): { domain: string; protocol: 'http' | 'https' } | null {
|
||||
const normalized = normalizeNodeDomain(domain);
|
||||
const developmentLoopback = process.env.NODE_ENV === 'development'
|
||||
&& /^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?$/i.test(normalized);
|
||||
if (developmentLoopback) return { domain: normalized, protocol: 'http' };
|
||||
|
||||
const publicDomain = getPublicSwarmDomain(normalized);
|
||||
return publicDomain ? { domain: publicDomain, protocol: 'https' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a payload with the node's private key
|
||||
*/
|
||||
export function signPayload(payload: any, privateKey: string): string {
|
||||
export function signPayload(payload: unknown, privateKey: string): string {
|
||||
const canonicalPayload = canonicalize(payload);
|
||||
const sign = crypto.createSign('SHA256');
|
||||
sign.update(canonicalPayload);
|
||||
@@ -40,7 +68,7 @@ function normalizePublicKey(publicKey: string): crypto.KeyObject | string {
|
||||
/**
|
||||
* Verify a signature using the sender's public key
|
||||
*/
|
||||
export function verifySignature(payload: any, signature: string, publicKey: string): boolean {
|
||||
export function verifySignature(payload: unknown, signature: string, publicKey: string): boolean {
|
||||
try {
|
||||
const canonicalPayload = canonicalize(payload);
|
||||
const verify = crypto.createVerify('SHA256');
|
||||
@@ -58,29 +86,51 @@ export function verifySignature(payload: any, signature: string, publicKey: stri
|
||||
*/
|
||||
export async function getNodePublicKey(domain: string): Promise<string | null> {
|
||||
try {
|
||||
const normalizedDomain = getPublicSwarmDomain(domain);
|
||||
if (!normalizedDomain) {
|
||||
const target = resolveFederationDomain(domain);
|
||||
if (!target) {
|
||||
console.warn(`[Signature] Refusing public key fetch for non-public node ${domain}`);
|
||||
return null;
|
||||
}
|
||||
const normalizedDomain = target.domain;
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we have a cached node info
|
||||
const response = await fetch(`https://${normalizedDomain}/api/node`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${response.status}`);
|
||||
return null;
|
||||
const cached = nodePublicKeyCache.get(normalizedDomain);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
nodePublicKeyCache.delete(normalizedDomain);
|
||||
nodePublicKeyCache.set(normalizedDomain, cached);
|
||||
return cached.publicKey;
|
||||
}
|
||||
if (cached) nodePublicKeyCache.delete(normalizedDomain);
|
||||
|
||||
const data = await response.json();
|
||||
return data.publicKey || null;
|
||||
const pending = pendingNodePublicKeyRequests.get(normalizedDomain);
|
||||
if (pending) return await pending;
|
||||
|
||||
const keyRequest = (async (): Promise<string | null> => {
|
||||
const response = await safeFederationRequest(`${target.protocol}://${normalizedDomain}/api/node`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
timeoutMs: 5_000,
|
||||
maxResponseBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = response.json() as { publicKey?: unknown };
|
||||
const publicKey = typeof data.publicKey === 'string' ? data.publicKey : null;
|
||||
if (publicKey) cacheNodePublicKey(normalizedDomain, publicKey);
|
||||
return publicKey;
|
||||
})();
|
||||
pendingNodePublicKeyRequests.set(normalizedDomain, keyRequest);
|
||||
try {
|
||||
return await keyRequest;
|
||||
} finally {
|
||||
pendingNodePublicKeyRequests.delete(normalizedDomain);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Signature] Error fetching public key from ${domain}:`, error);
|
||||
return null;
|
||||
@@ -96,15 +146,16 @@ export async function getNodePublicKey(domain: string): Promise<string | null> {
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
export async function verifySwarmRequest(
|
||||
payload: any,
|
||||
payload: unknown,
|
||||
signature: string,
|
||||
senderDomain: string
|
||||
): Promise<boolean> {
|
||||
const normalizedDomain = getPublicSwarmDomain(senderDomain);
|
||||
if (!normalizedDomain) {
|
||||
const target = resolveFederationDomain(senderDomain);
|
||||
if (!target) {
|
||||
console.warn(`[Signature] Rejected non-public swarm node ${senderDomain}`);
|
||||
return false;
|
||||
}
|
||||
const normalizedDomain = target.domain;
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
|
||||
return false;
|
||||
@@ -134,17 +185,18 @@ export async function verifySwarmRequest(
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
export async function verifyUserInteraction(
|
||||
payload: any,
|
||||
payload: unknown,
|
||||
signature: string,
|
||||
userHandle: string,
|
||||
userDomain: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const normalizedDomain = getPublicSwarmDomain(userDomain);
|
||||
if (!normalizedDomain) {
|
||||
const target = resolveFederationDomain(userDomain);
|
||||
if (!target) {
|
||||
console.warn(`[Signature] Rejected user interaction from non-public node ${userDomain}`);
|
||||
return false;
|
||||
}
|
||||
const normalizedDomain = target.domain;
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
|
||||
return false;
|
||||
@@ -152,7 +204,7 @@ export async function verifyUserInteraction(
|
||||
|
||||
// Try to get cached user
|
||||
const fullHandle = `${userHandle}@${normalizedDomain}`;
|
||||
let user = await db?.query.users.findFirst({
|
||||
const user = await db?.query.users.findFirst({
|
||||
where: { handle: fullHandle },
|
||||
});
|
||||
|
||||
@@ -162,18 +214,28 @@ export async function verifyUserInteraction(
|
||||
publicKey = user.publicKey;
|
||||
} else {
|
||||
// Fetch from remote node
|
||||
const response = await fetch(`https://${normalizedDomain}/api/users/${userHandle}`, {
|
||||
const response = await safeFederationRequest(`${target.protocol}://${normalizedDomain}/api/users/${encodeURIComponent(userHandle)}`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
timeoutMs: 5_000,
|
||||
maxResponseBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
console.error(`[Signature] Failed to fetch user ${userHandle}@${normalizedDomain}: ${response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
publicKey = userData.user?.publicKey || userData.publicKey;
|
||||
const userData = response.json() as {
|
||||
publicKey?: unknown;
|
||||
user?: {
|
||||
avatarUrl?: string | null;
|
||||
did?: string;
|
||||
displayName?: string | null;
|
||||
publicKey?: unknown;
|
||||
};
|
||||
};
|
||||
const resolvedPublicKey = userData.user?.publicKey || userData.publicKey;
|
||||
publicKey = typeof resolvedPublicKey === 'string' ? resolvedPublicKey : null;
|
||||
|
||||
// Cache the user if we don't have them
|
||||
if (!user && publicKey && db) {
|
||||
@@ -217,7 +279,7 @@ export async function getNodePrivateKey(): Promise<string> {
|
||||
/**
|
||||
* Create a signed payload for sending to another node
|
||||
*/
|
||||
export async function createSignedPayload(payload: any): Promise<{ payload: any; signature: string }> {
|
||||
export async function createSignedPayload<T>(payload: T): Promise<{ payload: T; signature: string }> {
|
||||
const privateKey = await getNodePrivateKey();
|
||||
const signature = signPayload(payload, privateKey);
|
||||
return { payload, signature };
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface SwarmNodeInfo {
|
||||
lastSeenAt?: string;
|
||||
}
|
||||
|
||||
export type SwarmCapability = 'handles' | 'gossip' | 'relay' | 'search' | 'interactions';
|
||||
export type SwarmCapability = 'handles' | 'gossip' | 'relay' | 'search' | 'interactions' | 'e2ee_dm_v1';
|
||||
|
||||
export interface SwarmAnnouncement {
|
||||
domain: string;
|
||||
|
||||
+34
-10
@@ -1,5 +1,6 @@
|
||||
import { db, users } from '@/db';
|
||||
import { eq, or } from 'drizzle-orm';
|
||||
import { normalizeSigningPublicKey } from '@/lib/crypto/did-key';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export interface RemoteProfile {
|
||||
handle: string;
|
||||
@@ -10,6 +11,14 @@ export interface RemoteProfile {
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
function signingKeysEqual(left: string, right: string): boolean {
|
||||
const normalizedLeft = normalizeSigningPublicKey(left);
|
||||
const normalizedRight = normalizeSigningPublicKey(right);
|
||||
return normalizedLeft && normalizedRight
|
||||
? normalizedLeft === normalizedRight
|
||||
: left === right;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a remote user into the local database for caching/display purposes.
|
||||
*
|
||||
@@ -19,20 +28,34 @@ export async function upsertRemoteUser(profile: RemoteProfile): Promise<void> {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
// Check if user already exists
|
||||
const existing = await db.query.users.findFirst({
|
||||
where: { OR: [{ did: profile.did }, { handle: profile.handle }] },
|
||||
});
|
||||
if (!profile.handle.includes('@')) {
|
||||
throw new Error('Remote user cache requires a fully qualified handle');
|
||||
}
|
||||
|
||||
const [byDid, byHandle] = await Promise.all([
|
||||
db.query.users.findFirst({ where: { did: profile.did } }),
|
||||
db.query.users.findFirst({ where: { handle: profile.handle } }),
|
||||
]);
|
||||
if (byDid && byHandle && byDid.id !== byHandle.id) {
|
||||
throw new Error('Remote user identity conflicts with the existing cache');
|
||||
}
|
||||
const existing = byDid || byHandle;
|
||||
|
||||
if (existing) {
|
||||
// Update metadata if changed
|
||||
// Self-healing: Update public key if missing
|
||||
if (!existing.handle.includes('@') && !existing.id.startsWith('swarm:')) {
|
||||
throw new Error('Federation cannot modify a local user');
|
||||
}
|
||||
if (existing.did !== profile.did || existing.handle !== profile.handle) {
|
||||
throw new Error('Remote user DID or handle changed unexpectedly');
|
||||
}
|
||||
if (profile.publicKey && existing.publicKey
|
||||
&& !signingKeysEqual(profile.publicKey, existing.publicKey)) {
|
||||
throw new Error('Remote user signing key changed unexpectedly');
|
||||
}
|
||||
const shouldUpdateKey = profile.publicKey && !existing.publicKey;
|
||||
|
||||
await db.update(users)
|
||||
.set({
|
||||
did: existing.did || profile.did,
|
||||
handle: existing.handle || profile.handle,
|
||||
displayName: profile.displayName || existing.displayName,
|
||||
avatarUrl: profile.avatarUrl || existing.avatarUrl,
|
||||
isBot: profile.isBot ?? existing.isBot,
|
||||
@@ -41,6 +64,7 @@ export async function upsertRemoteUser(profile: RemoteProfile): Promise<void> {
|
||||
})
|
||||
.where(eq(users.id, existing.id));
|
||||
} else {
|
||||
if (!profile.publicKey) throw new Error('Remote user signing key is required');
|
||||
// Create new placeholder user
|
||||
await db.insert(users).values({
|
||||
did: profile.did,
|
||||
@@ -48,7 +72,7 @@ export async function upsertRemoteUser(profile: RemoteProfile): Promise<void> {
|
||||
displayName: profile.displayName || profile.handle,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
isBot: profile.isBot || false,
|
||||
publicKey: profile.publicKey || '', // Cache provided key or default to empty
|
||||
publicKey: profile.publicKey,
|
||||
// Note: nodeId is null for remote placeholders unless we specifically link it
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user