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

Hop-State: A_06FPC0CGS3F7SJG3BGW0YF0
Hop-Proposal: R_06FPC0BK6YEV7XASNM7C908
Hop-Task: T_06FPAWA3279RBMJAR0BHM10
Hop-Attempt: AT_06FPBZWSBFTB9B5YH9JY9QR
This commit is contained in:
2026-07-15 06:40:06 -07:00
committed by Hop
parent 219a40bea4
commit b46be5c076
54 changed files with 14409 additions and 1192 deletions
+1
View File
@@ -10,6 +10,7 @@ PORT=43821
# Required: used for sessions, node key encryption, and bot API key encryption
AUTH_SECRET=replace-with-a-long-random-secret
E2EE_RECOVERY_SECRET=replace-with-a-separate-long-random-secret
# Required for admin access. Register with one of these emails locally.
ADMIN_EMAILS=admin@example.com
+6
View File
@@ -65,6 +65,12 @@ Stuffbox is the default integration. New installs use `https://stuffbox.xyz`; se
User-owned S3-compatible storage remains available as an advanced fallback. Supported providers include AWS S3, Cloudflare R2, Backblaze B2, Wasabi, and Contabo.
## Encrypted direct messages
New one-to-one text DMs use client-side end-to-end encryption with a separate PIN for new-device recovery. The PIN does not change the normal login flow or ordinary sending experience. Node installs generate a distinct `E2EE_RECOVERY_SECRET`; preserve it in a secret manager or protected operational backup because the database alone cannot restore PIN recovery.
V1 is intentionally limited and experimental: metadata remains visible, legacy DMs remain plaintext, and it does not provide forward secrecy or protection from a malicious home node that serves modified client code. See [the E2EE DM security and operations contract](docs/e2ee-dms.md) before enabling or describing the feature.
## Development
```bash
+2
View File
@@ -38,12 +38,14 @@ chown -R synapsis:synapsis "$APP_DIR"
if [[ ! -e "$ENV_FILE" ]]; then
auth_secret="$(openssl rand -base64 48 | tr -d '\n')"
e2ee_recovery_secret="$(openssl rand -base64 48 | tr -d '\n')"
port="43821"
install -m 0600 -o root -g synapsis /dev/null "$ENV_FILE"
{
echo "DATABASE_PATH=$DATA_DIR/synapsis.db"
echo "PORT=$port"
echo "AUTH_SECRET=$auth_secret"
echo "E2EE_RECOVERY_SECRET=$e2ee_recovery_secret"
echo "ADMIN_EMAILS=admin@example.com"
echo "NEXT_PUBLIC_NODE_DOMAIN=localhost:$port"
echo "STUFFBOX_URL=https://stuffbox.xyz"
+10
View File
@@ -19,6 +19,16 @@ set -a
source "$ENV_FILE"
set +a
# Existing installations predate encrypted-message recovery. Enroll them with
# a distinct stable secret before migrations/builds make E2EE available.
if [[ -z "${E2EE_RECOVERY_SECRET:-}" ]]; then
command -v openssl >/dev/null || { echo "Missing required command: openssl" >&2; exit 1; }
E2EE_RECOVERY_SECRET="$(openssl rand -base64 48 | tr -d '\n')"
printf '\nE2EE_RECOVERY_SECRET=%s\n' "$E2EE_RECOVERY_SECRET" >> "$ENV_FILE"
export E2EE_RECOVERY_SECRET
echo "Added E2EE_RECOVERY_SECRET to $ENV_FILE. Back it up separately from the database."
fi
install_update_units() {
units_changed=0
for unit in synapsis.service synapsis-maintenance.service synapsis-update.service synapsis-update.timer; do
+106
View File
@@ -0,0 +1,106 @@
# End-to-end encrypted direct messages (v1)
This document defines the security and product contract for Synapsis E2EE DMs v1. It is not a security audit or a claim that the feature is suitable for high-risk use.
## Scope
V1 encrypts the UTF-8 text body of new one-to-one direct messages. Existing messages are not protected retroactively. Group chats, images, video, audio, files, reactions, edits, calls, and server-generated link previews are not covered.
Routing metadata remains visible to the participating home nodes: sender and recipient identities and node domains, message and conversation identifiers, key versions, timestamps, delivery/read state, typing state, ciphertext size, request logs, and the social graph.
## Key and message flow
Each account has one static X25519 key pair in v1. The browser generates it during encrypted-message setup. Its public key, identifier, monotonic version, and replacement link are signed by the account's existing DID signing key.
For every message, the sender's browser:
1. Resolves and verifies the recipient's signed X25519 public-key record.
2. Generates a fresh random content key and XChaCha20-Poly1305 nonce.
3. Encrypts the text locally with authenticated sender, recipient, conversation, message, timestamp, protocol, and key-version context.
4. Seals the content key and a transcript hash independently to the sender and recipient X25519 keys. The sender copy makes sent history available on the sender's enrolled devices.
5. Computes a keyed commitment over the authenticated context, nonce, and ciphertext.
6. Signs the complete encrypted envelope with the existing DID signing key.
Nodes authorize, route, federate, and store the signed ciphertext envelope. They receive no plaintext body or content key. Server-side previews are generic. The recipient verifies the signature, bindings, commitment, and AEAD authentication before displaying locally decrypted text.
Delivery wrappers are node-signed and bind the source node, destination node, route, delivery identifier, and expiry. Durable message receipts make retries idempotent and prevent a deleted conversation from being recreated by replaying an old valid envelope.
Federation requests use bounded, redirect-free HTTPS with public-address DNS validation and per-request DNS pinning. Exact loopback HTTP is allowed only in development. Remote key-cache updates use compare-and-swap continuity checks so concurrent lookups cannot roll a cached key backward.
## PIN and device experience
The encrypted-message PIN is separate from login:
- Setup happens only when Chat is first opened, not during login and not for every message.
- The PIN is 612 digits and is processed in the browser. The raw PIN is not sent to the node.
- A recognized browser stores the account key under a non-extractable IndexedDB wrapping key, so ordinary visits do not prompt again. Logout clears active application memory while retaining this protected recognized-device record.
- A new or cleared browser completes normal login, then enters the encrypted-message PIN once to recover the same account key.
- Failed recovery attempts are stored durably. Ten failures lock recovery for one hour.
- A forgotten-PIN reset requires the current account password and creates a new encryption key. In v1, old encrypted history no longer opens after that reset; there is no historical-key recovery UI.
There is no administrator plaintext-recovery path. Reset does not decrypt or re-encrypt old messages.
## Recovery service
The private account key is encrypted by a key derived from both Argon2id PIN material and a random server contribution. The database stores the encrypted vault, an HMAC-protected PIN verifier, an encrypted server contribution, and the attempt state.
Every node must set `E2EE_RECOVERY_SECRET` to an independent, high-entropy secret. It must not reuse `AUTH_SECRET`, use a `NEXT_PUBLIC_` name, enter source control, appear in logs, or be included in ordinary exports. Recovery fails closed when it is absent or too short.
This secret is stable node state. Losing or changing it without a deliberate migration makes existing PIN recovery records unusable. A complete node backup needs the database and this secret stored separately in an operational secret manager.
The v1 counter is ordinary application/database enforcement. It is not a hardware-enforced, HSM-backed, threshold, or multi-operator guess limit. Anyone who compromises the running node and its secrets can test the small PIN space offline.
## Threat model
With uncompromised endpoints and the reviewed client build, v1 is intended to keep supported message bodies confidential from:
- a database or backup reader who does not also have the recovery secret;
- network intermediaries, in addition to the protection provided by TLS; and
- honest participating nodes that run the published code and do not capture endpoint secrets.
V1 does not protect message bodies from:
- a malicious home node that serves modified JavaScript capable of reading PINs, keys, or plaintext;
- XSS, a malicious browser extension, endpoint malware, or a compromised operating system;
- a recipient who copies, reports, photographs, or otherwise discloses plaintext; or
- compromise of the static account X25519 private key.
Nodes can still drop, delay, replay, reorder, or refuse ciphertext. Replay records protect local message state, not availability. Message timing, frequency, participant metadata, and approximate size remain observable.
When a node enables Cloudflare Turnstile, its third-party login script shares the credential and account-signing-key trust boundary. A hard navigation removes that realm before Chat creates or restores the E2EE account key, but it does not make a compromised login dependency harmless. Removing or isolating third-party login JavaScript is required before making a stronger endpoint-security claim.
## Legacy history and fail-closed behavior
Existing plaintext DMs remain legacy messages and are visibly marked as sent before encryption. Their original delivery cannot be made E2EE after the fact.
New sends have no plaintext fallback. Sending remains disabled when the recipient has no signed encryption key, key resolution fails, a key version changes, the crypto runtime fails, or the protocol is unsupported. The draft remains in the composer and the user receives an actionable error.
Invalid signatures, altered envelopes, authentication failures, and messages encrypted to an old reset key are displayed as unavailable ciphertext, never as partial or fabricated plaintext.
## Export and migration limits
Account export preserves new message envelopes as ciphertext and preserves legacy messages as legacy plaintext. Fresh v1.1 exports sign a canonical digest of the complete payload. A historical v1.0 account can still migrate without that digest, but all unauthenticated DM history is discarded and the destination warns that the remaining legacy payload must be reviewed for tampering. The server does not decrypt E2EE messages for export.
V1 does not migrate the account encryption private key or recovery enrollment between home nodes. It does migrate the signed public key-continuity anchor. On first opening Chat at the destination, the user sets a PIN once and the browser signs a monotonic replacement key. Preserved old ciphertext remains unreadable on the destination. Copying a node-wide `E2EE_RECOVERY_SECRET` for one account is not acceptable.
Federated DM relationships do not automatically follow a home-node move in v1. The broader federation layer has no signed handle-move proof that updates a peer's cached `user@node` mapping and existing conversation route. A peer that cached the old full handle can therefore reject the migrated handle even though the DID and encryption-key rotation are valid. The import UI warns about this limitation; seamless federated DM migration requires a separate signed move protocol.
Cross-node “delete for everyone” is not offered in v1 because a remote node cannot guarantee deletion. Deleting a federated conversation removes only the local copy.
## Explicit limitations
- No forward secrecy: compromise of the static account private key exposes retained history encrypted to it.
- No post-compromise security: future messages remain exposed until the account key is reset and peers use the replacement.
- One account key is shared across devices; there are no per-device sessions or independent device revocation.
- No key transparency or out-of-band safety-number verification.
- PIN recovery is not X's Juicebox design and is not equivalent to an HSM-backed threshold service.
- This is not Signal Protocol and has no Double Ratchet.
- This is not X Chat's protocol or implementation; only the low-friction PIN experience is a product inspiration.
- Coverage is text-only.
- A malicious home node or maliciously served web client can steal keys and plaintext.
## Review gate
Before broad production enablement or strong security claims, obtain an independent cryptographic and application-security review. It must cover serialization, DID/key binding and rotation, X25519 validation, XChaCha20-Poly1305 and sealed-key usage, nonce generation, authenticated context, replay behavior, local persistence, recovery derivation and limits, federation checks, migrations, XSS/CSP, dependencies, logs, and failure paths.
Publish interoperable protocol test vectors and resolve high-severity findings before presenting v1 as appropriate for sensitive communication. Until then, label it experimental.
@@ -0,0 +1,73 @@
CREATE TABLE `e2ee_key_bundles` (
`user_id` text PRIMARY KEY,
`did` text NOT NULL,
`key_id` text NOT NULL,
`key_version` integer NOT NULL,
`public_key` text NOT NULL,
`proof_action` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
CONSTRAINT `fk_e2ee_key_bundles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `e2ee_key_vaults` (
`user_id` text PRIMARY KEY,
`key_id` text NOT NULL,
`key_version` integer NOT NULL,
`owner_did` text NOT NULL,
`public_key` text NOT NULL,
`ciphertext` text NOT NULL,
`nonce` text NOT NULL,
`salt` text NOT NULL,
`kdf_algorithm` text NOT NULL,
`kdf_ops_limit` integer NOT NULL,
`kdf_mem_limit` integer NOT NULL,
`pin_verifier_mac` text NOT NULL,
`server_share_encrypted` text NOT NULL,
`failed_attempts` integer DEFAULT 0 NOT NULL,
`locked_until` integer,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
CONSTRAINT `fk_e2ee_key_vaults_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `e2ee_message_receipts` (
`id` text PRIMARY KEY,
`owner_user_id` text NOT NULL,
`sender_did` text NOT NULL,
`message_id` text NOT NULL,
`protocol_version` integer DEFAULT 1 NOT NULL,
`received_at` integer DEFAULT (unixepoch()) NOT NULL,
CONSTRAINT `fk_e2ee_message_receipts_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `e2ee_remote_key_bundles` (
`did` text PRIMARY KEY,
`handle` text NOT NULL,
`key_id` text NOT NULL,
`key_version` integer NOT NULL,
`public_key` text NOT NULL,
`proof_action` text NOT NULL,
`signing_public_key` text NOT NULL,
`first_seen_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
ALTER TABLE `chat_conversations` ADD `encryption_mode` text DEFAULT 'legacy' NOT NULL;--> statement-breakpoint
ALTER TABLE `chat_conversations` ADD `e2ee_activated_at` integer;--> statement-breakpoint
ALTER TABLE `chat_messages` ADD `protocol_version` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `chat_messages` ADD `client_message_id` text;--> statement-breakpoint
ALTER TABLE `chat_messages` ADD `encrypted_envelope` text;--> statement-breakpoint
ALTER TABLE `chat_messages` ADD `e2ee_signature` text;--> statement-breakpoint
ALTER TABLE `chat_messages` ADD `e2ee_action_nonce` text;--> statement-breakpoint
ALTER TABLE `chat_messages` ADD `e2ee_action_ts` integer;--> statement-breakpoint
CREATE UNIQUE INDEX `chat_messages_conversation_client_id_unique` ON `chat_messages` (`conversation_id`,`client_message_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `e2ee_key_bundles_did_unique` ON `e2ee_key_bundles` (`did`);--> statement-breakpoint
CREATE UNIQUE INDEX `e2ee_key_bundles_key_id_unique` ON `e2ee_key_bundles` (`key_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `e2ee_key_vaults_key_id_unique` ON `e2ee_key_vaults` (`key_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `e2ee_message_receipts_owner_sender_message_unique` ON `e2ee_message_receipts` (`owner_user_id`,`sender_did`,`message_id`);--> statement-breakpoint
CREATE INDEX `e2ee_message_receipts_received_idx` ON `e2ee_message_receipts` (`received_at`);--> statement-breakpoint
CREATE INDEX `e2ee_remote_key_bundles_key_id_idx` ON `e2ee_remote_key_bundles` (`key_id`);--> statement-breakpoint
CREATE INDEX `e2ee_remote_key_bundles_handle_idx` ON `e2ee_remote_key_bundles` (`handle`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_handle_unique_idx` ON `users` (`handle`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_did_unique_idx` ON `users` (`did`);
File diff suppressed because it is too large Load Diff
+29
View File
@@ -27,6 +27,21 @@ function getBuildCommitCount(): string {
}
}
const contentSecurityPolicy = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
`script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''} https://challenges.cloudflare.com`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"connect-src 'self' https: wss:",
"frame-src https://challenges.cloudflare.com",
"worker-src 'self' blob:",
].join('; ');
const nextConfig: NextConfig = {
env: {
APP_COMMIT: getBuildCommit(),
@@ -41,6 +56,20 @@ const nextConfig: NextConfig = {
turbopack: {
root: process.cwd(),
},
async headers() {
return [{
source: '/:path*',
headers: [
{ key: 'Content-Security-Policy', value: contentSecurityPolicy },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin-allow-popups' },
],
}];
},
};
export default nextConfig;
+35
View File
@@ -2,8 +2,43 @@ import { sql } from 'drizzle-orm';
import { migrate } from 'drizzle-orm/tursodatabase/migrator';
import { closeDb, db } from '../src/db';
interface DuplicateIdentityRow {
field: 'did' | 'handle';
value: string;
duplicateCount: number;
}
async function assertIdentityUniqueness(): Promise<void> {
const tables = await db.all<{ name: string }>(sql.raw(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users' LIMIT 1",
));
if (tables.length === 0) return;
const duplicates = await db.all<DuplicateIdentityRow>(sql.raw(`
SELECT 'did' AS field, did AS value, COUNT(*) AS duplicateCount
FROM users
GROUP BY did
HAVING COUNT(*) > 1
UNION ALL
SELECT 'handle' AS field, handle AS value, COUNT(*) AS duplicateCount
FROM users
GROUP BY handle
HAVING COUNT(*) > 1
LIMIT 20
`));
if (duplicates.length === 0) return;
const sample = duplicates
.map((row) => `${row.field}=${JSON.stringify(row.value)} (${row.duplicateCount} rows)`)
.join(', ');
throw new Error(
`Database identity preflight failed: duplicate users must be resolved before migration: ${sample}`,
);
}
async function main() {
try {
await assertIdentityUniqueness();
const result = await migrate(db, { migrationsFolder: './drizzle' });
if (result) {
+439
View File
@@ -0,0 +1,439 @@
import {
createHash,
createSign,
createVerify,
generateKeyPairSync,
randomBytes,
randomUUID,
} from 'node:crypto';
import { NextRequest } from 'next/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { generateDID } from '@/lib/crypto/did-key';
import { canonicalize } from '@/lib/crypto/user-signing';
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
import {
E2EE_CIPHER_SUITE,
E2EE_KEY_BUNDLE_ACTION,
E2EE_PROTOCOL,
} from '@/lib/e2ee/protocol';
const mocks = vi.hoisted(() => ({
tables: {
users: { table: 'users' },
posts: { table: 'posts' },
media: { table: 'media' },
follows: { table: 'follows' },
remoteFollows: { table: 'remoteFollows' },
chatConversations: { table: 'chatConversations' },
chatMessages: { table: 'chatMessages' },
e2eeKeyBundles: { table: 'e2eeKeyBundles' },
e2eeMessageReceipts: { table: 'e2eeMessageReceipts' },
bots: { table: 'bots' },
botContentSources: { table: 'botContentSources' },
botActivityLogs: { table: 'botActivityLogs' },
},
requireAuth: vi.fn(),
verifyPassword: vi.fn(),
hashPassword: vi.fn(),
createSession: vi.fn(),
findPosts: vi.fn(),
findFollows: vi.fn(),
findRemoteFollows: vi.fn(),
findConversations: vi.fn(),
findE2EEKeyBundle: vi.fn(),
findBots: vi.fn(),
findUsers: vi.fn(),
findNodes: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
transaction: vi.fn(),
insertedRows: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
safeFederationRequest: vi.fn(),
}));
vi.mock('@/lib/auth', () => ({
requireAuth: mocks.requireAuth,
verifyPassword: mocks.verifyPassword,
hashPassword: mocks.hashPassword,
createSession: mocks.createSession,
}));
vi.mock('@/db', () => ({
...mocks.tables,
db: {
insert: mocks.insert,
update: mocks.update,
transaction: mocks.transaction,
query: {
users: { findFirst: mocks.findUsers },
nodes: { findFirst: mocks.findNodes },
posts: { findMany: mocks.findPosts },
follows: { findMany: mocks.findFollows },
remoteFollows: { findMany: mocks.findRemoteFollows },
chatConversations: { findMany: mocks.findConversations },
e2eeKeyBundles: { findFirst: mocks.findE2EEKeyBundle },
bots: { findMany: mocks.findBots },
},
},
}));
vi.mock('drizzle-orm', async (importOriginal) => ({
...await importOriginal<typeof import('drizzle-orm')>(),
eq: vi.fn(() => ({})),
}));
vi.mock('@/lib/bots/encryption', () => ({
decryptApiKey: vi.fn(),
deserializeEncryptedData: vi.fn(),
encryptApiKey: vi.fn(),
serializeEncryptedData: vi.fn(),
}));
vi.mock('@/lib/federation/handles', () => ({ upsertHandleEntries: vi.fn() }));
vi.mock('@/lib/swarm/safe-federation-http', () => ({
safeFederationRequest: mocks.safeFederationRequest,
}));
import { POST } from './route';
import { POST as importAccount } from '../import/route';
interface ExportResponseBody {
export: {
manifest: {
version: '1.1';
did: string;
handle: string;
sourceNode: string;
exportedAt: string;
expiresAt: string;
publicKey: string;
privateKeyEncrypted: string;
payloadDigestAlgorithm: 'sha256';
payloadDigest: string;
signature: string;
};
profile: unknown;
posts: unknown[];
following: unknown[];
dms: unknown[];
bots: unknown[];
e2eeKeyBundle: unknown | null;
};
}
describe('account export manifest integrity', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.insertedRows.length = 0;
vi.stubEnv('NEXT_PUBLIC_NODE_DOMAIN', 'export.synapsis.social');
mocks.verifyPassword.mockResolvedValue(true);
mocks.hashPassword.mockResolvedValue('imported-password-hash');
mocks.createSession.mockResolvedValue('imported-session-token');
mocks.findPosts.mockResolvedValue([]);
mocks.findFollows.mockResolvedValue([]);
mocks.findRemoteFollows.mockResolvedValue([]);
mocks.findConversations.mockResolvedValue([]);
mocks.findE2EEKeyBundle.mockResolvedValue(undefined);
mocks.findBots.mockResolvedValue([]);
mocks.findUsers.mockResolvedValue(undefined);
mocks.findNodes.mockResolvedValue(undefined);
mocks.safeFederationRequest.mockResolvedValue({ status: 204 });
mocks.update.mockImplementation(() => ({
set: () => ({
where: () => Promise.resolve(),
}),
}));
mocks.insert.mockImplementation((table: unknown) => ({
values: (values: Record<string, unknown>) => {
mocks.insertedRows.push({ table, values });
const returnedRows = table === mocks.tables.users
? [{ id: 'imported-user-1', ...values }]
: table === mocks.tables.chatConversations
? [{ id: 'imported-conversation-1', ...values }]
: [];
return Object.assign(Promise.resolve(), {
returning: vi.fn(async () => returnedRows),
onConflictDoNothing: vi.fn(async () => undefined),
});
},
}));
mocks.transaction.mockImplementation(async (
callback: (tx: { insert: typeof mocks.insert }) => Promise<unknown>,
) => callback({ insert: mocks.insert }));
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('decrypts the stored key to sign v1.1 while exporting its encrypted blob unchanged', async () => {
const password = 'correct horse battery staple';
const { privateKey, publicKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
const storedPrivateKey = serializeEncryptedKey(encryptPrivateKey(privateKey, password));
const ownerDid = generateDID(publicKey);
const peerDid = 'did:synapsis:export-peer';
const envelopeCreatedAt = Math.floor(Date.now() / 1_000) * 1_000 + 537;
const storedCreatedAt = new Date(Math.floor(envelopeCreatedAt / 1_000) * 1_000);
const messageId = randomUUID();
const senderKeyId = `k1_${randomBytes(12).toString('base64url')}`;
const recipientKeyId = `k1_${randomBytes(12).toString('base64url')}`;
const envelope = {
protocol: E2EE_PROTOCOL,
cipherSuite: E2EE_CIPHER_SUITE,
messageId,
conversationId: `dm1_${randomBytes(12).toString('base64url')}`,
senderDid: ownerDid,
senderHandle: 'alice',
recipientDid: peerDid,
recipientHandle: 'bob',
createdAt: envelopeCreatedAt,
senderKeyId,
senderKeyVersion: 1,
recipientKeyId,
recipientKeyVersion: 1,
nonce: randomBytes(24).toString('base64url'),
ciphertext: randomBytes(17).toString('base64url'),
keyCommitment: randomBytes(32).toString('base64url'),
keyEnvelopes: [
{
did: ownerDid,
keyId: senderKeyId,
keyVersion: 1,
sealedKey: randomBytes(112).toString('base64url'),
},
{
did: peerDid,
keyId: recipientKeyId,
keyVersion: 1,
sealedKey: randomBytes(112).toString('base64url'),
},
],
};
const actionNonce = randomBytes(16).toString('base64url');
const actionPayload = {
action: 'chat_e2ee',
data: envelope,
did: ownerDid,
handle: 'alice',
nonce: actionNonce,
ts: envelopeCreatedAt,
};
const actionSigner = createSign('sha256');
actionSigner.update(canonicalize(actionPayload));
const actionSignature = actionSigner.sign({
key: privateKey,
dsaEncoding: 'ieee-p1363',
}).toString('base64url');
const encryptionPublicKey = randomBytes(32).toString('base64url');
const continuityKeyId = await encryptionKeyIdFromPublicKey(encryptionPublicKey);
if (!continuityKeyId) throw new Error('Could not derive test encryption key ID');
const continuityCreatedAt = Date.now();
const continuityBundle = {
protocol: E2EE_PROTOCOL,
keyId: continuityKeyId,
version: 1,
publicKey: encryptionPublicKey,
createdAt: continuityCreatedAt,
recoveryCommitment: randomBytes(32).toString('base64url'),
};
const continuityProofPayload = {
action: E2EE_KEY_BUNDLE_ACTION,
data: continuityBundle,
did: ownerDid,
handle: 'alice',
nonce: randomBytes(16).toString('base64url'),
ts: continuityCreatedAt,
};
const continuitySigner = createSign('sha256');
continuitySigner.update(canonicalize(continuityProofPayload));
const continuityProof = {
...continuityProofPayload,
sig: continuitySigner.sign({
key: privateKey,
dsaEncoding: 'ieee-p1363',
}).toString('base64url'),
};
mocks.findConversations.mockResolvedValue([{
id: 'conversation-1',
type: 'direct',
participant2Handle: 'bob',
lastMessageAt: storedCreatedAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: storedCreatedAt,
messages: [{
senderHandle: 'alice',
senderDisplayName: 'Alice',
senderAvatarUrl: null,
senderNodeDomain: null,
senderDid: ownerDid,
content: null,
protocolVersion: 1,
clientMessageId: messageId,
encryptedEnvelope: JSON.stringify(envelope),
e2eeSignature: actionSignature,
e2eeActionNonce: actionNonce,
e2eeActionTs: envelopeCreatedAt,
deliveredAt: null,
readAt: null,
createdAt: storedCreatedAt,
}],
}]);
mocks.requireAuth.mockResolvedValue({
id: 'user-1',
did: ownerDid,
handle: 'alice',
displayName: 'Alice',
bio: null,
avatarUrl: null,
headerUrl: null,
passwordHash: 'password-hash',
publicKey,
privateKeyEncrypted: storedPrivateKey,
movedTo: null,
});
mocks.findE2EEKeyBundle.mockResolvedValue({
did: ownerDid,
keyId: continuityKeyId,
keyVersion: 1,
publicKey: encryptionPublicKey,
proofAction: JSON.stringify(continuityProof),
});
const response = await POST(new NextRequest('https://export.synapsis.social/api/account/export', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password }),
}));
const body = await response.json() as ExportResponseBody;
expect(response.status).toBe(200);
expect(body.export.manifest.version).toBe('1.1');
expect(body.export.manifest.privateKeyEncrypted).toBe(storedPrivateKey);
expect(body.export.manifest).not.toHaveProperty('salt');
expect(body.export.manifest).not.toHaveProperty('iv');
expect(body.export.e2eeKeyBundle).toEqual({
did: ownerDid,
keyId: continuityKeyId,
keyVersion: 1,
publicKey: encryptionPublicKey,
proofAction: continuityProof,
});
const { signature, ...manifestData } = body.export.manifest;
const verifier = createVerify('sha256');
verifier.update(canonicalize(manifestData));
expect(verifier.verify(publicKey, signature, 'base64')).toBe(true);
const payload = {
profile: body.export.profile,
posts: body.export.posts,
following: body.export.following,
dms: body.export.dms,
bots: body.export.bots,
e2eeKeyBundle: body.export.e2eeKeyBundle,
};
expect(body.export.manifest.payloadDigest).toBe(
createHash('sha256').update(canonicalize(payload)).digest('hex'),
);
// The exported message carries millisecond-authenticated envelope
// time while its DB timestamp is second-precision. A fresh importer
// must accept the round-trip and preserve account login plus the
// signed public continuity anchor (but never a PIN vault).
const importResponse = await importAccount(new NextRequest('https://new.example/api/account/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
exportData: body.export,
password,
destinationEmail: ' Alice@New.Example ',
newHandle: 'alice_new',
acceptedCompliance: true,
}),
}));
expect(importResponse.status).toBe(200);
await expect(importResponse.json()).resolves.toMatchObject({
success: true,
stats: { dmsImported: 1 },
warnings: expect.arrayContaining([
expect.stringContaining('decryption key is not portable yet'),
]),
});
expect(mocks.hashPassword).toHaveBeenCalledWith(password);
const importedUser = mocks.insertedRows.find(({ table }) => table === mocks.tables.users);
expect(importedUser?.values).toMatchObject({
did: ownerDid,
email: 'alice@new.example',
passwordHash: 'imported-password-hash',
privateKeyEncrypted: storedPrivateKey,
});
expect(mocks.createSession).toHaveBeenCalledOnce();
expect(mocks.createSession).toHaveBeenCalledWith('imported-user-1');
const importedContinuity = mocks.insertedRows.find(
({ table }) => table === mocks.tables.e2eeKeyBundles,
);
expect(importedContinuity?.values).toMatchObject({
userId: 'imported-user-1',
did: ownerDid,
keyId: continuityKeyId,
keyVersion: 1,
publicKey: encryptionPublicKey,
});
expect(JSON.parse(String(importedContinuity?.values.proofAction))).toEqual(continuityProof);
expect(importedContinuity?.values).not.toHaveProperty('ciphertext');
expect(importedContinuity?.values).not.toHaveProperty('pinVerifierMac');
expect(mocks.safeFederationRequest).toHaveBeenCalledWith(
'https://export.synapsis.social/api/account/moved',
expect.objectContaining({ method: 'POST', timeoutMs: 5_000 }),
);
mocks.findUsers
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({ id: 'existing-email-user' });
const duplicateEmailResponse = await importAccount(new NextRequest('https://new.example/api/account/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
exportData: body.export,
password,
destinationEmail: 'alice@new.example',
newHandle: 'alice_other',
acceptedCompliance: true,
}),
}));
expect(duplicateEmailResponse.status).toBe(409);
await expect(duplicateEmailResponse.json()).resolves.toMatchObject({
error: 'Email is already registered on this node',
});
expect(mocks.createSession).toHaveBeenCalledOnce();
mocks.findUsers.mockResolvedValue(undefined);
mocks.createSession.mockRejectedValueOnce(new Error('session store unavailable'));
const sessionFailureResponse = await importAccount(new NextRequest('https://new.example/api/account/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
exportData: body.export,
password,
destinationEmail: 'alice-session-fallback@new.example',
newHandle: 'alice_retry',
acceptedCompliance: true,
}),
}));
expect(sessionFailureResponse.status).toBe(200);
await expect(sessionFailureResponse.json()).resolves.toMatchObject({
success: true,
warnings: expect.arrayContaining([
expect.stringContaining('automatic sign-in failed'),
]),
});
expect(mocks.createSession).toHaveBeenCalledTimes(2);
});
});
+145 -50
View File
@@ -7,25 +7,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, verifyPassword } from '@/lib/auth';
import { db, posts, media, follows, users, remoteFollows, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import * as crypto from 'crypto';
import { decryptApiKey, deserializeEncryptedData } from '@/lib/bots/encryption';
import { canonicalize, verifySignedActionSignature } from '@/lib/crypto/user-signing';
import {
decryptPrivateKey as decryptStoredPrivateKey,
deserializeEncryptedKey,
} from '@/lib/crypto/private-key';
import {
E2EE_KEY_BUNDLE_ACTION,
e2eeKeyBundleSchema,
signedUserActionSchema,
type SignedUserAction,
} from '@/lib/e2ee/protocol';
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
// We'll use a simple in-memory zip approach
// For production, consider using a streaming zip library
interface ExportManifest {
version: string;
version: '1.1';
did: string;
handle: string;
sourceNode: string;
exportedAt: string;
expiresAt: string; // Export expiration timestamp
publicKey: string;
privateKeyEncrypted: string; // Encrypted with user's password
salt: string; // For key derivation
iv: string; // For AES encryption
privateKeyEncrypted: string; // Original serialized AES-GCM key blob
payloadDigestAlgorithm: 'sha256';
payloadDigest: string; // Canonical digest of every non-manifest export field
signature: string; // Proof of ownership
}
@@ -59,6 +70,8 @@ interface ExportDMConversation {
participant2Handle: string;
lastMessageAt: string | null;
lastMessagePreview: string | null;
encryptionMode: string;
e2eeActivatedAt: string | null;
messages: ExportDMMessage[];
}
@@ -69,11 +82,25 @@ interface ExportDMMessage {
senderNodeDomain: string | null;
senderDid: string | null;
content: string | null;
protocolVersion: number;
clientMessageId: string | null;
encryptedEnvelope: string | null;
e2eeSignature: string | null;
e2eeActionNonce: string | null;
e2eeActionTs: number | null;
deliveredAt: string | null;
readAt: string | null;
createdAt: string;
}
interface ExportE2EEContinuityAnchor {
did: string;
keyId: string;
keyVersion: number;
publicKey: string;
proofAction: SignedUserAction;
}
interface ExportBot {
id: string;
name: string;
@@ -81,55 +108,91 @@ interface ExportBot {
bio: string | null;
avatarUrl: string | null;
headerUrl: string | null;
personalityConfig: any;
personalityConfig: unknown;
llmProvider: string;
llmModel: string;
llmApiKey: string; // Decrypted
botPrivateKey: string; // Decrypted
publicKey: string;
scheduleConfig: any;
scheduleConfig: unknown;
autonomousMode: boolean;
isActive: boolean;
sources: any[];
activityLogs: any[];
sources: unknown[];
activityLogs: unknown[];
}
/**
* Encrypt the private key with user's password using AES-256-GCM
*/
function encryptPrivateKey(privateKey: string, password: string): { encrypted: string; salt: string; iv: string } {
const salt = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);
// Derive key from password
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
// Encrypt
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(privateKey, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
// Combine encrypted data with auth tag
const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64');
return {
encrypted: combined,
salt: salt.toString('base64'),
iv: iv.toString('base64'),
};
interface ExportPayload {
profile: ExportProfile;
posts: ExportPost[];
following: ExportFollowing[];
dms: ExportDMConversation[];
bots: ExportBot[];
e2eeKeyBundle: ExportE2EEContinuityAnchor | null;
}
/**
* Sign the manifest to prove ownership
*/
function signManifest(manifest: Omit<ExportManifest, 'signature'>, privateKey: string): string {
const data = JSON.stringify(manifest);
const data = canonicalize(manifest);
const sign = crypto.createSign('sha256');
sign.update(data);
return sign.sign(privateKey, 'base64');
}
function digestExportPayload(payload: ExportPayload): string {
return crypto.createHash('sha256').update(canonicalize(payload)).digest('hex');
}
function signingKeyMatchesPublicKey(privateKey: string, publicKey: string): boolean {
try {
const derivedPublicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' });
const expectedPublicKey = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
return derivedPublicKey.length === expectedPublicKey.length
&& crypto.timingSafeEqual(derivedPublicKey, expectedPublicKey);
} catch {
return false;
}
}
async function exportE2EEContinuityAnchor(
row: {
did: string;
keyId: string;
keyVersion: number;
publicKey: string;
proofAction: string;
} | undefined,
user: { did: string; handle: string; publicKey: string },
): Promise<ExportE2EEContinuityAnchor | null> {
if (!row) return null;
const proof = signedUserActionSchema.parse(JSON.parse(row.proofAction));
const bundle = e2eeKeyBundleSchema.parse(proof.data);
if (proof.action !== E2EE_KEY_BUNDLE_ACTION
|| proof.did !== user.did
|| proof.handle.toLowerCase() !== user.handle.toLowerCase()
|| row.did !== proof.did
|| row.keyId !== bundle.keyId
|| row.keyVersion !== bundle.version
|| row.publicKey !== bundle.publicKey
|| Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1_000
|| Buffer.from(bundle.publicKey, 'base64url').length !== 32
|| Buffer.from(bundle.recoveryCommitment, 'base64url').length !== 32
|| await encryptionKeyIdFromPublicKey(bundle.publicKey) !== bundle.keyId
|| !await verifySignedActionSignature(proof, user.publicKey)) {
throw new Error('Stored E2EE continuity proof is invalid');
}
return {
did: row.did,
keyId: row.keyId,
keyVersion: row.keyVersion,
publicKey: row.publicKey,
proofAction: proof,
};
}
export async function POST(req: NextRequest) {
try {
const user = await requireAuth();
@@ -156,6 +219,23 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'This account has already been migrated' }, { status: 400 });
}
if (!user.privateKeyEncrypted) {
return NextResponse.json({ error: 'Account signing key is unavailable' }, { status: 500 });
}
let signingPrivateKey: string;
try {
signingPrivateKey = decryptStoredPrivateKey(
deserializeEncryptedKey(user.privateKeyEncrypted),
password,
);
} catch {
return NextResponse.json({ error: 'Account signing key could not be unlocked' }, { status: 500 });
}
if (!signingKeyMatchesPublicKey(signingPrivateKey, user.publicKey)) {
return NextResponse.json({ error: 'Account signing key does not match this account' }, { status: 500 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
// Fetch user's posts
@@ -187,6 +267,11 @@ export async function POST(req: NextRequest) {
}
});
const currentE2EEKeyBundle = await db.query.e2eeKeyBundles.findFirst({
where: { userId: user.id },
});
const e2eeKeyBundle = await exportE2EEContinuityAnchor(currentE2EEKeyBundle, user);
// Fetch Bots
const userBots = await db.query.bots.findMany({
where: { ownerId: user.id },
@@ -248,13 +333,21 @@ export async function POST(req: NextRequest) {
participant2Handle: conv.participant2Handle,
lastMessageAt: conv.lastMessageAt?.toISOString() || null,
lastMessagePreview: conv.lastMessagePreview,
encryptionMode: conv.encryptionMode,
e2eeActivatedAt: conv.e2eeActivatedAt?.toISOString() || null,
messages: conv.messages.map(msg => ({
senderHandle: msg.senderHandle,
senderDisplayName: msg.senderDisplayName,
senderAvatarUrl: msg.senderAvatarUrl,
senderNodeDomain: msg.senderNodeDomain,
senderDid: msg.senderDid,
content: msg.content,
content: msg.protocolVersion === 0 ? msg.content : null,
protocolVersion: msg.protocolVersion,
clientMessageId: msg.clientMessageId,
encryptedEnvelope: msg.encryptedEnvelope,
e2eeSignature: msg.e2eeSignature,
e2eeActionNonce: msg.e2eeActionNonce,
e2eeActionTs: msg.e2eeActionTs,
deliveredAt: msg.deliveredAt?.toISOString() || null,
readAt: msg.readAt?.toISOString() || null,
createdAt: msg.createdAt.toISOString()
@@ -311,39 +404,41 @@ export async function POST(req: NextRequest) {
};
});
// Encrypt private key
const privateKey = user.privateKeyEncrypted || '';
const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password);
const exportPayload: ExportPayload = {
profile,
posts: exportPosts,
following: exportFollowing,
dms: exportDMs,
bots: exportBots,
e2eeKeyBundle,
};
// Build manifest (without signature first)
// Version 1.1 fails closed on older importers and binds a canonical
// digest of every non-manifest field into the signed manifest.
const exportedAt = new Date();
const expiresAt = new Date(exportedAt.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
const manifestData: Omit<ExportManifest, 'signature'> = {
version: '1.0',
version: '1.1',
did: user.did,
handle: user.handle,
sourceNode: nodeDomain,
exportedAt: exportedAt.toISOString(),
expiresAt: expiresAt.toISOString(),
publicKey: user.publicKey,
privateKeyEncrypted: encrypted,
salt,
iv,
privateKeyEncrypted: user.privateKeyEncrypted,
payloadDigestAlgorithm: 'sha256',
payloadDigest: digestExportPayload(exportPayload),
};
// Sign the manifest
const signature = signManifest(manifestData, privateKey);
const signature = signManifest(manifestData, signingPrivateKey);
const manifest: ExportManifest = { ...manifestData, signature };
// Build the export package as JSON (ZIP would require additional library)
// For MVP, we'll use a JSON format that can be easily converted to ZIP later
const exportPackage = {
manifest,
profile,
posts: exportPosts,
following: exportFollowing,
dms: exportDMs,
bots: exportBots,
...exportPayload,
};
return NextResponse.json({
+529
View File
@@ -0,0 +1,529 @@
import { generateKeyPairSync, createHash, createSign, randomBytes, randomUUID } from 'node:crypto';
import { NextRequest } from 'next/server';
import { describe, expect, it } from 'vitest';
import { canonicalize } from '@/lib/crypto/user-signing';
import { generateDID } from '@/lib/crypto/did-key';
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
import {
E2EE_CIPHER_SUITE,
E2EE_KEY_BUNDLE_ACTION,
E2EE_PROTOCOL,
} from '@/lib/e2ee/protocol';
import { POST } from './route';
const peerDid = 'did:synapsis:account-import-peer';
const { privateKey, publicKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
const ownerDid = generateDID(publicKey);
const storedPrivateKey = serializeEncryptedKey(encryptPrivateKey(privateKey, 'correct-password'));
function basePayload(dms: unknown[]) {
return {
profile: {
displayName: 'Alice',
bio: null,
avatarUrl: null,
headerUrl: null,
},
posts: [],
following: [],
dms,
bots: [],
};
}
function signedExport(payload: ReturnType<typeof basePayload>, integrityProtected = true) {
const manifestData = {
version: '1.0' as const,
did: ownerDid,
handle: 'alice',
sourceNode: 'old.synapsis.social',
exportedAt: new Date(Date.now() - 1_000).toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
publicKey,
privateKeyEncrypted: 'AA==',
salt: randomBytes(32).toString('base64'),
iv: randomBytes(16).toString('base64'),
...(integrityProtected ? {
payloadDigestAlgorithm: 'sha256' as const,
payloadDigest: createHash('sha256').update(canonicalize(payload)).digest('hex'),
} : {}),
};
const signer = createSign('sha256');
signer.update(JSON.stringify(manifestData));
return {
manifest: {
...manifestData,
signature: signer.sign(privateKey, 'base64'),
},
...payload,
};
}
function signedV11Export(
payload: ReturnType<typeof basePayload>,
options: {
did?: string;
sourceNode?: string;
e2eeKeyBundle?: unknown;
} = {},
) {
const signedPayload = {
...payload,
e2eeKeyBundle: options.e2eeKeyBundle ?? null,
};
const manifestData = {
version: '1.1' as const,
did: options.did ?? ownerDid,
handle: 'alice',
sourceNode: options.sourceNode ?? 'old.synapsis.social',
exportedAt: new Date(Date.now() - 1_000).toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
publicKey,
privateKeyEncrypted: storedPrivateKey,
payloadDigestAlgorithm: 'sha256' as const,
payloadDigest: createHash('sha256').update(canonicalize(signedPayload)).digest('hex'),
};
const signer = createSign('sha256');
signer.update(canonicalize(manifestData));
return {
manifest: {
...manifestData,
signature: signer.sign(privateKey, 'base64'),
},
...signedPayload,
};
}
async function continuityAnchor() {
const encryptionPublicKey = randomBytes(32).toString('base64url');
const keyId = await encryptionKeyIdFromPublicKey(encryptionPublicKey);
if (!keyId) throw new Error('Could not derive test encryption key ID');
const createdAt = Date.now();
const bundle = {
protocol: E2EE_PROTOCOL,
keyId,
version: 1,
publicKey: encryptionPublicKey,
createdAt,
recoveryCommitment: randomBytes(32).toString('base64url'),
};
const proofPayload = {
action: E2EE_KEY_BUNDLE_ACTION,
data: bundle,
did: ownerDid,
handle: 'alice',
nonce: randomBytes(16).toString('base64url'),
ts: createdAt,
};
const proofSigner = createSign('sha256');
proofSigner.update(canonicalize(proofPayload));
return {
did: ownerDid,
keyId,
keyVersion: 1,
publicKey: encryptionPublicKey,
proofAction: {
...proofPayload,
sig: proofSigner.sign({
key: privateKey,
dsaEncoding: 'ieee-p1363',
}).toString('base64url'),
},
};
}
async function importResponse(
exportData: unknown,
requestOverrides: Record<string, unknown> = {},
) {
return POST(new NextRequest('http://localhost/api/account/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
exportData,
password: 'wrong-on-purpose',
destinationEmail: 'alice@new.example',
newHandle: 'alice_new',
acceptedCompliance: true,
...requestOverrides,
}),
}));
}
function commonMessage(createdAt: string) {
return {
senderHandle: 'alice',
senderDisplayName: 'Alice',
senderAvatarUrl: null,
senderNodeDomain: null,
senderDid: ownerDid,
deliveredAt: null,
readAt: null,
createdAt,
};
}
function modernConversation(messages: unknown[], overrides: Record<string, unknown> = {}) {
return {
id: randomUUID(),
type: 'direct',
participant2Handle: 'bob',
lastMessageAt: null,
lastMessagePreview: null,
encryptionMode: 'legacy',
e2eeActivatedAt: null,
messages,
...overrides,
};
}
function encryptedMessage(createdAtMs: number) {
const messageId = randomUUID();
const senderKeyId = `k1_${randomBytes(12).toString('base64url')}`;
const recipientKeyId = `k1_${randomBytes(12).toString('base64url')}`;
const envelope = {
protocol: E2EE_PROTOCOL,
cipherSuite: E2EE_CIPHER_SUITE,
messageId,
conversationId: `dm1_${randomBytes(12).toString('base64url')}`,
senderDid: ownerDid,
senderHandle: 'alice',
recipientDid: peerDid,
recipientHandle: 'bob',
createdAt: createdAtMs,
senderKeyId,
senderKeyVersion: 1,
recipientKeyId,
recipientKeyVersion: 1,
nonce: randomBytes(24).toString('base64url'),
ciphertext: randomBytes(17).toString('base64url'),
keyCommitment: randomBytes(32).toString('base64url'),
keyEnvelopes: [
{
did: ownerDid,
keyId: senderKeyId,
keyVersion: 1,
sealedKey: randomBytes(112).toString('base64url'),
},
{
did: peerDid,
keyId: recipientKeyId,
keyVersion: 1,
sealedKey: randomBytes(112).toString('base64url'),
},
],
};
const actionNonce = randomBytes(16).toString('base64url');
const actionPayload = {
action: 'chat_e2ee',
data: envelope,
did: ownerDid,
handle: 'alice',
nonce: actionNonce,
ts: createdAtMs,
};
const actionSigner = createSign('sha256');
actionSigner.update(canonicalize(actionPayload));
return {
// SQLite timestamp columns retain seconds, while the authenticated
// envelope retains the original millisecond value.
...commonMessage(new Date(Math.floor(createdAtMs / 1_000) * 1_000).toISOString()),
content: null,
protocolVersion: 1,
clientMessageId: messageId,
encryptedEnvelope: JSON.stringify(envelope),
e2eeSignature: actionSigner.sign({
key: privateKey,
dsaEncoding: 'ieee-p1363',
}).toString('base64url'),
e2eeActionNonce: actionNonce,
e2eeActionTs: createdAtMs,
};
}
describe('account import DM integrity', () => {
it('requires a valid destination email before importing', async () => {
const response = await importResponse(
signedV11Export(basePayload([])),
{ destinationEmail: 'not-an-email' },
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Enter a valid destination email address',
});
});
it('accepts a v1.1 export with no E2EE continuity anchor', async () => {
const response = await importResponse(signedV11Export(basePayload([])));
expect(response.status).toBe(401);
await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' });
});
it('accepts a valid account-signed E2EE continuity anchor', async () => {
const anchor = await continuityAnchor();
const response = await importResponse(signedV11Export(basePayload([]), {
e2eeKeyBundle: anchor,
}));
expect(response.status).toBe(401);
await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' });
});
it('rejects a continuity anchor with a forged proof signature', async () => {
const anchor = await continuityAnchor();
anchor.proofAction.sig = randomBytes(64).toString('base64url');
const response = await importResponse(signedV11Export(basePayload([]), {
e2eeKeyBundle: anchor,
}));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining('continuity anchor signature is invalid'),
});
});
it('rejects continuity row fields that do not match the signed bundle', async () => {
const anchor = await continuityAnchor();
anchor.keyVersion = 2;
const response = await importResponse(signedV11Export(basePayload([]), {
e2eeKeyBundle: anchor,
}));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining('does not match its signed proof'),
});
});
it('rejects a self-certifying DID claimed by a different signing key', async () => {
const otherKeys = generateKeyPairSync('ec', {
namedCurve: 'P-256',
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
const squattedDid = generateDID(otherKeys.publicKey);
const response = await importResponse(signedV11Export(basePayload([]), {
did: squattedDid,
}));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Export identity must be a self-certifying did:key that matches its signing key',
});
});
it('rejects legacy identity methods that are not self-certifying', async () => {
const response = await importResponse(signedV11Export(basePayload([]), {
did: 'did:synapsis:legacy-account',
}));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Export identity must be a self-certifying did:key that matches its signing key',
});
});
it.each([
'127.0.0.1',
'localhost:43821',
'10.0.0.8',
'https://old.synapsis.social',
'old.synapsis.social/api/account/moved',
'user@old.synapsis.social',
])('rejects unsafe migration source node %s', async (sourceNode) => {
const response = await importResponse(signedV11Export(basePayload([]), {
sourceNode,
}));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Export source node is unsafe or invalid',
});
});
it('rejects edits to any signed v1.1 export payload field', async () => {
const exportData = signedV11Export(basePayload([]));
exportData.profile.displayName = 'Mallory';
const response = await importResponse(exportData);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Export payload integrity check failed',
});
});
it('also verifies the digest when a legacy v1.0 manifest carries one', async () => {
const exportData = signedExport(basePayload([]));
exportData.profile.displayName = 'Mallory';
const response = await importResponse(exportData);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'Export payload integrity check failed',
});
});
it('rejects unknown protocol versions instead of treating them as plaintext', async () => {
const createdAt = new Date().toISOString();
const unknownMessage = {
...commonMessage(createdAt),
content: 'must not be imported',
protocolVersion: 2,
clientMessageId: null,
encryptedEnvelope: null,
e2eeSignature: null,
e2eeActionNonce: null,
e2eeActionTs: null,
};
const response = await importResponse(signedV11Export(basePayload([
modernConversation([unknownMessage]),
])));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining('Unsupported DM protocol version'),
});
});
it('rejects plaintext at or after a conversation E2EE cutover', async () => {
const cutoverMs = Math.floor(Date.now() / 1_000) * 1_000 + 789;
const cutover = new Date(cutoverMs);
const legacyMessage = {
...commonMessage(new Date(Math.floor(cutoverMs / 1_000) * 1_000).toISOString()),
content: 'late plaintext',
protocolVersion: 0,
clientMessageId: null,
encryptedEnvelope: null,
e2eeSignature: null,
e2eeActionNonce: null,
e2eeActionTs: null,
};
const response = await importResponse(signedV11Export(basePayload([
modernConversation([legacyMessage], {
encryptionMode: 'e2ee',
e2eeActivatedAt: cutover.toISOString(),
}),
])));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining('plaintext at or after its E2EE cutover'),
});
});
it('allows legacy plaintext from the second before the E2EE cutover', async () => {
const cutoverMs = Math.floor(Date.now() / 1_000) * 1_000 + 789;
const legacyMessage = {
...commonMessage(new Date(Math.floor(cutoverMs / 1_000) * 1_000 - 1_000).toISOString()),
content: 'plaintext before cutover',
protocolVersion: 0,
clientMessageId: null,
encryptedEnvelope: null,
e2eeSignature: null,
e2eeActionNonce: null,
e2eeActionTs: null,
};
const response = await importResponse(signedV11Export(basePayload([
modernConversation([legacyMessage], {
encryptionMode: 'e2ee',
e2eeActivatedAt: new Date(cutoverMs).toISOString(),
}),
])));
expect(response.status).toBe(401);
await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' });
});
it('strictly validates an encrypted envelope and its stored signature metadata', async () => {
const createdAt = Math.floor(Date.now() / 1_000) * 1_000 + 537;
const validMessage = encryptedMessage(createdAt);
const validResponse = await importResponse(signedV11Export(basePayload([
modernConversation([validMessage], {
encryptionMode: 'e2ee',
e2eeActivatedAt: new Date(createdAt).toISOString(),
}),
])));
expect(validResponse.status).toBe(401);
const invalidMessage = { ...validMessage, senderHandle: 'mallory' };
const invalidResponse = await importResponse(signedV11Export(basePayload([
modernConversation([invalidMessage], {
encryptionMode: 'e2ee',
e2eeActivatedAt: new Date(createdAt).toISOString(),
}),
])));
expect(invalidResponse.status).toBe(400);
await expect(invalidResponse.json()).resolves.toMatchObject({
error: expect.stringContaining('sender handle does not match'),
});
const invalidSignature = {
...validMessage,
e2eeSignature: randomBytes(64).toString('base64url'),
};
const invalidSignatureResponse = await importResponse(signedV11Export(basePayload([
modernConversation([invalidSignature], {
encryptionMode: 'e2ee',
e2eeActivatedAt: new Date(createdAt).toISOString(),
}),
])));
expect(invalidSignatureResponse.status).toBe(400);
await expect(invalidSignatureResponse.json()).resolves.toMatchObject({
error: expect.stringContaining('signature is invalid'),
});
});
it('rejects digest-less historical exports before importing unauthenticated payloads', async () => {
const createdAt = new Date().toISOString();
const historicalMessage = {
...commonMessage(createdAt),
content: 'historical plaintext',
};
const historicalConversation = {
id: randomUUID(),
type: 'direct',
participant2Handle: 'bob',
lastMessageAt: createdAt,
lastMessagePreview: 'historical plaintext',
messages: [historicalMessage],
};
const historical = await importResponse(signedExport(
basePayload([historicalConversation]),
false,
));
expect(historical.status).toBe(400);
await expect(historical.json()).resolves.toMatchObject({
error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.',
});
const injectedConversation = {
...historicalConversation,
messages: [{ ...historicalMessage, protocolVersion: 2 }],
};
const injected = await importResponse(signedExport(
basePayload([injectedConversation]),
false,
));
expect(injected.status).toBe(400);
await expect(injected.json()).resolves.toMatchObject({
error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.',
});
});
});
+691 -91
View File
@@ -6,25 +6,101 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, posts, media, follows, remoteFollows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
import { db, users, posts, media, follows, remoteFollows, chatConversations, chatMessages, e2eeKeyBundles, e2eeMessageReceipts, bots, botContentSources, botActivityLogs } from '@/db';
import { eq, sql } from 'drizzle-orm';
import * as crypto from 'crypto';
import { z } from 'zod';
import { encryptApiKey, serializeEncryptedData } from '@/lib/bots/encryption';
import { v4 as uuid } from 'uuid';
import { upsertHandleEntries } from '@/lib/federation/handles';
import { canonicalize, verifySignedActionSignature } from '@/lib/crypto/user-signing';
import {
decryptPrivateKey as decryptStoredPrivateKey,
deserializeEncryptedKey,
serializeEncryptedKey,
} from '@/lib/crypto/private-key';
import { didKeyMatchesPublicKey } from '@/lib/crypto/did-key';
import { createSession, hashPassword } from '@/lib/auth';
import {
E2EE_CHAT_ACTION,
E2EE_KEY_BUNDLE_ACTION,
e2eeKeyBundleSchema,
e2eeMessageEnvelopeSchema,
signedUserActionSchema,
validateMessageBindings,
} from '@/lib/e2ee/protocol';
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
import { getPublicSwarmDomain, normalizeNodeDomain } from '@/lib/swarm/node-domain';
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
interface ImportManifest {
version: string;
did: string;
handle: string;
sourceNode: string;
exportedAt: string;
expiresAt?: string; // Optional for backward compatibility
publicKey: string;
privateKeyEncrypted: string;
salt: string;
iv: string;
signature: string;
const isoTimestampSchema = z.iso.datetime({ offset: true });
const didSchema = z.string().min(8).max(2_048).regex(/^did:/);
const base64Schema = z.string().min(1).max(32_768).regex(/^[A-Za-z0-9+/=_-]+$/);
const destinationEmailSchema = z.string().trim().email().max(320);
const serializedEncryptedKeySchema = z.string().min(2).max(65_536).refine((value) => {
try {
const parsed = JSON.parse(value) as Record<string, unknown>;
return Object.keys(parsed).length === 3
&& typeof parsed.encrypted === 'string' && base64Schema.safeParse(parsed.encrypted).success
&& typeof parsed.salt === 'string' && base64Schema.max(256).safeParse(parsed.salt).success
&& typeof parsed.iv === 'string' && base64Schema.max(256).safeParse(parsed.iv).success;
} catch {
return false;
}
}, { message: 'Invalid encrypted private key' });
// The property order of the 1.0 schema intentionally matches the historical
// exporter because those manifests were signed with JSON.stringify().
const importManifestV10Schema = z.strictObject({
version: z.literal('1.0'),
did: didSchema,
handle: z.string().min(1).max(320),
sourceNode: z.string().min(1).max(320),
exportedAt: isoTimestampSchema,
expiresAt: isoTimestampSchema.optional(),
publicKey: z.string().min(1).max(16_384),
privateKeyEncrypted: base64Schema,
salt: base64Schema.max(256),
iv: base64Schema.max(256),
payloadDigestAlgorithm: z.literal('sha256').optional(),
payloadDigest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
signature: base64Schema,
}).superRefine((manifest, context) => {
if ((manifest.payloadDigestAlgorithm === undefined) !== (manifest.payloadDigest === undefined)) {
context.addIssue({
code: 'custom',
message: 'Payload digest fields must be provided together',
});
}
});
const importManifestV11Schema = z.strictObject({
version: z.literal('1.1'),
did: didSchema,
handle: z.string().min(1).max(320),
sourceNode: z.string().min(1).max(320),
exportedAt: isoTimestampSchema,
expiresAt: isoTimestampSchema,
publicKey: z.string().min(1).max(16_384),
privateKeyEncrypted: serializedEncryptedKeySchema,
payloadDigestAlgorithm: z.literal('sha256'),
payloadDigest: z.string().regex(/^[a-f0-9]{64}$/),
signature: base64Schema,
});
const importManifestSchema = z.union([
importManifestV10Schema,
importManifestV11Schema,
]);
type ImportManifest = z.infer<typeof importManifestSchema>;
function hasPayloadDigest(manifest: ImportManifest): manifest is ImportManifest & {
payloadDigestAlgorithm: 'sha256';
payloadDigest: string;
} {
return manifest.payloadDigestAlgorithm === 'sha256'
&& typeof manifest.payloadDigest === 'string';
}
interface ImportProfile {
@@ -56,6 +132,8 @@ interface ImportDMConversation {
participant2Handle: string;
lastMessageAt: string | null;
lastMessagePreview: string | null;
encryptionMode: 'legacy' | 'e2ee';
e2eeActivatedAt: string | null;
messages: ImportDMMessage[];
}
@@ -66,6 +144,12 @@ interface ImportDMMessage {
senderNodeDomain: string | null;
senderDid: string | null;
content: string | null;
protocolVersion: 0 | 1;
clientMessageId: string | null;
encryptedEnvelope: string | null;
e2eeSignature: string | null;
e2eeActionNonce: string | null;
e2eeActionTs: number | null;
deliveredAt: string | null;
readAt: string | null;
createdAt: string;
@@ -78,32 +162,379 @@ interface ImportBot {
bio: string | null;
avatarUrl: string | null;
headerUrl: string | null;
personalityConfig: any;
personalityConfig: unknown;
llmProvider: string;
llmModel: string;
llmApiKey: string;
botPrivateKey: string;
publicKey: string;
scheduleConfig: any;
scheduleConfig: unknown;
autonomousMode: boolean;
isActive: boolean;
sources: any[];
activityLogs: any[];
sources: ImportBotSource[];
activityLogs: ImportBotActivityLog[];
}
interface ImportPackage {
manifest: ImportManifest;
profile: ImportProfile;
posts: ImportPost[];
following: ImportFollowing[];
dms: ImportDMConversation[];
bots: ImportBot[];
interface ImportBotSource {
type: string;
url: string;
subreddit?: string | null;
apiKeyEncrypted?: string | null;
sourceConfig?: unknown;
keywords?: unknown;
isActive: boolean;
}
interface ImportBotActivityLog {
action: string;
details: unknown;
success: boolean;
errorMessage?: string | null;
createdAt: string;
}
const dmMessageCommonSchema = {
senderHandle: z.string().min(1).max(640),
senderDisplayName: z.string().max(1_000).nullable(),
senderAvatarUrl: z.string().max(16_384).nullable(),
senderNodeDomain: z.string().max(320).nullable(),
deliveredAt: isoTimestampSchema.nullable(),
readAt: isoTimestampSchema.nullable(),
createdAt: isoTimestampSchema,
};
const historicalLegacyDMMessageSchema = z.strictObject({
...dmMessageCommonSchema,
senderDid: didSchema.nullable(),
content: z.string().max(100_000).nullable(),
});
const legacyDMMessageSchema = z.strictObject({
...dmMessageCommonSchema,
senderDid: didSchema.nullable(),
content: z.string().max(100_000).nullable(),
protocolVersion: z.literal(0),
clientMessageId: z.null(),
encryptedEnvelope: z.null(),
e2eeSignature: z.null(),
e2eeActionNonce: z.null(),
e2eeActionTs: z.null(),
});
const encryptedDMMessageSchema = z.strictObject({
...dmMessageCommonSchema,
senderDid: didSchema,
content: z.null(),
protocolVersion: z.literal(1),
clientMessageId: z.string().uuid(),
encryptedEnvelope: z.string().min(2).max(32_768),
e2eeSignature: z.string().min(1).max(512).regex(/^[A-Za-z0-9_-]+$/),
e2eeActionNonce: z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/),
e2eeActionTs: z.number().int().positive(),
});
const e2eeContinuityAnchorSchema = z.strictObject({
did: didSchema,
keyId: z.string().min(12).max(96).regex(/^k1_[A-Za-z0-9_-]+$/),
keyVersion: z.number().int().positive().max(1_000_000),
publicKey: z.string().min(1).max(64).regex(/^[A-Za-z0-9_-]+$/),
proofAction: signedUserActionSchema,
});
interface ImportedE2EEContinuityAnchor {
did: string;
keyId: string;
keyVersion: number;
publicKey: string;
proofAction: z.infer<typeof signedUserActionSchema>;
createdAt: number;
}
const dmConversationCommonSchema = {
id: z.string().min(1).max(128),
type: z.string().min(1).max(32),
participant2Handle: z.string().min(1).max(640),
lastMessageAt: isoTimestampSchema.nullable(),
lastMessagePreview: z.string().max(100_000).nullable(),
messages: z.array(z.unknown()).max(100_000),
};
const dmConversationV10Schema = z.strictObject({
...dmConversationCommonSchema,
});
const dmConversationV11Schema = z.strictObject({
...dmConversationCommonSchema,
encryptionMode: z.enum(['legacy', 'e2ee']),
e2eeActivatedAt: isoTimestampSchema.nullable(),
});
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isExactDevelopmentLoopbackNode(value: string): boolean {
const match = value.match(/^(?:localhost|127\.0\.0\.1|\[::1\])(?::(\d{1,5}))?$/);
if (!match) return false;
if (!match[1]) return true;
const port = Number(match[1]);
return Number.isInteger(port) && port >= 1 && port <= 65_535;
}
function validatedSourceNode(value: string): string | null {
if (value !== value.trim()) return null;
const normalized = value.toLowerCase();
if (!normalized || normalizeNodeDomain(normalized) !== normalized) return null;
if (process.env.NODE_ENV === 'development' && isExactDevelopmentLoopbackNode(normalized)) {
return normalized;
}
const publicDomain = getPublicSwarmDomain(normalized);
return publicDomain === normalized ? publicDomain : null;
}
function sourceNodeProtocol(sourceNode: string): 'http' | 'https' {
return process.env.NODE_ENV === 'development'
&& isExactDevelopmentLoopbackNode(sourceNode)
? 'http'
: 'https';
}
async function validateE2EEContinuityAnchor(
rawAnchor: unknown,
manifest: ImportManifest,
): Promise<ImportedE2EEContinuityAnchor> {
const anchor = e2eeContinuityAnchorSchema.parse(rawAnchor);
const proof = anchor.proofAction;
const bundle = e2eeKeyBundleSchema.parse(proof.data);
if (proof.action !== E2EE_KEY_BUNDLE_ACTION
|| proof.did !== manifest.did
|| proof.handle.toLowerCase() !== manifest.handle.toLowerCase()
|| anchor.did !== manifest.did
|| anchor.did !== proof.did
|| anchor.keyId !== bundle.keyId
|| anchor.keyVersion !== bundle.version
|| anchor.publicKey !== bundle.publicKey
|| Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1_000) {
throw new Error('E2EE continuity anchor does not match its signed proof');
}
if (Buffer.from(bundle.publicKey, 'base64url').length !== 32
|| Buffer.from(bundle.recoveryCommitment, 'base64url').length !== 32
|| await encryptionKeyIdFromPublicKey(bundle.publicKey) !== bundle.keyId) {
throw new Error('E2EE continuity anchor key is invalid');
}
if (!await verifySignedActionSignature(proof, manifest.publicKey)) {
throw new Error('E2EE continuity anchor signature is invalid');
}
return {
did: anchor.did,
keyId: anchor.keyId,
keyVersion: anchor.keyVersion,
publicKey: anchor.publicKey,
proofAction: proof,
createdAt: bundle.createdAt,
};
}
function digestImportPayload(exportData: Record<string, unknown>): string {
const payload = Object.fromEntries(
Object.entries(exportData).filter(([key]) => key !== 'manifest'),
);
return crypto.createHash('sha256').update(canonicalize(payload)).digest('hex');
}
function payloadDigestMatches(expected: string, actual: string): boolean {
const expectedBytes = Buffer.from(expected, 'hex');
const actualBytes = Buffer.from(actual, 'hex');
return expectedBytes.length === actualBytes.length
&& expectedBytes.length === 32
&& crypto.timingSafeEqual(expectedBytes, actualBytes);
}
function sqliteTimestampSecond(timestamp: number): number {
return Math.floor(timestamp / 1_000);
}
function validateCiphertextSizes(envelope: z.infer<typeof e2eeMessageEnvelopeSchema>): void {
if (Buffer.from(envelope.nonce, 'base64url').length !== 24
|| Buffer.from(envelope.ciphertext, 'base64url').length < 17
|| Buffer.from(envelope.ciphertext, 'base64url').length > 8_192
|| Buffer.from(envelope.keyCommitment, 'base64url').length !== 32
|| envelope.keyEnvelopes.some((item) => Buffer.from(item.sealedKey, 'base64url').length !== 112)) {
throw new Error('Encrypted DM has invalid field sizes');
}
}
async function normalizeEncryptedDMMessage(
rawMessage: unknown,
ownerDid: string,
ownerPublicKey: string,
): Promise<ImportDMMessage> {
const message = encryptedDMMessageSchema.parse(rawMessage);
let rawEnvelope: unknown;
try {
rawEnvelope = JSON.parse(message.encryptedEnvelope);
} catch {
throw new Error('Encrypted DM envelope is not valid JSON');
}
const envelope = e2eeMessageEnvelopeSchema.parse(rawEnvelope);
const storedSenderHandle = message.senderHandle.toLowerCase().replace(/^@/, '');
const signedSenderHandle = envelope.senderHandle.toLowerCase().replace(/^@/, '');
if (storedSenderHandle !== signedSenderHandle
&& !storedSenderHandle.startsWith(`${signedSenderHandle}@`)) {
throw new Error('Encrypted DM sender handle does not match its envelope');
}
const signedAction = signedUserActionSchema.parse({
action: E2EE_CHAT_ACTION,
data: envelope,
did: message.senderDid,
handle: envelope.senderHandle,
ts: message.e2eeActionTs,
nonce: message.e2eeActionNonce,
sig: message.e2eeSignature,
});
validateMessageBindings(envelope, signedAction);
validateCiphertextSizes(envelope);
// The export carries the imported account's signing key, so messages sent
// by that account can and must retain a valid original action signature.
// Incoming peer signatures remain structurally validated here and are
// verified against resolved peer keys when displayed by the chat client.
if (envelope.senderDid === ownerDid
&& !await verifySignedActionSignature(signedAction, ownerPublicKey)) {
throw new Error('Encrypted DM signature is invalid');
}
if (message.clientMessageId !== envelope.messageId) {
throw new Error('Encrypted DM message ID does not match its envelope');
}
if (sqliteTimestampSecond(Date.parse(message.createdAt))
!== sqliteTimestampSecond(envelope.createdAt)) {
throw new Error('Encrypted DM timestamp does not match its envelope');
}
if (envelope.senderDid !== ownerDid && envelope.recipientDid !== ownerDid) {
throw new Error('Encrypted DM does not include the imported account');
}
if (envelope.senderDid === envelope.recipientDid || envelope.keyEnvelopes.length !== 2) {
throw new Error('Encrypted DM participants are invalid');
}
return {
...message,
encryptedEnvelope: JSON.stringify(envelope),
};
}
function normalizeLegacyDMMessage(
rawMessage: unknown,
integrityProtected: boolean,
): ImportDMMessage {
const message = integrityProtected
? legacyDMMessageSchema.parse(rawMessage)
: historicalLegacyDMMessageSchema.parse(rawMessage);
return {
...message,
protocolVersion: 0,
clientMessageId: null,
encryptedEnvelope: null,
e2eeSignature: null,
e2eeActionNonce: null,
e2eeActionTs: null,
};
}
async function normalizeImportedDMs(
rawConversations: unknown[],
manifest: ImportManifest,
payloadIntegrityVerified: boolean,
): Promise<ImportDMConversation[]> {
const integrityProtected = payloadIntegrityVerified;
return Promise.all(rawConversations.map(async (rawConversation, conversationIndex) => {
const protectedConversation = integrityProtected
? dmConversationV11Schema.parse(rawConversation)
: null;
const conversation = protectedConversation
?? dmConversationV10Schema.parse(rawConversation);
const messages: ImportDMMessage[] = [];
let sawEncrypted = false;
for (const [messageIndex, rawMessage] of conversation.messages.entries()) {
const rawVersion = isRecord(rawMessage) ? rawMessage.protocolVersion : undefined;
try {
if (!integrityProtected) {
// Historical 1.0 exports predate encrypted DM fields. The
// strict historical schema rejects injected protocol
// markers instead of guessing that they are plaintext.
messages.push(normalizeLegacyDMMessage(rawMessage, false));
} else if (rawVersion === 0) {
messages.push(normalizeLegacyDMMessage(rawMessage, true));
} else if (rawVersion === 1) {
messages.push(await normalizeEncryptedDMMessage(
rawMessage,
manifest.did,
manifest.publicKey,
));
sawEncrypted = true;
} else {
throw new Error('Unsupported DM protocol version');
}
} catch (error) {
const reason = error instanceof Error ? error.message : 'invalid message';
throw new Error(`Invalid DM ${conversationIndex + 1}.${messageIndex + 1}: ${reason}`);
}
}
let encryptionMode: 'legacy' | 'e2ee' = 'legacy';
let activatedAt: number | null = null;
if (protectedConversation) {
encryptionMode = protectedConversation.encryptionMode;
activatedAt = protectedConversation.e2eeActivatedAt
? Date.parse(protectedConversation.e2eeActivatedAt)
: null;
if (encryptionMode === 'legacy' && activatedAt !== null) {
throw new Error(`Legacy DM conversation ${conversationIndex + 1} has an E2EE activation time`);
}
if (encryptionMode === 'legacy' && sawEncrypted) {
throw new Error(`Legacy DM conversation ${conversationIndex + 1} contains encrypted messages`);
}
if (encryptionMode === 'e2ee' && activatedAt === null) {
throw new Error(`Encrypted DM conversation ${conversationIndex + 1} has no activation time`);
}
}
if (activatedAt !== null) {
const legacyAfterCutover = messages.some((message) => (
message.protocolVersion === 0
&& sqliteTimestampSecond(Date.parse(message.createdAt))
>= sqliteTimestampSecond(activatedAt!)
));
if (legacyAfterCutover) {
throw new Error(`DM conversation ${conversationIndex + 1} contains plaintext at or after its E2EE cutover`);
}
}
return {
id: conversation.id,
type: conversation.type,
participant2Handle: conversation.participant2Handle,
lastMessageAt: conversation.lastMessageAt,
lastMessagePreview: conversation.lastMessagePreview,
encryptionMode,
e2eeActivatedAt: activatedAt === null ? null : new Date(activatedAt).toISOString(),
messages,
};
}));
}
/**
* Decrypt the private key using the user's password
*/
function decryptPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
function decryptLegacyExportPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
const saltBuffer = Buffer.from(salt, 'base64');
const ivBuffer = Buffer.from(iv, 'base64');
const encryptedBuffer = Buffer.from(encrypted, 'base64');
@@ -125,13 +556,26 @@ function decryptPrivateKey(encrypted: string, password: string, salt: string, iv
return decrypted.toString('utf8');
}
function signingKeyMatchesPublicKey(privateKey: string, publicKey: string): boolean {
try {
const derivedPublicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' });
const expectedPublicKey = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
return derivedPublicKey.length === expectedPublicKey.length
&& crypto.timingSafeEqual(derivedPublicKey, expectedPublicKey);
} catch {
return false;
}
}
/**
* Verify the manifest signature
*/
function verifyManifestSignature(manifest: ImportManifest): boolean {
try {
const { signature, ...manifestData } = manifest;
const data = JSON.stringify(manifestData);
const data = manifest.version === '1.1'
? canonicalize(manifestData)
: JSON.stringify(manifestData);
const verify = crypto.createVerify('sha256');
verify.update(data);
@@ -145,31 +589,39 @@ function verifyManifestSignature(manifest: ImportManifest): boolean {
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { exportData, password, newHandle, acceptedCompliance } = body as {
exportData: ImportPackage;
password: string;
newHandle: string;
acceptedCompliance: boolean;
};
const body: unknown = await req.json();
if (!isRecord(body)) {
return NextResponse.json({ error: 'Invalid import request' }, { status: 400 });
}
const { exportData, password, newHandle, destinationEmail, acceptedCompliance } = body;
// Validate required fields
if (!exportData || !password || !newHandle) {
if (!isRecord(exportData)
|| typeof password !== 'string' || password.length === 0
|| typeof newHandle !== 'string' || newHandle.length === 0
|| typeof destinationEmail !== 'string' || destinationEmail.length === 0) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (!acceptedCompliance) {
const destinationEmailResult = destinationEmailSchema.safeParse(destinationEmail);
if (!destinationEmailResult.success) {
return NextResponse.json({
error: 'Enter a valid destination email address',
}, { status: 400 });
}
const destinationEmailClean = destinationEmailResult.data.toLowerCase();
if (acceptedCompliance !== true) {
return NextResponse.json({
error: 'You must accept the content compliance agreement'
}, { status: 400 });
}
const { manifest, profile, posts: importPosts, following } = exportData;
// Validate manifest version
if (manifest.version !== '1.0') {
return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 });
const manifestResult = importManifestSchema.safeParse(exportData.manifest);
if (!manifestResult.success) {
return NextResponse.json({ error: 'Invalid or unsupported export manifest' }, { status: 400 });
}
const manifest = manifestResult.data;
// Check if export has expired
if (manifest.expiresAt) {
@@ -181,25 +633,103 @@ export async function POST(req: NextRequest) {
}
}
const { dms: importDMs = [], bots: importBots = [] } = exportData;
// Verify signature
if (!verifyManifestSignature(manifest)) {
return NextResponse.json({ error: 'Invalid export signature' }, { status: 400 });
}
if (!didKeyMatchesPublicKey(manifest.did, manifest.publicKey)) {
return NextResponse.json({
error: 'Export identity must be a self-certifying did:key that matches its signing key',
}, { status: 400 });
}
const sourceNode = validatedSourceNode(manifest.sourceNode);
if (!sourceNode) {
return NextResponse.json({ error: 'Export source node is unsafe or invalid' }, { status: 400 });
}
if (!hasPayloadDigest(manifest)) {
return NextResponse.json({
error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.',
}, { status: 400 });
}
const actualDigest = digestImportPayload(exportData);
if (!payloadDigestMatches(manifest.payloadDigest, actualDigest)) {
return NextResponse.json({ error: 'Export payload integrity check failed' }, { status: 400 });
}
const { profile: rawProfile, posts: rawPosts, following: rawFollowing } = exportData;
if (!isRecord(rawProfile) || !Array.isArray(rawPosts) || !Array.isArray(rawFollowing)) {
return NextResponse.json({ error: 'Invalid export payload' }, { status: 400 });
}
const rawDMs = exportData.dms === undefined ? [] : exportData.dms;
const rawBots = exportData.bots === undefined ? [] : exportData.bots;
if (!Array.isArray(rawDMs) || !Array.isArray(rawBots)) {
return NextResponse.json({ error: 'Invalid export payload' }, { status: 400 });
}
let importedE2EEKeyBundle: ImportedE2EEContinuityAnchor | null = null;
if (manifest.version === '1.1' && exportData.e2eeKeyBundle != null) {
try {
importedE2EEKeyBundle = await validateE2EEContinuityAnchor(
exportData.e2eeKeyBundle,
manifest,
);
} catch (error) {
const reason = error instanceof Error ? error.message : 'invalid E2EE continuity anchor';
return NextResponse.json({ error: `Invalid E2EE continuity anchor: ${reason}` }, { status: 400 });
}
}
let importDMs: ImportDMConversation[];
try {
importDMs = await normalizeImportedDMs(rawDMs, manifest, true);
} catch (error) {
const reason = error instanceof Error ? error.message : 'invalid direct-message history';
return NextResponse.json({ error: `Invalid direct-message history: ${reason}` }, { status: 400 });
}
const encryptedDMImportWarning = importDMs.some((conversation) => (
conversation.messages.some((message) => message.protocolVersion === 1)
))
? 'Encrypted DM records were preserved, but their decryption key is not portable yet. This history cannot be opened on this node.'
: null;
const federatedMoveWarning = importedE2EEKeyBundle
? 'Federated DM relationships do not automatically follow a home-node move. Peers that cached your old full handle may reject the new handle until signed account-move support is implemented.'
: null;
const profile = rawProfile as unknown as ImportProfile;
const importPosts = rawPosts as ImportPost[];
const following = rawFollowing as ImportFollowing[];
const importBots = rawBots as ImportBot[];
// Decrypt private key to verify password is correct
let privateKey: string;
let privateKeyEncryptedForStorage: string;
try {
privateKey = decryptPrivateKey(
manifest.privateKeyEncrypted,
password,
manifest.salt,
manifest.iv
);
} catch (error) {
if (manifest.version === '1.1') {
privateKey = decryptStoredPrivateKey(
deserializeEncryptedKey(manifest.privateKeyEncrypted),
password,
);
privateKeyEncryptedForStorage = manifest.privateKeyEncrypted;
} else {
privateKey = decryptLegacyExportPrivateKey(
manifest.privateKeyEncrypted,
password,
manifest.salt,
manifest.iv,
);
privateKeyEncryptedForStorage = serializeEncryptedKey({
encrypted: manifest.privateKeyEncrypted,
salt: manifest.salt,
iv: manifest.iv,
});
}
} catch {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
if (!signingKeyMatchesPublicKey(privateKey, manifest.publicKey)) {
return NextResponse.json({ error: 'Export private key does not match its signing key' }, { status: 400 });
}
const importedPasswordHash = await hashPassword(password);
// Check if DID already exists on this node
const existingDid = await db.query.users.findFirst({
@@ -212,6 +742,16 @@ export async function POST(req: NextRequest) {
}, { status: 409 });
}
const existingEmail = await db.query.users.findFirst({
where: { email: destinationEmailClean },
});
if (existingEmail) {
return NextResponse.json({
error: 'Email is already registered on this node',
}, { status: 409 });
}
// Validate handle format
const handleClean = newHandle.toLowerCase().replace(/^@/, '').trim();
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handleClean)) {
@@ -233,23 +773,43 @@ export async function POST(req: NextRequest) {
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const oldActorUrl = `https://${manifest.sourceNode}/users/${manifest.handle}`;
const oldActorUrl = `${sourceNodeProtocol(sourceNode)}://${sourceNode}/users/${manifest.handle}`;
const newActorUrl = `https://${nodeDomain}/users/${handleClean}`;
// Create the user with the same DID
const [newUser] = await db.insert(users).values({
did: manifest.did,
handle: handleClean,
displayName: profile.displayName,
bio: profile.bio,
avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading
headerUrl: profile.headerUrl,
publicKey: manifest.publicKey,
privateKeyEncrypted: privateKey,
movedFrom: oldActorUrl,
migratedAt: new Date(),
postsCount: importPosts.length,
}).returning();
// The identity and its public continuity anchor are one unit. A key-ID
// conflict must not leave behind an account that cannot be retried.
const newUser = await db.transaction(async (tx) => {
const [createdUser] = await tx.insert(users).values({
did: manifest.did,
handle: handleClean,
email: destinationEmailClean,
displayName: profile.displayName,
bio: profile.bio,
avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading
headerUrl: profile.headerUrl,
publicKey: manifest.publicKey,
privateKeyEncrypted: privateKeyEncryptedForStorage,
passwordHash: importedPasswordHash,
movedFrom: oldActorUrl,
migratedAt: new Date(),
postsCount: importPosts.length,
}).returning();
if (importedE2EEKeyBundle) {
await tx.insert(e2eeKeyBundles).values({
userId: createdUser.id,
did: importedE2EEKeyBundle.did,
keyId: importedE2EEKeyBundle.keyId,
keyVersion: importedE2EEKeyBundle.keyVersion,
publicKey: importedE2EEKeyBundle.publicKey,
proofAction: JSON.stringify(importedE2EEKeyBundle.proofAction),
createdAt: new Date(importedE2EEKeyBundle.createdAt),
updatedAt: new Date(),
});
}
return createdUser;
});
// Check if this is an NSFW node and auto-enable NSFW settings
const node = await db.query.nodes.findFirst({
@@ -346,31 +906,53 @@ export async function POST(req: NextRequest) {
.set({ followingCount: importedFollowing })
.where(eq(users.id, newUser.id));
// Import DMs
// Import each conversation and all of its messages atomically. A
// failed message cannot leave a partial conversation behind.
let importedDMs = 0;
for (const conv of importDMs) {
try {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: newUser.id,
participant2Handle: conv.participant2Handle,
type: conv.type,
lastMessageAt: conv.lastMessageAt ? new Date(conv.lastMessageAt) : null,
lastMessagePreview: conv.lastMessagePreview
}).returning();
await db.transaction(async (tx) => {
const [newConv] = await tx.insert(chatConversations).values({
participant1Id: newUser.id,
participant2Handle: conv.participant2Handle,
type: conv.type,
lastMessageAt: conv.lastMessageAt ? new Date(conv.lastMessageAt) : null,
lastMessagePreview: conv.encryptionMode === 'e2ee'
? 'Encrypted message'
: conv.lastMessagePreview,
encryptionMode: conv.encryptionMode,
e2eeActivatedAt: conv.e2eeActivatedAt ? new Date(conv.e2eeActivatedAt) : null,
}).returning();
for (const msg of conv.messages) {
await db.insert(chatMessages).values({
conversationId: newConv.id,
senderHandle: msg.senderHandle,
senderDisplayName: msg.senderDisplayName,
senderAvatarUrl: msg.senderAvatarUrl,
senderNodeDomain: msg.senderNodeDomain,
senderDid: msg.senderDid,
content: msg.content,
deliveredAt: msg.deliveredAt ? new Date(msg.deliveredAt) : null,
readAt: msg.readAt ? new Date(msg.readAt) : null,
createdAt: new Date(msg.createdAt)
});
}
for (const msg of conv.messages) {
await tx.insert(chatMessages).values({
conversationId: newConv.id,
senderHandle: msg.senderHandle,
senderDisplayName: msg.senderDisplayName,
senderAvatarUrl: msg.senderAvatarUrl,
senderNodeDomain: msg.senderNodeDomain,
senderDid: msg.senderDid,
content: msg.protocolVersion === 0 ? msg.content : null,
protocolVersion: msg.protocolVersion,
clientMessageId: msg.clientMessageId,
encryptedEnvelope: msg.encryptedEnvelope,
e2eeSignature: msg.e2eeSignature,
e2eeActionNonce: msg.e2eeActionNonce,
e2eeActionTs: msg.e2eeActionTs,
deliveredAt: msg.deliveredAt ? new Date(msg.deliveredAt) : null,
readAt: msg.readAt ? new Date(msg.readAt) : null,
createdAt: new Date(msg.createdAt),
});
if (msg.protocolVersion === 1 && msg.clientMessageId && msg.senderDid) {
await tx.insert(e2eeMessageReceipts).values({
ownerUserId: newUser.id,
senderDid: msg.senderDid,
messageId: msg.clientMessageId,
}).onConflictDoNothing();
}
}
});
importedDMs += 1;
} catch (error) {
console.error('Failed to import DM conversation:', error);
}
@@ -447,12 +1029,23 @@ export async function POST(req: NextRequest) {
// Notify old node about the migration
try {
await notifyOldNode(manifest.sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey);
await notifyOldNode(sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey);
} catch (error) {
console.error('Failed to notify old node:', error);
// Don't fail the import if notification fails
}
// Match registration/login behavior so the successful import can
// redirect directly into the authenticated app. The imported email
// and password hash also make ordinary email login work thereafter.
let sessionWarning: string | null = null;
try {
await createSession(newUser.id);
} catch (error) {
console.error('Account imported but automatic sign-in failed:', error);
sessionWarning = 'The account was imported, but automatic sign-in failed. Sign in with the destination email and import password.';
}
return NextResponse.json({
success: true,
user: {
@@ -464,10 +1057,12 @@ export async function POST(req: NextRequest) {
stats: {
postsImported: importedPosts,
followingImported: importedFollowing,
dmsImported: importDMs.length,
dmsImported: importedDMs,
botsImported: importBots.length,
},
message: 'Account imported successfully. Your followers on other Synapsis nodes will be automatically migrated.',
warnings: [encryptedDMImportWarning, federatedMoveWarning, sessionWarning]
.filter((warning): warning is string => warning !== null),
message: 'Account imported successfully. The old node was notified of the move when reachable.',
});
} catch (error) {
@@ -498,7 +1093,8 @@ async function notifyOldNode(
sign.update(JSON.stringify(payload));
const signature = sign.sign(privateKey, 'base64');
const response = await fetch(`https://${sourceNode}/api/account/moved`, {
const response = await safeFederationRequest(
`${sourceNodeProtocol(sourceNode)}://${sourceNode}/api/account/moved`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -507,9 +1103,13 @@ async function notifyOldNode(
...payload,
signature,
}),
timeoutMs: 5_000,
maxResponseBytes: 64 * 1024,
});
if (!response.ok) {
// The safe requester never follows redirects. Treat every non-2xx,
// including redirects, as a failed best-effort notification.
if (response.status < 200 || response.status >= 300) {
throw new Error(`Old node returned ${response.status}`);
}
}
+349 -301
View File
@@ -1,313 +1,361 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import {
chatConversations,
chatMessages,
db,
e2eeMessageReceipts,
handleRegistry,
} from '@/db';
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
import { normalizeSigningPublicKey } from '@/lib/crypto/did-key';
import { signingPublicKeyFromDid } from '@/lib/e2ee/bundle-proof';
import {
E2EE_PROTOCOL_VERSION,
e2eeMessageEnvelopeSchema,
signedUserActionSchema,
validateMessageBindings,
} from '@/lib/e2ee/protocol';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { isRateLimited } from '@/lib/rate-limit';
const signedChatActionSchema = z.object({
action: z.string().min(1),
did: z.string().regex(/^did:/, 'Must be a valid DID'),
handle: z.string().min(3).max(30),
ts: z.number(),
nonce: z.string().min(1),
sig: z.string().min(1),
data: z.object({
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
content: z.string().min(1).max(5000),
}),
const federatedEnvelopeSchema = z.strictObject({
userAction: signedUserActionSchema,
fullSenderHandle: z.string().min(3).max(640),
sourceDomain: z.string().min(1).max(253),
destinationDomain: z.string().min(1).max(253),
route: z.literal('/api/chat/receive'),
deliveryId: z.string().min(12).max(512),
ts: z.number().int().positive(),
expiresAt: z.number().int().positive(),
});
// Backward compatibility for older nodes that sent legacy field names.
const legacyChatActionSchema = z.object({
did: z.string().regex(/^did:/, 'Must be a valid DID'),
handle: z.string().min(3).max(30),
data: z.object({
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
content: z.string().min(1).max(5000),
}),
signature: z.string().min(1),
timestamp: z.number(),
});
const remoteProfileResponseSchema = z.object({
user: z.object({
did: z.string(),
publicKey: z.string(),
displayName: z.string().nullish(),
avatarUrl: z.string().nullish(),
isBot: z.boolean().optional(),
}).passthrough(),
}).passthrough();
const incomingChatActionSchema = z.union([signedChatActionSchema, legacyChatActionSchema]);
const federatedEnvelopeSchema = z.object({
userAction: incomingChatActionSchema,
fullSenderHandle: z.string().min(3).max(60),
sourceDomain: z.string().min(1),
ts: z.number(),
});
function normalizeSignedAction(action: z.infer<typeof incomingChatActionSchema>): SignedAction {
if ('sig' in action) {
return action;
}
return {
action: 'chat',
data: action.data,
did: action.did,
handle: action.handle,
ts: action.timestamp,
nonce: `legacy:${action.did}:${action.timestamp}`,
sig: action.signature,
};
class E2EEIdentityContinuityError extends Error {
constructor() {
super('Sender identity continuity check failed');
this.name = 'E2EEIdentityContinuityError';
}
}
/**
* POST /api/chat/receive
* Endpoint for receiving federated chat messages from other nodes.
* Expects either:
* 1. A SignedAction payload from the sender (legacy, for backward compatibility)
* 2. A federated envelope with node's signature and user's signed action
*/
const MAX_CONCURRENT_NODE_VERIFICATIONS = 32;
let activeNodeVerifications = 0;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Check if this is a federated envelope (node-signed)
const swarmSignature = request.headers.get('X-Swarm-Signature');
const sourceDomain = request.headers.get('X-Swarm-Source-Domain');
let signedAction: SignedAction;
let fullSenderHandle: string | null = null;
if (swarmSignature && sourceDomain && body.userAction) {
const normalizedSourceDomain = normalizeNodeDomain(sourceDomain);
if (await isNodeBlocked(normalizedSourceDomain)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
// Federated envelope format - validate and verify node signature
const envelopeValidation = federatedEnvelopeSchema.safeParse(body);
if (!envelopeValidation.success) {
return NextResponse.json(
{ error: 'Invalid envelope payload', details: envelopeValidation.error.issues },
{ status: 400 }
);
}
const isValidNodeSig = await verifySwarmRequest(
{ userAction: body.userAction, fullSenderHandle: body.fullSenderHandle, sourceDomain: body.sourceDomain, ts: body.ts },
swarmSignature,
sourceDomain
);
if (!isValidNodeSig) {
console.error('[Chat Receive] Invalid node signature from:', sourceDomain);
return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 });
}
// Extract user's signed action and full handle from envelope
signedAction = normalizeSignedAction(body.userAction);
fullSenderHandle = body.fullSenderHandle;
console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`);
} else {
// Legacy format - direct user signed action
const actionValidation = incomingChatActionSchema.safeParse(body);
if (!actionValidation.success) {
return NextResponse.json(
{ error: 'Invalid action payload', details: actionValidation.error.issues },
{ status: 400 }
);
}
signedAction = normalizeSignedAction(actionValidation.data);
}
const { did, handle, data } = signedAction;
const { recipientDid, content } = data || {};
// Use full handle if provided in envelope, otherwise fall back to signed handle
const senderHandle = fullSenderHandle || handle;
const senderDomainFromHandle = senderHandle.includes('@')
? normalizeNodeDomain(senderHandle.split('@').pop() || '')
: null;
if (senderDomainFromHandle && await isNodeBlocked(senderDomainFromHandle)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`);
// 1. Resolve Sender Public Key
let senderUser = await db.query.users.findFirst({
where: { did: did }
});
let publicKey = senderUser?.publicKey;
let senderDisplayName = senderUser?.displayName || senderHandle;
let senderAvatarUrl = senderUser?.avatarUrl;
let senderNodeDomain: string | null = null;
if (!senderUser) {
// Unknown user - likely remote. We need to fetch their profile to get the public key.
// Derive domain from full sender handle if possible
if (senderHandle.includes('@')) {
const parts = senderHandle.split('@');
senderNodeDomain = normalizeNodeDomain(parts[parts.length - 1]);
} else {
// Try to get from header first
const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain');
if (sourceDomainHeader) {
senderNodeDomain = normalizeNodeDomain(sourceDomainHeader);
} else {
// Try handle registry (though we likely don't have it if we don't have the user)
const registryEntry = await db.query.handleRegistry.findFirst({
where: { did: did }
});
if (registryEntry) senderNodeDomain = normalizeNodeDomain(registryEntry.nodeDomain);
}
}
if (senderNodeDomain && await isNodeBlocked(senderNodeDomain)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
if (senderNodeDomain) {
try {
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
const remoteHandle = senderHandle.includes('@') ? senderHandle.split('@')[0] : senderHandle;
// Fetch public key with TOFU validation
const { publicKey: cachedOrFreshKey, fromCache, keyChanged } = await fetchAndCacheRemoteKey(
did,
senderHandle,
senderNodeDomain,
async () => {
// Fetch profile from remote node
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
if (!res.ok) return null;
const profileData = await res.json();
const remoteProfile = profileData.user;
if (remoteProfile?.did !== did) {
console.error('[Chat Receive] DID mismatch for remote user');
return null;
}
return remoteProfile?.publicKey || null;
}
);
if (cachedOrFreshKey) {
publicKey = cachedOrFreshKey;
console.log(`[Chat Receive] Using ${fromCache ? 'cached' : 'fetched'} public key for ${senderHandle}${keyChanged ? ' (KEY CHANGED!)' : ''}`);
// Also fetch display name/avatar if not from cache (or if we need fresh data)
if (!fromCache || keyChanged) {
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
if (res.ok) {
const profileData = await res.json();
const remoteProfile = profileData.user;
senderDisplayName = remoteProfile?.displayName || senderHandle;
senderAvatarUrl = remoteProfile?.avatarUrl;
// CACHE: Upsert the remote user into our local database
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
await upsertRemoteUser({
handle: senderHandle, // use full handle (user@domain)
displayName: senderDisplayName,
avatarUrl: senderAvatarUrl || null,
did: did || '',
isBot: remoteProfile.isBot || false
});
}
}
} else {
console.error('Failed to fetch remote profile: no key returned');
}
} catch (e) {
console.error('Remote profile fetch failed:', e);
}
}
}
if (!publicKey) {
return NextResponse.json({ error: 'Could not resolve sender public key' }, { status: 401 });
}
// 2. Verify Signature
const isValid = await verifyActionSignature(signedAction, publicKey);
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
}
// 3. Find Local Recipient
const recipientUser = await db.query.users.findFirst({
where: { did: recipientDid }
});
if (!recipientUser) {
return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 });
}
// 4. Find or Create Conversation
// For the RECIPIENT, the conversation is with the SENDER
// Use full handle from envelope if available, otherwise construct from handle + domain
const computedFullSenderHandle = senderHandle.includes('@') ? senderHandle : (senderNodeDomain ? `${senderHandle}@${senderNodeDomain}` : senderHandle);
let conversation = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: computedFullSenderHandle }] }
});
if (!conversation) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipientUser.id,
participant2Handle: computedFullSenderHandle,
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50)
}).returning();
conversation = newConv;
} else {
// Update preview
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, conversation.id));
}
// 5. Store Message
await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: computedFullSenderHandle,
senderDisplayName: senderDisplayName,
senderAvatarUrl: senderAvatarUrl,
senderNodeDomain: senderNodeDomain,
senderDid: did,
content: content,
deliveredAt: new Date(),
});
// 6. Update Registry (to ensure we can reply efficiently)
if (senderNodeDomain) {
await db.insert(handleRegistry).values({
handle: computedFullSenderHandle, // user@domain
did: did,
nodeDomain: senderNodeDomain
}).onConflictDoUpdate({
target: handleRegistry.handle,
set: {
did: did,
nodeDomain: senderNodeDomain
}
});
}
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Federated Receive Failed:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.issues },
{ status: 400 }
);
}
return NextResponse.json({ error: error.message }, { status: 500 });
try {
const swarmSignature = request.headers.get('X-Swarm-Signature');
const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain');
if (!swarmSignature || !sourceDomainHeader) {
return NextResponse.json({
error: 'Encrypted federated messages require a node-signed envelope',
code: 'E2EE_REQUIRED',
}, { status: 426 });
}
const body = federatedEnvelopeSchema.parse(await request.json());
const sourceDomain = normalizeNodeDomain(sourceDomainHeader);
if (normalizeNodeDomain(body.sourceDomain) !== sourceDomain) {
return NextResponse.json({ error: 'Source node mismatch' }, { status: 403 });
}
const localDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || '');
if (!localDomain || normalizeNodeDomain(body.destinationDomain) !== localDomain) {
return NextResponse.json({ error: 'Destination node mismatch' }, { status: 403 });
}
if (await isNodeBlocked(sourceDomain)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
if (Math.abs(Date.now() - body.ts) > 5 * 60 * 1000 || body.expiresAt < Date.now()
|| body.expiresAt - body.ts > 5 * 60 * 1000) {
return NextResponse.json({ error: 'Federated envelope is stale' }, { status: 400 });
}
if (isRateLimited('e2ee-federation-preauth-global', 1_200, 60 * 1000)
|| activeNodeVerifications >= MAX_CONCURRENT_NODE_VERIFICATIONS) {
return NextResponse.json({
error: 'Federated encrypted-message verification is busy',
code: 'E2EE_REMOTE_RATE_LIMITED',
}, { status: 429, headers: { 'Retry-After': '60' } });
}
activeNodeVerifications += 1;
let nodeSignatureValid = false;
try {
nodeSignatureValid = await verifySwarmRequest(body, swarmSignature, sourceDomain);
} finally {
activeNodeVerifications -= 1;
}
if (!nodeSignatureValid) {
return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 });
}
if (isRateLimited(`e2ee-federation-node:${sourceDomain}`, 600, 60 * 1000)) {
return NextResponse.json({
error: 'This node is sending encrypted messages too quickly',
code: 'E2EE_REMOTE_RATE_LIMITED',
}, { status: 429 });
}
const signedAction = body.userAction as SignedAction;
const envelope = e2eeMessageEnvelopeSchema.parse(signedAction.data);
validateMessageBindings(envelope, signedAction);
if (Math.abs(Date.now() - signedAction.ts) > 5 * 60 * 1000) {
return NextResponse.json({ error: 'Encrypted message action is stale' }, { status: 400 });
}
if (body.deliveryId !== `${envelope.messageId}:${normalizeNodeDomain(body.destinationDomain)}`) {
return NextResponse.json({ error: 'Federated delivery identity mismatch' }, { status: 403 });
}
const senderHandle = body.fullSenderHandle.toLowerCase().replace(/^@/, '');
const senderParts = senderHandle.split('@');
if (senderParts.length !== 2
|| senderParts[0] !== signedAction.handle.toLowerCase()
|| normalizeNodeDomain(senderParts[1]) !== sourceDomain) {
return NextResponse.json({ error: 'Federated sender handle mismatch' }, { status: 403 });
}
if (Buffer.from(envelope.nonce, 'base64url').length !== 24
|| Buffer.from(envelope.ciphertext, 'base64url').length < 17
|| Buffer.from(envelope.ciphertext, 'base64url').length > 8_192
|| Buffer.from(envelope.keyCommitment, 'base64url').length !== 32
|| envelope.keyEnvelopes.some((item) => Buffer.from(item.sealedKey, 'base64url').length !== 112)) {
return NextResponse.json({ error: 'Invalid encrypted message sizes' }, { status: 400 });
}
// Reject nonexistent, unwilling, or stale-key recipients before resolving
// or persisting any attacker-controlled remote user profile.
const recipient = await db.query.users.findFirst({ where: { did: envelope.recipientDid } });
if (!recipient || recipient.handle.includes('@') || recipient.id.startsWith('swarm:')) {
return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 });
}
const envelopeRecipientHandle = envelope.recipientHandle.toLowerCase().replace(/^@/, '');
const recipientHandleParts = envelopeRecipientHandle.split('@');
const recipientHandleMatches = recipientHandleParts.length === 1
? recipientHandleParts[0] === recipient.handle.toLowerCase()
: recipientHandleParts.length === 2
&& recipientHandleParts[0] === recipient.handle.toLowerCase()
&& normalizeNodeDomain(recipientHandleParts[1]) === localDomain;
if (!recipientHandleMatches) {
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 });
}
if (recipient.isBot || recipient.dmPrivacy === 'none') {
return NextResponse.json({ error: 'Recipient does not accept direct messages' }, { status: 403 });
}
if (recipient.dmPrivacy === 'following') {
const followsSender = await db.query.remoteFollows.findFirst({
where: { AND: [{ followerId: recipient.id }, { targetHandle: senderHandle }] },
});
if (!followsSender) {
return NextResponse.json({ error: 'Recipient only accepts messages from accounts they follow' }, { status: 403 });
}
}
const [recipientKey, recipientVault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: recipient.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: recipient.id } }),
]);
if (!recipientKey || !recipientVault) {
return NextResponse.json({
error: recipientKey
? 'Recipient needs to finish encrypted message setup on this node'
: 'Recipient has not set up encrypted messages',
code: 'E2EE_NOT_CONFIGURED',
}, { status: 409 });
}
if (recipientKey.keyId !== recipientVault.keyId
|| recipientKey.keyVersion !== recipientVault.keyVersion
|| recipientKey.publicKey !== recipientVault.publicKey
|| recipientVault.ownerDid !== recipient.did
|| recipientKey.keyId !== envelope.recipientKeyId
|| recipientKey.keyVersion !== envelope.recipientKeyVersion) {
return NextResponse.json({
error: 'Recipient encryption key changed',
code: 'E2EE_RECIPIENT_KEY_STALE',
}, { status: 409 });
}
let senderUser = await db.query.users.findFirst({ where: { did: signedAction.did } });
if (senderUser && !senderUser.handle.includes('@') && !senderUser.id.startsWith('swarm:')) {
return NextResponse.json({ error: 'A remote node cannot claim a local identity' }, { status: 403 });
}
if (senderUser && senderUser.handle !== senderHandle) {
return NextResponse.json({ error: 'Sender identity continuity check failed' }, { status: 403 });
}
const didPublicKey = signingPublicKeyFromDid(signedAction.did);
let signingPublicKey = didPublicKey || senderUser?.publicKey || null;
let senderDisplayName = senderUser?.displayName || signedAction.handle;
let senderAvatarUrl = senderUser?.avatarUrl || null;
let resolvedProfile: z.infer<typeof remoteProfileResponseSchema>['user'] | null = null;
let signatureVerified = !!signingPublicKey
&& await verifyActionSignature(signedAction, signingPublicKey);
if (didPublicKey && !signatureVerified) {
return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 });
}
if (!senderUser || !signatureVerified) {
const isDevelopmentLoopback = process.env.NODE_ENV === 'development'
&& /^localhost(?::\d{1,5})?$/i.test(sourceDomain);
const protocol = isDevelopmentLoopback ? 'http' : 'https';
const profileResponse = await safeFederationRequest(`${protocol}://${sourceDomain}/api/users/${encodeURIComponent(signedAction.handle)}`, {
headers: { Accept: 'application/json' },
maxResponseBytes: 64 * 1024,
});
if (profileResponse.status < 200 || profileResponse.status >= 300) {
return NextResponse.json({ error: 'Could not resolve sender identity' }, { status: 401 });
}
const profileData = remoteProfileResponseSchema.parse(profileResponse.json());
const profile = profileData.user;
if (profile?.did !== signedAction.did || !profile.publicKey) {
return NextResponse.json({ error: 'Sender identity does not match their node profile' }, { status: 403 });
}
const profileSigningPublicKey = normalizeSigningPublicKey(profile.publicKey);
if (!profileSigningPublicKey) {
return NextResponse.json({ error: 'Sender profile contains an invalid signing key' }, { status: 403 });
}
if (didPublicKey && didPublicKey !== profileSigningPublicKey) {
return NextResponse.json({ error: 'Sender signing key does not match their DID' }, { status: 403 });
}
const resolvedSigningPublicKey = didPublicKey || profileSigningPublicKey;
signingPublicKey = resolvedSigningPublicKey;
signatureVerified = await verifyActionSignature(signedAction, resolvedSigningPublicKey);
if (!signatureVerified) {
return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 });
}
senderDisplayName = profile.displayName || signedAction.handle;
senderAvatarUrl = profile.avatarUrl || null;
resolvedProfile = profile;
}
if (!signingPublicKey || !signatureVerified) {
return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 });
}
if (isRateLimited(`e2ee-federation-sender:${sourceDomain}:${signedAction.did}`, 120, 60 * 1000)) {
return NextResponse.json({
error: 'This sender is sending encrypted messages too quickly',
code: 'E2EE_REMOTE_RATE_LIMITED',
}, { status: 429 });
}
if (senderUser) {
const blocked = await db.query.blocks.findFirst({
where: { AND: [{ userId: recipient.id }, { blockedUserId: senderUser.id }] },
});
if (blocked) return NextResponse.json({ error: 'Message not permitted' }, { status: 403 });
}
if (resolvedProfile) {
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
await upsertRemoteUser({
handle: senderHandle,
displayName: senderDisplayName,
avatarUrl: senderAvatarUrl,
did: signedAction.did,
isBot: resolvedProfile.isBot || false,
publicKey: normalizeSigningPublicKey(resolvedProfile.publicKey)!,
});
senderUser = await db.query.users.findFirst({ where: { did: signedAction.did } });
}
const createdAt = new Date(envelope.createdAt);
await db.transaction(async (tx) => {
const [receipt] = await tx.insert(e2eeMessageReceipts).values({
ownerUserId: recipient.id,
senderDid: signedAction.did,
messageId: envelope.messageId,
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
if (!receipt) return;
const [insertedRegistryEntry] = await tx.insert(handleRegistry).values({
handle: senderHandle,
did: signedAction.did,
nodeDomain: sourceDomain,
}).onConflictDoNothing().returning({
did: handleRegistry.did,
nodeDomain: handleRegistry.nodeDomain,
});
const [registryEntry] = insertedRegistryEntry
? [insertedRegistryEntry]
: await tx.select({
did: handleRegistry.did,
nodeDomain: handleRegistry.nodeDomain,
}).from(handleRegistry).where(eq(handleRegistry.handle, senderHandle)).limit(1);
if (!registryEntry
|| registryEntry.did !== signedAction.did
|| normalizeNodeDomain(registryEntry.nodeDomain) !== sourceDomain) {
throw new E2EEIdentityContinuityError();
}
let [conversation] = await tx.select().from(chatConversations).where(and(
eq(chatConversations.participant1Id, recipient.id),
eq(chatConversations.participant2Handle, senderHandle),
)).limit(1);
if (!conversation) {
[conversation] = await tx.insert(chatConversations).values({
participant1Id: recipient.id,
participant2Handle: senderHandle,
lastMessageAt: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: createdAt,
}).returning();
} else {
await tx.update(chatConversations).set({
lastMessageAt: conversation.lastMessageAt && conversation.lastMessageAt > createdAt
? conversation.lastMessageAt
: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: conversation.e2eeActivatedAt ?? createdAt,
updatedAt: new Date(),
}).where(eq(chatConversations.id, conversation.id));
}
if (!conversation) throw new Error('Failed to create encrypted conversation');
await tx.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle,
senderDisplayName,
senderAvatarUrl,
senderNodeDomain: sourceDomain,
senderDid: signedAction.did,
content: null,
protocolVersion: E2EE_PROTOCOL_VERSION,
clientMessageId: envelope.messageId,
encryptedEnvelope: JSON.stringify(envelope),
e2eeSignature: signedAction.sig,
e2eeActionNonce: signedAction.nonce,
e2eeActionTs: signedAction.ts,
deliveredAt: new Date(),
createdAt,
});
});
return NextResponse.json({ success: true, messageId: envelope.messageId });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({
error: 'This node only accepts encrypted message envelopes',
code: 'E2EE_REQUIRED',
details: error.issues,
}, { status: 426 });
}
if (error instanceof E2EEIdentityContinuityError) {
return NextResponse.json({
error: error.message,
code: 'E2EE_IDENTITY_KEY_CHANGED',
}, { status: 409 });
}
console.error('[E2EE Chat] Federated receive failed:', error);
return NextResponse.json({ error: error instanceof Error ? error.message : 'Receive failed' }, { status: 500 });
}
}
+408 -256
View File
@@ -1,263 +1,415 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatConversations, chatMessages, users, handleRegistry, follows } from '@/db/schema';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { eq, and } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import {
chatConversations,
chatMessages,
db,
e2eeMessageReceipts,
} from '@/db';
import { requireSignedAction, SignedActionError, type SignedAction } from '@/lib/auth/verify-signature';
import {
E2EE_PROTOCOL_VERSION,
e2eeMessageEnvelopeSchema,
signedUserActionSchema,
validateMessageBindings,
} from '@/lib/e2ee/protocol';
import { createSignedPayload } from '@/lib/swarm/signature';
import { federatedHandleSchema } from '@/lib/utils/federation';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { getPublicSwarmDomain } from '@/lib/swarm/node-domain';
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
const chatSendSchema = z.object({
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
recipientHandle: federatedHandleSchema,
content: z.string().min(1).max(5000),
});
/**
* POST /api/chat/send
* Send a signed chat message (verified with DID)
* Stores plain text content.
*/
export async function POST(request: NextRequest) {
try {
// Parse the signed action from the request body
const signedAction = await request.json();
// Strictly verify the signature and get the user
const user = await requireSignedAction(signedAction);
// Extract message data
const data = chatSendSchema.parse(signedAction.data);
const { recipientDid, recipientHandle, content } = data;
// Check if recipient is local
const recipientUser = await db.query.users.findFirst({
where: { did: recipientDid }
});
// Check if recipient is a local user (not remote/swarm cached)
// Remote users have handles with @domain or IDs starting with "swarm:"
const isRemoteUser = recipientUser && (
recipientUser.handle.includes('@') ||
recipientUser.id.startsWith('swarm:')
);
if (recipientUser && !isRemoteUser) {
// Reject if recipient is a bot
if (recipientUser.isBot) {
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
}
// Check DM privacy settings
if (recipientUser.dmPrivacy === 'none') {
return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 });
} else if (recipientUser.dmPrivacy === 'following') {
// Check if recipient follows the sender
const isFollowingSender = await db.query.follows.findFirst({
where: { AND: [{ followerId: recipientUser.id }, { followingId: user.id }] }
});
if (!isFollowingSender) {
return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 });
}
}
// LOCAL RECIPIENT
// Ensure conversations exist for both sides
// 1. Recipient's Inbox (Recipient -> User)
let recipientConv = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: user.handle }] }
});
if (!recipientConv) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipientUser.id,
participant2Handle: user.handle,
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50)
}).returning();
recipientConv = newConv;
} else {
// Update preview
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, recipientConv.id));
}
// 2. Sender's Sent Box (User -> Recipient)
let senderConv = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: user.id }, { participant2Handle: recipientUser.handle }] }
});
if (!senderConv) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: recipientUser.handle,
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50)
}).returning();
senderConv = newConv;
} else {
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, senderConv.id));
}
// Create message for recipient (Inbox)
await db.insert(chatMessages).values({
conversationId: recipientConv.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
content: content,
deliveredAt: new Date(),
});
// Create message for sender (Sent)
await db.insert(chatMessages).values({
conversationId: senderConv.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
content: content,
deliveredAt: new Date(),
readAt: new Date() // Sender has read their own message
});
return NextResponse.json({ success: true });
} else {
// REMOTE RECIPIENT
// 1. Resolve recipient node
const registryEntry = await db.query.handleRegistry.findFirst({
where: { did: recipientDid }
});
// If not in registry, try to parse from handle if it has domain
let targetDomain: string | null = registryEntry?.nodeDomain || null;
let targetHandle = recipientHandle;
if (!targetDomain && recipientHandle.includes('@')) {
const parts = recipientHandle.split('@');
targetDomain = parts[parts.length - 1];
}
if (!targetDomain) {
console.error('Recipient node domain not found for:', recipientHandle);
return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
}
targetDomain = normalizeNodeDomain(targetDomain);
if (await isNodeBlocked(targetDomain)) {
return NextResponse.json({ error: 'This node is blocked by the server administrator' }, { status: 403 });
}
console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`);
// 2. Send to Remote Node (Forward the Signed Action)
try {
const protocol = targetDomain.includes('localhost') ? 'http' : 'https';
const sourceDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || '';
// Construct full sender handle for federation
const fullSenderHandle = `${user.handle}@${sourceDomain}`;
console.log(`[Remote Send] Debug:`, {
targetDomain,
targetUrl: `${protocol}://${targetDomain}/api/chat/receive`,
sourceDomainEnv: sourceDomain,
fullSenderHandle,
});
// Create a federated envelope with node's signature
// This wraps the user's signed action with the full handle
const federatedPayload = {
userAction: signedAction,
fullSenderHandle,
sourceDomain,
ts: Date.now(),
};
const { payload, signature } = await createSignedPayload(federatedPayload);
const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Swarm-Source-Domain': sourceDomain,
'X-Swarm-Signature': signature,
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const errText = await res.text();
console.error(`[Remote Send] Remote node rejected chat (${res.status}):`, errText);
return NextResponse.json({ error: `Remote delivery failed: ${res.statusText} - ${errText}` }, { status: 502 });
}
} catch (err: any) {
console.error('[Remote Send] Failed to contact remote node:', err);
return NextResponse.json({ error: 'Failed to contact remote node' }, { status: 504 });
}
// 3. Store "Sent" copy locally
// Ensure conversation exists locally
let senderConv = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: user.id }, { participant2Handle: recipientHandle }] }
});
if (!senderConv) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: recipientHandle,
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50)
}).returning();
senderConv = newConv;
} else {
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, senderConv.id));
}
await db.insert(chatMessages).values({
conversationId: senderConv.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
content: content,
deliveredAt: new Date(),
readAt: new Date()
});
return NextResponse.json({ success: true });
}
} catch (error: any) {
console.error('Send chat failed:', error);
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
return NextResponse.json({ error: error.message || 'Failed to send message' }, { status: 500 });
function validateCiphertextLengths(envelope: z.infer<typeof e2eeMessageEnvelopeSchema>): void {
if (Buffer.from(envelope.nonce, 'base64url').length !== 24) throw new Error('Invalid message nonce');
const ciphertextLength = Buffer.from(envelope.ciphertext, 'base64url').length;
if (ciphertextLength < 17 || ciphertextLength > 8_192) throw new Error('Invalid encrypted message');
if (Buffer.from(envelope.keyCommitment, 'base64url').length !== 32) {
throw new Error('Invalid encrypted message key commitment');
}
for (const wrappedKey of envelope.keyEnvelopes) {
if (Buffer.from(wrappedKey.sealedKey, 'base64url').length !== 112) {
throw new Error('Invalid encrypted message key');
}
}
}
function messageValues(input: {
conversationId: string;
senderHandle: string;
senderDisplayName: string | null;
senderAvatarUrl: string | null;
senderDid: string;
envelope: z.infer<typeof e2eeMessageEnvelopeSchema>;
signedAction: SignedAction;
createdAt: Date;
readAt?: Date;
}) {
return {
conversationId: input.conversationId,
senderHandle: input.senderHandle,
senderDisplayName: input.senderDisplayName,
senderAvatarUrl: input.senderAvatarUrl,
senderNodeDomain: null,
senderDid: input.senderDid,
content: null,
protocolVersion: E2EE_PROTOCOL_VERSION,
clientMessageId: input.envelope.messageId,
encryptedEnvelope: JSON.stringify(input.envelope),
e2eeSignature: input.signedAction.sig,
e2eeActionNonce: input.signedAction.nonce,
e2eeActionTs: input.signedAction.ts,
deliveredAt: new Date(),
readAt: input.readAt,
createdAt: input.createdAt,
};
}
export async function POST(request: NextRequest) {
try {
const signedAction = signedUserActionSchema.parse(await request.json()) as SignedAction;
const user = await requireSignedAction(signedAction);
const envelope = e2eeMessageEnvelopeSchema.parse(signedAction.data);
validateMessageBindings(envelope, signedAction);
validateCiphertextLengths(envelope);
if (envelope.senderDid !== user.did || envelope.senderHandle !== user.handle) {
return NextResponse.json({ error: 'Sender identity mismatch' }, { status: 403 });
}
const [senderKey, senderVault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }),
]);
if (!senderKey || !senderVault
|| senderKey.keyId !== senderVault.keyId
|| senderKey.keyVersion !== senderVault.keyVersion
|| senderKey.publicKey !== senderVault.publicKey
|| senderVault.ownerDid !== user.did
|| senderKey.keyId !== envelope.senderKeyId
|| senderKey.keyVersion !== envelope.senderKeyVersion) {
return NextResponse.json({
error: senderKey && !senderVault
? 'Finish encrypted message setup on this node before sending'
: 'Your encrypted message key changed. Reload Chat and try again.',
code: 'E2EE_SENDER_KEY_STALE',
}, { status: 409 });
}
const recipientUser = await db.query.users.findFirst({ where: { did: envelope.recipientDid } });
const isRemoteRecipient = !recipientUser
|| recipientUser.handle.includes('@')
|| recipientUser.id.startsWith('swarm:');
const createdAt = new Date(envelope.createdAt);
if (recipientUser && !isRemoteRecipient) {
if (recipientUser.id === user.id) {
return NextResponse.json({ error: 'Cannot send an encrypted DM to yourself' }, { status: 400 });
}
if (envelope.recipientHandle.toLowerCase() !== recipientUser.handle.toLowerCase()) {
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 });
}
if (recipientUser.isBot) {
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
}
if (recipientUser.dmPrivacy === 'none') {
return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 });
}
if (recipientUser.dmPrivacy === 'following') {
const followsSender = await db.query.follows.findFirst({
where: { AND: [{ followerId: recipientUser.id }, { followingId: user.id }] },
});
if (!followsSender) {
return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 });
}
}
const [recipientBlockedSender, senderBlockedRecipient] = await Promise.all([
db.query.blocks.findFirst({
where: { AND: [{ userId: recipientUser.id }, { blockedUserId: user.id }] },
}),
db.query.blocks.findFirst({
where: { AND: [{ userId: user.id }, { blockedUserId: recipientUser.id }] },
}),
]);
if (recipientBlockedSender || senderBlockedRecipient) {
return NextResponse.json({ error: 'Message not permitted' }, { status: 403 });
}
const [recipientKey, recipientVault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: recipientUser.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: recipientUser.id } }),
]);
if (!recipientKey || !recipientVault) {
return NextResponse.json({
error: recipientKey
? 'Recipient needs to finish encrypted message setup on this node'
: 'Recipient has not set up encrypted messages',
code: 'E2EE_NOT_CONFIGURED',
}, { status: 409 });
}
if (recipientKey.keyId !== recipientVault.keyId
|| recipientKey.keyVersion !== recipientVault.keyVersion
|| recipientKey.publicKey !== recipientVault.publicKey
|| recipientVault.ownerDid !== recipientUser.did
|| recipientKey.keyId !== envelope.recipientKeyId
|| recipientKey.keyVersion !== envelope.recipientKeyVersion) {
return NextResponse.json({
error: 'Recipient encryption key changed. Reload Chat and try again.',
code: 'E2EE_RECIPIENT_KEY_STALE',
}, { status: 409 });
}
await db.transaction(async (tx) => {
const [recipientReceipt] = await tx.insert(e2eeMessageReceipts).values({
ownerUserId: recipientUser.id,
senderDid: user.did,
messageId: envelope.messageId,
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
if (!recipientReceipt) return;
const [senderReceipt] = await tx.insert(e2eeMessageReceipts).values({
ownerUserId: user.id,
senderDid: user.did,
messageId: envelope.messageId,
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
if (!senderReceipt) throw new Error('Encrypted message receipt state is inconsistent');
let [recipientConversation] = await tx.select().from(chatConversations).where(and(
eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, user.handle),
)).limit(1);
if (!recipientConversation) {
[recipientConversation] = await tx.insert(chatConversations).values({
participant1Id: recipientUser.id,
participant2Handle: user.handle,
lastMessageAt: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: createdAt,
}).returning();
} else {
await tx.update(chatConversations).set({
lastMessageAt: recipientConversation.lastMessageAt && recipientConversation.lastMessageAt > createdAt
? recipientConversation.lastMessageAt
: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: recipientConversation.e2eeActivatedAt ?? createdAt,
updatedAt: new Date(),
}).where(eq(chatConversations.id, recipientConversation.id));
}
let [senderConversation] = await tx.select().from(chatConversations).where(and(
eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, recipientUser.handle),
)).limit(1);
if (!senderConversation) {
[senderConversation] = await tx.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: recipientUser.handle,
lastMessageAt: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: createdAt,
}).returning();
} else {
await tx.update(chatConversations).set({
lastMessageAt: senderConversation.lastMessageAt && senderConversation.lastMessageAt > createdAt
? senderConversation.lastMessageAt
: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: senderConversation.e2eeActivatedAt ?? createdAt,
updatedAt: new Date(),
}).where(eq(chatConversations.id, senderConversation.id));
}
if (!recipientConversation || !senderConversation) {
throw new Error('Failed to create encrypted conversations');
}
await tx.insert(chatMessages).values([
messageValues({
conversationId: recipientConversation.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderDid: user.did,
envelope,
signedAction,
createdAt,
}),
messageValues({
conversationId: senderConversation.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderDid: user.did,
envelope,
signedAction,
createdAt,
readAt: new Date(),
}),
]);
});
return NextResponse.json({ success: true, messageId: envelope.messageId });
}
const cachedRecipientKey = await db.query.e2eeRemoteKeyBundles.findFirst({
where: { did: envelope.recipientDid },
});
if (!cachedRecipientKey || cachedRecipientKey.keyId !== envelope.recipientKeyId
|| cachedRecipientKey.keyVersion !== envelope.recipientKeyVersion) {
return NextResponse.json({
error: 'Recipient encryption key must be verified again',
code: 'E2EE_RECIPIENT_KEY_STALE',
}, { status: 409 });
}
if (cachedRecipientKey.handle.toLowerCase().replace(/^@/, '')
!== envelope.recipientHandle.toLowerCase().replace(/^@/, '')) {
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 });
}
if (recipientUser) {
const senderBlockedRecipient = await db.query.blocks.findFirst({
where: { AND: [{ userId: user.id }, { blockedUserId: recipientUser.id }] },
});
if (senderBlockedRecipient) {
return NextResponse.json({ error: 'Message not permitted' }, { status: 403 });
}
}
const fullRecipientHandle = envelope.recipientHandle.toLowerCase().replace(/^@/, '');
const recipientHandleParts = fullRecipientHandle.split('@');
if (recipientHandleParts.length !== 2 || !recipientHandleParts[0] || !recipientHandleParts[1]) {
return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
}
let targetDomain: string | null = normalizeNodeDomain(recipientHandleParts[1]);
const registryEntry = await db.query.handleRegistry.findFirst({
where: { handle: fullRecipientHandle },
});
if (registryEntry && normalizeNodeDomain(registryEntry.nodeDomain) !== targetDomain) {
return NextResponse.json({
error: 'Recipient node identity does not match the verified handle',
code: 'E2EE_IDENTITY_KEY_CHANGED',
}, { status: 409 });
}
const normalizedTargetDomain = normalizeNodeDomain(targetDomain);
const isDevelopmentLoopback = process.env.NODE_ENV === 'development'
&& /^localhost(?::\d{1,5})?$/i.test(normalizedTargetDomain);
targetDomain = isDevelopmentLoopback
? normalizedTargetDomain
: getPublicSwarmDomain(normalizedTargetDomain);
if (!targetDomain) {
return NextResponse.json({ error: 'Recipient node domain is invalid' }, { status: 400 });
}
if (await isNodeBlocked(targetDomain)) {
return NextResponse.json({ error: 'Recipient node is blocked' }, { status: 403 });
}
const protocol = isDevelopmentLoopback ? 'http' : 'https';
const sourceDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || '');
if (!sourceDomain) {
return NextResponse.json({ error: 'This node is not configured for federated messages' }, { status: 503 });
}
const deliveredAt = Date.now();
const federatedPayload = {
userAction: signedAction,
fullSenderHandle: `${user.handle}@${sourceDomain}`,
sourceDomain,
destinationDomain: targetDomain,
route: '/api/chat/receive' as const,
deliveryId: `${envelope.messageId}:${targetDomain}`,
ts: deliveredAt,
expiresAt: deliveredAt + 5 * 60 * 1000,
};
const { payload, signature } = await createSignedPayload(federatedPayload);
const remoteResponse = await safeFederationRequest(`${protocol}://${targetDomain}/api/chat/receive`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Swarm-Source-Domain': sourceDomain,
'X-Swarm-Signature': signature,
},
body: JSON.stringify(payload),
maxResponseBytes: 64 * 1024,
});
if (remoteResponse.status < 200 || remoteResponse.status >= 300) {
let remoteBody: { error?: string; code?: string } | null = null;
try {
remoteBody = remoteResponse.json() as { error?: string; code?: string };
} catch {
// Preserve a generic delivery error for non-JSON remote failures.
}
return NextResponse.json({
error: remoteBody?.error || 'Recipient node rejected the encrypted message',
code: remoteBody?.code || 'E2EE_REMOTE_DELIVERY_FAILED',
}, { status: remoteResponse.status === 426 ? 409 : 502 });
}
await db.transaction(async (tx) => {
const [receipt] = await tx.insert(e2eeMessageReceipts).values({
ownerUserId: user.id,
senderDid: user.did,
messageId: envelope.messageId,
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
if (!receipt) return;
let [senderConversation] = await tx.select().from(chatConversations).where(and(
eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, envelope.recipientHandle),
)).limit(1);
if (!senderConversation) {
[senderConversation] = await tx.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: envelope.recipientHandle,
lastMessageAt: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: createdAt,
}).returning();
} else {
await tx.update(chatConversations).set({
lastMessageAt: senderConversation.lastMessageAt && senderConversation.lastMessageAt > createdAt
? senderConversation.lastMessageAt
: createdAt,
lastMessagePreview: 'Encrypted message',
encryptionMode: 'e2ee',
e2eeActivatedAt: senderConversation.e2eeActivatedAt ?? createdAt,
updatedAt: new Date(),
}).where(eq(chatConversations.id, senderConversation.id));
}
if (!senderConversation) throw new Error('Failed to create encrypted conversation');
await tx.insert(chatMessages).values(messageValues({
conversationId: senderConversation.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderDid: user.did,
envelope,
signedAction,
createdAt,
readAt: new Date(),
}));
});
return NextResponse.json({ success: true, messageId: envelope.messageId });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({
error: 'This client must send an encrypted message envelope',
code: 'E2EE_REQUIRED',
details: error.issues,
}, { status: 426 });
}
if (error instanceof SignedActionError) {
const rateLimited = error.message === 'RATE_LIMITED';
return NextResponse.json({
error: rateLimited ? 'Too many encrypted messages; try again shortly' : 'Message signature was rejected',
code: rateLimited ? 'E2EE_RATE_LIMITED' : 'E2EE_SIGNATURE_REJECTED',
}, { status: rateLimited ? 429 : 403 });
}
console.error('[E2EE Chat] Send failed:', error);
return NextResponse.json({ error: error instanceof Error ? error.message : 'Send failed' }, { status: 500 });
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { e2eeKeyBundleSchema, signedUserActionSchema } from '@/lib/e2ee/protocol';
type RouteContext = { params: Promise<{ did: string }> };
export async function GET(_request: Request, context: RouteContext) {
const { did } = await context.params;
const user = await db.query.users.findFirst({ where: { did } });
if (!user || user.handle.includes('@') || user.id.startsWith('swarm:')) {
return NextResponse.json({ error: 'Encryption key not found' }, { status: 404 });
}
const [row, vault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }),
]);
if (!row && !vault) {
return NextResponse.json({
error: 'Encrypted messages are not configured for this account',
code: 'E2EE_NOT_CONFIGURED',
}, { status: 404 });
}
if (!row || !vault || row.keyId !== vault.keyId || row.keyVersion !== vault.keyVersion
|| row.publicKey !== vault.publicKey || vault.ownerDid !== user.did) {
return NextResponse.json({
error: vault ? 'Encrypted message key state is inconsistent' : 'Encrypted messages need setup on this node',
code: vault ? 'E2EE_KEY_STATE_INVALID' : 'E2EE_NOT_CONFIGURED',
}, { status: vault ? 500 : 404 });
}
try {
const proof = signedUserActionSchema.parse(JSON.parse(row.proofAction));
const bundle = e2eeKeyBundleSchema.parse(proof.data);
return NextResponse.json({
bundle,
proof,
signingPublicKey: user.publicKey,
}, { headers: { 'Cache-Control': 'public, max-age=60, must-revalidate' } });
} catch (error) {
console.error('[E2EE Keys] Stored key proof is invalid:', error);
return NextResponse.json({ error: 'Encryption key proof is invalid' }, { status: 500 });
}
}
+186
View File
@@ -0,0 +1,186 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { db, e2eeRemoteKeyBundles } from '@/db';
import { getSession } from '@/lib/auth';
import { normalizeSigningPublicKey } from '@/lib/crypto/did-key';
import { signingPublicKeyFromDid, verifyE2EEPublicBundle } from '@/lib/e2ee/bundle-proof';
import { e2eePublicBundleResponseSchema } from '@/lib/e2ee/protocol';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { getPublicSwarmDomain } from '@/lib/swarm/node-domain';
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
const querySchema = z.object({
did: z.string().min(8).max(2_048).regex(/^did:/),
handle: z.string().min(1).max(320),
});
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const query = querySchema.parse({
did: request.nextUrl.searchParams.get('did'),
handle: request.nextUrl.searchParams.get('handle'),
});
const localUser = await db.query.users.findFirst({ where: { did: query.did } });
const isLocal = localUser && !localUser.handle.includes('@') && !localUser.id.startsWith('swarm:');
if (isLocal) {
const requestedHandle = query.handle.toLowerCase().replace(/^@/, '').split('@')[0];
if (requestedHandle !== localUser.handle.toLowerCase()) {
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 409 });
}
const [bundle, vault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: localUser.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: localUser.id } }),
]);
if (!bundle && !vault) {
return NextResponse.json({ code: 'E2EE_NOT_CONFIGURED', error: 'Recipient has not set up encrypted messages' }, { status: 404 });
}
if (!bundle || !vault || bundle.keyId !== vault.keyId || bundle.keyVersion !== vault.keyVersion
|| bundle.publicKey !== vault.publicKey || vault.ownerDid !== localUser.did) {
return NextResponse.json({
code: vault ? 'E2EE_KEY_STATE_INVALID' : 'E2EE_NOT_CONFIGURED',
error: vault
? 'Recipient encryption key state is inconsistent'
: 'Recipient needs to finish encrypted message setup on this node',
}, { status: vault ? 500 : 404 });
}
const response = e2eePublicBundleResponseSchema.parse({
bundle: JSON.parse(bundle.proofAction).data,
proof: JSON.parse(bundle.proofAction),
signingPublicKey: localUser.publicKey,
});
return NextResponse.json(response, { headers: { 'Cache-Control': 'no-store' } });
}
const handleParts = query.handle.toLowerCase().replace(/^@/, '').split('@');
if (handleParts.length < 2) {
return NextResponse.json({ error: 'Remote recipient domain is missing' }, { status: 400 });
}
const remoteHandle = handleParts[0];
const normalizedDomain = normalizeNodeDomain(handleParts.at(-1) || '');
const isDevelopmentLoopback = process.env.NODE_ENV === 'development'
&& /^localhost(?::\d{1,5})?$/i.test(normalizedDomain);
const domain = isDevelopmentLoopback
? normalizedDomain
: getPublicSwarmDomain(normalizedDomain);
if (!domain) {
return NextResponse.json({ error: 'Recipient node domain is invalid' }, { status: 400 });
}
if (await isNodeBlocked(domain)) {
return NextResponse.json({ error: 'Recipient node is blocked' }, { status: 403 });
}
const protocol = isDevelopmentLoopback ? 'http' : 'https';
const remote = await safeFederationRequest(`${protocol}://${domain}/api/e2ee/keys/${encodeURIComponent(query.did)}`, {
headers: { Accept: 'application/json' },
maxResponseBytes: 64 * 1024,
});
if (remote.status < 200 || remote.status >= 300) {
let remoteBody: { code?: string } | null = null;
try {
remoteBody = remote.json() as { code?: string };
} catch {
// A non-JSON error response still maps to a bounded lookup failure.
}
const code = remoteBody?.code === 'E2EE_NOT_CONFIGURED' ? 'E2EE_NOT_CONFIGURED' : 'E2EE_KEY_LOOKUP_FAILED';
return NextResponse.json({
error: code === 'E2EE_NOT_CONFIGURED'
? 'Recipient has not set up encrypted messages'
: 'Recipient encryption key could not be verified',
code,
}, { status: code === 'E2EE_NOT_CONFIGURED' ? 404 : 502 });
}
const response = e2eePublicBundleResponseSchema.parse(remote.json());
if (response.proof.handle.toLowerCase() !== remoteHandle || !await verifyE2EEPublicBundle(response, query.did)) {
return NextResponse.json({ error: 'Recipient encryption key proof is invalid' }, { status: 502 });
}
const resolvedSigningPublicKey = normalizeSigningPublicKey(response.signingPublicKey);
if (!resolvedSigningPublicKey) {
return NextResponse.json({ error: 'Recipient signing key is invalid' }, { status: 502 });
}
const canonicalSigningPublicKey = signingPublicKeyFromDid(query.did)
|| resolvedSigningPublicKey;
const now = new Date();
const persistedValues = {
did: query.did,
handle: query.handle,
keyId: response.bundle.keyId,
keyVersion: response.bundle.version,
publicKey: response.bundle.publicKey,
proofAction: JSON.stringify(response.proof),
signingPublicKey: canonicalSigningPublicKey,
updatedAt: now,
};
let persisted = false;
for (let attempt = 0; attempt < 16; attempt += 1) {
const cached = await db.query.e2eeRemoteKeyBundles.findFirst({ where: { did: query.did } });
const cachedSigningPublicKey = cached
? normalizeSigningPublicKey(cached.signingPublicKey)
: null;
if (cached && cachedSigningPublicKey !== canonicalSigningPublicKey) {
return NextResponse.json({
error: 'Recipient signing identity changed unexpectedly',
code: 'E2EE_IDENTITY_KEY_CHANGED',
}, { status: 409 });
}
if (cached && (
response.bundle.version < cached.keyVersion
|| (response.bundle.version === cached.keyVersion && response.bundle.keyId !== cached.keyId)
|| (response.bundle.version === cached.keyVersion && response.bundle.publicKey !== cached.publicKey)
|| (response.bundle.version > cached.keyVersion && !response.bundle.replacesKeyId)
)) {
return NextResponse.json({
error: 'Recipient encryption key continuity check failed',
code: 'E2EE_KEY_ROLLBACK',
}, { status: 409 });
}
if (!cached) {
const [inserted] = await db.insert(e2eeRemoteKeyBundles)
.values(persistedValues)
.onConflictDoNothing()
.returning({ did: e2eeRemoteKeyBundles.did });
if (inserted) {
persisted = true;
break;
}
continue;
}
const [updated] = await db.update(e2eeRemoteKeyBundles).set(persistedValues).where(and(
eq(e2eeRemoteKeyBundles.did, cached.did),
eq(e2eeRemoteKeyBundles.signingPublicKey, cached.signingPublicKey),
eq(e2eeRemoteKeyBundles.keyId, cached.keyId),
eq(e2eeRemoteKeyBundles.keyVersion, cached.keyVersion),
eq(e2eeRemoteKeyBundles.publicKey, cached.publicKey),
)).returning({ did: e2eeRemoteKeyBundles.did });
if (updated) {
persisted = true;
break;
}
}
if (!persisted) {
return NextResponse.json({
error: 'Recipient encryption key changed during verification; try again',
code: 'E2EE_KEY_CONFLICT',
}, { status: 409 });
}
return NextResponse.json({ ...response, signingPublicKey: canonicalSigningPublicKey }, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid key lookup request' }, { status: 400 });
}
console.error('[E2EE Keys] Resolution failed:', error);
return NextResponse.json({ error: 'Recipient encryption key lookup failed', code: 'E2EE_KEY_LOOKUP_FAILED' }, { status: 502 });
}
}
+248
View File
@@ -0,0 +1,248 @@
import crypto from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { db, e2eeKeyBundles, e2eeKeyVaults } from '@/db';
import { requireSignedAction, SignedActionError, type SignedAction } from '@/lib/auth/verify-signature';
import {
E2EE_KEY_BUNDLE_ACTION,
E2EE_MAX_UNLOCK_ATTEMPTS,
e2eeKeyBundleSchema,
e2eeVaultSetupSchema,
signedUserActionSchema,
} from '@/lib/e2ee/protocol';
import { createPinVerifierMac, sealServerShare } from '@/lib/e2ee/server-secrets';
import { getSession, verifyPassword } from '@/lib/auth';
import { canonicalize } from '@/lib/crypto/user-signing';
const setupRequestSchema = z.strictObject({
proof: signedUserActionSchema,
recovery: e2eeVaultSetupSchema,
currentPassword: z.string().min(8).max(256).optional(),
});
class E2EEKeyConflictError extends Error {}
function byteLength(value: string): number {
return Buffer.from(value, 'base64url').length;
}
function validateEncodedLengths(recovery: z.infer<typeof e2eeVaultSetupSchema>): void {
if (byteLength(recovery.vault.publicKey) !== 32) throw new Error('Invalid encryption public key');
if (byteLength(recovery.serverShare) !== 32) throw new Error('Invalid recovery share');
if (byteLength(recovery.pinVerifier) !== 32) throw new Error('Invalid PIN verifier');
if (byteLength(recovery.vault.salt) !== 16) throw new Error('Invalid PIN salt');
if (byteLength(recovery.vault.nonce) !== 24) throw new Error('Invalid vault nonce');
const ciphertextLength = byteLength(recovery.vault.ciphertext);
if (ciphertextLength < 64 || ciphertextLength > 3_072) throw new Error('Invalid encrypted vault');
}
export async function GET() {
const session = await getSession();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const [bundle, vault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: session.user.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: session.user.id } }),
]);
if (!bundle && !vault) {
return NextResponse.json(
{ ownerDid: session.user.did, configured: false },
{ headers: { 'Cache-Control': 'no-store' } },
);
}
if (bundle && !vault && bundle.did === session.user.did) {
return NextResponse.json({
ownerDid: session.user.did,
configured: false,
previousKey: {
keyId: bundle.keyId,
keyVersion: bundle.keyVersion,
},
}, { headers: { 'Cache-Control': 'no-store' } });
}
if (!bundle || !vault
|| bundle.did !== session.user.did
|| vault.ownerDid !== session.user.did
|| bundle.keyId !== vault.keyId
|| bundle.keyVersion !== vault.keyVersion
|| bundle.publicKey !== vault.publicKey) {
return NextResponse.json({ error: 'Encrypted message recovery is inconsistent' }, { status: 500 });
}
return NextResponse.json({
ownerDid: session.user.did,
configured: true,
keyId: bundle.keyId,
keyVersion: bundle.keyVersion,
publicKey: bundle.publicKey,
salt: vault.salt,
kdfAlgorithm: vault.kdfAlgorithm,
kdfOpsLimit: vault.kdfOpsLimit,
kdfMemLimit: vault.kdfMemLimit,
failedAttempts: vault.failedAttempts,
attemptsRemaining: vault.lockedUntil && vault.lockedUntil > new Date()
? 0
: Math.max(0, E2EE_MAX_UNLOCK_ATTEMPTS - vault.failedAttempts),
lockedUntil: vault.lockedUntil?.toISOString() ?? null,
}, { headers: { 'Cache-Control': 'no-store' } });
}
export async function POST(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = setupRequestSchema.parse(await request.json());
const proof = body.proof;
if (proof.action !== E2EE_KEY_BUNDLE_ACTION) {
return NextResponse.json({ error: 'Invalid encryption key proof' }, { status: 400 });
}
if (proof.did !== session.user.did || proof.handle !== session.user.handle) {
return NextResponse.json({ error: 'Active account does not match encryption setup' }, { status: 403 });
}
const user = await requireSignedAction(proof as SignedAction);
if (user.id !== session.user.id) {
return NextResponse.json({ error: 'Active account does not match encryption setup' }, { status: 403 });
}
const bundle = e2eeKeyBundleSchema.parse(proof.data);
if (byteLength(bundle.recoveryCommitment) !== 32) {
return NextResponse.json({ error: 'Invalid recovery commitment' }, { status: 400 });
}
const recovery = body.recovery;
validateEncodedLengths(recovery);
const recoveryCommitment = crypto.createHash('sha256')
.update(canonicalize(recovery))
.digest('base64url');
if (bundle.recoveryCommitment !== recoveryCommitment) {
return NextResponse.json({ error: 'Recovery vault does not match the signed key proof' }, { status: 400 });
}
if (recovery.vault.ownerDid !== proof.did
|| recovery.vault.keyId !== bundle.keyId
|| recovery.vault.keyVersion !== bundle.version
|| recovery.vault.publicKey !== bundle.publicKey) {
return NextResponse.json({ error: 'Recovery vault does not match the signed key' }, { status: 400 });
}
if (Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1000) {
return NextResponse.json({ error: 'Encryption key timestamp is invalid' }, { status: 400 });
}
const [existing, existingVault] = await Promise.all([
db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }),
db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }),
]);
if (existing && existing.did !== user.did) {
return NextResponse.json({ error: 'Encryption key identity is inconsistent' }, { status: 409 });
}
if (!existing && bundle.replacesKeyId) {
return NextResponse.json({ error: 'There is no encryption key to replace' }, { status: 409 });
}
if (existing && bundle.replacesKeyId !== existing.keyId) {
return NextResponse.json({
error: 'Encrypted messages are already configured',
code: 'E2EE_ALREADY_CONFIGURED',
}, { status: 409 });
}
if ((!existing && bundle.version !== 1)
|| (existing && bundle.version !== existing.keyVersion + 1)) {
return NextResponse.json({ error: 'Encryption key version is invalid' }, { status: 409 });
}
if (existingVault) {
if (!body.currentPassword || !user.passwordHash
|| !await verifyPassword(body.currentPassword, user.passwordHash)) {
return NextResponse.json({ error: 'Current password is required to reset encrypted messages' }, { status: 403 });
}
}
const now = new Date();
const verifierMac = createPinVerifierMac(recovery.pinVerifier, user.id, bundle.keyId);
const sealedShare = sealServerShare(recovery.serverShare, user.id, bundle.keyId);
const vaultValues = {
keyId: bundle.keyId,
keyVersion: bundle.version,
ownerDid: proof.did,
publicKey: bundle.publicKey,
ciphertext: recovery.vault.ciphertext,
nonce: recovery.vault.nonce,
salt: recovery.vault.salt,
kdfAlgorithm: recovery.vault.kdfAlgorithm,
kdfOpsLimit: recovery.vault.kdfOpsLimit,
kdfMemLimit: recovery.vault.kdfMemLimit,
pinVerifierMac: verifierMac,
serverShareEncrypted: sealedShare,
failedAttempts: 0,
lockedUntil: null,
updatedAt: now,
};
await db.transaction(async (tx) => {
if (existing) {
const [updatedBundle] = await tx.update(e2eeKeyBundles).set({
did: user.did,
keyId: bundle.keyId,
keyVersion: bundle.version,
publicKey: bundle.publicKey,
proofAction: JSON.stringify(proof),
updatedAt: now,
}).where(and(
eq(e2eeKeyBundles.userId, user.id),
eq(e2eeKeyBundles.keyId, existing.keyId),
eq(e2eeKeyBundles.keyVersion, existing.keyVersion),
)).returning({ userId: e2eeKeyBundles.userId });
if (!updatedBundle) throw new E2EEKeyConflictError('Encryption key changed during reset');
if (existingVault) {
const [updatedVault] = await tx.update(e2eeKeyVaults).set(vaultValues).where(and(
eq(e2eeKeyVaults.userId, user.id),
eq(e2eeKeyVaults.keyId, existing.keyId),
eq(e2eeKeyVaults.keyVersion, existing.keyVersion),
)).returning({ userId: e2eeKeyVaults.userId });
if (!updatedVault) throw new E2EEKeyConflictError('Recovery vault changed during reset');
} else {
await tx.insert(e2eeKeyVaults).values({ userId: user.id, ...vaultValues });
}
return;
}
await tx.insert(e2eeKeyBundles).values({
userId: user.id,
did: user.did,
keyId: bundle.keyId,
keyVersion: bundle.version,
publicKey: bundle.publicKey,
proofAction: JSON.stringify(proof),
updatedAt: now,
});
await tx.insert(e2eeKeyVaults).values({ userId: user.id, ...vaultValues });
});
return NextResponse.json({ success: true, keyId: bundle.keyId });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid encrypted message setup', details: error.issues }, { status: 400 });
}
if (error instanceof SignedActionError) {
return NextResponse.json({
error: error.message === 'RATE_LIMITED'
? 'Too many encryption setup attempts; try again shortly'
: 'Encryption key proof was rejected',
code: error.message === 'RATE_LIMITED' ? 'E2EE_RATE_LIMITED' : 'E2EE_SIGNATURE_REJECTED',
}, { status: error.message === 'RATE_LIMITED' ? 429 : 403 });
}
if (error instanceof E2EEKeyConflictError
|| (error instanceof Error && /unique|constraint/i.test(error.message))) {
return NextResponse.json({
error: 'Encrypted messages changed in another tab. Reload Chat and try again.',
code: 'E2EE_KEY_CONFLICT',
}, { status: 409 });
}
console.error('[E2EE Vault] Setup failed:', error);
return NextResponse.json({ error: error instanceof Error ? error.message : 'Setup failed' }, { status: 500 });
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db, e2eeKeyVaults } from '@/db';
import { getSession } from '@/lib/auth';
import {
E2EE_LOCKOUT_MS,
E2EE_MAX_UNLOCK_ATTEMPTS,
E2EE_PROTOCOL,
} from '@/lib/e2ee/protocol';
import { openServerShare, pinVerifierMatches } from '@/lib/e2ee/server-secrets';
const unlockSchema = z.strictObject({
ownerDid: z.string().min(8).max(2_048).regex(/^did:/),
keyId: z.string().min(12).max(96).regex(/^k1_[A-Za-z0-9_-]+$/),
keyVersion: z.number().int().positive().max(1_000_000),
pinVerifier: z.string().min(40).max(64).regex(/^[A-Za-z0-9_-]+$/),
});
const MAX_CAS_RETRIES = 16;
export async function POST(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { ownerDid, keyId, keyVersion, pinVerifier } = unlockSchema.parse(await request.json());
let observedKey: { keyId: string; keyVersion: number } | null = null;
for (let retry = 0; retry < MAX_CAS_RETRIES; retry += 1) {
const vault = await db.query.e2eeKeyVaults.findFirst({ where: { userId: session.user.id } });
if (!vault) return NextResponse.json({ error: 'Encrypted messages are not configured' }, { status: 404 });
if (ownerDid !== session.user.did || vault.ownerDid !== ownerDid
|| vault.keyId !== keyId || vault.keyVersion !== keyVersion) {
return NextResponse.json({
error: 'The active account or encryption key changed during unlock',
code: 'E2EE_ACCOUNT_CHANGED',
}, { status: 409 });
}
if (observedKey && (vault.keyId !== observedKey.keyId || vault.keyVersion !== observedKey.keyVersion)) {
return NextResponse.json({
error: 'Encryption key changed during unlock',
code: 'E2EE_KEY_CONFLICT',
}, { status: 409 });
}
observedKey ??= { keyId: vault.keyId, keyVersion: vault.keyVersion };
const now = new Date();
if (vault.lockedUntil && vault.lockedUntil > now) {
return NextResponse.json({
error: 'Too many PIN attempts',
code: 'E2EE_PIN_LOCKED',
lockedUntil: vault.lockedUntil.toISOString(),
}, { status: 429 });
}
const unchangedVault = and(
eq(e2eeKeyVaults.userId, session.user.id),
eq(e2eeKeyVaults.keyId, vault.keyId),
eq(e2eeKeyVaults.keyVersion, vault.keyVersion),
eq(e2eeKeyVaults.pinVerifierMac, vault.pinVerifierMac),
eq(e2eeKeyVaults.failedAttempts, vault.failedAttempts),
vault.lockedUntil
? eq(e2eeKeyVaults.lockedUntil, vault.lockedUntil)
: isNull(e2eeKeyVaults.lockedUntil),
);
if (!pinVerifierMatches(pinVerifier, vault.pinVerifierMac, session.user.id, vault.keyId)) {
const priorAttempts = vault.lockedUntil && vault.lockedUntil <= now ? 0 : vault.failedAttempts;
const failedAttempts = priorAttempts + 1;
const shouldLock = failedAttempts >= E2EE_MAX_UNLOCK_ATTEMPTS;
const lockedUntil = shouldLock ? new Date(Date.now() + E2EE_LOCKOUT_MS) : null;
const [updated] = await db.update(e2eeKeyVaults).set({
failedAttempts: shouldLock ? 0 : failedAttempts,
lockedUntil,
updatedAt: now,
}).where(unchangedVault).returning({ userId: e2eeKeyVaults.userId });
if (!updated) continue;
return NextResponse.json({
error: shouldLock ? 'Too many PIN attempts' : 'Incorrect PIN',
code: shouldLock ? 'E2EE_PIN_LOCKED' : 'E2EE_PIN_INCORRECT',
attemptsRemaining: shouldLock ? 0 : E2EE_MAX_UNLOCK_ATTEMPTS - failedAttempts,
lockedUntil: lockedUntil?.toISOString() ?? null,
}, { status: shouldLock ? 429 : 403 });
}
const [updated] = await db.update(e2eeKeyVaults).set({
failedAttempts: 0,
lockedUntil: null,
updatedAt: now,
}).where(unchangedVault).returning({ userId: e2eeKeyVaults.userId });
if (!updated) continue;
return NextResponse.json({
serverShare: openServerShare(vault.serverShareEncrypted, session.user.id, vault.keyId),
vault: {
protocol: E2EE_PROTOCOL,
ownerDid: session.user.did,
keyId: vault.keyId,
keyVersion: vault.keyVersion,
publicKey: vault.publicKey,
ciphertext: vault.ciphertext,
nonce: vault.nonce,
salt: vault.salt,
kdfAlgorithm: vault.kdfAlgorithm,
kdfOpsLimit: vault.kdfOpsLimit,
kdfMemLimit: vault.kdfMemLimit,
},
}, { headers: { 'Cache-Control': 'no-store' } });
}
return NextResponse.json({
error: 'Encrypted message unlock was busy; retry',
code: 'E2EE_UNLOCK_CONFLICT',
}, { status: 409 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid unlock request' }, { status: 400 });
}
console.error('[E2EE Vault] Unlock failed:', error);
return NextResponse.json({ error: 'Failed to unlock encrypted messages' }, { status: 500 });
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ const announcementSchema = z.object({
userCount: z.number().optional(),
postCount: z.number().optional(),
isNsfw: z.boolean().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
timestamp: z.string().optional(),
});
@@ -5,10 +5,9 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, and } from 'drizzle-orm';
import { db, chatConversations } from '@/db';
import { eq } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { z } from 'zod';
// Schema for conversation ID parameter
@@ -62,67 +61,31 @@ export async function DELETE(
}
if (deleteFor === 'both') {
const participant2Handle = conversation.participant2Handle;
if (participant2Handle.includes('@')) {
return NextResponse.json({
error: 'Delete for everyone is not supported across nodes. You can still delete this conversation for yourself.',
code: 'REMOTE_DELETE_FOR_EVERYONE_UNSUPPORTED',
}, { status: 409 });
}
// Delete the entire conversation and all messages (cascade will handle messages)
await db.delete(chatConversations).where(eq(chatConversations.id, id));
// Send deletion request to the other party
const participant2Handle = conversation.participant2Handle;
const isRemote = participant2Handle.includes('@');
// Local user - find and delete their conversation too.
const recipientUser = await db.query.users.findFirst({
where: { handle: participant2Handle },
});
if (isRemote) {
// Extract domain from handle (format: handle@domain)
const domain = normalizeNodeDomain(participant2Handle.split('@')[1]);
const handle = participant2Handle.split('@')[0];
try {
if (await isNodeBlocked(domain)) {
return NextResponse.json({ success: true });
}
const protocol = domain.includes('localhost') ? 'http' : 'https';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// SECURITY: Sign the deletion request
const { signPayload, getNodePrivateKey } = await import('@/lib/swarm/signature');
const privateKey = await getNodePrivateKey();
const payload = {
senderHandle: session.user.handle,
senderNodeDomain: nodeDomain,
recipientHandle: handle,
conversationId: id,
timestamp: new Date().toISOString(),
};
const signature = signPayload(payload, privateKey);
await fetch(`${protocol}://${domain}/api/swarm/chat/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, signature }),
});
console.log(`[Chat Delete] Sent deletion request to ${domain}`);
} catch (error) {
console.error('[Chat Delete] Failed to notify remote node:', error);
// Continue anyway - local deletion succeeded
}
} else {
// Local user - find and delete their conversation too
const recipientUser = await db.query.users.findFirst({
where: { handle: participant2Handle },
if (recipientUser) {
// Find their conversation with us
const recipientConversation = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] },
});
if (recipientUser) {
// Find their conversation with us
const recipientConversation = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] },
});
if (recipientConversation) {
await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id));
console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`);
}
if (recipientConversation) {
await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id));
console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`);
}
}
+61 -11
View File
@@ -4,12 +4,13 @@
* GET: List all conversations for the current user
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, desc, and, isNull, sql } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db, chatMessages } from '@/db';
import { eq, and, isNull, sql } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol';
export async function GET(request: NextRequest) {
export async function GET() {
try {
if (!db) {
return NextResponse.json({ conversations: [] });
@@ -87,12 +88,13 @@ export async function GET(request: NextRequest) {
avatarUrl: profileData.profile.avatarUrl || null,
did: profileData.profile.did || '',
isBot: profileData.profile.isBot || false,
publicKey: profileData.profile.publicKey,
});
// Re-query to get the new cached user
cachedUser = await db.query.users.findFirst({
where: { handle: participant2Handle },
}) as any;
});
}
} catch (e) {
console.error(`[Lazy Load] Failed for ${participant2Handle}:`, e);
@@ -102,18 +104,66 @@ export async function GET(request: NextRequest) {
if (cachedUser) {
participant2Info = {
handle: cachedUser.handle,
displayName: (cachedUser as any).displayName || cachedUser.handle,
avatarUrl: (cachedUser as any).avatarUrl || null,
did: (cachedUser as any).did || '',
displayName: cachedUser.displayName || cachedUser.handle,
avatarUrl: cachedUser.avatarUrl || null,
did: cachedUser.did || '',
};
}
const latest = conv.messages[0] || null;
let lastMessage: {
protocolVersion: number;
content: string | null;
encryptedEnvelope: unknown;
signedAction: unknown;
senderPublicKey: string | null;
} | null = latest ? {
protocolVersion: 0,
content: latest.content,
encryptedEnvelope: null,
signedAction: null,
senderPublicKey: null as string | null,
} : null;
if (latest?.protocolVersion === E2EE_PROTOCOL_VERSION && latest.encryptedEnvelope
&& latest.senderDid && latest.e2eeSignature && latest.e2eeActionNonce && latest.e2eeActionTs) {
try {
const encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(latest.encryptedEnvelope));
const senderPublicKey = latest.senderDid === session.user.did
? session.user.publicKey
: cachedUser?.did === latest.senderDid
? cachedUser.publicKey
: null;
lastMessage = {
protocolVersion: E2EE_PROTOCOL_VERSION,
content: null,
encryptedEnvelope,
signedAction: {
action: E2EE_CHAT_ACTION,
data: encryptedEnvelope,
did: latest.senderDid,
handle: encryptedEnvelope.senderHandle,
ts: latest.e2eeActionTs,
nonce: latest.e2eeActionNonce,
sig: latest.e2eeSignature,
},
senderPublicKey,
};
} catch (error) {
console.error(`[E2EE Chat] Invalid conversation preview ${latest.id}:`, error);
lastMessage = null;
}
}
const { messages: _rawMessages, ...conversation } = conv;
void _rawMessages;
return {
...conv,
...conversation,
participant2: {
...participant2Info,
isBot: (cachedUser as any)?.isBot || false,
isBot: cachedUser?.isBot || false,
},
lastMessage,
unreadCount: Number(unreadCount[0]?.count || 0),
};
})
@@ -121,7 +171,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
conversations: conversationsWithUnread.filter(c => !c.participant2.isBot),
});
}, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
console.error('List conversations error:', error);
return NextResponse.json({ error: 'Failed to list conversations' }, { status: 500 });
+36 -7
View File
@@ -6,10 +6,11 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm';
import { db, chatMessages, users } from '@/db';
import { eq, and, isNull } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { z } from 'zod';
import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol';
// Schema for query parameters
const messagesQuerySchema = z.object({
@@ -23,6 +24,8 @@ const markReadSchema = z.object({
conversationId: z.string().uuid(),
});
type ChatUser = typeof users.$inferSelect;
export async function GET(request: NextRequest) {
try {
@@ -83,8 +86,8 @@ export async function GET(request: NextRequest) {
});
// Fetch users
const usersByDid: Record<string, any> = {};
const usersByHandle: Record<string, any> = {};
const usersByDid: Record<string, ChatUser> = {};
const usersByHandle: Record<string, ChatUser> = {};
if (senderDids.size > 0) {
const found = await db.query.users.findMany({
@@ -102,7 +105,7 @@ export async function GET(request: NextRequest) {
}
const messagesMapped = messages.map((msg) => {
const isSentByMe = msg.senderHandle === session.user.handle;
const isSentByMe = msg.senderDid === session.user.did || msg.senderHandle === session.user.handle;
// Resolve fresh user data
const user = msg.senderDid ? usersByDid[msg.senderDid] : usersByHandle[msg.senderHandle];
@@ -110,13 +113,39 @@ export async function GET(request: NextRequest) {
const displayName = user?.displayName || msg.senderDisplayName || msg.senderHandle;
const avatarUrl = user?.avatarUrl || msg.senderAvatarUrl;
let encryptedEnvelope = null;
let signedAction = null;
if (msg.protocolVersion === E2EE_PROTOCOL_VERSION && msg.encryptedEnvelope) {
try {
encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(msg.encryptedEnvelope));
if (!msg.senderDid || !msg.e2eeSignature || !msg.e2eeActionNonce || !msg.e2eeActionTs) {
throw new Error('Encrypted message signature metadata is incomplete');
}
signedAction = {
action: E2EE_CHAT_ACTION,
data: encryptedEnvelope,
did: msg.senderDid,
handle: encryptedEnvelope.senderHandle,
ts: msg.e2eeActionTs,
nonce: msg.e2eeActionNonce,
sig: msg.e2eeSignature,
};
} catch (error) {
console.error(`[E2EE Chat] Invalid stored envelope ${msg.id}:`, error);
}
}
return {
id: msg.id,
senderHandle: msg.senderHandle,
senderDisplayName: displayName,
senderAvatarUrl: avatarUrl,
senderDid: msg.senderDid,
content: msg.content,
content: msg.protocolVersion === 0 ? msg.content : null,
protocolVersion: msg.protocolVersion,
encryptedEnvelope,
signedAction,
senderPublicKey: user?.publicKey || null,
deliveredAt: msg.deliveredAt,
readAt: msg.readAt,
createdAt: msg.createdAt,
@@ -127,7 +156,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
messages: messagesMapped.reverse(), // Oldest first for display
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
});
}, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
+1 -1
View File
@@ -31,7 +31,7 @@ const nodeInfoSchema = z.object({
userCount: z.number().optional(),
postCount: z.number().optional(),
isNsfw: z.boolean().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
lastSeenAt: z.string().optional(),
});
+825 -222
View File
File diff suppressed because it is too large Load Diff
+73 -23
View File
@@ -2,7 +2,6 @@
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { TriangleAlert, X } from 'lucide-react';
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
@@ -31,7 +30,6 @@ interface AuthScreenProps {
}
export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProps) {
const router = useRouter();
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -53,10 +51,12 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
const [importFile, setImportFile] = useState<File | null>(null);
const [importPassword, setImportPassword] = useState('');
const [importEmail, setImportEmail] = useState('');
const [importHandle, setImportHandle] = useState('');
const [acceptedCompliance, setAcceptedCompliance] = useState(false);
const [importAgeVerified, setImportAgeVerified] = useState(false);
const [importSuccess, setImportSuccess] = useState<string | null>(null);
const [importWarnings, setImportWarnings] = useState<string[]>([]);
// Fetch node info
useEffect(() => {
@@ -105,7 +105,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
if (turnstileWidgetId.current && window.turnstile) {
try {
window.turnstile.remove(turnstileWidgetId.current);
} catch (e) {
} catch {
// Ignore errors
}
}
@@ -130,7 +130,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
if (turnstileWidgetId.current && window.turnstile) {
try {
window.turnstile.remove(turnstileWidgetId.current);
} catch (e) {
} catch {
// Ignore errors
}
}
@@ -165,7 +165,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
const handleImport = async (e: React.FormEvent) => {
e.preventDefault();
if (!importFile || !importPassword || !importHandle || !acceptedCompliance) {
if (!importFile || !importPassword || !importEmail || !importHandle || !acceptedCompliance) {
setError('Please fill in all fields and accept the compliance agreement');
return;
}
@@ -178,6 +178,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
setLoading(true);
setError('');
setImportSuccess(null);
setImportWarnings([]);
try {
const fileContent = await importFile.text();
@@ -189,6 +190,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
body: JSON.stringify({
exportData,
password: importPassword,
destinationEmail: importEmail,
newHandle: importHandle,
acceptedCompliance,
}),
@@ -200,16 +202,21 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
throw new Error(data.error || 'Import failed');
}
setImportSuccess(data.message);
// Soft navigation to preserve AuthContext/KeyStore state
setTimeout(() => {
router.refresh();
if (onSuccess) {
onSuccess();
} else {
router.push('/');
}
}, 2000);
const warnings = Array.isArray(data.warnings)
? data.warnings.filter((warning: unknown): warning is string => typeof warning === 'string')
: [];
setImportSuccess(data.message || 'Account imported successfully.');
setImportWarnings(warnings);
if (warnings.length === 0) {
setTimeout(() => {
if (onSuccess) {
onSuccess();
window.location.reload();
} else {
window.location.assign('/');
}
}, 2000);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed');
@@ -280,7 +287,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
// Import and set in memory store
// Remove PEM headers if present and clean whitespace
let cleanKey = privateKeyDecrypted
const cleanKey = privateKeyDecrypted
.replace(/-----BEGIN [A-Z ]+-----/, '')
.replace(/-----END [A-Z ]+-----/, '')
.replace(/\s/g, '');
@@ -306,7 +313,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
if (data.user?.privateKeyEncrypted) {
try {
// Update AuthContext first so it has the user and key
login(data.user);
await login(data.user);
// Now unlock (passing user explicitly to avoid async state delay)
await unlockIdentity(password, data.user);
@@ -315,12 +322,14 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
}
}
// Soft navigation to preserve AuthContext/KeyStore state
router.refresh();
// Start Chat in a fresh JavaScript realm before it creates or loads
// the E2EE account key. Turnstile-enabled credential handling remains
// part of the login trust boundary and is documented accordingly.
if (onSuccess) {
onSuccess();
window.location.reload();
} else {
router.push('/');
window.location.assign('/');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
@@ -688,7 +697,29 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
color: '#000',
fontSize: '14px',
}}>
{importSuccess} Redirecting...
<div>{importSuccess}</div>
{importWarnings.length > 0 ? (
<>
<ul style={{ margin: '8px 0 10px', paddingLeft: 20 }}>
{importWarnings.map((warning) => <li key={warning}>{warning}</li>)}
</ul>
<button
type="button"
className="btn"
onClick={() => {
if (onSuccess) {
onSuccess();
window.location.reload();
} else {
window.location.assign('/');
}
}}
style={{ width: '100%', justifyContent: 'center' }}
>
Continue
</button>
</>
) : ' Redirecting…'}
</div>
)}
@@ -738,6 +769,25 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Email on this node
</label>
<input
type="email"
className="input"
value={importEmail}
onChange={(e) => setImportEmail(e.target.value)}
placeholder="you@example.com"
autoComplete="email"
required
maxLength={320}
/>
<span style={{ display: 'block', color: 'var(--foreground-tertiary)', fontSize: '12px', marginTop: '4px' }}>
You&apos;ll use this email to sign in after the import.
</span>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Handle on this node
@@ -801,7 +851,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
<span style={{ fontSize: '12px', color: 'var(--foreground-secondary)', lineHeight: 1.4 }}>
<strong style={{ color: 'var(--warning)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<TriangleAlert size={12} /> Compliance:
</strong> I agree to comply with this node's rules and take responsibility for my migrated content.
</strong> I agree to comply with this node&apos;s rules and take responsibility for my migrated content.
</span>
</label>
</div>
@@ -834,7 +884,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
type="submit"
className="btn btn-primary btn-lg"
style={{ width: '100%' }}
disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)}
disabled={loading || !importFile || !importPassword || !importEmail || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)}
>
{loading ? 'Importing...' : 'Import Account'}
</button>
+21 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { useAuth } from '@/lib/contexts/AuthContext';
@@ -114,8 +114,8 @@ export default function MigrationPage() {
</h2>
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
Download a complete backup of your account including your identity, posts, and media.
You can use this file to migrate to another Synapsis node by selecting "Import" on the login page of that node.
Download a signed backup of your account identity, posts, and media.
You can use this file to migrate to another Synapsis node by selecting &quot;Import&quot; on the login page of that node.
</p>
<div style={{
@@ -139,11 +139,28 @@ export default function MigrationPage() {
<li>Your profile information</li>
<li>All your posts</li>
<li>Your following list</li>
<li>All DMs and conversation history</li>
<li>DM conversation records and encrypted message envelopes</li>
<li>Your automated bots and their configuration</li>
</ul>
</div>
<div style={{
display: 'flex',
gap: '12px',
padding: '16px',
marginBottom: '20px',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.35)',
borderRadius: '8px',
}}>
<TriangleAlert size={20} style={{ flexShrink: 0, color: '#f59e0b' }} />
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', margin: 0, lineHeight: 1.5 }}>
Encrypted DM keys are not portable yet. This export preserves encrypted message records,
but E2EE history will not open after importing it on another node. Keep access to this
browser and node if you need to read that history.
</p>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
Confirm your password
+294
View File
@@ -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 nodes key; old encrypted history will remain unavailable here.'
: 'Create a PIN to protect and restore your encrypted message history. Youll 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 612 digits.');
return;
}
if ((isSetup || resetMode) && pin !== confirmation) {
setLocalError('PINs dont 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)' }}>612 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
View File
@@ -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.
+8 -7
View File
@@ -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
View File
@@ -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;
}
+69 -21
View File
@@ -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) {
+125
View File
@@ -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;
}
+82 -26
View File
@@ -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();
});
});
+42 -14
View File
@@ -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
+126
View File
@@ -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);
});
});
+73
View File
@@ -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);
}
+198
View File
@@ -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/);
});
});
+347
View File
@@ -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);
}
}
+121
View File
@@ -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);
});
});
+210
View File
@@ -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,
};
}
+135
View File
@@ -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)),
]);
}
+249
View File
@@ -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`);
}
}
+56
View File
@@ -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);
}
+196
View File
@@ -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 };
}
+48 -21
View File
@@ -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();
+15
View File
@@ -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)
+3 -3
View File
@@ -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,
+81 -20
View File
@@ -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'));
});
});
+4
View File
@@ -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
View File
@@ -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 };
+1 -1
View File
@@ -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
View File
@@ -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
});
}