### 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>
301 lines
8.8 KiB
TypeScript
301 lines
8.8 KiB
TypeScript
import db from "@/lib/db";
|
|
import { olmDeviceKeys, userIdentityKeys } from "@/lib/db/schema";
|
|
import type { BetterAuthPlugin } from "better-auth";
|
|
import { createAuthEndpoint, getSessionFromCtx } from "better-auth/api";
|
|
import { eq } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import {
|
|
IdentityRegisterBodySchema,
|
|
KeysUploadBodySchema,
|
|
type SignedFallbackKey,
|
|
type SignedKey,
|
|
} from "./schema";
|
|
|
|
/**
|
|
* Sipher Oven plugin — server side.
|
|
*
|
|
* Security model
|
|
* --------------
|
|
* This plugin only ever touches PUBLIC cryptographic material:
|
|
*
|
|
* - `userIdentityKeys` stores the user's stable Ed25519 verification key
|
|
* derived client-side from their BIP-39 mnemonic. The matching secret
|
|
* key is encrypted in the client's Dexie store and never reaches us.
|
|
* - `olmDeviceKeys` stores one row per device. The single `bundleJson`
|
|
* column holds the full Matrix `{ device_keys, one_time_keys, fallback_keys }`
|
|
* blob published by the OlmMachine. The OlmMachine keeps its own private
|
|
* state in IndexedDB.
|
|
*
|
|
* The schema in `./schema.ts` rejects anything that isn't a 32-byte
|
|
* unpadded-base64 public key, which makes it structurally impossible for a
|
|
* client to land a 64-byte NaCl secret key in any of the OLM key fields.
|
|
*/
|
|
|
|
interface DeviceBundle {
|
|
device_keys: z.infer<typeof KeysUploadBodySchema>["device_keys"];
|
|
one_time_keys: Record<string, SignedKey>;
|
|
fallback_keys: Record<string, SignedFallbackKey>;
|
|
}
|
|
|
|
export const sipherOven = () => {
|
|
return {
|
|
id: "sipher-oven",
|
|
schema: {
|
|
/**
|
|
* Per-user stable identity keys.
|
|
* The Ed25519 signing key derived from the user's mnemonic seed.
|
|
* One row per user — must remain stable across all devices.
|
|
*/
|
|
userIdentityKeys: {
|
|
fields: {
|
|
userId: {
|
|
type: "string",
|
|
required: true,
|
|
unique: true,
|
|
references: {
|
|
model: "user",
|
|
field: "id",
|
|
onDelete: "cascade",
|
|
},
|
|
},
|
|
signingPublicKey: {
|
|
type: "string",
|
|
required: true,
|
|
unique: true,
|
|
},
|
|
fingerprint: {
|
|
type: "string",
|
|
required: true,
|
|
unique: true,
|
|
},
|
|
createdAt: {
|
|
type: "date",
|
|
required: true,
|
|
},
|
|
updatedAt: {
|
|
type: "date",
|
|
required: true,
|
|
},
|
|
},
|
|
},
|
|
/**
|
|
* Per-device OLM key bundle. One row per device, single JSON blob
|
|
* holding `{ device_keys, one_time_keys, fallback_keys }` exactly
|
|
* as published by the OlmMachine. Incremental OTK uploads merge
|
|
* into the JSON map in place — never spawn additional rows.
|
|
*/
|
|
olmDeviceKeys: {
|
|
fields: {
|
|
userId: {
|
|
type: "string",
|
|
required: true,
|
|
references: {
|
|
model: "user",
|
|
field: "id",
|
|
onDelete: "cascade",
|
|
},
|
|
},
|
|
deviceId: {
|
|
type: "string",
|
|
required: true,
|
|
unique: true,
|
|
},
|
|
bundleJson: {
|
|
type: "string",
|
|
required: true,
|
|
},
|
|
createdAt: {
|
|
type: "date",
|
|
required: true,
|
|
},
|
|
updatedAt: {
|
|
type: "date",
|
|
required: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
endpoints: {
|
|
/**
|
|
* Register the user's stable identity public key.
|
|
*
|
|
* Called once when the client first generates its mnemonic-derived
|
|
* keypair. Subsequent calls upsert (so a client that re-derives the
|
|
* same key from the same mnemonic is idempotent), but the keys
|
|
* themselves should never change for a given user.
|
|
*
|
|
* Only public material is accepted; the body schema enforces this.
|
|
*/
|
|
registerIdentity: createAuthEndpoint("/oven/identity/register", {
|
|
method: "POST",
|
|
body: IdentityRegisterBodySchema,
|
|
}, async (context) => {
|
|
const session = await getSessionFromCtx(context);
|
|
if (!session) {
|
|
return context.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const { signingPublicKey, fingerprint } = context.body;
|
|
const now = new Date();
|
|
|
|
const checkIdentity = await db.select().from(userIdentityKeys).where(eq(userIdentityKeys.userId, session.user.id)).limit(1);
|
|
if (checkIdentity.length > 0) {
|
|
return context.json({ error: "Identity already registered, if you need to rotate your keys, please use the key rotation flow instead." }, { status: 400 });
|
|
}
|
|
|
|
await db.transaction(async (tx) => {
|
|
const updated = await tx
|
|
.update(userIdentityKeys)
|
|
.set({ signingPublicKey, fingerprint, updatedAt: now })
|
|
.where(eq(userIdentityKeys.userId, session.user.id))
|
|
.returning({ id: userIdentityKeys.id });
|
|
|
|
if (updated.length === 0) {
|
|
await tx.insert(userIdentityKeys).values({
|
|
id: crypto.randomUUID(),
|
|
userId: session.user.id,
|
|
signingPublicKey,
|
|
fingerprint,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
});
|
|
|
|
return context.json({ success: true });
|
|
}),
|
|
|
|
/**
|
|
* Upload (or incrementally update) a device's OLM key bundle.
|
|
*
|
|
* Bundle structure persisted as `bundle_json`:
|
|
* {
|
|
* device_keys: { ... full Matrix DeviceKeys ... },
|
|
* one_time_keys: { "<algo>:<id>": SignedKey, ... },
|
|
* fallback_keys: { "<algo>:<id>": SignedFallbackKey, ... },
|
|
* }
|
|
*
|
|
* Matrix's incremental-upload semantics for OTKs/fallback keys are
|
|
* applied to the JSON map in place: a string value is treated as a
|
|
* "delete this key" marker, an object value adds/replaces the entry.
|
|
*/
|
|
keysUpload: createAuthEndpoint("/oven/keys/upload", {
|
|
method: "POST",
|
|
body: z.string().transform((val) => {
|
|
const parsed = KeysUploadBodySchema.safeParse(JSON.parse(val));
|
|
if (!parsed.success) {
|
|
throw new Error(parsed.error.message);
|
|
}
|
|
return parsed.data;
|
|
}),
|
|
}, async (context) => {
|
|
const session = await getSessionFromCtx(context);
|
|
if (!session) {
|
|
return context.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const { device_keys, one_time_keys, fallback_keys } = context.body;
|
|
if (!device_keys) {
|
|
return context.json({ error: "Device keys are required", code: "DEVICE_KEYS_REQUIRED" }, { status: 400 });
|
|
}
|
|
if (!one_time_keys) {
|
|
return context.json({ error: "One time keys are required", code: "ONE_TIME_KEYS_REQUIRED" }, { status: 400 });
|
|
}
|
|
if (!fallback_keys) {
|
|
return context.json({ error: "Fallback keys are required", code: "FALLBACK_KEYS_REQUIRED" }, { status: 400 });
|
|
}
|
|
|
|
const userId = session.user.id;
|
|
const deviceId = device_keys.device_id;
|
|
const now = new Date();
|
|
|
|
let otkCount = 0;
|
|
|
|
await db.transaction(async (tx) => {
|
|
const [existing] = await tx
|
|
.select({ bundleJson: olmDeviceKeys.bundleJson })
|
|
.from(olmDeviceKeys)
|
|
.where(eq(olmDeviceKeys.deviceId, deviceId))
|
|
.limit(1);
|
|
|
|
const previous: DeviceBundle = existing
|
|
? (JSON.parse(existing.bundleJson) as DeviceBundle)
|
|
: { device_keys, one_time_keys: {}, fallback_keys: {} };
|
|
|
|
const mergedOtks: Record<string, SignedKey> = { ...previous.one_time_keys };
|
|
for (const [keyId, value] of Object.entries(one_time_keys)) {
|
|
if (typeof value === "string") {
|
|
delete mergedOtks[keyId];
|
|
} else {
|
|
mergedOtks[keyId] = value;
|
|
}
|
|
}
|
|
|
|
const mergedFallback: Record<string, SignedFallbackKey> = { ...previous.fallback_keys };
|
|
for (const [keyId, value] of Object.entries(fallback_keys)) {
|
|
if (typeof value === "string") {
|
|
delete mergedFallback[keyId];
|
|
} else {
|
|
mergedFallback[keyId] = value;
|
|
}
|
|
}
|
|
|
|
const bundleJson = JSON.stringify({
|
|
device_keys,
|
|
one_time_keys: mergedOtks,
|
|
fallback_keys: mergedFallback,
|
|
} satisfies DeviceBundle);
|
|
|
|
if (existing) {
|
|
await tx
|
|
.update(olmDeviceKeys)
|
|
.set({ bundleJson, updatedAt: now })
|
|
.where(eq(olmDeviceKeys.deviceId, deviceId));
|
|
} else {
|
|
await tx.insert(olmDeviceKeys).values({
|
|
id: crypto.randomUUID(),
|
|
userId,
|
|
deviceId,
|
|
bundleJson,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
otkCount = Object.keys(mergedOtks).length;
|
|
});
|
|
|
|
return context.json({
|
|
success: true,
|
|
one_time_key_counts: {
|
|
signed_curve25519: otkCount,
|
|
},
|
|
});
|
|
}),
|
|
|
|
/**
|
|
* Returns whether the authenticated user has registered their
|
|
* mnemonic-derived identity public key with the server.
|
|
*
|
|
* Always responds 200 so the caller doesn't have to disambiguate
|
|
* "not registered" from a transport error.
|
|
*/
|
|
checkIdentity: createAuthEndpoint("/oven/identity/check", {
|
|
method: "GET",
|
|
}, async (context) => {
|
|
const session = await getSessionFromCtx(context);
|
|
if (!session) {
|
|
return context.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const [identity] = await db
|
|
.select({ id: userIdentityKeys.id })
|
|
.from(userIdentityKeys)
|
|
.where(eq(userIdentityKeys.userId, session.user.id))
|
|
.limit(1);
|
|
|
|
return context.json({ exists: !!identity });
|
|
}),
|
|
},
|
|
} satisfies BetterAuthPlugin;
|
|
};
|