sipher/tests/proxies/follow.ts
Nixyan 66ebebd105 refactor: modularize plugins with federation and encryption infrastructure
Major changes:
- Restructure plugin architecture: moved federation logic into a dedicated `federation` plugin with Better Auth integration, defining schemas for server registry, key rotation, and blacklist management
- Extract encryption layer: new `oven` plugin handles end-to-end encryption (E2EE) with OLM client/server implementations
- Reorganize social features: consolidated social endpoints (posts, follows, blocks, mutes) and removed legacy plugin patterns in favor of unified plugin structure
- Decentralized key management: refactored `keytools` and `keygen` to support federation key rotation with challenge tokens and health checks

Infrastructure updates:
- Upgrade dependencies: bump Better Auth to 1.6.9, React to 19.2.5, Next.js to 16.2.3, Tailwind to 4.2.4
- Add cryptographic libraries: @scure/bip39, @signalapp/libsignal-client, @matrix-org/matrix-sdk-crypto-wasm for enhanced federation security
- Add utilities: base58-js, uuid for federation identifier handling
- Update database schema with new federation tables (serverRegistry, rotateChallengeTokens, blacklistedServers)

Minor updates: test suite alignment, storage client cleanup, PostFederationSchema refinements

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 11:40:14 -03:00

596 lines
20 KiB
TypeScript
Raw 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.

/**
* Manual proxy chain test script.
*
* You need 3 different instances up and running to use this test script. That includes yours.
*
* Exercises the full A → B → C → B → A proxy relay against real federation
* instances. Run this from Server A while Server B (proxy) and Server C
* (target) are already up.
*
* Usage:
* bun run testProxy.ts --proxy <B_URL> --target <C_URL>
*
* Examples:
* bun run testProxy.ts --proxy https://proxy.example.com --target https://target.example.com
* bun run testProxy.ts --proxy http://localhost:3001 --target http://localhost:3002
*/
import db from "@/lib/db";
import { deliveryJobs, follows, serverRegistry } from "@/lib/db/schema";
import { encryptPayload, fingerprintKey, signMessage } from "@/lib/federation/keytools";
import { config } from "dotenv";
import { desc, eq } from "drizzle-orm";
import nacl from "tweetnacl";
config({ path: ".env.local" });
const FETCH_TIMEOUT_MS = 15_000;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface FedKeys {
signingPublicKey: string;
signingSecretKey: string;
encryptionPublicKey: string;
encryptionSecretKey: string;
}
function generateEnvKeyPair(): FedKeys {
const signing = nacl.sign.keyPair();
const encryption = nacl.box.keyPair();
return {
signingPublicKey: Buffer.from(signing.publicKey).toString("base64"),
signingSecretKey: Buffer.from(signing.secretKey).toString("base64"),
encryptionPublicKey: Buffer.from(encryption.publicKey).toString("base64"),
encryptionSecretKey: Buffer.from(encryption.secretKey).toString("base64"),
};
}
async function readErrorBody(response: Response): Promise<string> {
try {
const body = await response.json();
return body?.error ?? body?.message ?? JSON.stringify(body);
} catch {
try {
return await response.text();
} catch {
return response.statusText;
}
}
}
interface TestResult {
name: string;
passed: boolean;
message: string;
}
const results: TestResult[] = [];
function pass(name: string, message = "OK") {
console.log(`${name}`);
if (message !== "OK") console.log(` ${message}`);
results.push({ name, passed: true, message });
}
function fail(name: string, message: string) {
console.error(`${name}`);
console.error(` ${message}`);
results.push({ name, passed: false, message });
}
// ---------------------------------------------------------------------------
// Validate environment
// ---------------------------------------------------------------------------
const REQUIRED_ENV = [
"FEDERATION_PUBLIC_KEY",
"FEDERATION_PRIVATE_KEY",
"FEDERATION_ENCRYPTION_PUBLIC_KEY",
"FEDERATION_ENCRYPTION_PRIVATE_KEY",
"BETTER_AUTH_URL",
] as const;
const missing = REQUIRED_ENV.filter((k) => !process.env[k]);
if (missing.length > 0) {
console.error("Missing required environment variables:");
missing.forEach((k) => console.error(` - ${k}`));
console.error("Ensure .env.local is present and populated.");
process.exit(1);
}
const ORIGIN = process.env.BETTER_AUTH_URL!;
const OWN_SIGNING_PUB = process.env.FEDERATION_PUBLIC_KEY!;
const OWN_ENCRYPTION_PUB = process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!;
// ---------------------------------------------------------------------------
// Parse arguments
// ---------------------------------------------------------------------------
function argAfter(flag: string): string | undefined {
const idx = process.argv.indexOf(flag);
return idx !== -1 ? process.argv[idx + 1] : undefined;
}
const proxyUrl = argAfter("--proxy");
const targetUrl = argAfter("--target");
const bearerToken = argAfter("--bearer");
const targetUserId = argAfter("--user");
if (!proxyUrl || !targetUrl) {
console.error("Usage: bun run testProxy.ts --proxy <B_URL> --target <C_URL> [options]");
console.error("");
console.error(" --proxy URL of Server B (the proxy)");
console.error(" --target URL of Server C (the target)");
console.error(" --test-fallback Enable proxy fallback test (requires C blocked from A)");
console.error(" --bearer <tok> Bearer token for A's API (required for --test-fallback)");
console.error(" --user <id> User ID on Server C to follow (required for --test-fallback)");
process.exit(1);
}
console.log("Proxy chain test");
console.log(` Server A (us): ${ORIGIN}`);
console.log(` Server B (proxy): ${proxyUrl}`);
console.log(` Server C (target): ${targetUrl}`);
console.log(` A signing key: ${fingerprintKey(OWN_SIGNING_PUB).slice(0, 16)}`);
console.log(` A encryption key: ${fingerprintKey(OWN_ENCRYPTION_PUB).slice(0, 16)}`);
// ---------------------------------------------------------------------------
// 1. Discovery check
// ---------------------------------------------------------------------------
interface DiscoverResponse {
url: string;
publicKey: string;
encryptionPublicKey: string;
peers: { url: string; isHealthy: boolean }[];
}
console.log("\n── Discovery ────────────────────────────────────────────");
let proxyInfo: DiscoverResponse;
let targetInfo: DiscoverResponse;
try {
const res = await fetch(`${proxyUrl}/discover`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
console.error(`Server B (${proxyUrl}) returned ${res.status}: ${await readErrorBody(res)}`);
process.exit(1);
}
proxyInfo = await res.json();
console.log(` B: ${proxyInfo.url}`);
console.log(` signing: ${fingerprintKey(proxyInfo.publicKey).slice(0, 16)}`);
console.log(` encryption: ${fingerprintKey(proxyInfo.encryptionPublicKey).slice(0, 16)}`);
console.log(` peers: ${proxyInfo.peers.length}`);
} catch (err) {
console.error(`Cannot reach Server B at ${proxyUrl}/discover: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}
const isFallbackMode = process.argv.includes("--test-fallback");
if (isFallbackMode) {
// C is blocked from A — load C's info from A's local registry instead
const [cRecord] = await db.select().from(serverRegistry).where(eq(serverRegistry.url, targetUrl)).limit(1);
if (!cRecord) {
console.error(` Server C (${targetUrl}) not found in local registry. Run mutual discovery before blocking.`);
process.exit(1);
}
targetInfo = {
url: cRecord.url,
publicKey: cRecord.publicKey,
encryptionPublicKey: cRecord.encryptionPublicKey,
peers: [],
};
console.log(` C: ${targetInfo.url} (from local registry — blocked)`);
console.log(` signing: ${fingerprintKey(targetInfo.publicKey).slice(0, 16)}`);
console.log(` encryption: ${fingerprintKey(targetInfo.encryptionPublicKey).slice(0, 16)}`);
} else {
try {
const res = await fetch(`${targetUrl}/discover`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
console.error(`Server C (${targetUrl}) returned ${res.status}: ${await readErrorBody(res)}`);
process.exit(1);
}
targetInfo = await res.json();
console.log(` C: ${targetInfo.url}`);
console.log(` signing: ${fingerprintKey(targetInfo.publicKey).slice(0, 16)}`);
console.log(` encryption: ${fingerprintKey(targetInfo.encryptionPublicKey).slice(0, 16)}`);
console.log(` peers: ${targetInfo.peers.length}`);
} catch (err) {
console.error(`Cannot reach Server C at ${targetUrl}/discover: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}
}
const aOnB = proxyInfo.peers.some((p) => p.url === ORIGIN);
console.log(` A registered on B: ${aOnB}`);
if (!aOnB) {
console.error("\n A is not registered on B. Run mutual discovery first.");
process.exit(1);
}
if (!isFallbackMode) {
const aOnC = targetInfo.peers.some((p) => p.url === ORIGIN);
console.log(` A registered on C: ${aOnC}`);
if (!aOnC) {
console.error("\n A is not registered on C. Run mutual discovery first.");
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// 25: Direct tests (skipped in --test-fallback mode since C is blocked)
// ---------------------------------------------------------------------------
if (isFallbackMode) {
console.log("\n Skipping direct tests (25) — C is blocked in fallback mode.");
}
if (!isFallbackMode) {
// ---------------------------------------------------------------------------
// 2. Full proxy relay: A → B → C → B → A
// ---------------------------------------------------------------------------
console.log("\n── Test: Full proxy relay (A → B → C → B → A) ─────────");
{
const testName = "full proxy relay";
try {
const nonce = crypto.randomUUID();
const innerPayload = JSON.stringify({
action: "proxy-test",
nonce,
timestamp: Date.now(),
sender: ORIGIN,
});
const targetEncKey = new Uint8Array(Buffer.from(targetInfo.encryptionPublicKey, "base64"));
const encrypted = encryptPayload(innerPayload, targetEncKey);
const signature = signMessage(innerPayload, new Uint8Array(Buffer.from(process.env.FEDERATION_PRIVATE_KEY!, "base64")));
const proxyBody = {
method: "PROXY",
targetUrl: targetUrl + "/proxy",
publicSigningKey: OWN_SIGNING_PUB,
publicEncryptionKey: OWN_ENCRYPTION_PUB,
payload: encrypted,
signature,
};
const res = await fetch(`${proxyUrl}/proxy`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Federation-Origin": ORIGIN,
"Origin": ORIGIN,
},
body: JSON.stringify(proxyBody),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const body = await res.json();
if (res.status !== 200) {
fail(testName, `expected 200, got ${res.status}: ${JSON.stringify(body)}`);
} else if (body.method !== "PROXY_RESPONSE") {
fail(testName, `expected method=PROXY_RESPONSE, got ${body.method}`);
} else if (!body.payload) {
fail(testName, "response missing payload (B did not relay C's response)");
} else if (body.payload.method !== "PROXY_RESPONSE") {
fail(testName, `inner payload method=${body.payload.method}, expected PROXY_RESPONSE`);
} else {
pass(testName, `nonce=${nonce}, C responded: "${body.payload.message ?? JSON.stringify(body.payload)}"`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// 3. Direct TARGETED: A → C
// ---------------------------------------------------------------------------
console.log("\n── Test: Direct TARGETED (A → C) ────────────────────────");
{
const testName = "direct TARGETED to C";
try {
const innerPayload = JSON.stringify({
action: "targeted-test",
nonce: crypto.randomUUID(),
sender: ORIGIN,
});
const targetEncKey = new Uint8Array(Buffer.from(targetInfo.encryptionPublicKey, "base64"));
const encrypted = encryptPayload(innerPayload, targetEncKey);
const res = await fetch(`${targetUrl}/proxy`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Federation-Origin": ORIGIN,
"Origin": ORIGIN,
},
body: JSON.stringify({
method: "TARGETED",
payload: encrypted,
}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const body = await res.json();
if (res.status !== 200) {
fail(testName, `expected 200, got ${res.status}: ${JSON.stringify(body)}`);
} else if (body.method !== "PROXY_RESPONSE") {
fail(testName, `expected method=PROXY_RESPONSE, got ${body.method}`);
} else {
pass(testName, `C says: "${body.message}"`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// 4. Sender validation — bad signing key
// ---------------------------------------------------------------------------
console.log("\n── Test: Sender validation (bad keys → B) ──────────────");
{
const testName = "reject mismatched signing key";
try {
const fakeKeys = generateEnvKeyPair();
const innerPayload = JSON.stringify({ action: "bad-key-test" });
const targetEncKey = new Uint8Array(Buffer.from(targetInfo.encryptionPublicKey, "base64"));
const encrypted = encryptPayload(innerPayload, targetEncKey);
const proxyBody = {
method: "PROXY",
targetUrl: targetUrl + "/proxy",
publicSigningKey: fakeKeys.signingPublicKey,
publicEncryptionKey: OWN_ENCRYPTION_PUB,
payload: encrypted,
};
const res = await fetch(`${proxyUrl}/proxy`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Federation-Origin": ORIGIN,
"Origin": ORIGIN,
},
body: JSON.stringify(proxyBody),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const body = await res.json();
if (res.status === 403 && body.code === "INCORRECT_KEYS") {
pass(testName, `B correctly rejected: "${body.error}"`);
} else {
fail(testName, `expected 403/INCORRECT_KEYS, got ${res.status}/${body.code}: ${JSON.stringify(body)}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// 5. Unknown sender
// ---------------------------------------------------------------------------
console.log("\n── Test: Unknown sender (→ B) ────────────────────────────");
{
const testName = "reject unknown sender";
try {
const unknownKeys = generateEnvKeyPair();
const unknownOrigin = "https://totally-unknown-federation-" + crypto.randomUUID().slice(0, 8) + ".test";
const innerPayload = JSON.stringify({ action: "unknown-sender-test" });
const targetEncKey = new Uint8Array(Buffer.from(targetInfo.encryptionPublicKey, "base64"));
const encrypted = encryptPayload(innerPayload, targetEncKey);
const proxyBody = {
method: "PROXY",
targetUrl: targetUrl + "/proxy",
publicSigningKey: unknownKeys.signingPublicKey,
publicEncryptionKey: unknownKeys.encryptionPublicKey,
payload: encrypted,
};
const res = await fetch(`${proxyUrl}/proxy`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Federation-Origin": unknownOrigin,
"Origin": unknownOrigin,
},
body: JSON.stringify(proxyBody),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const body = await res.json();
if (res.status === 403 && body.code === "UNKNOWN_FEDERATION_SERVER_INTERACTION") {
pass(testName, `B correctly rejected: "${body.error}"`);
} else {
fail(testName, `expected 403/UNKNOWN_FEDERATION_SERVER_INTERACTION, got ${res.status}/${body.code}: ${JSON.stringify(body)}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
} // end if (!isFallbackMode)
// ---------------------------------------------------------------------------
// 6. Auto proxy fallback via real follow delivery pipeline
// Sends a follow request through A's API → BullMQ worker picks it up →
// federationFetch with proxyFallback:true → direct to C fails → proxied
// through B → C processes → worker updates follow.accepted
//
// Requires:
// - Server C blocked from A (firewall)
// - --bearer <token> and --user <userId> flags
//
// Block C: netsh advfirewall firewall add rule name="Block Federation C" dir=out action=block remoteip=<C_IP> remoteport=<C_PORT> protocol=tcp
// Unblock: netsh advfirewall firewall delete rule name="Block Federation C"
// ---------------------------------------------------------------------------
if (isFallbackMode) {
console.log("\n── Test: Auto proxy fallback (follow delivery pipeline) ─");
if (!bearerToken || !targetUserId) {
console.error(" --test-fallback requires --bearer <token> and --user <userId>");
process.exit(1);
}
// Step 1: verify C is unreachable directly
{
const testName = "direct fetch to C fails";
try {
const res = await fetch(`${targetUrl}/discover`, {
signal: AbortSignal.timeout(5_000),
});
fail(testName, `direct fetch succeeded (${res.status}) — C is not blocked from A. Block it first.`);
} catch {
pass(testName, "C is unreachable from A (blocked)");
}
}
// Step 2: send follow request through A's API
{
const testName = "follow delivery via proxy fallback";
try {
console.log(` Sending follow request for user ${targetUserId} on ${targetUrl}...`);
const followRes = await fetch(`${ORIGIN}/api/auth/social/follows`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${bearerToken}`,
},
body: JSON.stringify({
method: "INSERT",
userId: targetUserId,
federationUrl: targetUrl,
}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const followBody = await followRes.json();
if (!followRes.ok) {
fail(testName, `follow request failed (${followRes.status}): ${JSON.stringify(followBody)}`);
} else {
const followId = followBody.following?.[0]?.id;
if (!followId) {
fail(testName, `follow created but no ID returned: ${JSON.stringify(followBody)}`);
} else {
console.log(` Follow record created: ${followId}`);
console.log(" Waiting for BullMQ worker to process delivery job...");
// Step 3: poll until the delivery job completes (worker processes it)
const maxWait = 60_000;
const pollInterval = 2_000;
let elapsed = 0;
let delivered = false;
while (elapsed < maxWait) {
await new Promise((r) => setTimeout(r, pollInterval));
elapsed += pollInterval;
// Check if delivery job for this target still exists (removed on success)
const pendingJobs = await db.select()
.from(deliveryJobs)
.where(eq(deliveryJobs.targetUrl, targetUrl + "/api/auth/social/follows"))
.orderBy(desc(deliveryJobs.createdAt))
.limit(5);
// Check if follow.accepted was updated (worker sets this on success)
const [followRecord] = await db.select()
.from(follows)
.where(eq(follows.id, followId))
.limit(1);
const jobCount = pendingJobs.length;
const accepted = followRecord?.accepted;
process.stdout.write(`\r Polling... ${Math.round(elapsed / 1000)}s — jobs pending: ${jobCount}, accepted: ${accepted} `);
if (accepted === true) {
delivered = true;
break;
}
}
console.log("");
if (delivered) {
pass(testName, "follow delivered through proxy and accepted by C");
} else {
// Check final state for diagnostics
const [finalFollow] = await db.select()
.from(follows)
.where(eq(follows.id, followId))
.limit(1);
const remainingJobs = await db.select()
.from(deliveryJobs)
.where(eq(deliveryJobs.targetUrl, targetUrl + "/api/auth/social/follows"))
.limit(5);
fail(testName,
`timed out after ${maxWait / 1000}s. ` +
`follow.accepted=${finalFollow?.accepted}, ` +
`pending delivery jobs=${remainingJobs.length}. ` +
`Check worker logs (DEBUG=app:federation:*) for details.`,
);
}
// Cleanup: remove the test follow record
console.log(" Cleaning up test follow record...");
// await db.delete(follows).where(eq(follows.id, followId));
}
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
} else {
console.log("\n Skipping auto-fallback test (pass --test-fallback to enable).");
console.log(" Requires: --test-fallback --bearer <token> --user <userId>");
console.log(" And C must be blocked from A's machine (firewall rule).");
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
const passed = results.filter((r) => r.passed);
const failed = results.filter((r) => !r.passed);
console.log("\n════════════════════════════════════════════════════════");
console.log(`Results: ${passed.length} passed, ${failed.length} failed out of ${results.length}`);
if (failed.length > 0) {
console.error("\nFailed tests:");
failed.forEach((f) => console.error(`${f.name}: ${f.message}`));
process.exit(1);
}
console.log("\nAll tests passed.");
process.exit(0);