- Updated version badge from 0.1.1 to 0.2.0. - Renamed "Manual integration tests" section to "Docker-based integration tests" and clarified the testing process with Docker. - Enhanced descriptions of integration scripts to reflect their functionality with a Docker cluster. - Updated the coverage section to align with the new testing approach, emphasizing the discover round-trips.
430 lines
No EOL
22 KiB
Markdown
430 lines
No EOL
22 KiB
Markdown
# SiPher
|
|
|
|
> *Silent Whisper — A federated social network built for the modern age.*
|
|
|
|
[](./LICENSE)
|
|

|
|

|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
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](https://better-auth.com) — email/password, username, 2FA, bearer tokens |
|
|
| Database | PostgreSQL via [Drizzle ORM](https://orm.drizzle.team) |
|
|
| 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 |
|
|
|
|
|
|
### Custom Better Auth Plugins
|
|
|
|
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).
|
|
|
|
### API Routes
|
|
|
|
|
|
| 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 |
|
|
|
|
|
|
---
|
|
|
|
## Identity & E2EE
|
|
|
|
### User Identity (the "Oven")
|
|
|
|
Every SiPher user has a cryptographic identity generated entirely in the browser:
|
|
|
|
1. A **BIP-39 mnemonic** (12 words, 128-bit entropy) is generated.
|
|
2. An **Ed25519 keypair** is derived from the mnemonic seed via HKDF-SHA256.
|
|
3. 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).
|
|
4. The public key (base58) and a fingerprint are uploaded to the server.
|
|
5. **OLM device keys** are generated and all public keys are uploaded to the server for Matrix-protocol-based E2EE messaging.
|
|
|
|
### Session Key Store
|
|
|
|
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.
|
|
|
|
### Unlock Flow
|
|
|
|
1. On page load, `UnlockIdentityModal` tries `restoreSessionKey()` from sessionStorage.
|
|
2. If that fails, the user is prompted for their master password.
|
|
3. On correct password, `unlockSessionKey()` decrypts the Dexie blob, caches the keypair, and notifies listeners via a pub/sub pattern.
|
|
4. On logout, `clearSessionKey()` zeroes the in-memory secret and clears sessionStorage.
|
|
|
|
---
|
|
|
|
## Federation
|
|
|
|
### Discovery & Registration
|
|
|
|
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:
|
|
|
|
1. Validates the URL is safe (SSRF guard — see Security section).
|
|
2. Fetches the remote `/discover` to confirm the claimed keys match.
|
|
3. Upserts into the `serverRegistry` table.
|
|
4. 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.
|
|
|
|
### Proxy Relay
|
|
|
|
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 `TARGETED` request, 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 |
|
|
|
|
|
|
### Background Jobs (BullMQ)
|
|
|
|
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 /discover` with 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`.
|
|
|
|
### Key Rotation
|
|
|
|
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:
|
|
|
|
1. Old signing key (sign a challenge nonce)
|
|
2. New signing key (sign a challenge nonce)
|
|
3. Old encryption key (decrypt a challenge nonce)
|
|
4. 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.
|
|
|
|
---
|
|
|
|
## Setup
|
|
|
|
### Prerequisites
|
|
|
|
- [Node.js](https://nodejs.org/) 20+
|
|
- [Bun](https://bun.sh/) (for tooling scripts and key generation)
|
|
- [PostgreSQL](https://www.postgresql.org/) 15+
|
|
- [Redis](https://redis.io/) 7+
|
|
- (Optional) [MinIO](https://min.io/) or any S3-compatible object store for media
|
|
|
|
### Environment Variables
|
|
|
|
Copy `.env.local.example` to `.env.local` and populate:
|
|
|
|
```env
|
|
# 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:*
|
|
```
|
|
|
|
### Database
|
|
|
|
The schema is managed via Drizzle Kit. Everything is auto-generated from Better Auth's schema generator plus the custom plugin schemas.
|
|
|
|
```sh
|
|
# Push schema directly (development)
|
|
npm run db:push
|
|
|
|
# Or generate a migration and apply it
|
|
npm run db:generate
|
|
npm run db:migrate
|
|
```
|
|
|
|
### Federation Keys
|
|
|
|
```sh
|
|
npm run keygen
|
|
```
|
|
|
|
This runs `src/lib/federation/keygen.ts`, which generates both Ed25519 (signing) and X25519 (encryption) keypairs and writes them to `.env.local`.
|
|
|
|
---
|
|
|
|
## Scripts
|
|
|
|
|
|
| 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
|
|
|
|
**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.
|
|
|
|
### Prerequisites
|
|
|
|
- A running database with the server registry populated (at least one peer federation).
|
|
- `.env.local` with valid `FEDERATION_`* keys and `BETTER_AUTH_URL`.
|
|
|
|
### Basic rotation
|
|
|
|
```sh
|
|
bun run rotateKeys.ts
|
|
```
|
|
|
|
The script will:
|
|
|
|
1. List all federations in the registry.
|
|
2. Ask for confirmation before proceeding.
|
|
3. For each federation: request a challenge, solve it, and confirm.
|
|
4. On full success: back up `.env.local` and write the new keys.
|
|
5. On any failure: print a retry command and exit without writing keys.
|
|
|
|
### Retrying after partial failure
|
|
|
|
If some federations failed while others succeeded, the script prints a ready-to-copy command targeting only the failures:
|
|
|
|
```sh
|
|
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`:
|
|
|
|
```sh
|
|
bun run rotateKeys.ts --resume '<keys-json>'
|
|
```
|
|
|
|
|
|
|
|
---
|
|
|
|
## Tests
|
|
|
|
SiPher uses [Playwright](https://playwright.dev) for integration/e2e tests (matched by `**/*.e2e.ts`) and [Bun's test runner](https://bun.sh/docs/cli/test) for unit tests (matched by `**/*.test.ts`).
|
|
|
|
### Running tests
|
|
|
|
```sh
|
|
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.
|
|
|
|
### Docker-based integration tests
|
|
|
|
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.
|
|
|
|
```sh
|
|
# 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:3002
|
|
```
|
|
|
|
These 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.
|
|
|
|
### Test coverage
|
|
|
|
- **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.
|
|
|
|
---
|
|
|
|
## Roadmap
|
|
|
|
- **[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.
|
|
|
|
---
|
|
|
|
## What is public/private?
|
|
|
|
### Public (visible to receiving federations)
|
|
|
|
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).
|
|
|
|
### Private (server-side, not federated)
|
|
|
|
- **Mutes/blocks**: Stored server-side, never sent to other federations.
|
|
- **Passwords**: Hashed by Better Auth, never stored in plaintext.
|
|
|
|
### Private (client-side, never sent to server)
|
|
|
|
- **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.
|
|
|
|
### Client-side signing
|
|
|
|
All social actions are signed by the user's Ed25519 identity key before submission:
|
|
|
|
- **Posts** — The `authorSignature` field 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 includes `federationUrl` to prevent cross-server replay.
|
|
|
|
---
|
|
|
|
## Security
|
|
|
|
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.
|
|
|
|
### What SiPher does for safety
|
|
|
|
- **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 via `DEV_ALLOWED_HOSTNAMES` env 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](mailto: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.**
|
|
|
|
---
|
|
|
|
## Author
|
|
|
|
**Marcello Brito** (Tocka) — [tockanest.com](https://tockanest.com)
|
|
|
|
## Mirrors
|
|
|
|
[Forgejo](https://git.tockanest.com/Cete/sipher)
|
|
|
|
[GitHub](https://github.com/tockawaffle/sipher)
|
|
|
|
## License
|
|
|
|
[AGPL-3.0](./LICENSE) |