### 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>
269 lines
9.1 KiB
TypeScript
269 lines
9.1 KiB
TypeScript
/**
|
|
* HTTP-based helpers that create Better Auth users against a running Sipher
|
|
* instance (A, B, or C in the test cluster) and register the user-identity
|
|
* Ed25519 keys that the Oven and social plugins require for follows / posts.
|
|
*
|
|
* Test scripts running inside the Docker network call this helper instead of
|
|
* being passed `--bearer <token>` manually, so the entire integration
|
|
* suite can boot a fresh cluster, create its own users, sign and submit
|
|
* payloads, and shut everything down without hoomans in the loop.
|
|
*
|
|
* Returned `identity.signingPublicKey` matches the format expected by
|
|
* `/api/auth/oven/identity/register` and the follow/post signature verifiers
|
|
* (base58 of the raw 32-byte Ed25519 verification key). The fingerprint format
|
|
* mirrors `generateUserKeyPair` in `src/lib/federation/keytools.ts`:
|
|
* `base58(sha256(base64(publicKey)))`.
|
|
*/
|
|
|
|
import { binary_to_base58 } from "@/lib/federation/keytools";
|
|
import { canonicalFollowRequestBytes } from "@/lib/identity/followSignature";
|
|
import { canonicalPostBytes } from "@/lib/identity/postSignature";
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import nacl from "tweetnacl";
|
|
|
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
|
|
interface IdentityKeyPair {
|
|
/** Base58 of the 32-byte Ed25519 verification key. */
|
|
signingPublicKey: string;
|
|
/** Raw 64-byte Ed25519 secret key (nacl form: seed || public). */
|
|
signingSecretKey: Uint8Array;
|
|
/** Base58 of sha256(base64(publicKey)). */
|
|
fingerprint: string;
|
|
}
|
|
|
|
export interface SipherTestUser {
|
|
instanceUrl: string;
|
|
userId: string;
|
|
email: string;
|
|
password: string;
|
|
username: string;
|
|
bearerToken: string;
|
|
identity: IdentityKeyPair;
|
|
}
|
|
|
|
export interface CreateUserOptions {
|
|
emailPrefix?: string;
|
|
name?: string;
|
|
password?: string;
|
|
usernamePrefix?: string;
|
|
}
|
|
|
|
function randomSuffix(len = 10): string {
|
|
return crypto.randomUUID().replace(/-/g, "").slice(0, len);
|
|
}
|
|
|
|
/**
|
|
* The auth config has the `haveIBeenPwned()` plugin enabled, which rejects any
|
|
* password found in the HIBP breach database. A hex random gives 64+ bits of
|
|
* entropy in a small alphabet so the result is virtually guaranteed to be a
|
|
* miss — `T#` and `!2026` keep the upper / digit / symbol mix in case future
|
|
* password policy plugins are added.
|
|
*/
|
|
function strongRandomPassword(): string {
|
|
return `T#${randomBytes(24).toString("hex")}!2026`;
|
|
}
|
|
|
|
async function readJsonSafe(res: Response): Promise<unknown> {
|
|
try {
|
|
return await res.json();
|
|
} catch {
|
|
try {
|
|
return await res.text();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function generateUserIdentityKeyPair(): IdentityKeyPair {
|
|
const signing = nacl.sign.keyPair();
|
|
const signingPubB64 = Buffer.from(signing.publicKey).toString("base64");
|
|
const fingerprintBytes = createHash("sha256").update(signingPubB64).digest();
|
|
return {
|
|
signingPublicKey: binary_to_base58(signing.publicKey),
|
|
signingSecretKey: signing.secretKey,
|
|
fingerprint: binary_to_base58(new Uint8Array(fingerprintBytes)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sign up + sign in + register identity in three sequential HTTP calls.
|
|
*
|
|
* Requires the target instance to expose:
|
|
* • Better Auth email/password (POST /api/auth/sign-up/email, /sign-in/email)
|
|
* • The `bearer()` plugin (returns `set-auth-token` on sign-in)
|
|
* • The Sipher Oven plugin (POST /api/auth/oven/identity/register)
|
|
*/
|
|
export async function createSipherUser(
|
|
instanceUrl: string,
|
|
opts: CreateUserOptions = {},
|
|
): Promise<SipherTestUser> {
|
|
const baseUrl = instanceUrl.replace(/\/$/, "");
|
|
const suffix = randomSuffix(10);
|
|
const email = `${opts.emailPrefix ?? "test"}-${suffix}@sipher.test`;
|
|
const name = opts.name ?? `Test User ${suffix}`;
|
|
const password = opts.password ?? strongRandomPassword();
|
|
const username = `${opts.usernamePrefix ?? "testuser"}_${suffix}`.toLowerCase();
|
|
|
|
// 1. Sign up — autoSignIn is false in this project's auth.ts, so the response
|
|
// has no session/token; we sign in below to get the bearer token.
|
|
const signUpRes = await fetch(`${baseUrl}/api/auth/sign-up/email`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password, name, username }),
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
});
|
|
if (!signUpRes.ok) {
|
|
const body = await readJsonSafe(signUpRes);
|
|
throw new Error(`signUp on ${baseUrl} failed (${signUpRes.status}): ${JSON.stringify(body)}`);
|
|
}
|
|
const signUpBody = (await readJsonSafe(signUpRes)) as { user?: { id?: string }; id?: string } | null;
|
|
const userId = signUpBody?.user?.id ?? signUpBody?.id;
|
|
if (!userId) {
|
|
throw new Error(`signUp on ${baseUrl} returned no user.id: ${JSON.stringify(signUpBody)}`);
|
|
}
|
|
|
|
// 2. Sign in to obtain the bearer token.
|
|
const signInRes = await fetch(`${baseUrl}/api/auth/sign-in/email`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password }),
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
});
|
|
if (!signInRes.ok) {
|
|
const body = await readJsonSafe(signInRes);
|
|
throw new Error(`signIn on ${baseUrl} failed (${signInRes.status}): ${JSON.stringify(body)}`);
|
|
}
|
|
const bearerToken = signInRes.headers.get("set-auth-token");
|
|
if (!bearerToken) {
|
|
throw new Error(
|
|
`signIn on ${baseUrl} returned no \`set-auth-token\` header — is the bearer plugin enabled?`,
|
|
);
|
|
}
|
|
// Drain the body so the connection can be reused.
|
|
await readJsonSafe(signInRes);
|
|
|
|
// 3. Register the user's stable identity key.
|
|
const identity = generateUserIdentityKeyPair();
|
|
const registerRes = await fetch(`${baseUrl}/api/auth/oven/identity/register`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${bearerToken}`,
|
|
},
|
|
body: JSON.stringify({
|
|
signingPublicKey: identity.signingPublicKey,
|
|
fingerprint: identity.fingerprint,
|
|
}),
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
});
|
|
if (!registerRes.ok) {
|
|
const body = await readJsonSafe(registerRes);
|
|
throw new Error(
|
|
`identity register on ${baseUrl} failed (${registerRes.status}): ${JSON.stringify(body)}`,
|
|
);
|
|
}
|
|
|
|
return { instanceUrl: baseUrl, userId, email, password, username, bearerToken, identity };
|
|
}
|
|
|
|
/**
|
|
* Authenticated `POST /api/auth/social/follows` with `INSERT` method on `user`'s
|
|
* instance, signed with `user.identity.signingSecretKey`. The `followingUserId`
|
|
* is the user being followed; `targetFederationUrl` is the homeserver of that
|
|
* user (omit for a local follow).
|
|
*/
|
|
async function followUserOverHttp(
|
|
user: SipherTestUser,
|
|
params: { followingUserId: string; targetFederationUrl?: string },
|
|
): Promise<{ followId: string; raw: unknown }> {
|
|
const followId = crypto.randomUUID();
|
|
const createdAt = new Date().toISOString();
|
|
const federationUrl = params.targetFederationUrl ?? user.instanceUrl;
|
|
|
|
const msg = canonicalFollowRequestBytes({
|
|
followId,
|
|
followerId: user.userId,
|
|
followingId: params.followingUserId,
|
|
createdAt,
|
|
federationUrl: user.instanceUrl,
|
|
});
|
|
const sig = nacl.sign.detached(msg, user.identity.signingSecretKey);
|
|
const signature = Buffer.from(sig).toString("base64");
|
|
|
|
const body: Record<string, unknown> = {
|
|
method: "INSERT",
|
|
userId: params.followingUserId,
|
|
followId,
|
|
createdAt,
|
|
signature,
|
|
};
|
|
if (params.targetFederationUrl && params.targetFederationUrl !== user.instanceUrl) {
|
|
body.federationUrl = federationUrl;
|
|
}
|
|
|
|
const res = await fetch(`${user.instanceUrl}/api/auth/social/follows`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${user.bearerToken}`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
});
|
|
const json = await readJsonSafe(res);
|
|
if (!res.ok) {
|
|
throw new Error(`follow INSERT on ${user.instanceUrl} failed (${res.status}): ${JSON.stringify(json)}`);
|
|
}
|
|
return { followId, raw: json };
|
|
}
|
|
|
|
export interface PostContentBlock {
|
|
type: "text" | "image" | "video" | "audio" | "link";
|
|
value?: string;
|
|
url?: string;
|
|
[k: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* Authenticated `POST /api/auth/social/posts` signed with the author's identity
|
|
* key. Returns the API response body (which includes `id` and
|
|
* `federationDeliveriesQueued`).
|
|
*/
|
|
export async function createPostOverHttp(
|
|
author: SipherTestUser,
|
|
content: PostContentBlock[],
|
|
): Promise<{ postId: string; federationDeliveriesQueued: number; raw: unknown }> {
|
|
const postId = crypto.randomUUID();
|
|
const publishedAt = new Date().toISOString();
|
|
|
|
const msg = canonicalPostBytes({
|
|
postId,
|
|
authorId: author.userId,
|
|
publishedAt,
|
|
content,
|
|
federationUrl: author.instanceUrl,
|
|
});
|
|
const sig = nacl.sign.detached(msg, author.identity.signingSecretKey);
|
|
const signature = Buffer.from(sig).toString("base64");
|
|
|
|
const res = await fetch(`${author.instanceUrl}/api/auth/social/posts`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${author.bearerToken}`,
|
|
},
|
|
body: JSON.stringify({ postId, publishedAt, signature, content }),
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
});
|
|
const json = (await readJsonSafe(res)) as { id?: string; federationDeliveriesQueued?: number } | null;
|
|
if (!res.ok) {
|
|
throw new Error(`createPost on ${author.instanceUrl} failed (${res.status}): ${JSON.stringify(json)}`);
|
|
}
|
|
return {
|
|
postId: json?.id ?? postId,
|
|
federationDeliveriesQueued: json?.federationDeliveriesQueued ?? 0,
|
|
raw: json,
|
|
};
|
|
}
|