sipher/src/lib/bull/processors/delivery.ts
Nixyan 660c17b319 feat: add client-side identity system, rate limiting, proxy hardening, and full test suite
### Major changes

- **Client-side identity** — New session key store (`sessionKey.ts`) backed by
  `sessionStorage` with a module-level caching, a `crypto.subtle` cache, a `useIdentityLock`
  hook for decrypt-once signing, `followSignature.ts` for signed follows, and
  two new UI modals (`IdentityBackup.tsx`, `UnlockIdentityModal.tsx`).
  `CreateIdentity.tsx` is rewritten to generate BIP-39 mnemonics and encrypt the
  Ed25519 keypair with AES-256-GCM via PBKDF2 (600k iterations) before storing
  in IndexedDB.

- **Rate limiting** — New `rate-limit-config.ts` and `rate-limit.ts` provide a
  per-IP sliding-window rate limiter backed by Redis. All external-facing routes
  (`/discover`, `/discover/rotate/*`, `/proxy`, social API endpoints) now have
  conservative defaults wired into the custom HTTP server before requests reach
  Next.js handlers.

- **Proxy route hardening** — The `/proxy` route now enforces a 256 KB payload
  limit (HTTP 413), validates JSON before parsing, applies a per-origin rate
  limit (100 req/min), and imports the `blocks` table to reject requests from
  blocked servers.

- **Docker integration-test cluster** — New `Dockerfile`, `.dockerignore`, and
  `tests/docker-compose.yml` orchestrate three SiPher instances (A, B, C) plus
  shared PostgreSQL and Redis. Key generation (`generate-keys.ts`) and discovery
  setup (`setup-discovery.ts`) scripts automate cluster bootstrap. Three example
  env files document required per-instance configuration.

- **Full test suite overhaul** — Replaces the old attack/auth/discover/key/proxy
  tests with a structured suite:
  * `tests/federation/` — Keytools unit tests + key-rotation e2e test
  * `tests/proxy/` — Proxy relay e2e tests (single-server validation)
  * `tests/integration/` — Multi-instance integration tests for discover,
    proxy-chain relay, and federated post delivery via BullMQ
  * `tests/helpers/` — Reusable DB, identity, and auth-user utilities
  * Playwright config updated to match new file conventions
  * Unused helpers (`tests/helpers/queue.ts`) removed

- **Social plugin endpoints** — Rewritten `follows.ts`, `blocks.ts`, `mutes.ts`,
  and `posts.ts` with proper federation integration. `social.ts` gains helpers
  for looking up posts by federation URL.

### Minor changes

- **README** — Expanded from a 42-line stub to a full architecture guide with
  tables for every layer (auth, DB, queues, storage, real-time), API route
  documentation, setup instructions, environment variables, test coverage, and
  the updated roadmap.

- **Federation helpers** — `keytools.ts` refactors imports and cleans up the public surface.
  `fetch.ts`, `registry.ts`, and `proxy-helpers/federated-post.ts` pick up small
  improvements. `PostFederationSchema` simplifies its encryption type assertion.

- **Plugin infrastructure** — Oven plugin schema and server index gain minor
  refactors. Social client adds a `muteUser` method.

- **UI components** — `switch.tsx` and `tooltip.tsx` rewritten for Radix v2 /
  Tailwind 4; `accordion.tsx`, `dropdown-menu.tsx`, `form`, `button`, `card` get
  minor consistency fixes. `dialog.tsx` removes unused `DialogHeader`.

- **Server bootstrap** — `server.ts` imports DB schema before `instrumentation`
  for correct Drizzle initialization, rate-limiting routes are wired, and CORS
  allows federation origins. `auth.ts` regenerates Oven and social plugin schemas.

- **Dependencies** — Added `@noble/ciphers` and `@noble/hashes` (crypto
  primitives). Removed `@signalapp/libsignal-client`, `base58-js`, `nanostores`,
  `tweetnacl-util`, `dexie-react-hooks`, `socket.io-client`. Updated all Better
  Auth packages to 1.6.11, BullMQ to 5.76.10, and various dev deps across the
  board.

- **.gitignore** — Added `/audits` and `tests/docker/*.env` to prevent secret
  leakage.

- **DB schema** — `blocks` table imported in `src/lib/db/schema/index.ts`.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 09:48:42 -03:00

191 lines
6.8 KiB
TypeScript

import db from '@/lib/db';
import { blacklistedServers, deliveryJobs, serverRegistry } from '@/lib/db/schema';
import { federationFetch } from '@/lib/federation/fetch';
import { encryptPayload, getOwnSigningSecretKey, signMessage } from '@/lib/federation/keytools';
import { discoverAndRegister, DiscoveryError } from '@/lib/federation/registry';
import { UnrecoverableError, type Job } from 'bullmq';
import createDebug from 'debug';
import { eq } from 'drizzle-orm';
import type { FederationDeliveryJob } from '../queues';
import { handleFollowAck } from './handlers/follow';
const debug = createDebug('app:federation:worker');
const ALLOWED_METHODS = new Set(['FEDERATE', 'FEDERATE_POST', 'INSERT', 'UNFOLLOW']);
// ---------------------------------------------------------------------------
// Ack handlers keyed by job name
// ---------------------------------------------------------------------------
type AckPayload = { method: 'PROXY_RESPONSE'; data: unknown; signature: string };
type AckHandler = (
ackPayload: AckPayload,
serverUrl: string,
serverPublicKey: string | undefined,
deliveryJobId: string,
jobId: string | undefined,
) => Promise<void>;
const ackHandlers: Record<string, AckHandler> = {
'deliver-follow': handleFollowAck,
};
function getFederationOrigin(): string {
const origin = process.env.BETTER_AUTH_URL;
if (!origin) {
throw new UnrecoverableError('BETTER_AUTH_URL environment variable is not set, cannot send federation requests');
}
return origin;
}
// ---------------------------------------------------------------------------
// Main processor
// ---------------------------------------------------------------------------
export async function processFederationDelivery(job: Job<FederationDeliveryJob>): Promise<void> {
const { deliveryJobId, targetUrl, serverUrl, payload } = job.data;
debug('processing job %s (%s) → %s (attempt %d)', job.id, job.name, targetUrl, job.attemptsMade + 1);
// 1. Validate method early — before any I/O.
let parsedPayload: Record<string, unknown>;
try {
parsedPayload = JSON.parse(payload);
} catch {
await db.delete(deliveryJobs).where(eq(deliveryJobs.id, deliveryJobId));
throw new UnrecoverableError(`Malformed payload JSON, dropping job ${job.id}`);
}
if (typeof parsedPayload?.method !== 'string') {
await db.delete(deliveryJobs).where(eq(deliveryJobs.id, deliveryJobId));
throw new UnrecoverableError(`Payload missing or non-string method, dropping job ${job.id}`);
}
const method = parsedPayload.method;
if (!ALLOWED_METHODS.has(method)) {
debug('invalid method: %s, dropping job %s', method, job.id);
await db.delete(deliveryJobs).where(eq(deliveryJobs.id, deliveryJobId));
throw new UnrecoverableError(`Invalid method: ${method}, dropping job ${job.id}`);
}
// 2. Blacklist check.
const [blacklisted] = await db
.select({ id: blacklistedServers.id })
.from(blacklistedServers)
.where(eq(blacklistedServers.serverUrl, serverUrl))
.limit(1);
if (blacklisted) {
debug('server %s is blacklisted, dropping job %s', serverUrl, job.id);
await db.delete(deliveryJobs).where(eq(deliveryJobs.id, deliveryJobId));
throw new UnrecoverableError(`Server ${serverUrl} is blacklisted, skipping delivery`);
}
// 3. Resolve encryption key (and keep the full server row for later).
let encryptionPublicKey: string;
let serverPublicKey: string | undefined;
const [server] = await db
.select({
encryptionPublicKey: serverRegistry.encryptionPublicKey,
publicKey: serverRegistry.publicKey,
})
.from(serverRegistry)
.where(eq(serverRegistry.url, serverUrl))
.limit(1);
if (server) {
encryptionPublicKey = server.encryptionPublicKey;
serverPublicKey = server.publicKey;
} else {
debug('server %s not in registry, attempting auto-discovery', serverUrl);
try {
encryptionPublicKey = await discoverAndRegister(serverUrl);
} catch (err) {
if (err instanceof DiscoveryError) {
debug('auto-discovery of %s failed: %s', serverUrl, err.message);
throw new Error(`Auto-discovery of ${serverUrl} failed: ${err.message}`);
}
throw err;
}
// serverPublicKey stays undefined; follow handler will re-fetch it.
}
// 4. Encrypt payload and record the attempt.
debug('encrypting payload for %s (key: %s…)', serverUrl, encryptionPublicKey.slice(0, 8));
const recipientKey = new Uint8Array(Buffer.from(encryptionPublicKey, 'base64'));
const encrypted = encryptPayload(payload, recipientKey);
await db.update(deliveryJobs).set({
lastAttemptedAt: new Date(),
attempts: job.attemptsMade + 1,
}).where(eq(deliveryJobs.id, deliveryJobId));
// 5. Send.
debug('sending encrypted payload to %s', targetUrl);
const signature = signMessage(payload, getOwnSigningSecretKey());
const origin = getFederationOrigin();
const { response } = await federationFetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': origin,
'X-Federation-Origin': origin,
'X-Federation-Target': targetUrl,
},
body: JSON.stringify({ method, payload: encrypted, signature }),
timeout: 15_000,
proxyFallback: true,
serverUrl,
});
if (!response.ok) {
debug('delivery to %s failed with status %d', targetUrl, response.status);
throw new Error(`Federation delivery to ${targetUrl} failed: ${response.status}`);
}
// 6. Parse ack.
let responseBody: unknown;
try {
responseBody = await response.json();
} catch {
throw new UnrecoverableError(
`Federation delivery to ${targetUrl} returned non-JSON response`,
);
}
debug('delivery to %s acknowledged (body length: %d)', targetUrl, JSON.stringify(responseBody).length);
// The ack envelope can arrive in two shapes:
// • Proxy-wrapped (delivered through /proxy):
// { payload: { method: 'PROXY_RESPONSE', data, signature }, ... }
// • Direct (delivered straight to the target endpoint, e.g. /api/auth/...):
// { method: 'PROXY_RESPONSE', data, signature, status: 'acknowledged' }
// Accept either — proxy routing is an optimisation, not part of the ack contract.
const body = (responseBody ?? {}) as Record<string, unknown>;
const wrappedPayload =
body.payload !== null && body.payload !== undefined ? (body.payload as AckPayload) : null;
const inlinePayload =
body.method === 'PROXY_RESPONSE' ? (body as unknown as AckPayload) : null;
const ackPayload: AckPayload | null = wrappedPayload ?? inlinePayload;
if (!ackPayload || ackPayload.method !== 'PROXY_RESPONSE') {
debug('delivery to %s not acknowledged', targetUrl);
throw new UnrecoverableError(
`Federation delivery to ${targetUrl} not acknowledged`,
);
}
// 7. Dispatch to job-specific ack handler (if any).
const handleAck = ackHandlers[job.name];
if (handleAck) {
await handleAck(ackPayload, serverUrl, serverPublicKey, deliveryJobId, job.id);
} else {
debug('job %s has no ack handler, skipping ack processing', job.name);
}
debug('job %s delivered successfully to %s', job.id, targetUrl);
}