Silent Whisper — A federated social network built for the modern age.
SiPher is a federated social network. Each server is independent, no central authority, no single point of failure.
Your identity is you@<base58_id>. Your data, your rules.
Every user controls their own Ed25519 keypair generated from a BIP-39 mnemonic. The secret key never leaves the browser. Posts, follows, and every social action are signed client-side and verified server-side.
SiPher runs as a single Node.js process that serves both the web app and the federation API.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, React 19, standalone output) |
| Authentication | Better Auth — email/password, username, 2FA, bearer tokens |
| Database | PostgreSQL via Drizzle ORM |
| Cache / Queues | Redis — session storage, rate limiting, BullMQ background jobs |
| Object Storage | MinIO (S3-compatible) — media uploads with presigned URLs |
| Client Storage | IndexedDB (Dexie) — encrypted identity keypairs |
| Real-time | Socket.IO — firehose channel for live updates |
| UI | Tailwind CSS v4, shadcn/ui, Radix primitives, Framer Motion |
SiPher extends Better Auth with three custom plugins, each registering its own database schema and API endpoints:
- sipher-federation — Server registry, key rotation challenges, blacklist management.
- sipher-social — Posts, follows, blocks, mutes as Better Auth API endpoints.
- sipher-oven — E2EE identity registration (Ed25519 keys, OLM device key bundles).
| Route | Purpose |
|---|---|
GET /discover |
Return this server's public keys and healthy peers |
POST /discover |
Discover or register a remote server |
POST /discover/rotate/init |
Initiate key rotation (4 challenges) |
POST /discover/rotate/confirm |
Submit key rotation proofs |
POST /proxy |
Relay federation traffic through a proxy peer |
POST /api/auth/social/posts |
Create or receive a federated post |
GET /api/auth/social/posts/:id |
Get a post |
POST /api/auth/social/follows |
Follow, respond, or federate a follow |
POST /api/auth/social/blocks |
Block a user (auto-cleanses follows both ways) |
POST /api/auth/social/mutes |
Mute a user |
POST /oven/identity/register |
Register a user's public identity key |
POST /oven/keys/upload |
Upload OLM device key bundle |
GET /oven/identity/check |
Check identity registration status |
Every SiPher user has a cryptographic identity generated entirely in the browser:
- A BIP-39 mnemonic (12 words, 128-bit entropy) is generated.
- An Ed25519 keypair is derived from the mnemonic seed via HKDF-SHA256.
- The keypair is encrypted with AES-256-GCM and stored in IndexedDB via Dexie. The encryption key is derived from the user's master password (PBKDF2, 600k iterations).
- The public key (base58) and a fingerprint are uploaded to the server.
- OLM device keys are generated and all public keys are uploaded to the server for Matrix-protocol-based E2EE messaging.
Once unlocked, the Ed25519 keypair is held in module-level memory and sessionStorage. The sessionStorage layer means the key survives hard page reloads within the same tab but is cleared when the tab closes. It is intentionally NOT stored in localStorage.
The session key store exposes a simple sign(message) function that uses the cached secret key. For one-shot operations where you don't want to cache the key across the session, the Oven plugin provides useSigningKey — a callback API that decrypts the key, hands the caller a sign closure, and zeroes the in-memory secret immediately after the callback resolves. The secret never escapes that scope.
- On page load,
UnlockIdentityModaltriesrestoreSessionKey()from sessionStorage. - If that fails, the user is prompted for their master password.
- On correct password,
unlockSessionKey()decrypts the Dexie blob, caches the keypair, and notifies listeners via a pub/sub pattern. - On logout,
clearSessionKey()zeroes the in-memory secret and clears sessionStorage.
Every server exposes its public keys via GET /discover. A server can register a peer by sending a REGISTER request to the peer's /discover endpoint. The registration flow:
- Validates the URL is safe (SSRF guard — see Security section).
- Fetches the remote
/discoverto confirm the claimed keys match. - Upserts into the
serverRegistrytable. - Returns an echo of the registering server's own keys.
The DISCOVER method lets a server look up another server by signing public key and confirm that the stored keys still match the live peer.
When two servers cannot reach each other directly (censorship, NAT, firewall), traffic can be routed through a mutual peer:
- PROXY method — Server A sends an encrypted payload + target URL + its public keys to proxy peer B. B verifies both A and C are registered, forwards to C as a
TARGETEDrequest, and returns the encrypted response. - TARGETED method — Server C decrypts the inner payload, validates signatures, processes the action (follow, post), and returns an encrypted acknowledgment.
The proxy never sees plaintext content. It only knows "Server A is talking to Server B."
A threat model (threat-model.ts) classifies network errors and determines whether proxy fallback is eligible:
| Error | Proxy-Eligible | Direct Health-Checkable |
|---|---|---|
| DNS_BLOCKED | Yes | No |
| TLS_ERROR | No | Yes |
| TIMEOUT | No | Yes |
| CONN_REFUSED | No | Yes |
| INVALID_RESPONSE | Yes | No |
Two Redis-backed queues handle asynchronous federation operations:
- Federation delivery queue — Encrypts and delivers activity (follows, posts, unfollows) to remote servers. 10 concurrent workers, up to 5 retries with exponential backoff (5s base). On success, the delivery job record is cleaned up automatically.
- Health-check queue — Probes unhealthy servers via
GET /discoverwith exponential backoff (5min, 15min, 25min...), up to 5 attempts. If a server responds, it is re-marked healthy.
Workers are started automatically at application bootstrap via Next.js instrumentation.ts.
Federation identity is tied to two keypairs (Ed25519 for signing, X25519 for encryption). The rotateKeys.ts script walks through every known federation, proves ownership of both the old and new keys via a challenge-response protocol, and updates .env.local when all federations confirm.
Each rotation requires proving possession of four things to each peer:
- Old signing key (sign a challenge nonce)
- New signing key (sign a challenge nonce)
- Old encryption key (decrypt a challenge nonce)
- New encryption key (decrypt a challenge nonce)
Failed confirmations do not auto-blacklist the server — preventing griefing attacks where anyone could spam init for a victim URL to get them banned.
- Node.js 20+
- Bun (for tooling scripts and key generation)
- PostgreSQL 15+
- Redis 7+
- (Optional) MinIO or any S3-compatible object store for media
Copy .env.local.example to .env.local and populate:
# SiPher server URL (your canonical public address)
BETTER_AUTH_URL=https://your-server.com
# Better Auth secret (generate with: openssl rand -hex 32)
BETTER_AUTH_SECRET=<random-hex>
# PostgreSQL connection
DATABASE_URL=postgresql://user:password@host:5432/sipher
# Redis connection
REDIS_URL=redis://host:6379
# Federation signing keypair (Ed25519, base64)
# Generate with: npm run keygen
FEDERATION_PUBLIC_KEY=<base64>
FEDERATION_PRIVATE_KEY=<base64>
# Federation encryption keypair (X25519, base64)
FEDERATION_ENCRYPTION_PUBLIC_KEY=<base64>
FEDERATION_ENCRYPTION_PRIVATE_KEY=<base64>
# MinIO / S3 storage (optional, only if using media uploads)
MINIO_BUCKET=sipher
MINIO_ENDPOINT=minio.your-server.com
MINIO_PORT=443
MINIO_USE_SSL=true
MINIO_ACCESS_KEY=<access-key>
MINIO_SECRET_KEY=<secret-key>
# SMTP email (optional, used for verification emails)
EMAIL_HOST=smtp.your-server.com
EMAIL_PORT=587
EMAIL_SECURE=false
EMAIL_USER=noreply@your-server.com
EMAIL_PASSWORD=<password>
# Development only: override SSRF guard to allow private IPs
# DEV_ALLOWED_HOSTNAMES=localhost,127.0.0.1,::1
# Debug logging namespaces
DEBUG=app:*,test:*The schema is managed via Drizzle Kit. Everything is auto-generated from Better Auth's schema generator plus the custom plugin schemas.
# Push schema directly (development)
npm run db:push
# Or generate a migration and apply it
npm run db:generate
npm run db:migratenpm run keygenThis runs src/lib/federation/keygen.ts, which generates both Ed25519 (signing) and X25519 (encryption) keypairs and writes them to .env.local.
| Command | Purpose |
|---|---|
npm run dev |
Start development server (enables private URLs for local dev) |
npm run build |
next build |
npm run start |
Production start (node src/server.ts) |
npm run keygen |
Generate federation signing + encryption keys |
npm run build:matrix |
Download native Matrix crypto WASM binary |
npm run email:dev |
React Email dev server (port 3001) |
npm run db:push |
Push Drizzle schema to database |
npm run db:migrate |
Push + migrate (two-step) |
npm run db:generate |
Regenerate Better Auth schema + Drizzle files |
npm run db:update |
Generate + push |
npm run test |
Run Playwright e2e tests (**/*.e2e.ts) |
npm run test:federation |
Key rotation e2e tests |
npm run test:key |
Key rotation e2e tests only |
npm run test:proxy |
Proxy /proxy route e2e tests (single-server validation) |
npm run docker:test:discover |
/discover integration tests against the 3-instance cluster |
npm run docker:test:proxy-chain |
Proxy relay + failover integration tests |
npm run docker:test:post-delivery |
Federated post delivery integration test |
Rotating Federation Keys
Federation identity is tied to two keypairs (Ed25519 for signing, X25519 for encryption). The rotateKeys.ts script walks through every known federation, proves ownership of both the old and new keys via a challenge-response protocol, and updates .env.local when all federations confirm.
You need the old keys in order to run this script. If you lost them, there is currently no recovery mechanism.
- A running database with the server registry populated (at least one peer federation).
.env.localwith validFEDERATION_* keys andBETTER_AUTH_URL.
bun run rotateKeys.tsThe script will:
- List all federations in the registry.
- Ask for confirmation before proceeding.
- For each federation: request a challenge, solve it, and confirm.
- On full success: back up
.env.localand write the new keys. - On any failure: print a retry command and exit without writing keys.
If some federations failed while others succeeded, the script prints a ready-to-copy command targeting only the failures:
bun run rotateKeys.ts --resume '<keys-json>' --only '<failed-urls>'--resume <json>— Reuse the new keys from the previous run instead of generating fresh ones (required because successful federations already registered them).--only <urls>— Comma-separated list of federation URLs to retry. Federations not in this list are skipped.
You can also retry all federations with just --resume:
bun run rotateKeys.ts --resume '<keys-json>'SiPher uses Playwright for integration/e2e tests (matched by **/*.e2e.ts) and Bun's test runner for unit tests (matched by **/*.test.ts).
npm test # All Playwright e2e tests
npm run test:federation # Discover + key rotation tests
npm run test:proxy # Proxy relay tests
bun test # Bun unit tests (keytools, etc.)Playwright starts the server automatically via tsx src/server.ts with NODE_ENV=test. The Playwright suites that remain (tests/proxy/proxy.e2e.ts, tests/federation/key-rotation.e2e.ts) drive their own local DB state and don't need the federation cluster. Anything that exercises real peer-to-peer federation — /discover REGISTER/DISCOVER, proxy relay, federated post delivery — lives under tests/integration/ and runs against the dockerized 3-instance cluster.
Three integration scripts exercise the federation protocol against a real 3-instance Docker cluster (A, B, C) with mutual discovery. All three auto-create their own Better Auth users + identity keys via HTTP — no --bearer token needed.
# Run inside the Docker test cluster (A, B, C must be healthy):
docker compose -f tests/docker-compose.yml run --rm test-runner \
tests/integration/discover.ts --peer http://sipher-c:3002
docker compose -f tests/docker-compose.yml run --rm test-runner \
tests/integration/federation-post-delivery.ts \
--proxy http://sipher-b:3001 --target http://sipher-c:3002
docker compose -f tests/docker-compose.yml run --rm test-runner \
tests/integration/proxy-chain.ts \
--proxy http://sipher-b:3001 --target http://sipher-c:3002These test the /discover REGISTER and DISCOVER handshake with real encrypted envelopes, the Post → BullMQ delivery → proxy fallback pipeline, and the full PROXY/TARGETED relay chain respectively.
- Discover e2e — SSRF guards, key mismatch rejection, REGISTER and DISCOVER happy paths, encrypted envelope validation.
- Key rotation e2e — Full init → solve → confirm flow, rate limiting, expired challenges, exhausted attempts without blacklist-griefing.
- Proxy e2e — PROXY and TARGETED validation, unknown sender rejection, blacklist enforcement, signature verification, duplicate follow rejection, rate limiting, payload size limits.
- Keytools unit — Encryption round-trip, tamper detection, signature verification, deterministic fingerprinting.
- Integration (docker) — Post delivery via proxy fallback, full proxy chain relay, discover round-trips.
- [X] Federation key rotation — Challenge-response protocol for rotating Ed25519 + X25519 keypairs across all peers.
- [X] Proxy relay — Traffic routed through mutual peers when direct connections fail. Proxy is blind to content.
- [X] Background delivery — BullMQ queues for async federation delivery with retries and health monitoring.
- [X] Serialization format — The JSON-based federation schema (EncryptedEnvelope, FollowSchema, PostFederationSchema).
- [X] SSRF protection — URL guard blocking private/internal IPs, blocked hostnames, non-HTTP protocols.
- [X] Client-side identity — BIP-39 mnemonics, Ed25519 keypairs, encrypted IndexedDB storage, session key cache.
- [X] Rate limiting — Per-route and per-origin sliding-window rate limits enforced server-side.
- [X] Threat model — Error-code classification dictating proxy eligibility and health-check strategy.
- [ ] Discovery propagation — When a new server is registered, propagate its existence to all known peers.
- [ ] Server trust scoring — A public vouch ledger so servers can signal trustworthiness about peers.
- [ ] End-to-end encryption — OLM device keys are already uploaded. The encrypted message transport ("Oven") needs to be wired into the messaging layer.
- [ ] Web UI — The frontend is minimal (dev test form and auth pages). A proper feed, profile pages, notifications, and settings UI need to be built.
- [ ] Federation status dashboard — View connected peers, health status, pending deliveries, and rotation logs.
Post content is encrypted in transit between servers (X25519 key agreement + HKDF + AES-256-GCM), but the receiving federation decrypts and stores the plaintext:
- Posts: Content (text, images, video, audio, links), authorId, publication date, and the federation of origin.
- Profiles: Username, display name, public key fingerprint.
- Follow graph: Who follows whom (used for federation routing to deliver posts to the right servers).
- Mutes/blocks: Stored server-side, never sent to other federations.
- Passwords: Hashed by Better Auth, never stored in plaintext.
- Ed25519 secret key: Derived from a BIP-39 mnemonic. Encrypted in IndexedDB (AES-256-GCM with PBKDF2, 600k iterations), decrypted on login and held in module memory + sessionStorage for tab-scoped persistence. Cleared on logout or tab close. Never transmitted to any server.
- BIP-39 mnemonic: Shown to the user once during identity creation, then discarded. The only recovery mechanism.
All social actions are signed by the user's Ed25519 identity key before submission:
- Posts — The
authorSignaturefield contains a detached Ed25519 signature covering the canonical post payload (postId,authorId,publishedAt,content,federationUrl). Verified server-side before storage. - Follows — Follow requests and responses carry detached Ed25519 signatures (
requesterSignature,responderSignature) covering a canonical payload that includesfederationUrlto prevent cross-server replay.
SiPher implements custom federation and cryptographic protocols. I am not a professional cryptographer or security researcher — this system has not been audited and almost certainly contains multiple vulnerabilities I am not aware of.
- SSRF protection: A URL guard (
url-guard.ts) blocks requests to private/internal IPv4 ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, link-local), IPv6 ULA and link-local addresses, blocked hostnames (localhost, metadata endpoints), and non-HTTP(S) protocols. Only overridable viaDEV_ALLOWED_HOSTNAMESenv var. - Federation encryption: All cross-server payloads are encrypted with X25519 + AES-256-GCM (hybrid ECIES). Ephemeral keys per message.
- Canonical signatures: Posts and follows are signed with a versioned byte format (
v: 2) that includes the federation URL to prevent cross-server replay attacks. - Key rotation requires 4 proofs: Possession of both old and new keypairs must be proven before keys are updated. Failed confirmations don't auto-blacklist (prevents griefing).
- No secret key on server: User Ed25519 secret keys never leave the browser. The server only stores public keys and OLM device key bundles.
- Session-scoped key caching: The signing key lives in module memory + sessionStorage (not localStorage). It is zeroed on logout and cleared when the tab closes.
- Rate limiting: Per-route sliding-window limits prevent abuse of registration, key rotation, and proxy endpoints.
If you find a vulnerability, please open an issue or contact me directly at tocka@tockanest.com. Responsible disclosure is appreciated.
Contributions from people with security or cryptography experience are especially welcome, even if just pure criticism.
Do not use SiPher in any context where your physical safety depends on it — not yet.
Marcello Brito (Tocka) — tockanest.com