sipher/tests/federation/key-rotation.e2e.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

406 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Key rotation: /discover/rotate/init and /discover/rotate/confirm.
*
* Security note: confirm intentionally does **not** auto-blacklist the rotating
* server after failed proofs (that would let anyone spam-init for a victim URL and ban them).
*/
import db from "@/lib/db";
import { rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
import type { EncryptedEnvelope } from "@/lib/federation/keytools";
import { decryptPayload, encryptPayload, signMessage } from "@/lib/federation/keytools";
import { expect, test } from "@playwright/test";
import createDebug from "debug";
import { eq } from "drizzle-orm";
import {
clearTables,
generateEnvKeyPair,
getBlacklistedServer,
getChallengesByServerUrl,
getServerByUrl,
seedBlacklist,
seedChallenge,
seedServer,
} from "../helpers/db";
const debug = createDebug("test:key-rotation");
const SERVER_URL = "https://test-server.com";
test.beforeEach(async ({ }, testInfo) => {
debug("beforeEach clearing tables for: %s", testInfo.title);
await clearTables();
});
test.afterEach(async ({ }, testInfo) => {
debug("afterEach clearing tables after: %s", testInfo.title);
await clearTables();
});
function getOwnEncryptionPublicKey(): Uint8Array {
return new Uint8Array(Buffer.from(process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!, "base64"));
}
function buildBadEnvelope() {
return encryptPayload(
JSON.stringify({
signingOldSignature: "wrong",
signingNewSignature: "wrong",
encryptionOldPlaintext: "wrong",
encryptionNewPlaintext: "wrong",
}),
getOwnEncryptionPublicKey(),
);
}
interface InitChallenges {
signingOldChallenge: string;
signingNewChallenge: string;
encryptionOldChallenge: EncryptedEnvelope;
encryptionNewChallenge: EncryptedEnvelope;
}
function solveInitChallenges(
challenges: InitChallenges,
oldKeys: ReturnType<typeof generateEnvKeyPair>,
newKeys: ReturnType<typeof generateEnvKeyPair>,
) {
const oldSigningSecret = new Uint8Array(Buffer.from(oldKeys.signingSecretKey, "base64"));
const newSigningSecret = new Uint8Array(Buffer.from(newKeys.signingSecretKey, "base64"));
const oldEncSecret = new Uint8Array(Buffer.from(oldKeys.encryptionSecretKey, "base64"));
const newEncSecret = new Uint8Array(Buffer.from(newKeys.encryptionSecretKey, "base64"));
return {
signingOldSignature: signMessage(challenges.signingOldChallenge, oldSigningSecret),
signingNewSignature: signMessage(challenges.signingNewChallenge, newSigningSecret),
encryptionOldPlaintext: decryptPayload(challenges.encryptionOldChallenge, oldEncSecret),
encryptionNewPlaintext: decryptPayload(challenges.encryptionNewChallenge, newEncSecret),
};
}
test("init rejects invalid JSON", async ({ request }) => {
const res = await request.post("/discover/rotate/init", {
headers: { "Content-Type": "application/json" },
data: "{",
});
expect(res.status()).toBe(400);
expect(await res.json()).toMatchObject({ code: "INVALID_JSON" });
});
test("init rejects malformed body", async ({ request }) => {
const res = await request.post("/discover/rotate/init", {
data: {
url: "not-a-url",
newSigningPublicKey: "AA",
newEncryptionPublicKey: "BB",
},
});
expect(res.status()).toBe(400);
});
test("init rejects unregistered server", async ({ request }) => {
const newKeys = generateEnvKeyPair();
const res = await request.post("/discover/rotate/init", {
data: {
url: "https://unknown-server.com",
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
},
});
expect(res.status()).toBe(404);
});
test("init rejects when server URL is blacklisted", async ({ request }) => {
const oldKeys = generateEnvKeyPair();
const newKeys = generateEnvKeyPair();
await seedBlacklist(SERVER_URL);
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey);
const res = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
},
});
expect(res.status()).toBe(403);
expect(await res.json()).toMatchObject({ error: /blacklisted/i });
});
test("init returns 429 after too many inits for same server URL (cleared challenges between)", async ({
request,
}) => {
const oldKeys = generateEnvKeyPair();
const newKeys = generateEnvKeyPair();
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey);
const payload = {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
};
const r1 = await request.post("/discover/rotate/init", { data: payload });
expect(r1.status()).toBe(200);
await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.serverUrl, SERVER_URL));
const r2 = await request.post("/discover/rotate/init", { data: payload });
expect(r2.status()).toBe(200);
await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.serverUrl, SERVER_URL));
const r3 = await request.post("/discover/rotate/init", { data: payload });
expect(r3.status()).toBe(429);
expect(await r3.json()).toMatchObject({ error: /Too many rotation init attempts/i });
});
test("init rejects same keys as currently registered", async ({ request }) => {
const keys = generateEnvKeyPair();
await seedServer(SERVER_URL, keys.signingPublicKey, keys.encryptionPublicKey);
const res = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: keys.signingPublicKey,
newEncryptionPublicKey: keys.encryptionPublicKey,
},
});
expect(res.status()).toBe(400);
expect(await res.json()).toMatchObject({ error: /already registered/i });
});
test("init issues 4 challenges", async ({ request }) => {
const oldKeys = generateEnvKeyPair();
const newKeys = generateEnvKeyPair();
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey);
const res = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
},
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.signingOldChallenge).toBeDefined();
expect(body.signingNewChallenge).toBeDefined();
expect(body.encryptionOldChallenge).toBeDefined();
expect(body.encryptionOldChallenge.ephemeralPublicKey).toBeDefined();
expect(body.encryptionNewChallenge).toBeDefined();
expect(body.encryptionNewChallenge.ephemeralPublicKey).toBeDefined();
});
test("init rejects duplicate while challenge is pending", async ({ request }) => {
const oldKeys = generateEnvKeyPair();
const newKeys1 = generateEnvKeyPair();
const newKeys2 = generateEnvKeyPair();
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey);
const res1 = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys1.signingPublicKey,
newEncryptionPublicKey: newKeys1.encryptionPublicKey,
},
});
expect(res1.status()).toBe(200);
const res2 = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys2.signingPublicKey,
newEncryptionPublicKey: newKeys2.encryptionPublicKey,
},
});
expect(res2.status()).toBe(409);
expect(await res2.json()).toMatchObject({ error: /already pending/i });
});
test("confirm rejects invalid JSON", async ({ request }) => {
const res = await request.post("/discover/rotate/confirm", {
headers: { "Content-Type": "application/json" },
data: "{",
});
expect(res.status()).toBe(400);
expect(await res.json()).toMatchObject({ code: "INVALID_JSON" });
});
test("confirm rejects missing challenge", async ({ request }) => {
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://ghost-server.com",
envelope: buildBadEnvelope(),
},
});
expect(res.status()).toBe(404);
});
test("confirm rejects malformed envelope shape without touching attempts counter", async ({
request,
}) => {
const oldKeys = generateEnvKeyPair();
const newKeys = generateEnvKeyPair();
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey);
const initRes = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
},
});
expect(initRes.status()).toBe(200);
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope: {
ephemeralPublicKey: "AA",
iv: "AA",
ciphertext: "AA",
},
},
});
expect(res.status()).toBe(400);
const rows = await getChallengesByServerUrl(SERVER_URL);
expect(rows).toHaveLength(1);
expect(rows[0].attemptsLeft).toBe(3);
});
test("confirm rejects expired challenge", async ({ request }) => {
await seedChallenge({ expiresAt: new Date(Date.now() - 1000) });
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
},
});
expect(res.status()).toBe(400);
expect(await res.json()).toMatchObject({ error: /expired/ });
});
test("confirm rejects wrong proofs (init → confirm)", async ({ request }) => {
const oldKeys = generateEnvKeyPair();
const newKeys = generateEnvKeyPair();
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey);
const initRes = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
},
});
expect(initRes.status()).toBe(200);
const confirmRes = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
},
});
expect(confirmRes.status()).toBe(400);
expect(await confirmRes.json()).toMatchObject({ error: /verification failed|attempt\(s\) left/i });
});
test("confirm cancels challenge after attempts exhausted (does NOT blacklist)", async ({ request }) => {
const oldKeys = generateEnvKeyPair();
const newKeys = generateEnvKeyPair();
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey);
const initRes = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
},
});
expect(initRes.status()).toBe(200);
for (let i = 0; i < 3; i++) {
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
},
});
expect(res.status()).toBe(400);
expect(await res.json()).toMatchObject({ error: /decrypt envelope|verification failed|attempt\(s\) left/i });
}
const finalRes = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
},
});
expect(finalRes.status()).toBe(403);
expect(await finalRes.json()).toMatchObject({ error: /cancelled/i });
expect(await getBlacklistedServer(SERVER_URL)).toBeUndefined();
expect(await getChallengesByServerUrl(SERVER_URL)).toHaveLength(0);
});
test("confirm returns 404 when registry row removed after init", async ({ request }) => {
const oldKeys = generateEnvKeyPair();
const newKeys = generateEnvKeyPair();
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey);
const initRes = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
},
});
expect(initRes.status()).toBe(200);
await db.delete(serverRegistry).where(eq(serverRegistry.url, SERVER_URL));
const confirmRes = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
},
});
expect(confirmRes.status()).toBe(404);
expect(await confirmRes.json()).toMatchObject({ error: /not found in registry/i });
});
test("full rotation flow: init → solve → confirm rotates both keys and clears challenge", async ({
request,
}) => {
const oldKeys = generateEnvKeyPair();
const newKeys = generateEnvKeyPair();
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey);
const initRes = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
},
});
expect(initRes.status()).toBe(200);
const challenges: InitChallenges = await initRes.json();
const proofs = solveInitChallenges(challenges, oldKeys, newKeys);
const envelope = encryptPayload(JSON.stringify(proofs), getOwnEncryptionPublicKey());
const confirmRes = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope,
},
});
expect(confirmRes.status()).toBe(200);
expect(await confirmRes.json()).toMatchObject({ message: /confirmed/i });
const server = await getServerByUrl(SERVER_URL);
expect(server).toBeDefined();
expect(server!.publicKey).toBe(newKeys.signingPublicKey);
expect(server!.encryptionPublicKey).toBe(newKeys.encryptionPublicKey);
expect(await getChallengesByServerUrl(SERVER_URL)).toHaveLength(0);
});