feat: enhance federation key rotation and server discovery functionality

- Added new environment variables for MinIO configuration in .env.local.example.
- Updated package.json and bun.lock to include new dependencies for key management and encryption.
- Refactored server and route handling to support Ed25519 and X25519 key pairs for improved security during key rotation.
- Implemented validation for public keys and enhanced error handling in the discovery routes.
- Introduced new challenges for key rotation, ensuring secure communication between federations.
- Updated README with additional instructions for the new key rotation process.
This commit is contained in:
Nixyan 2026-03-12 18:42:52 -03:00
parent 75f3a0ed04
commit c587737f38
35 changed files with 2480 additions and 1759 deletions

View file

@ -8,3 +8,12 @@ EMAIL_PORT=
EMAIL_SECURE=
EMAIL_USER=
EMAIL_PASSWORD=
DEBUG=
MINIO_BUCKET=
MINIO_ENDPOINT=
MINIO_PORT=
MINIO_USE_SSL=
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=

View file

@ -18,11 +18,60 @@ Your identity is `you@yourserver.com`. Your server, your data, your rules.
- - [ ] — One server can create posts, have users following each other and dms (unencrypted for now) works.
- - [ ] — Two servers can fetch posts, follows and other data from their users, including DMs.
- **Phase 2** — Server trust scoring and a public vouch ledger.
- - [ ] — Add a "nuke" endpoint where if a federation loses their old keys and cannot rotate them, it'll nuke everything and make the other federations reset that federation score.
- **Phase 3** — Opt-in relay network for censorship resistance.
- **Phase 4** — End-to-end encryption via TBD.
---
## Instructions
<details>
<summary><strong>Rotating Federation Keys</strong></summary>
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, you'll have to use the nuke endpoint. (Yet to be made)
### 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>'
```
</details>
## Author
**Marcello Brito** (Tocka) — [tockanest.com](https://tockanest.com)

View file

@ -20,14 +20,12 @@
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"framer-motion": "^12.35.2",
"http-signature": "^1.4.0",
"ioredis": "^5.10.0",
"lucide-react": "^0.577.0",
"minio": "^8.0.7",
"nanostores": "^1.1.1",
"next": "16.1.6",
"next-themes": "^0.4.6",
"node-forge": "^1.3.3",
"nodemailer": "^8.0.2",
"pg": "^8.20.0",
"radix-ui": "^1.4.3",
@ -38,6 +36,8 @@
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"zod": "^4.3.6",
},
"devDependencies": {
@ -45,8 +45,7 @@
"@tailwindcss/postcss": "^4.2.1",
"@types/bun": "^1.3.10",
"@types/debug": "^4.1.12",
"@types/node": "^25.3.5",
"@types/node-forge": "^1.3.14",
"@types/node": "^25.4.0",
"@types/nodemailer": "^7.0.11",
"@types/pg": "^8.18.0",
"@types/react": "^19.2.14",
@ -56,7 +55,7 @@
"cross-env": "^10.1.0",
"drizzle-kit": "^0.31.9",
"react-email": "5.2.9",
"shadcn": "^4.0.2",
"shadcn": "^4.0.5",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
@ -630,8 +629,6 @@
"@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="],
"@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="],
"@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="],
"@types/pg": ["@types/pg@8.18.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q=="],
@ -666,10 +663,6 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
@ -688,8 +681,6 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"better-auth": ["better-auth@1.5.4", "", { "dependencies": { "@better-auth/core": "1.5.4", "@better-auth/drizzle-adapter": "1.5.4", "@better-auth/kysely-adapter": "1.5.4", "@better-auth/memory-adapter": "1.5.4", "@better-auth/mongo-adapter": "1.5.4", "@better-auth/prisma-adapter": "1.5.4", "@better-auth/telemetry": "1.5.4", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-ReykcEKx6Kp9560jG1wtlDBnftA7L7xb3ZZdDWm5yGXKKe2pUf+oBjH0fqekrkRII0m4XBVQbQ0mOrFv+3FdYg=="],
"better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
@ -778,8 +769,6 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
@ -794,8 +783,6 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debounce": ["debounce@2.2.0", "", {}, "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw=="],
@ -854,8 +841,6 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="],
"eciesjs": ["eciesjs@0.4.17", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@ -916,8 +901,6 @@
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"extsprintf": ["extsprintf@1.3.0", "", {}, "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@ -986,8 +969,6 @@
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
@ -1018,8 +999,6 @@
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http-signature": ["http-signature@1.4.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^2.0.2", "sshpk": "^1.18.0" } }, "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg=="],
"http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@ -1088,14 +1067,10 @@
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@ -1104,8 +1079,6 @@
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"jsprim": ["jsprim@2.0.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="],
@ -1232,8 +1205,6 @@
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="],
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
@ -1446,7 +1417,7 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shadcn": ["shadcn@4.0.3", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-dWN9aw+fszDEMPdRqAVmv5xgrsO60r5XDinHvrCjyfSW/FOiOrJJpYHGDI67+ItIwhTGHK1cXCM5YPAWVOsUtw=="],
"shadcn": ["shadcn@4.0.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-z0SOHEU1+ADam1UJHrgxJhUsOb0/jBoYc+u9mhWs071KrnORq48X7uCwG3mD2ysQEBtOfeK/MxMGsmzL5Jt+Jg=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
@ -1490,8 +1461,6 @@
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
"sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@ -1564,7 +1533,9 @@
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="],
"tweetnacl-util": ["tweetnacl-util@0.15.1", "", {}, "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw=="],
"type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
@ -1602,8 +1573,6 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
@ -1692,8 +1661,6 @@
"@types/cors/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
"@types/node-forge/@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="],
"@types/nodemailer/@types/node": ["@types/node@20.19.36", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-+3TQ+XhRjbmeKGHMhmUZfKlkF2/mAc+PpO2B90PBI7hRpkgPCSo5PaJ8tfWBJ4LMIuqrnKLD5TveeGMy+curtg=="],
"@types/pg/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
@ -1842,8 +1809,6 @@
"@types/cors/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@types/node-forge/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@types/nodemailer/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],

View file

@ -3,6 +3,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
allowedDevOrigins: ["172.21.157.201", "172.21.144.1"]
};
export default nextConfig;

View file

@ -43,14 +43,12 @@
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"framer-motion": "^12.35.2",
"http-signature": "^1.4.0",
"ioredis": "^5.10.0",
"lucide-react": "^0.577.0",
"minio": "^8.0.7",
"nanostores": "^1.1.1",
"next": "16.1.6",
"next-themes": "^0.4.6",
"node-forge": "^1.3.3",
"nodemailer": "^8.0.2",
"pg": "^8.20.0",
"radix-ui": "^1.4.3",
@ -61,6 +59,8 @@
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"zod": "^4.3.6"
},
"devDependencies": {
@ -69,7 +69,6 @@
"@types/bun": "^1.3.10",
"@types/debug": "^4.1.12",
"@types/node": "^25.4.0",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^7.0.11",
"@types/pg": "^8.18.0",
"@types/react": "^19.2.14",
@ -79,7 +78,7 @@
"cross-env": "^10.1.0",
"drizzle-kit": "^0.31.9",
"react-email": "5.2.9",
"shadcn": "^4.0.3",
"shadcn": "^4.0.5",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",

379
rotateKeys.ts Normal file
View file

@ -0,0 +1,379 @@
/**
* This script is used to rotate the keys of the federation.
* It will go through all known federations and request the key rotation challenge one by one.
* It will then solve the challenges and send the proofs to the federation that we are who we say we are.
*
* This script is meant to be run manually and should not under any circumstances be run automatically under an endpoint.
*
* Usage:
* bun run rotateKeys.ts generate fresh keys and rotate all federations
* bun run rotateKeys.ts --resume <json> retry all federations with previously generated keys
* bun run rotateKeys.ts --resume <json> --only <urls> retry only specific federations (comma-separated URLs)
*/
import db from "@/lib/db";
import { serverRegistry } from "@/lib/db/schema";
import { decryptPayload, EncryptedEnvelope, encryptPayload, signMessage } from "@/lib/federation/keytools";
import { config } from "dotenv";
import nacl from "tweetnacl";
config({ path: ".env.local" });
const FETCH_TIMEOUT_MS = 30_000;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface FedKeys {
signingPublicKey: string;
signingSecretKey: string;
encryptionPublicKey: string;
encryptionSecretKey: string;
}
function generateKeypair(): 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"),
};
}
function printKeys(label: string, keys: FedKeys) {
console.log(label);
console.log(` FEDERATION_PUBLIC_KEY=${keys.signingPublicKey}`);
console.log(` FEDERATION_PRIVATE_KEY=${keys.signingSecretKey}`);
console.log(` FEDERATION_ENCRYPTION_PUBLIC_KEY=${keys.encryptionPublicKey}`);
console.log(` FEDERATION_ENCRYPTION_PRIVATE_KEY=${keys.encryptionSecretKey}`);
}
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;
}
}
}
async function confirm(prompt: string): Promise<boolean> {
process.stdout.write(`${prompt} [y/N] `);
for await (const line of console) {
return line.trim().toLowerCase() === "y";
}
return false;
}
// ---------------------------------------------------------------------------
// 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 oldFedKeys: FedKeys = {
signingPublicKey: process.env.FEDERATION_PUBLIC_KEY!,
signingSecretKey: process.env.FEDERATION_PRIVATE_KEY!,
encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!,
encryptionSecretKey: process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY!,
};
const ORIGIN = process.env.BETTER_AUTH_URL!;
// ---------------------------------------------------------------------------
// Parse --resume flag
// ---------------------------------------------------------------------------
let newFedKeys: FedKeys;
const resumeIdx = process.argv.indexOf("--resume");
if (resumeIdx !== -1) {
const raw = process.argv[resumeIdx + 1];
if (!raw) {
console.error("--resume requires a JSON string argument containing the new keys.");
process.exit(1);
}
try {
const parsed = JSON.parse(raw);
if (
!parsed.signingPublicKey ||
!parsed.signingSecretKey ||
!parsed.encryptionPublicKey ||
!parsed.encryptionSecretKey
) {
throw new Error("Missing key fields");
}
newFedKeys = parsed as FedKeys;
console.log("Resuming rotation with previously generated keys.");
} catch (err) {
console.error("Failed to parse --resume keys:", (err as Error).message);
process.exit(1);
}
} else {
newFedKeys = generateKeypair();
}
// ---------------------------------------------------------------------------
// Parse --only filter
// ---------------------------------------------------------------------------
const onlyIdx = process.argv.indexOf("--only");
let onlyUrls: Set<string> | null = null;
if (onlyIdx !== -1) {
const raw = process.argv[onlyIdx + 1];
if (!raw) {
console.error("--only requires a comma-separated list of federation URLs.");
process.exit(1);
}
onlyUrls = new Set(raw.split(",").map((u) => u.trim()).filter(Boolean));
if (onlyUrls.size === 0) {
console.error("--only list is empty.");
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// Fetch federations
// ---------------------------------------------------------------------------
const allFederations = await db.select().from(serverRegistry);
if (allFederations.length === 0) {
console.log("No federations found in the registry. Nothing to rotate.");
process.exit(0);
}
const federations = onlyUrls
? allFederations.filter((f) => onlyUrls!.has(f.url))
: allFederations;
if (federations.length === 0) {
console.error("None of the --only URLs matched federations in the registry:");
onlyUrls!.forEach((u) => console.error(` - ${u}`));
process.exit(1);
}
if (onlyUrls) {
const unmatched = [...onlyUrls].filter((u) => !federations.some((f) => f.url === u));
if (unmatched.length > 0) {
console.warn("Warning: these --only URLs were not found in the registry and will be skipped:");
unmatched.forEach((u) => console.warn(` - ${u}`));
}
}
console.log(`Targeting ${federations.length} federation(s) for key rotation:`);
federations.forEach((f) => console.log(` - ${f.url}`));
if (!await confirm("\nProceed with key rotation?")) {
console.log("Aborted.");
process.exit(0);
}
// ---------------------------------------------------------------------------
// Solve init challenges
// ---------------------------------------------------------------------------
interface InitChallenges {
signingOldChallenge: string;
signingNewChallenge: string;
encryptionOldChallenge: EncryptedEnvelope;
encryptionNewChallenge: EncryptedEnvelope;
}
function solveInitChallenges(challenges: InitChallenges, oldKeys: FedKeys, newKeys: FedKeys) {
const oldSigningSecret = new Uint8Array(Buffer.from(oldKeys.signingSecretKey, "base64"));
const newSigningSecret = new Uint8Array(Buffer.from(newKeys.signingSecretKey, "base64"));
const oldEncSecret = new Uint8Array(Buffer.from(oldKeys.encryptionSecretKey, "base64"));
const newEncSecret = new Uint8Array(Buffer.from(newKeys.encryptionSecretKey, "base64"));
return {
signingOldSignature: signMessage(challenges.signingOldChallenge, oldSigningSecret),
signingNewSignature: signMessage(challenges.signingNewChallenge, newSigningSecret),
encryptionOldPlaintext: decryptPayload(challenges.encryptionOldChallenge, oldEncSecret),
encryptionNewPlaintext: decryptPayload(challenges.encryptionNewChallenge, newEncSecret),
};
}
// ---------------------------------------------------------------------------
// Rotate each federation
// ---------------------------------------------------------------------------
const transactions: Array<{
url: string;
success: boolean;
message: string;
}> = [];
for (const federation of federations) {
const tag = federation.url;
console.log(`\n[${tag}] Requesting rotation challenge...`);
try {
// Step 1 — Init challenge
const initResponse = await fetch(`${federation.url}/discover/rotate/init`, {
method: "POST",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
"Content-Type": "application/json",
"Origin": ORIGIN,
"x-federation-origin": ORIGIN,
},
body: JSON.stringify({
url: ORIGIN,
newSigningPublicKey: newFedKeys.signingPublicKey,
newEncryptionPublicKey: newFedKeys.encryptionPublicKey,
}),
});
if (!initResponse.ok) {
const detail = await readErrorBody(initResponse);
console.error(`[${tag}] Init failed (${initResponse.status}): ${detail}`);
transactions.push({ url: tag, success: false, message: detail });
continue;
}
const challenges: InitChallenges = await initResponse.json();
// Step 2 — Fetch the federation's public encryption key
const discoverResponse = await fetch(`${federation.url}/discover`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
"Content-Type": "application/json",
"Origin": ORIGIN,
"x-federation-origin": ORIGIN,
},
});
if (!discoverResponse.ok) {
const detail = await readErrorBody(discoverResponse);
console.error(`[${tag}] Discover failed (${discoverResponse.status}): ${detail}`);
transactions.push({ url: tag, success: false, message: detail });
continue;
}
const discoverData: {
url: string;
publicKey: string;
encryptionPublicKey: string;
} = await discoverResponse.json();
// Step 3 — Solve challenges
const proofs = solveInitChallenges(challenges, oldFedKeys, newFedKeys);
// Step 4 — Encrypt proofs with the federation's encryption public key
const encPubKey = new Uint8Array(Buffer.from(discoverData.encryptionPublicKey, "base64"));
const encryptedProofs = encryptPayload(JSON.stringify(proofs), encPubKey);
// Step 5 — Confirm
const confirmResponse = await fetch(`${federation.url}/discover/rotate/confirm`, {
method: "POST",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
"Content-Type": "application/json",
"Origin": ORIGIN,
"x-federation-origin": ORIGIN,
},
body: JSON.stringify({
serverUrl: ORIGIN,
envelope: encryptedProofs,
}),
});
if (!confirmResponse.ok) {
const detail = await readErrorBody(confirmResponse);
console.error(`[${tag}] Confirm failed (${confirmResponse.status}): ${detail}`);
transactions.push({ url: tag, success: false, message: detail });
continue;
}
const confirmData = await confirmResponse.json();
console.log(`[${tag}] ${confirmData.message}`);
transactions.push({ url: tag, success: true, message: confirmData.message });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[${tag}] Unexpected error: ${message}`);
transactions.push({ url: tag, success: false, message });
}
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
const successes = transactions.filter((t) => t.success);
const failures = transactions.filter((t) => !t.success);
console.log("\n================================");
console.log(`Results: ${successes.length} succeeded, ${failures.length} failed out of ${transactions.length}`);
if (failures.length > 0) {
console.error("\nFailed federations:");
failures.forEach((f) => console.error(` - ${f.url}: ${f.message}`));
const resumePayload = JSON.stringify(newFedKeys);
const failedUrls = failures.map((f) => f.url).join(",");
if (successes.length > 0) {
console.error("\nKeys NOT written to .env.local (some federations succeeded, some failed).");
console.error("Retry ONLY the failed federations with:\n");
console.log(` bun run rotateKeys.ts --resume '${resumePayload}' --only '${failedUrls}'\n`);
} else {
console.error("\nKeys NOT written to .env.local. Retry with:\n");
console.log(` bun run rotateKeys.ts --resume '${resumePayload}'\n`);
}
process.exit(1);
}
// ---------------------------------------------------------------------------
// Write new keys to .env.local (with backup)
// ---------------------------------------------------------------------------
const envPath = ".env.local";
const envContent = await Bun.file(envPath).text();
const backupPath = `.env.local.bak.${Date.now()}`;
await Bun.write(backupPath, envContent);
console.log(`\nBacked up .env.local → ${backupPath}`);
const envReplacements: [RegExp, string][] = [
[/FEDERATION_PUBLIC_KEY=.*/, `FEDERATION_PUBLIC_KEY="${newFedKeys.signingPublicKey}"`],
[/FEDERATION_PRIVATE_KEY=.*/, `FEDERATION_PRIVATE_KEY="${newFedKeys.signingSecretKey}"`],
[/FEDERATION_ENCRYPTION_PUBLIC_KEY=.*/, `FEDERATION_ENCRYPTION_PUBLIC_KEY="${newFedKeys.encryptionPublicKey}"`],
[/FEDERATION_ENCRYPTION_PRIVATE_KEY=.*/, `FEDERATION_ENCRYPTION_PRIVATE_KEY="${newFedKeys.encryptionSecretKey}"`],
];
let updatedEnv = envContent;
for (const [pattern, replacement] of envReplacements) {
if (!pattern.test(updatedEnv)) {
console.error(`Warning: ${pattern.source.split("=")[0]} not found in .env.local — appending.`);
updatedEnv += `\n${replacement}`;
} else {
updatedEnv = updatedEnv.replace(pattern, replacement);
}
}
await Bun.write(envPath, updatedEnv);
console.log("New keys written to .env.local successfully.");
printKeys("\nOld keys (displayed once, not stored anywhere):", oldFedKeys);

View file

@ -1,6 +1,6 @@
import db from "@/lib/db";
import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
import { decryptPayload } from "@/lib/federation/keytools";
import { decryptPayload, verifySignature } from "@/lib/federation/keytools";
import createDebug from "debug";
import { eq, sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
@ -14,29 +14,27 @@ const debug = createDebug("app:discover:rotate:confirm");
* Terminology: SA = this server (Server A), SB = the server rotating its keys (Server B).
*
* Full rotation flow:
* 1. SB generates a new keypair. It keeps the old private key accessible until rotation is complete.
* 2. SB sends { url, newPublicKey } to SA's /discover/rotate/init.
* 3. SA issues two independent challenges and returns them to SB:
* - oldKeyChallenge: a random token encrypted with SB's CURRENT (old) public key.
* - newKeyChallenge: a random token encrypted with SB's NEW public key.
* 4. SB decrypts both challenges using the respective private keys:
* - oldKeyChallenge decrypted with old private key oldPlaintext
* - newKeyChallenge decrypted with new private key newPlaintext
* 5. SB fetches SA's public key from /discover, then re-encrypts both plaintexts with it:
* - signedOldChallenge = encrypt(oldPlaintext, SA.publicKey)
* - signedNewChallenge = encrypt(newPlaintext, SA.publicKey)
* 6. SB sends { serverUrl, signedOldChallenge, signedNewChallenge } to this route.
* 7. SA decrypts both with its own private key and compares to the stored tokens.
* - If either mismatches: decrement attemptsLeft; blacklist server at 0 attempts.
* - If both match: update serverRegistry with newPublicKey and delete the challenge.
* 1. SB generates new Ed25519 + X25519 keypairs.
* 2. SB sends { url, newSigningPublicKey, newEncryptionPublicKey } to SA's /discover/rotate/init.
* 3. SA issues 4 challenges:
* - signingOldChallenge: plaintext nonce (SB signs with old Ed25519 key)
* - signingNewChallenge: plaintext nonce (SB signs with new Ed25519 key)
* - encryptionOldChallenge: nonce encrypted with SB's current X25519 key
* - encryptionNewChallenge: nonce encrypted with SB's new X25519 key
* 4. SB solves all 4 challenges:
* - Signs the signing challenges with respective Ed25519 keys
* - Decrypts the encryption challenges with respective X25519 keys
* 5. SB fetches SA's /discover to get SA's X25519 public key, then encrypts
* all 4 proof values into a single EncryptedEnvelope using SA's X25519 key.
* 6. SA decrypts the envelope and verifies all 4 proofs.
*
* What each check proves:
* - signedOldChallenge match SB holds the old private key (identity proof: "they are who they say they are")
* - signedNewChallenge match SB holds the new private key (ownership proof: "they own the key they want to rotate to")
* - re-encryption with SA's public key → SB fetched SA's identity from /discover
*
* TODO: on success, announce the completed rotation to other known federation peers
* so they can treat SA as a trusted proxy for confirming SB's new key. (Other federation servers could ignore this information and force the challenge to be completed for themselves.)
* - signingOldSignature: SB holds the old Ed25519 private key (identity proof)
* - signingNewSignature: SB holds the new Ed25519 private key (ownership proof)
* - encryptionOldPlaintext: SB holds the old X25519 private key (encryption identity proof)
* - encryptionNewPlaintext: SB holds the new X25519 private key (encryption ownership proof)
* - Envelope encrypted with SA's X25519 key: SB fetched SA's /discover (identity binding)
* - Discover being fetched: SB fetched SA's /discover endpoint (liveliness proof) <- Not accounted for but it is a proof that the other federation is alive and responsive.
*/
export async function POST(request: NextRequest) {
const body = await request.json();
@ -44,12 +42,12 @@ export async function POST(request: NextRequest) {
const validated = z.object({
serverUrl: z.url(),
// SA decrypted oldKeyChallenge with their OLD private key,
// then re-encrypted the plaintext with OUR public key.
signedOldChallenge: z.string(),
// SA decrypted newKeyChallenge with their NEW private key,
// then re-encrypted the plaintext with OUR public key.
signedNewChallenge: z.string(),
envelope: z.object({
ephemeralPublicKey: z.string(),
iv: z.string(),
ciphertext: z.string(),
authTag: z.string(),
}),
}).safeParse(body);
if (!validated.success) {
@ -57,9 +55,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: validated.error.message }, { status: 400 });
}
const [blacklisted] = await db.select().from(blacklistedServers)
.where(eq(blacklistedServers.serverUrl, validated.data.serverUrl));
if (blacklisted) {
debug("POST /discover/rotate/confirm server %s is blacklisted", validated.data.serverUrl);
return NextResponse.json({ error: "Your server has been blacklisted. Please contact support to unblacklist your server." }, { status: 403 });
}
debug("POST /discover/rotate/confirm fetching pending challenge for %s", validated.data.serverUrl);
// transaction to ensure that the challenge is deleted and the server registry is updated atomically and that there's no race condition.
return await db.transaction(async (tx) => {
const [challenge] = await tx.select().from(rotateChallengeTokens)
.where(eq(rotateChallengeTokens.serverUrl, validated.data.serverUrl))
@ -88,38 +92,75 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Your server has been blacklisted. Please contact support to unblacklist your server." }, { status: 403 });
}
debug("POST /discover/rotate/confirm %d attempt(s) left, decrypting challenges", challenge.attemptsLeft);
let decryptedOld: string;
let decryptedNew: string;
debug("POST /discover/rotate/confirm %d attempt(s) left, decrypting envelope", challenge.attemptsLeft);
const ownEncryptionSecretKey = new Uint8Array(
Buffer.from(process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY!, "base64"),
);
let proofs: {
signingOldSignature: string;
signingNewSignature: string;
encryptionOldPlaintext: string;
encryptionNewPlaintext: string;
};
try {
decryptedOld = decryptPayload(validated.data.signedOldChallenge, process.env.FEDERATION_PRIVATE_KEY!);
decryptedNew = decryptPayload(validated.data.signedNewChallenge, process.env.FEDERATION_PRIVATE_KEY!);
const decrypted = decryptPayload(validated.data.envelope, ownEncryptionSecretKey);
proofs = JSON.parse(decrypted);
} catch {
debug("POST /discover/rotate/confirm decryption failed, decrementing attempts");
debug("POST /discover/rotate/confirm envelope decryption failed, decrementing attempts");
await tx.update(rotateChallengeTokens).set({
attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`,
}).where(eq(rotateChallengeTokens.id, challenge.id))
}).where(eq(rotateChallengeTokens.id, challenge.id));
return NextResponse.json({
error: `Failed to decrypt one or both challenges. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`,
error: `Failed to decrypt envelope. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`,
}, { status: 400 });
}
if (decryptedOld !== challenge.oldKeyToken || decryptedNew !== challenge.newKeyToken) {
debug("POST /discover/rotate/confirm token mismatch (old=%s, new=%s), decrementing attempts",
decryptedOld === challenge.oldKeyToken ? "ok" : "MISMATCH",
decryptedNew === challenge.newKeyToken ? "ok" : "MISMATCH",
const [server] = await tx.select().from(serverRegistry)
.where(eq(serverRegistry.url, challenge.serverUrl));
if (!server) {
debug("POST /discover/rotate/confirm server not found in registry");
return NextResponse.json({ error: "Server not found in registry." }, { status: 404 });
}
const currentSigningPub = new Uint8Array(Buffer.from(server.publicKey, "base64"));
const newSigningPub = new Uint8Array(Buffer.from(challenge.newSigningPublicKey, "base64"));
const signingOldValid = verifySignature(
challenge.signingOldToken,
proofs.signingOldSignature,
currentSigningPub,
);
const signingNewValid = verifySignature(
challenge.signingNewToken,
proofs.signingNewSignature,
newSigningPub,
);
const encOldValid = proofs.encryptionOldPlaintext === challenge.encryptionOldToken;
const encNewValid = proofs.encryptionNewPlaintext === challenge.encryptionNewToken;
if (!signingOldValid || !signingNewValid || !encOldValid || !encNewValid) {
debug(
"POST /discover/rotate/confirm proof mismatch (sigOld=%s, sigNew=%s, encOld=%s, encNew=%s), decrementing",
signingOldValid ? "ok" : "FAIL",
signingNewValid ? "ok" : "FAIL",
encOldValid ? "ok" : "FAIL",
encNewValid ? "ok" : "FAIL",
);
await tx.update(rotateChallengeTokens).set({
attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`,
}).where(eq(rotateChallengeTokens.id, challenge.id));
return NextResponse.json({
error: `Challenge mismatch. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`,
error: `Challenge verification failed. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`,
}, { status: 400 });
}
debug("POST /discover/rotate/confirm both challenges passed, rotating key for %s", challenge.serverUrl);
debug("POST /discover/rotate/confirm all 4 proofs passed, rotating keys for %s", challenge.serverUrl);
await tx.update(serverRegistry).set({
publicKey: challenge.newPublicKey,
publicKey: challenge.newSigningPublicKey,
encryptionPublicKey: challenge.newEncryptionPublicKey,
updatedAt: new Date(),
}).where(eq(serverRegistry.url, challenge.serverUrl));

View file

@ -1,42 +1,47 @@
import db from "@/lib/db";
import { rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
import { encryptPayload } from "@/lib/federation/keytools";
import createDebug from "debug";
import { eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import forge from "node-forge";
import { z } from "zod";
const debug = createDebug("app:discover:rotate:init");
const publicKeySchema = z.string().refine((key) => {
const ED25519_PUBLIC_KEY_BYTES = 32;
const X25519_PUBLIC_KEY_BYTES = 32;
function isValidBase64Key(key: string, expectedBytes: number): boolean {
try {
const pub = forge.pki.publicKeyFromPem(key);
return pub.n.bitLength() >= 4096;
const decoded = Buffer.from(key, "base64");
return decoded.length === expectedBytes;
} catch {
return false;
}
}, { message: "Invalid public key" });
}
const schema = z.object({
url: z.url(),
newPublicKey: publicKeySchema,
newSigningPublicKey: z.string().refine(
(key) => isValidBase64Key(key, ED25519_PUBLIC_KEY_BYTES),
{ message: "Invalid Ed25519 signing public key" },
),
newEncryptionPublicKey: z.string().refine(
(key) => isValidBase64Key(key, X25519_PUBLIC_KEY_BYTES),
{ message: "Invalid X25519 encryption public key" },
),
});
/**
* Initializes a key rotation challenge for a server.
*
* This route is used to initiate the key rotation process. It will issue two independent challenges:
* - oldKeyChallenge: a random token encrypted with the server's current public key.
* - newKeyChallenge: a random token encrypted with the server's new public key.
* Issues 4 independent challenges:
* - signingOldChallenge: plaintext nonce (SB signs with old Ed25519 key)
* - signingNewChallenge: plaintext nonce (SB signs with new Ed25519 key)
* - encryptionOldChallenge: nonce encrypted with SB's current X25519 key (SB decrypts)
* - encryptionNewChallenge: nonce encrypted with SB's new X25519 key (SB decrypts)
*
* The challenges are stored in the database and will expire in 5 minutes.
*
* The challenges are returned to the client and must be decrypted using the respective private keys.
*
* The client must then send the challenges to the server's /discover/rotate/confirm route to confirm the key rotation.
*
* The server will not send his current public key to the client, the client must fetch it from the server's /discover route as a part of the challenge validation.
* Challenges expire in 5 minutes. SB confirms via /discover/rotate/confirm.
*/
export async function POST(request: NextRequest) {
const body = await request.json();
@ -48,6 +53,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: validated.error.message }, { status: 400 });
}
const [blacklisted] = await db.select().from(blacklistedServers)
.where(eq(blacklistedServers.serverUrl, validated.data.url.toString()));
if (blacklisted) {
debug("POST /discover/rotate/init server %s is blacklisted", validated.data.url);
return NextResponse.json({ error: "Your server has been blacklisted." }, { status: 403 });
}
debug("POST /discover/rotate/init looking up server %s", validated.data.url);
const server = await db.select().from(serverRegistry).where(eq(serverRegistry.url, validated.data.url.toString()));
if (server.length === 0) {
@ -55,13 +67,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Server not found, please register your server first." }, { status: 404 });
}
if (server[0].publicKey === validated.data.newPublicKey) {
debug("POST /discover/rotate/init new key is identical to current key, rejecting");
return NextResponse.json({ error: "Your server is already registered with this public key." }, { status: 400 });
if (
server[0].publicKey === validated.data.newSigningPublicKey &&
server[0].encryptionPublicKey === validated.data.newEncryptionPublicKey
) {
debug("POST /discover/rotate/init keys are identical to current keys, rejecting");
return NextResponse.json({ error: "Your server is already registered with these keys." }, { status: 400 });
}
// Check for existing pending challenges, only one active challenge per server is allowed.
// This got removed by accident on a previous commit.
const [existing] = await db.select().from(rotateChallengeTokens)
.where(eq(rotateChallengeTokens.serverUrl, validated.data.url.toString()));
@ -77,37 +90,39 @@ export async function POST(request: NextRequest) {
await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, existing.id));
}
// Issue two independent challenges:
//
// oldKeyChallenge — encrypted with the SA's CURRENT registered public key.
// Only the holder of the current private key can decrypt this.
// This is the identity proof: it shows the requester really is the
// registered server and not someone who merely knows its URL.
//
// newKeyChallenge — encrypted with the submitted new public key.
// Only the holder of the new private key can decrypt this.
// This proves the SA actually owns the key they want to rotate to.
//
// Both plaintexts are stored. On confirm the SA must re-encrypt both
// with OUR public key so we can decrypt and compare — proving they
// fetched our identity as well.
const oldKeyPlaintext = crypto.randomUUID();
const newKeyPlaintext = crypto.randomUUID();
const signingOldPlaintext = crypto.randomUUID();
const signingNewPlaintext = crypto.randomUUID();
const encryptionOldPlaintext = crypto.randomUUID();
const encryptionNewPlaintext = crypto.randomUUID();
debug("POST /discover/rotate/init issuing challenges for server %s", validated.data.url);
const oldKeyChallenge = encryptPayload(oldKeyPlaintext, server[0].publicKey);
const newKeyChallenge = encryptPayload(newKeyPlaintext, validated.data.newPublicKey);
debug("POST /discover/rotate/init issuing 4 challenges for server %s", validated.data.url);
const currentEncPubKey = new Uint8Array(Buffer.from(server[0].encryptionPublicKey, "base64"));
const newEncPubKey = new Uint8Array(Buffer.from(validated.data.newEncryptionPublicKey, "base64"));
const encryptionOldChallenge = encryptPayload(encryptionOldPlaintext, currentEncPubKey);
const encryptionNewChallenge = encryptPayload(encryptionNewPlaintext, newEncPubKey);
await db.insert(rotateChallengeTokens).values({
id: crypto.randomUUID(),
oldKeyToken: oldKeyPlaintext,
newKeyToken: newKeyPlaintext,
newPublicKey: validated.data.newPublicKey,
signingOldToken: signingOldPlaintext,
signingNewToken: signingNewPlaintext,
encryptionOldToken: encryptionOldPlaintext,
encryptionNewToken: encryptionNewPlaintext,
newSigningPublicKey: validated.data.newSigningPublicKey,
newEncryptionPublicKey: validated.data.newEncryptionPublicKey,
serverUrl: validated.data.url.toString(),
createdAt: new Date(),
expiresAt: new Date(Date.now() + 1000 * 60 * 5),
});
debug("POST /discover/rotate/init challenges issued, expires in 5 minutes");
return NextResponse.json({ oldKeyChallenge, newKeyChallenge });
const response = {
signingOldChallenge: signingOldPlaintext,
signingNewChallenge: signingNewPlaintext,
encryptionOldChallenge,
encryptionNewChallenge,
}
debug("POST /discover/rotate/init response: %o", response);
return NextResponse.json(response);
}

View file

@ -1,15 +1,75 @@
import db from "@/lib/db";
import { serverRegistry } from "@/lib/db/schema";
import { decryptPayload } from "@/lib/federation/keytools";
import { decryptPayload, fingerprintKey } from "@/lib/federation/keytools";
import { assertSafeUrl, UrlGuardError } from "@/lib/federation/url-guard";
import createDebug from "debug";
import { desc, eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import forge from "node-forge";
import { z } from "zod";
const debug = createDebug("app:discover");
const ED25519_PUBLIC_KEY_BYTES = 32;
const X25519_PUBLIC_KEY_BYTES = 32;
function isValidBase64Key(key: string, expectedBytes: number): boolean {
try {
const decoded = Buffer.from(key, "base64");
return decoded.length === expectedBytes;
} catch {
return false;
}
}
const signingKeySchema = z.string().refine(
(key) => isValidBase64Key(key, ED25519_PUBLIC_KEY_BYTES),
{ message: `Signing public key must be a base64-encoded Ed25519 key (${ED25519_PUBLIC_KEY_BYTES} bytes)` },
);
const encryptionKeySchema = z.string().refine(
(key) => isValidBase64Key(key, X25519_PUBLIC_KEY_BYTES),
{ message: `Encryption public key must be a base64-encoded X25519 key (${X25519_PUBLIC_KEY_BYTES} bytes)` },
);
function getOwnEncryptionSecretKey(): Uint8Array {
return new Uint8Array(Buffer.from(process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY!, "base64"));
}
const discoverSchema = z.object({
method: z.literal("DISCOVER"),
publicKey: signingKeySchema,
encryptionPublicKey: encryptionKeySchema,
envelope: z.object({
ephemeralPublicKey: z.string(),
iv: z.string(),
ciphertext: z.string(),
authTag: z.string(),
}),
}).superRefine((data, ctx) => {
try {
const decrypted = decryptPayload(data.envelope, getOwnEncryptionSecretKey());
const parsed = JSON.parse(decrypted);
if (parsed.publicKeyFingerprint !== fingerprintKey(data.publicKey)) {
ctx.addIssue({ code: "custom", message: "Envelope does not match the provided signing public key" });
}
if (parsed.encryptionPublicKeyFingerprint !== fingerprintKey(data.encryptionPublicKey)) {
ctx.addIssue({ code: "custom", message: "Envelope does not match the provided encryption public key" });
}
if (!parsed.url) {
ctx.addIssue({ code: "custom", message: "Envelope is missing the url field" });
}
} catch {
ctx.addIssue({ code: "custom", message: "Invalid envelope" });
}
});
const registerSchema = z.object({
method: z.literal("REGISTER"),
url: z.url(),
publicKey: signingKeySchema,
encryptionPublicKey: encryptionKeySchema,
});
export async function GET() {
debug("GET /discover fetching healthy peers");
const peers = await db.select({
@ -19,17 +79,19 @@ export async function GET() {
debug("GET /discover found %d peer(s)", peers.length);
return NextResponse.json({
url: process.env.BETTER_AUTH_URL,
url: process.env.BETTER_AUTH_URL!,
publicKey: process.env.FEDERATION_PUBLIC_KEY,
peers
encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY,
peers,
});
}
async function upsertServer(url: string, publicKey: string) {
async function upsertServer(url: string, publicKey: string, encryptionPublicKey: string) {
return await db.insert(serverRegistry).values({
id: crypto.randomUUID(),
url: url,
publicKey: publicKey,
url,
publicKey,
encryptionPublicKey,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
@ -37,56 +99,6 @@ async function upsertServer(url: string, publicKey: string) {
}).onConflictDoNothing();
}
const publicKeySchema = z.string().superRefine((key, ctx) => {
let pub: forge.pki.rsa.PublicKey;
try {
pub = forge.pki.publicKeyFromPem(key) as forge.pki.rsa.PublicKey;
} catch {
ctx.addIssue({ code: "custom", message: "Public key is not a valid PEM-encoded RSA key", input: key });
return;
}
if (!pub.n) {
ctx.addIssue({ code: "custom", message: "Public key is not an RSA key", input: key });
return;
}
if (pub.n.bitLength() < 2048) {
ctx.addIssue({ code: "custom", message: `RSA key must be at least 2048 bits (got ${pub.n.bitLength()})`, input: key });
}
});
function fingerprintKey(pem: string): string {
const md = forge.md.sha256.create();
md.update(pem, "utf8");
return md.digest().toHex();
}
const discoverSchema = z.object({
method: z.literal("DISCOVER"),
publicKey: publicKeySchema,
signature: z.string(),
}).superRefine((data, ctx) => {
try {
const decrypted = decryptPayload(data.signature, process.env.FEDERATION_PRIVATE_KEY!);
const parsed = JSON.parse(decrypted);
// The signature contains a SHA-256 fingerprint of the public key
// (since the full PEM exceeds RSA-OAEP's size limit) plus a url.
if (parsed.publicKeyFingerprint !== fingerprintKey(data.publicKey)) {
ctx.addIssue({ code: "custom", message: "Signature does not match the provided public key" });
}
if (!parsed.url) {
ctx.addIssue({ code: "custom", message: "Signature is missing the url field" });
}
} catch {
ctx.addIssue({ code: "custom", message: "Invalid signature" });
}
});
const registerSchema = z.object({
method: z.literal("REGISTER"),
url: z.url(),
publicKey: publicKeySchema,
});
async function discoverServer(validated: z.infer<typeof discoverSchema>) {
debug("DISCOVER looking up server by public key");
const server = await db.select().from(serverRegistry).where(eq(serverRegistry.publicKey, validated.publicKey));
@ -96,7 +108,9 @@ async function discoverServer(validated: z.infer<typeof discoverSchema>) {
}
try {
if (process.env.NODE_ENV !== "development") {
assertSafeUrl(server[0].url);
}
} catch (err) {
debug("DISCOVER stored URL failed SSRF check: %s", server[0].url);
if (err instanceof UrlGuardError) {
@ -126,7 +140,9 @@ async function discoverServer(validated: z.infer<typeof discoverSchema>) {
async function registerServer(validated: z.infer<typeof registerSchema>) {
try {
if (process.env.NODE_ENV !== "development") {
assertSafeUrl(validated.url);
}
} catch (err) {
debug("REGISTER URL failed SSRF check: %s", validated.url);
if (err instanceof UrlGuardError) {
@ -136,7 +152,7 @@ async function registerServer(validated: z.infer<typeof registerSchema>) {
}
debug("REGISTER fetching /discover from %s to validate server", validated.url);
let response: { publicKey?: string };
let response: { publicKey?: string; encryptionPublicKey?: string };
try {
response = await (await fetch(validated.url + "/discover")).json();
} catch (err) {
@ -144,31 +160,30 @@ async function registerServer(validated: z.infer<typeof registerSchema>) {
return NextResponse.json({ error: "Failed to reach the server" }, { status: 502 });
}
if (!response.publicKey) {
debug("REGISTER remote server returned no public key");
if (!response.publicKey || !response.encryptionPublicKey) {
debug("REGISTER remote server returned incomplete keys");
return NextResponse.json({ error: "Invalid server" }, { status: 400 });
} else if (response.publicKey !== validated.publicKey) {
debug("REGISTER public key mismatch: provided vs fetched");
debug("REGISTER provided public key: %s", validated.publicKey);
debug("REGISTER fetched public key: %s", response.publicKey);
return NextResponse.json({ error: "Invalid public key" }, { status: 400 });
} else if (response.publicKey !== validated.publicKey || response.encryptionPublicKey !== validated.encryptionPublicKey) {
debug("REGISTER key mismatch: provided vs fetched");
return NextResponse.json({ error: "Public keys do not match the ones reported by the server" }, { status: 400 });
}
debug("REGISTER checking for existing registration at %s", validated.url);
const server = await db.select().from(serverRegistry).where(eq(serverRegistry.url, validated.url.toString()));
if (server.length > 0 && server[0].publicKey !== validated.publicKey) {
debug("REGISTER key mismatch against existing registration");
return NextResponse.json({ error: "Your public key does not match the one registered on the server, to update your public key, please send a PATCH request instead." }, { status: 400 });
return NextResponse.json({ error: "Your public key does not match the one registered on the server, to update your public key, please use the key rotation flow instead." }, { status: 400 });
}
debug("REGISTER upserting server %s", validated.url);
await upsertServer(validated.url.toString(), validated.publicKey);
await upsertServer(validated.url.toString(), validated.publicKey, validated.encryptionPublicKey);
debug("REGISTER server registered successfully");
return NextResponse.json({
message: "Server registered successfully", echo: {
url: process.env.NEXT_PUBLIC_APP_URL,
url: process.env.BETTER_AUTH_URL!,
publicKey: process.env.FEDERATION_PUBLIC_KEY,
encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY,
}
});
}

View file

@ -65,11 +65,16 @@
--shadow-2xs: 0px 2px 4px 0px rgb(0 0 0 / 0.04);
--shadow-xs: 0px 2px 4px 0px rgb(0 0 0 / 0.04);
--shadow-sm: 0px 2px 8px 0px rgb(0 0 0 / 0.06), 0px 1px 2px -1px rgb(0 0 0 / 0.06);
--shadow: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 1px 2px -1px rgb(0 0 0 / 0.08);
--shadow-md: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 2px 4px -1px rgb(0 0 0 / 0.08);
--shadow-lg: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 4px 6px -1px rgb(0 0 0 / 0.08);
--shadow-xl: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 8px 10px -1px rgb(0 0 0 / 0.08);
--shadow-sm:
0px 2px 8px 0px rgb(0 0 0 / 0.06), 0px 1px 2px -1px rgb(0 0 0 / 0.06);
--shadow:
0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 1px 2px -1px rgb(0 0 0 / 0.08);
--shadow-md:
0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 2px 4px -1px rgb(0 0 0 / 0.08);
--shadow-lg:
0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 4px 6px -1px rgb(0 0 0 / 0.08);
--shadow-xl:
0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 8px 10px -1px rgb(0 0 0 / 0.08);
--shadow-2xl: 0px 2px 8px 0px rgb(0 0 0 / 0.15);
}
@ -110,11 +115,16 @@
--shadow-2xs: 0px 4px 12px 0px rgb(0 0 0 / 0.25);
--shadow-xs: 0px 4px 12px 0px rgb(0 0 0 / 0.25);
--shadow-sm: 0px 4px 12px 0px rgb(0 0 0 / 0.35), 0px 1px 2px -1px rgb(0 0 0 / 0.35);
--shadow: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 1px 2px -1px rgb(0 0 0 / 0.4);
--shadow-md: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 2px 4px -1px rgb(0 0 0 / 0.4);
--shadow-lg: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 4px 6px -1px rgb(0 0 0 / 0.4);
--shadow-xl: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 8px 10px -1px rgb(0 0 0 / 0.4);
--shadow-sm:
0px 4px 12px 0px rgb(0 0 0 / 0.35), 0px 1px 2px -1px rgb(0 0 0 / 0.35);
--shadow:
0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 1px 2px -1px rgb(0 0 0 / 0.4);
--shadow-md:
0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 2px 4px -1px rgb(0 0 0 / 0.4);
--shadow-lg:
0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 4px 6px -1px rgb(0 0 0 / 0.4);
--shadow-xl:
0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 8px 10px -1px rgb(0 0 0 / 0.4);
--shadow-2xl: 0px 4px 12px 0px rgb(0 0 0 / 0.6);
}

View file

@ -1,4 +1,3 @@
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Card,
@ -15,6 +14,7 @@ import {
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
export function LoginForm({
className,

View file

@ -1,6 +1,6 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"

View file

@ -82,11 +82,6 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle
}

View file

@ -1,11 +1,11 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
function Dialog({
...props
@ -154,5 +154,6 @@ export {
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
DialogTrigger
}

View file

@ -1,8 +1,8 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"
@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -155,7 +155,7 @@ function DropdownMenuLabel({
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
"px-2 py-1.5 text-sm font-medium data-inset:pl-8",
className
)}
{...props}
@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
@ -230,7 +230,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
@ -239,19 +239,11 @@ function DropdownMenuSubContent({
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger
}

View file

@ -1,11 +1,11 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { useMemo } from "react"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
@ -46,7 +46,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
@ -59,15 +59,15 @@ const fieldVariants = cva(
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
vertical: ["flex-col *:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"*:data-[slot=field-label]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"flex-col @md/field-group:flex-row @md/field-group:items-center *:w-full @md/field-group:*:w-auto [&>.sr-only]:w-auto",
"@md/field-group:*:data-[slot=field-label]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
@ -116,7 +116,7 @@ function FieldLabel({
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className
)}
@ -143,7 +143,7 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="field-description"
className={cn(
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
"text-sm leading-normal font-normal text-muted-foreground group-has-data-[orientation=horizontal]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
@ -235,14 +235,10 @@ function FieldError({
}
export {
Field,
FieldLabel,
FieldDescription,
Field, FieldContent, FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldGroup, FieldLabel, FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
FieldSet, FieldTitle
}

View file

@ -1,8 +1,8 @@
"use client"
import * as React from "react"
import type { Label as LabelPrimitive } from "radix-ui"
import { Slot } from "radix-ui"
import * as React from "react"
import {
Controller,
FormProvider,
@ -13,8 +13,8 @@ import {
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
const Form = FormProvider
@ -156,12 +156,8 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
Form, FormControl,
FormDescription, FormField, FormItem,
FormLabel, FormMessage, useFormField
}

View file

@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"

View file

@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"

View file

@ -12,9 +12,17 @@ import minioClient from "./plugins/server/storage/minio.client";
const isTest = process.env.NODE_ENV === "test";
const emailService: EmailService | undefined = isTest ? undefined : new EmailService();
const federationKeysExist = process.env.FEDERATION_PUBLIC_KEY && process.env.FEDERATION_PRIVATE_KEY;
const federationKeysExist =
process.env.FEDERATION_PUBLIC_KEY &&
process.env.FEDERATION_PRIVATE_KEY &&
process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY &&
process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY;
if (!federationKeysExist) {
throw new Error("FEDERATION_PUBLIC_KEY and FEDERATION_PRIVATE_KEY must be set, please run `bun run keygen` to generate them.");
throw new Error(
"All federation keys must be set (FEDERATION_PUBLIC_KEY, FEDERATION_PRIVATE_KEY, " +
"FEDERATION_ENCRYPTION_PUBLIC_KEY, FEDERATION_ENCRYPTION_PRIVATE_KEY). " +
"Run `bun run keygen` to generate them.",
);
}
const bAuth = betterAuth({

View file

@ -1,12 +1,12 @@
import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
boolean,
integer,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
@ -164,6 +164,7 @@ export const serverRegistry = pgTable(
id: text("id").primaryKey(),
url: text("url").notNull().unique(),
publicKey: text("public_key").notNull().unique(),
encryptionPublicKey: text("encryption_public_key").notNull().unique(),
lastSeen: timestamp("last_seen").notNull(),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
@ -171,6 +172,9 @@ export const serverRegistry = pgTable(
},
(table) => [
uniqueIndex("serverRegistry_publicKey_uidx").on(table.publicKey),
uniqueIndex("serverRegistry_encryptionPublicKey_uidx").on(
table.encryptionPublicKey,
),
index("serverRegistry_lastSeen_idx").on(table.lastSeen),
],
);
@ -179,18 +183,18 @@ export const rotateChallengeTokens = pgTable(
"rotate_challenge_tokens",
{
id: text("id").primaryKey(),
oldKeyToken: text("old_key_token").notNull(),
newKeyToken: text("new_key_token").notNull().unique(),
newPublicKey: text("new_public_key").notNull(),
signingOldToken: text("signing_old_token").notNull(),
signingNewToken: text("signing_new_token").notNull(),
encryptionOldToken: text("encryption_old_token").notNull(),
encryptionNewToken: text("encryption_new_token").notNull(),
newSigningPublicKey: text("new_signing_public_key").notNull(),
newEncryptionPublicKey: text("new_encryption_public_key").notNull(),
serverUrl: text("server_url").notNull(),
createdAt: timestamp("created_at").notNull(),
attemptsLeft: integer("attempts_left").default(3).notNull(),
expiresAt: timestamp("expires_at").notNull(),
},
(table) => [
uniqueIndex("rotateChallengeTokens_newKeyToken_uidx").on(table.newKeyToken),
index("rotateChallengeTokens_serverUrl_idx").on(table.serverUrl),
],
(table) => [index("rotateChallengeTokens_serverUrl_idx").on(table.serverUrl)],
);
export const blacklistedServers = pgTable(

View file

@ -1,32 +1,43 @@
import Bun from "bun";
import forge from "node-forge";
import nacl from "tweetnacl";
export async function generateKeyPair() {
// Check if .env file exists
if (!Bun.file(".env.local").exists()) { throw new Error("No .env.local file found"); }
const keypair = forge.pki.rsa.generateKeyPair(4096);
const keys = {
publicKey: forge.pki.publicKeyToPem(keypair.publicKey),
privateKey: forge.pki.privateKeyToPem(keypair.privateKey)
const envFile = Bun.file(".env.local");
if (!await envFile.exists()) {
throw new Error("No .env.local file found");
}
// Sanity check to make sure the keys are not already in the file
const env = await Bun.file(".env.local").text();
if (env.includes("FEDERATION_PUBLIC_KEY") && env.includes("FEDERATION_PRIVATE_KEY")) {
throw new Error("Keys already exist in .env.local file, if you wish to regenerate them, please delete the keys and run the command again");
const signing = nacl.sign.keyPair();
const encryption = nacl.box.keyPair();
const env = await envFile.text();
if (
env.includes("FEDERATION_PUBLIC_KEY") ||
env.includes("FEDERATION_PRIVATE_KEY") ||
env.includes("FEDERATION_ENCRYPTION_PUBLIC_KEY") ||
env.includes("FEDERATION_ENCRYPTION_PRIVATE_KEY")
) {
throw new Error(
"Federation keys already exist in .env.local. Delete them first if you want to regenerate.",
);
}
// Read the .env.local file and append the keys to the file without overwriting the existing keys
// Escape newlines for .env format (single-line value that expands to PEM when loaded)
const publicKey = keys.publicKey.replace(/\r?\n/g, "\\n");
const privateKey = keys.privateKey.replace(/\r?\n/g, "\\n");
const separator = "#------------- SERVER KEYS -------------\n# DO NOT EDIT THIS SECTION, THIS IS AUTOMATICALLY GENERATED BY THE SERVER\n#DO NOT PUBLISH THE PRIVATE KEY TO THE PUBLIC!";
await Bun.write(".env.local", `${env}\n\n${separator}\n\nFEDERATION_PUBLIC_KEY="${publicKey}"\nFEDERATION_PRIVATE_KEY="${privateKey}"`);
const signingPublicKey = Buffer.from(signing.publicKey).toString("base64");
const signingPrivateKey = Buffer.from(signing.secretKey).toString("base64");
const encryptionPublicKey = Buffer.from(encryption.publicKey).toString("base64");
const encryptionPrivateKey = Buffer.from(encryption.secretKey).toString("base64");
console.log("Keys generated successfully");
return keys;
const block = [
"",
"# Federation keys (Ed25519 signing + X25519 encryption)",
`FEDERATION_PUBLIC_KEY="${signingPublicKey}"`,
`FEDERATION_PRIVATE_KEY="${signingPrivateKey}"`,
`FEDERATION_ENCRYPTION_PUBLIC_KEY="${encryptionPublicKey}"`,
`FEDERATION_ENCRYPTION_PRIVATE_KEY="${encryptionPrivateKey}"`,
].join("\n");
await Bun.write(".env.local", env + block);
console.log("Federation keys generated and written to .env.local");
}
generateKeyPair();

View file

@ -1,49 +1,86 @@
import forge from "node-forge";
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
import nacl from "tweetnacl";
export function encryptPayload(payload: string, recipientPublicKey: string) {
const pub = forge.pki.publicKeyFromPem(recipientPublicKey);
return forge.util.encode64(
pub.encrypt(
forge.util.encodeUtf8(payload),
"RSA-OAEP"
)
)
export interface EncryptedEnvelope {
ephemeralPublicKey: string;
iv: string;
ciphertext: string;
authTag: string;
}
export function decryptPayload(payload: string, privateKey: string) {
const priv = forge.pki.privateKeyFromPem(privateKey);
function toBase64(buf: Uint8Array): string {
return Buffer.from(buf).toString("base64");
}
function fromBase64(str: string): Uint8Array {
return new Uint8Array(Buffer.from(str, "base64"));
}
function deriveAesKey(sharedSecret: Uint8Array): Buffer {
return Buffer.from(
hkdfSync("sha256", sharedSecret, Buffer.from("sipher-federation-v1-salt"), "sipher-federation-v1-aes", 32)
);
}
export function signMessage(message: string, ed25519SecretKey: Uint8Array): string {
const msgBytes = new TextEncoder().encode(message);
const sig = nacl.sign.detached(msgBytes, ed25519SecretKey);
return toBase64(sig);
}
export function verifySignature(message: string, signature: string, ed25519PublicKey: Uint8Array): boolean {
try {
return forge.util.decodeUtf8(
priv.decrypt(
forge.util.decode64(payload),
"RSA-OAEP"
)
)
const msgBytes = new TextEncoder().encode(message);
const sigBytes = fromBase64(signature);
return nacl.sign.detached.verify(msgBytes, sigBytes, ed25519PublicKey);
} catch {
return false;
}
}
export function encryptPayload(plaintext: string, recipientX25519PublicKey: Uint8Array): EncryptedEnvelope {
try {
const ephemeral = nacl.box.keyPair();
const sharedPoint = nacl.box.before(recipientX25519PublicKey, ephemeral.secretKey);
const aesKey = deriveAesKey(sharedPoint);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", aesKey, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
ephemeralPublicKey: toBase64(ephemeral.publicKey),
iv: toBase64(iv),
ciphertext: toBase64(encrypted),
authTag: toBase64(authTag),
};
} catch (error) {
console.error("Failed to decrypt payload", error);
throw error;
}
}
export function verifyChallenge(
challenge: string,
signedChallenge: string,
publicKeyPem: string
): boolean {
export function decryptPayload(envelope: EncryptedEnvelope, ownX25519SecretKey: Uint8Array): string {
try {
const pub = forge.pki.publicKeyFromPem(publicKeyPem)
const md = forge.md.sha256.create()
md.update(challenge, 'utf8')
const sig = forge.util.decode64(signedChallenge)
return pub.verify(md.digest().bytes(), sig)
} catch {
return false
const ephemeralPub = fromBase64(envelope.ephemeralPublicKey);
const sharedPoint = nacl.box.before(ephemeralPub, ownX25519SecretKey);
const aesKey = deriveAesKey(sharedPoint);
const iv = fromBase64(envelope.iv);
const ciphertext = fromBase64(envelope.ciphertext);
const authTag = fromBase64(envelope.authTag);
const decipher = createDecipheriv("aes-256-gcm", aesKey, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString("utf8");
} catch (error) {
console.error("If you're trying to rotate keys, then your old keys are invalid and doesn't match the keys that the other federation has. You'll have to contact that federation in order to rotate your keys.")
console.error("If you're not trying to rotate keys, then you're either doing something wrong or the other federation shouldn't be trusted anymore. Most likely the first.")
throw error;
}
}
export function signChallenge(challenge: string, privateKeyPem: string): string {
const priv = forge.pki.privateKeyFromPem(privateKeyPem)
const md = forge.md.sha256.create()
md.update(challenge, 'utf8')
return forge.util.encode64(priv.sign(md.digest().bytes()))
import { createHash } from "node:crypto";
export function fingerprintKey(keyBase64: string): string {
const hash = createHash("sha256").update(fromBase64(keyBase64)).digest("hex");
return hash;
}

View file

@ -1,5 +1,5 @@
import { pixelBasedPreset } from "@react-email/tailwind";
import type { TailwindConfig } from "@react-email/tailwind";
import { pixelBasedPreset } from "@react-email/tailwind";
/**
* React Email Tailwind config matching globals.css design tokens.

View file

@ -98,7 +98,20 @@ export const sipherSocialClientPlugin = () => {
});
}
console.log("Resolved content:", resolvedContent);
const { data, error } = await $fetch<{
postId: string;
}>("/social/posts", {
method: "POST",
body: {
content: resolvedContent,
},
});
if (error || !data) {
throw new Error("Failed to create post");
}
return data.postId;
}
}
},

View file

@ -18,6 +18,12 @@ export const federation = () => {
unique: true,
index: true
},
encryptionPublicKey: {
type: "string",
required: true,
unique: true,
index: true
},
lastSeen: {
type: "date",
required: true,
@ -42,18 +48,32 @@ export const federation = () => {
},
rotateChallengeTokens: {
fields: {
oldKeyToken: {
signingOldToken: {
type: "string",
required: true,
index: false
},
newKeyToken: {
signingNewToken: {
type: "string",
required: true,
unique: true,
index: true
index: false
},
newPublicKey: {
encryptionOldToken: {
type: "string",
required: true,
index: false
},
encryptionNewToken: {
type: "string",
required: true,
index: false
},
newSigningPublicKey: {
type: "string",
required: true,
index: false
},
newEncryptionPublicKey: {
type: "string",
required: true,
index: false

View file

@ -1,6 +1,7 @@
import { config } from 'dotenv'
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
import next from 'next'
import { Server } from 'socket.io'
config({ path: '.env.local' })
const port = parseInt(process.env.PORT || '3000', 10)
@ -8,10 +9,19 @@ const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare().then(() => {
createServer(async (req: IncomingMessage, res: ServerResponse) => {
app.prepare().then(async () => {
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
handle(req, res)
}).listen(port)
})
const io = new Server(server)
io.on('connection', (socket) => {
socket.on('join-firehose', () => socket.join('firehose'))
socket.on('leave-firehose', () => socket.leave('firehose'))
})
server.listen(port)
console.log(
`> Server listening at http://localhost:${port} as ${dev ? 'development' : process.env.NODE_ENV

View file

@ -13,8 +13,8 @@
* After fixing the vulnerabilitie, the tests were updated to match the new behavior.
* -----
*/
import { encryptPayload, fingerprintKey } from "@/lib/federation/keytools"
import { expect, test } from "@playwright/test"
import forge from "node-forge"
import http from "node:http"
import {
clearTables,
@ -27,33 +27,28 @@ import {
const BASE = "http://localhost:3000"
function encryptPayload(payload: string, recipientPublicKeyPem: string) {
const pub = forge.pki.publicKeyFromPem(recipientPublicKeyPem)
return forge.util.encode64(
pub.encrypt(forge.util.encodeUtf8(payload), "RSA-OAEP"),
function getOwnEncryptionPublicKey(): Uint8Array {
return new Uint8Array(Buffer.from(process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!, "base64"))
}
function buildBadEnvelope() {
return encryptPayload(
JSON.stringify({
signingOldSignature: "wrong",
signingNewSignature: "wrong",
encryptionOldPlaintext: "wrong",
encryptionNewPlaintext: "wrong",
}),
getOwnEncryptionPublicKey(),
)
}
function fingerprintKey(pem: string): string {
const md = forge.md.sha256.create()
md.update(pem, "utf8")
return md.digest().toHex()
}
function generate4096Keypair() {
const kp = forge.pki.rsa.generateKeyPair(4096)
return {
publicKey: forge.pki.publicKeyToPem(kp.publicKey),
privateKey: forge.pki.privateKeyToPem(kp.privateKey),
}
}
function createTrapServer(fakePublicKey: string) {
function createTrapServer(fakePublicKey: string, fakeEncryptionPublicKey: string) {
const hits: { method: string; url: string }[] = []
const server = http.createServer((req, res) => {
hits.push({ method: req.method!, url: req.url! })
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ publicKey: fakePublicKey }))
res.end(JSON.stringify({ publicKey: fakePublicKey, encryptionPublicKey: fakeEncryptionPublicKey }))
})
return {
@ -75,8 +70,8 @@ test.afterEach(async () => { await clearTables() })
// ---------------------------------------------------------------------------
test.describe("SSRF protection", () => {
test("REGISTER rejects loopback URLs", async ({ request }) => {
const { publicKey: fakePub } = generateKeypair()
const trap = createTrapServer(fakePub)
const keys = generateKeypair()
const trap = createTrapServer(keys.signingPublicKey, keys.encryptionPublicKey)
const port = await trap.start()
try {
@ -84,7 +79,8 @@ test.describe("SSRF protection", () => {
data: {
method: "REGISTER",
url: `http://127.0.0.1:${port}`,
publicKey: fakePub,
publicKey: keys.signingPublicKey,
encryptionPublicKey: keys.encryptionPublicKey,
},
})
@ -92,7 +88,6 @@ test.describe("SSRF protection", () => {
const body = await res.json()
expect(body.error).toMatch(/blocked/i)
// The trap server should NOT have been hit
expect(trap.hits.length).toBe(0)
} finally {
await trap.stop()
@ -107,9 +102,14 @@ test.describe("SSRF protection", () => {
]
for (const url of internalUrls) {
const { publicKey } = generateKeypair()
const keys = generateKeypair()
const res = await request.post(`${BASE}/discover`, {
data: { method: "REGISTER", url, publicKey },
data: {
method: "REGISTER",
url,
publicKey: keys.signingPublicKey,
encryptionPublicKey: keys.encryptionPublicKey,
},
})
expect(res.status()).toBe(400)
@ -119,25 +119,25 @@ test.describe("SSRF protection", () => {
})
test("DISCOVER rejects stored internal URLs", async ({ request }) => {
const { publicKey: maliciousPub } = generateKeypair()
await seedServer("http://127.0.0.1:9999", maliciousPub)
const keys = generateKeypair()
await seedServer("http://127.0.0.1:9999", keys.signingPublicKey, keys.encryptionPublicKey)
// Build a valid signature using the fingerprint approach
const signaturePayload = JSON.stringify({
publicKeyFingerprint: fingerprintKey(maliciousPub),
const envelopePayload = JSON.stringify({
publicKeyFingerprint: fingerprintKey(keys.signingPublicKey),
encryptionPublicKeyFingerprint: fingerprintKey(keys.encryptionPublicKey),
url: "http://127.0.0.1:9999",
})
const signature = encryptPayload(signaturePayload, process.env.FEDERATION_PUBLIC_KEY!)
const envelope = encryptPayload(envelopePayload, getOwnEncryptionPublicKey())
const res = await request.post(`${BASE}/discover`, {
data: {
method: "DISCOVER",
publicKey: maliciousPub,
signature,
publicKey: keys.signingPublicKey,
encryptionPublicKey: keys.encryptionPublicKey,
envelope,
},
})
// Should be blocked rather than fetching the internal URL
expect(res.status()).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/blocked/i)
@ -149,7 +149,6 @@ test.describe("SSRF protection", () => {
// ---------------------------------------------------------------------------
test.describe("Blacklist enforcement (fixed)", () => {
async function blacklistServer(serverUrl: string, request: any) {
// Seed a challenge with 1 attempt left
await seedChallenge({
serverUrl,
attemptsLeft: 1,
@ -160,8 +159,7 @@ test.describe("Blacklist enforcement (fixed)", () => {
await request.post(`${BASE}/discover/rotate/confirm`, {
data: {
serverUrl,
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
envelope: buildBadEnvelope(),
},
})
@ -169,8 +167,7 @@ test.describe("Blacklist enforcement (fixed)", () => {
await request.post(`${BASE}/discover/rotate/confirm`, {
data: {
serverUrl,
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
envelope: buildBadEnvelope(),
},
})
@ -179,14 +176,18 @@ test.describe("Blacklist enforcement (fixed)", () => {
}
test("blacklisted server is rejected by rotate/init", async ({ request }) => {
const { publicKey: oldPub } = generateKeypair()
const oldKeys = generateKeypair()
const serverUrl = "https://blacklisted-server.example"
await seedServer(serverUrl, oldPub)
await seedServer(serverUrl, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
await blacklistServer(serverUrl, request as any)
const { publicKey: newPub } = generate4096Keypair()
const newKeys = generateKeypair()
const initRes = await request.post(`${BASE}/discover/rotate/init`, {
data: { url: serverUrl, newPublicKey: newPub },
data: {
url: serverUrl,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
},
})
expect(initRes.status()).toBe(403)
const body = await initRes.json()
@ -195,15 +196,14 @@ test.describe("Blacklist enforcement (fixed)", () => {
test("blacklisted server is rejected by rotate/confirm", async ({ request }) => {
const serverUrl = "https://blacklisted-confirm.example"
const { publicKey } = generateKeypair()
await seedServer(serverUrl, publicKey)
const keys = generateKeypair()
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
await blacklistServer(serverUrl, request as any)
const confirmRes = await request.post(`${BASE}/discover/rotate/confirm`, {
data: {
serverUrl,
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
envelope: buildBadEnvelope(),
},
})
expect(confirmRes.status()).toBe(403)
@ -218,8 +218,8 @@ test.describe("Blacklist enforcement (fixed)", () => {
test.describe("Race condition fixed on rotate/confirm", () => {
test("concurrent requests are serialised by the row lock", async () => {
const serverUrl = "https://race-target.example"
const { publicKey } = generateKeypair()
await seedServer(serverUrl, publicKey)
const keys = generateKeypair()
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
await seedChallenge({
serverUrl,
@ -229,8 +229,7 @@ test.describe("Race condition fixed on rotate/confirm", () => {
const payload = JSON.stringify({
serverUrl,
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
envelope: buildBadEnvelope(),
})
const fire = () =>
@ -247,10 +246,6 @@ test.describe("Race condition fixed on rotate/confirm", () => {
const blacklisted = statuses.filter((s) => s === 403).length
const notFound = statuses.filter((s) => s === 404).length
// With the transaction lock, exactly 1 request should process the
// mismatch (400), then the next sees attemptsLeft=0 and blacklists (403),
// and the rest find no challenge (404) or hit the blacklist check.
// The key point: no more than 1 mismatch (400) should get through.
expect(mismatch).toBeLessThanOrEqual(1)
expect(mismatch + blacklisted + notFound).toBe(statuses.length)
})
@ -262,59 +257,66 @@ test.describe("Race condition fixed on rotate/confirm", () => {
test.describe("Challenge deduplication (fixed)", () => {
test("second init is rejected while a challenge is pending", async ({ request }) => {
const serverUrl = "https://dedup-target.example"
const { publicKey } = generateKeypair()
await seedServer(serverUrl, publicKey)
const keys = generateKeypair()
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
const { publicKey: newPub1 } = generate4096Keypair()
const { publicKey: newPub2 } = generate4096Keypair()
const newKeys1 = generateKeypair()
const newKeys2 = generateKeypair()
const res1 = await request.post(`${BASE}/discover/rotate/init`, {
data: { url: serverUrl, newPublicKey: newPub1 },
data: {
url: serverUrl,
newSigningPublicKey: newKeys1.signingPublicKey,
newEncryptionPublicKey: newKeys1.encryptionPublicKey,
},
})
expect(res1.status()).toBe(200)
// Second init while the first is still active → 409
const res2 = await request.post(`${BASE}/discover/rotate/init`, {
data: { url: serverUrl, newPublicKey: newPub2 },
data: {
url: serverUrl,
newSigningPublicKey: newKeys2.signingPublicKey,
newEncryptionPublicKey: newKeys2.encryptionPublicKey,
},
})
expect(res2.status()).toBe(409)
const body = await res2.json()
expect(body.error).toMatch(/already pending/i)
// Only one challenge exists
const challenges = await getChallengesByServerUrl(serverUrl)
expect(challenges.length).toBe(1)
})
test("init succeeds after the previous challenge expires", async ({ request }) => {
const serverUrl = "https://dedup-expire.example"
const { publicKey } = generateKeypair()
await seedServer(serverUrl, publicKey)
const keys = generateKeypair()
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
// Seed an already-expired challenge directly
await seedChallenge({
serverUrl,
expiresAt: new Date(Date.now() - 1000),
})
const { publicKey: newPub } = generate4096Keypair()
const newKeys = generateKeypair()
const res = await request.post(`${BASE}/discover/rotate/init`, {
data: { url: serverUrl, newPublicKey: newPub },
data: {
url: serverUrl,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
},
})
expect(res.status()).toBe(200)
// Old challenge was replaced, only new one exists
const challenges = await getChallengesByServerUrl(serverUrl)
expect(challenges.length).toBe(1)
expect(challenges[0].newPublicKey).toBe(newPub)
expect(challenges[0].newSigningPublicKey).toBe(newKeys.signingPublicKey)
})
test("blacklisted server cannot reset attempts via new init", async ({ request }) => {
const serverUrl = "https://reset-blocked.example"
const { publicKey } = generateKeypair()
await seedServer(serverUrl, publicKey)
const keys = generateKeypair()
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
// Exhaust attempts → get blacklisted
await seedChallenge({
serverUrl,
attemptsLeft: 1,
@ -323,102 +325,109 @@ test.describe("Challenge deduplication (fixed)", () => {
await request.post(`${BASE}/discover/rotate/confirm`, {
data: {
serverUrl,
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
envelope: buildBadEnvelope(),
},
})
await request.post(`${BASE}/discover/rotate/confirm`, {
data: {
serverUrl,
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
envelope: buildBadEnvelope(),
},
})
const bl = await getBlacklistedServer(serverUrl)
expect(bl).toBeDefined()
// Try init → blocked by blacklist check
const { publicKey: freshPub } = generate4096Keypair()
const freshKeys = generateKeypair()
const initRes = await request.post(`${BASE}/discover/rotate/init`, {
data: { url: serverUrl, newPublicKey: freshPub },
data: {
url: serverUrl,
newSigningPublicKey: freshKeys.signingPublicKey,
newEncryptionPublicKey: freshKeys.encryptionPublicKey,
},
})
expect(initRes.status()).toBe(403)
})
})
// ---------------------------------------------------------------------------
// 5. Signature validation — field values must match the request
// 5. Envelope validation — field values must match the request
// ---------------------------------------------------------------------------
test.describe("Signature validation (fixed)", () => {
test("signature with mismatched publicKey fingerprint is rejected", async ({ request }) => {
const { publicKey: peerPub } = generateKeypair()
await seedServer("https://sig-test.example", peerPub)
test.describe("Envelope validation (fixed)", () => {
test("envelope with mismatched publicKey fingerprint is rejected", async ({ request }) => {
const keys = generateKeypair()
await seedServer("https://sig-test.example", keys.signingPublicKey, keys.encryptionPublicKey)
// Encrypt a signature where the fingerprint doesn't match
const badSignature = encryptPayload(
JSON.stringify({ publicKeyFingerprint: "wrong-fingerprint", url: "https://sig-test.example" }),
process.env.FEDERATION_PUBLIC_KEY!,
const badEnvelope = encryptPayload(
JSON.stringify({
publicKeyFingerprint: "wrong-fingerprint",
encryptionPublicKeyFingerprint: fingerprintKey(keys.encryptionPublicKey),
url: "https://sig-test.example",
}),
getOwnEncryptionPublicKey(),
)
const res = await request.post(`${BASE}/discover`, {
data: {
method: "DISCOVER",
publicKey: peerPub,
signature: badSignature,
publicKey: keys.signingPublicKey,
encryptionPublicKey: keys.encryptionPublicKey,
envelope: badEnvelope,
},
})
expect(res.status()).toBe(400)
})
test("signature with placeholder values is rejected", async ({ request }) => {
const { publicKey: peerPub } = generateKeypair()
await seedServer("https://sig-test2.example", peerPub)
test("envelope with placeholder values is rejected", async ({ request }) => {
const keys = generateKeypair()
await seedServer("https://sig-test2.example", keys.signingPublicKey, keys.encryptionPublicKey)
// The old bypass: { publicKey: "x", url: "y" }, now invalid
const forgerySignature = encryptPayload(
const forgeryEnvelope = encryptPayload(
JSON.stringify({ publicKey: "x", url: "y" }),
process.env.FEDERATION_PUBLIC_KEY!,
getOwnEncryptionPublicKey(),
)
const res = await request.post(`${BASE}/discover`, {
data: {
method: "DISCOVER",
publicKey: peerPub,
signature: forgerySignature,
publicKey: keys.signingPublicKey,
encryptionPublicKey: keys.encryptionPublicKey,
envelope: forgeryEnvelope,
},
})
expect(res.status()).toBe(400)
})
test("signature with correct fingerprint passes validation", async ({ request }) => {
const { publicKey: peerPub } = generateKeypair()
const trap = createTrapServer(peerPub)
test("envelope with correct fingerprints passes validation", async ({ request }) => {
const keys = generateKeypair()
const trap = createTrapServer(keys.signingPublicKey, keys.encryptionPublicKey)
const port = await trap.start()
const peerUrl = `http://127.0.0.1:${port}`
try {
await seedServer(peerUrl, peerPub)
await seedServer(peerUrl, keys.signingPublicKey, keys.encryptionPublicKey)
const validSignature = encryptPayload(
const validEnvelope = encryptPayload(
JSON.stringify({
publicKeyFingerprint: fingerprintKey(peerPub),
publicKeyFingerprint: fingerprintKey(keys.signingPublicKey),
encryptionPublicKeyFingerprint: fingerprintKey(keys.encryptionPublicKey),
url: peerUrl,
}),
process.env.FEDERATION_PUBLIC_KEY!,
getOwnEncryptionPublicKey(),
)
const res = await request.post(`${BASE}/discover`, {
data: {
method: "DISCOVER",
publicKey: peerPub,
signature: validSignature,
publicKey: keys.signingPublicKey,
encryptionPublicKey: keys.encryptionPublicKey,
envelope: validEnvelope,
},
})
// The signature is valid, but the stored URL is internal → blocked by SSRF guard
// Envelope is valid, but the stored URL is internal → blocked by SSRF guard
expect(res.status()).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/blocked/i)
@ -433,10 +442,10 @@ test.describe("Signature validation (fixed)", () => {
// ---------------------------------------------------------------------------
test.describe("Information disclosure", () => {
test("GET /discover only returns url and isHealthy for peers", async ({ request }) => {
const { publicKey: peerPub1 } = generateKeypair()
const { publicKey: peerPub2 } = generateKeypair()
await seedServer("https://peer-one.example", peerPub1)
await seedServer("https://peer-two.example", peerPub2)
const keys1 = generateKeypair()
const keys2 = generateKeypair()
await seedServer("https://peer-one.example", keys1.signingPublicKey, keys1.encryptionPublicKey)
await seedServer("https://peer-two.example", keys2.signingPublicKey, keys2.encryptionPublicKey)
const res = await request.get(`${BASE}/discover`)
expect(res.status()).toBe(200)
@ -448,12 +457,10 @@ test.describe("Information disclosure", () => {
for (const peer of body.peers) {
expect(peer.url).toBeDefined()
expect(peer.isHealthy).toBeDefined()
// Internal fields must NOT be exposed
expect(peer.id).toBeUndefined()
expect(peer.createdAt).toBeUndefined()
expect(peer.updatedAt).toBeUndefined()
expect(peer.lastSeen).toBeUndefined()
expect(peer.isHealthy).toBeUndefined()
}
})
})

View file

@ -19,6 +19,7 @@ test("discover server", async ({ request, page }) => {
method: "REGISTER",
url: new URL(url).toString(),
publicKey: process.env.FEDERATION_PUBLIC_KEY!,
encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!,
}
})
const status = response.status()
@ -27,12 +28,14 @@ test("discover server", async ({ request, page }) => {
debug("response body: %o", body);
expect(status).toBe(200)
expect(body).toMatchObject({ message: "Server registered successfully" })
expect(body.echo).toBeInstanceOf(Object) // We can't assert the exact object because the echo is generated by the server
expect(body.echo).toBeInstanceOf(Object)
// Insert the server echo into our database
await insertServerEcho("http://192.168.3.26:3000", body.echo.publicKey as string);
await insertServerEcho(
"http://192.168.3.26:3000",
body.echo.publicKey as string,
body.echo.encryptionPublicKey as string,
);
// check on the database itself if the server was registered
const server = await getServerByUrl("http://192.168.3.26:3000");
expect(server).toBeDefined()
expect(server?.publicKey).toBe(body.echo.publicKey as string)

View file

@ -2,21 +2,25 @@
import db from "@/lib/db";
import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import forge from "node-forge";
import nacl from "tweetnacl";
export function generateKeypair() {
const keypair = forge.pki.rsa.generateKeyPair(2048);
const signing = nacl.sign.keyPair();
const encryption = nacl.box.keyPair();
return {
publicKey: forge.pki.publicKeyToPem(keypair.publicKey),
privateKey: forge.pki.privateKeyToPem(keypair.privateKey),
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"),
}
}
export async function seedServer(url: string, publicKey: string) {
export async function seedServer(url: string, publicKey: string, encryptionPublicKey: string) {
await db.insert(serverRegistry).values({
id: crypto.randomUUID(),
url,
publicKey,
encryptionPublicKey,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
@ -25,13 +29,16 @@ export async function seedServer(url: string, publicKey: string) {
}
export async function seedChallenge(overrides?: Partial<typeof rotateChallengeTokens.$inferInsert>) {
const { publicKey: defaultNewPublicKey } = generateKeypair()
const keys = generateKeypair()
const defaults = {
id: crypto.randomUUID(),
serverUrl: "https://test-server.com",
oldKeyToken: crypto.randomUUID(),
newKeyToken: crypto.randomUUID(),
newPublicKey: defaultNewPublicKey,
signingOldToken: crypto.randomUUID(),
signingNewToken: crypto.randomUUID(),
encryptionOldToken: crypto.randomUUID(),
encryptionNewToken: crypto.randomUUID(),
newSigningPublicKey: keys.signingPublicKey,
newEncryptionPublicKey: keys.encryptionPublicKey,
attemptsLeft: 3,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 1000 * 60 * 5),
@ -53,11 +60,12 @@ export async function clearRotateChallengeTokens() {
return await db.delete(rotateChallengeTokens)
}
export async function insertServerEcho(url: string, publicKey: string) {
export async function insertServerEcho(url: string, publicKey: string, encryptionPublicKey: string) {
await db.insert(serverRegistry).values({
id: crypto.randomUUID(),
url,
publicKey,
encryptionPublicKey,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),

View file

@ -2,19 +2,23 @@
* Tests the key rotation flow.
*
* This test covers:
* - Missing challenge
* - Expired challenge
* - Wrong challenge plaintext
* - Init endpoint: validation, not-found, duplicate challenge
* - Missing challenge on confirm
* - Expired challenge on confirm
* - Wrong challenge proofs (full init confirm flow)
* - Blacklists server after too many failed attempts
* - Confirms valid challenge and rotates key
* - Full init confirm happy path that rotates both keys
*/
import { expect, test } from "@playwright/test"
import createDebug from "debug"
import forge from "node-forge"
import { clearTables, generateKeypair, seedChallenge, seedServer } from "./helpers/db"
import type { EncryptedEnvelope } from "@/lib/federation/keytools"
import { decryptPayload, encryptPayload, signMessage } from "@/lib/federation/keytools"
import { clearTables, generateKeypair, getServerByUrl, seedChallenge, seedServer } from "./helpers/db"
const debug = createDebug("test:key")
const SERVER_URL = "https://test-server.com"
test.beforeEach(async ({ }, testInfo) => {
debug("beforeEach clearing tables for: %s", testInfo.title)
await clearTables()
@ -24,119 +28,252 @@ test.afterEach(async ({ }, testInfo) => {
await clearTables()
})
function encryptPayload(payload: string, recipientPublicKey: string) {
const pub = forge.pki.publicKeyFromPem(recipientPublicKey);
return forge.util.encode64(
pub.encrypt(
forge.util.encodeUtf8(payload),
"RSA-OAEP"
)
function getOwnEncryptionPublicKey(): Uint8Array {
return new Uint8Array(Buffer.from(process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!, "base64"))
}
function buildBadEnvelope() {
return encryptPayload(
JSON.stringify({
signingOldSignature: "wrong",
signingNewSignature: "wrong",
encryptionOldPlaintext: "wrong",
encryptionNewPlaintext: "wrong",
}),
getOwnEncryptionPublicKey(),
)
}
test("rejects missing challenge", async ({ request }) => {
debug("test: rejects missing challenge posting with unknown serverUrl")
const res = await request.post("/discover/rotate/confirm", {
interface InitChallenges {
signingOldChallenge: string
signingNewChallenge: string
encryptionOldChallenge: EncryptedEnvelope
encryptionNewChallenge: EncryptedEnvelope
}
function solveInitChallenges(
challenges: InitChallenges,
oldKeys: ReturnType<typeof generateKeypair>,
newKeys: ReturnType<typeof generateKeypair>,
) {
const oldSigningSecret = new Uint8Array(Buffer.from(oldKeys.signingSecretKey, "base64"))
const newSigningSecret = new Uint8Array(Buffer.from(newKeys.signingSecretKey, "base64"))
const oldEncSecret = new Uint8Array(Buffer.from(oldKeys.encryptionSecretKey, "base64"))
const newEncSecret = new Uint8Array(Buffer.from(newKeys.encryptionSecretKey, "base64"))
return {
signingOldSignature: signMessage(challenges.signingOldChallenge, oldSigningSecret),
signingNewSignature: signMessage(challenges.signingNewChallenge, newSigningSecret),
encryptionOldPlaintext: decryptPayload(challenges.encryptionOldChallenge, oldEncSecret),
encryptionNewPlaintext: decryptPayload(challenges.encryptionNewChallenge, newEncSecret),
}
}
// ---------------------------------------------------------------------------
// rotate/init tests
// ---------------------------------------------------------------------------
test("init rejects unregistered server", async ({ request }) => {
const newKeys = generateKeypair()
const res = await request.post("/discover/rotate/init", {
data: {
serverUrl: "https://ghost-server.com",
signedOldChallenge: "fake",
signedNewChallenge: "fake",
url: "https://unknown-server.com",
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
}
})
debug("test: rejects missing challenge status %d", res.status())
expect(res.status()).toBe(404)
})
test("rejects expired challenge", async ({ request }) => {
debug("test: rejects expired challenge seeding expired challenge")
test("init rejects same keys as currently registered", async ({ request }) => {
const keys = generateKeypair()
await seedServer(SERVER_URL, keys.signingPublicKey, keys.encryptionPublicKey)
const res = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: keys.signingPublicKey,
newEncryptionPublicKey: keys.encryptionPublicKey,
}
})
expect(res.status()).toBe(400)
expect(await res.json()).toMatchObject({ error: /already registered/i })
})
test("init issues 4 challenges", async ({ request }) => {
const oldKeys = generateKeypair()
const newKeys = generateKeypair()
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
const res = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
}
})
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.signingOldChallenge).toBeDefined()
expect(body.signingNewChallenge).toBeDefined()
expect(body.encryptionOldChallenge).toBeDefined()
expect(body.encryptionOldChallenge.ephemeralPublicKey).toBeDefined()
expect(body.encryptionNewChallenge).toBeDefined()
expect(body.encryptionNewChallenge.ephemeralPublicKey).toBeDefined()
})
test("init rejects duplicate while challenge is pending", async ({ request }) => {
const oldKeys = generateKeypair()
const newKeys1 = generateKeypair()
const newKeys2 = generateKeypair()
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
const res1 = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys1.signingPublicKey,
newEncryptionPublicKey: newKeys1.encryptionPublicKey,
}
})
expect(res1.status()).toBe(200)
const res2 = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys2.signingPublicKey,
newEncryptionPublicKey: newKeys2.encryptionPublicKey,
}
})
expect(res2.status()).toBe(409)
expect(await res2.json()).toMatchObject({ error: /already pending/i })
})
// ---------------------------------------------------------------------------
// rotate/confirm tests
// ---------------------------------------------------------------------------
test("confirm rejects missing challenge", async ({ request }) => {
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://ghost-server.com",
envelope: buildBadEnvelope(),
}
})
expect(res.status()).toBe(404)
})
test("confirm rejects expired challenge", async ({ request }) => {
await seedChallenge({ expiresAt: new Date(Date.now() - 1000) })
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://test-server.com",
signedOldChallenge: "fake",
signedNewChallenge: "fake",
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
}
})
debug("test: rejects expired challenge status %d", res.status())
expect(res.status()).toBe(400)
expect(await res.json()).toMatchObject({ error: /expired/ })
})
test("rejects wrong challenge plaintext", async ({ request }) => {
debug("test: rejects wrong challenge plaintext seeding valid challenge")
await seedChallenge()
debug("test: rejects wrong challenge plaintext posting with incorrect plaintexts")
const res = await request.post("/discover/rotate/confirm", {
test("confirm rejects wrong proofs (init → confirm)", async ({ request }) => {
const oldKeys = generateKeypair()
const newKeys = generateKeypair()
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
debug("test: wrong proofs calling init")
const initRes = await request.post("/discover/rotate/init", {
data: {
serverUrl: "https://test-server.com",
// encrypt wrong plaintexts with your server's public key
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
}
})
debug("test: rejects wrong challenge plaintext status %d", res.status())
expect(res.status()).toBe(400)
expect(await res.json()).toMatchObject({ error: /mismatch/ })
expect(initRes.status()).toBe(200)
debug("test: wrong proofs confirming with garbage proofs")
const confirmRes = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
}
})
expect(confirmRes.status()).toBe(400)
expect(await confirmRes.json()).toMatchObject({ error: /failed/i })
})
test("blacklists server after too many failed attempts", async ({ request }) => {
debug("test: blacklists server after too many failed attempts seeding server and challenge (attemptsLeft=3)")
await seedServer("https://test-server.com", process.env.FEDERATION_PUBLIC_KEY!)
await seedChallenge({ expiresAt: new Date(Date.now() + 1000 * 60) })
test("confirm blacklists after too many failed attempts", async ({ request }) => {
const oldKeys = generateKeypair()
const newKeys = generateKeypair()
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
debug("test: blacklists calling init")
const initRes = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
}
})
expect(initRes.status()).toBe(200)
// 3 wrong attempts exhaust attemptsLeft (3 → 0), each returning 400 mismatch
for (let i = 0; i < 3; i++) {
debug("test: blacklists server after too many failed attempts wrong attempt %d/3", i + 1)
debug("test: blacklists wrong attempt %d/3", i + 1)
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://test-server.com",
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
}
})
debug("test: blacklists server after too many failed attempts status %d", res.status())
expect(res.status()).toBe(400)
expect(await res.json()).toMatchObject({ error: /mismatch/ })
expect(await res.json()).toMatchObject({ error: /failed/i })
}
// 4th attempt: attemptsLeft is now 0, server gets blacklisted
debug("test: blacklists server after too many failed attempts 4th attempt should trigger blacklist (403)")
debug("test: blacklists 4th attempt triggers blacklist")
const finalRes = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://test-server.com",
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
}
})
debug("test: blacklists server after too many failed attempts final status %d", finalRes.status())
expect(finalRes.status()).toBe(403)
expect(await finalRes.json()).toMatchObject({ error: /blacklisted/ })
})
test("confirms valid challenge and rotates key", async ({ request }) => {
debug("test: confirms valid challenge generating old and new keypairs")
// SB's old keypair — what is currently registered
const { publicKey: oldPublicKey } = generateKeypair()
// SB's new keypair — what SB wants to rotate to
const { publicKey: newPublicKey } = generateKeypair()
// ---------------------------------------------------------------------------
// Full init → confirm happy path
// ---------------------------------------------------------------------------
test("full rotation flow: init → solve → confirm rotates both keys", async ({ request }) => {
const oldKeys = generateKeypair()
const newKeys = generateKeypair()
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
debug("test: confirms valid challenge seeding server and challenge")
await seedServer("https://test-server.com", oldPublicKey)
const challenge = await seedChallenge({ newPublicKey })
// Simulate SB: re-encrypt the plaintext tokens with SA's public key
debug("test: confirms valid challenge re-encrypting tokens with SA public key")
const signedOldChallenge = encryptPayload(challenge.oldKeyToken, process.env.FEDERATION_PUBLIC_KEY!)
const signedNewChallenge = encryptPayload(challenge.newKeyToken, process.env.FEDERATION_PUBLIC_KEY!)
const res = await request.post("/discover/rotate/confirm", {
debug("test: full flow calling init")
const initRes = await request.post("/discover/rotate/init", {
data: {
serverUrl: "https://test-server.com",
signedOldChallenge,
signedNewChallenge,
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
}
})
debug("test: confirms valid challenge status %d", res.status())
expect(res.status()).toBe(200)
expect(await res.json()).toMatchObject({ message: /confirmed/ })
expect(initRes.status()).toBe(200)
const challenges: InitChallenges = await initRes.json()
debug("test: full flow solving challenges")
const proofs = solveInitChallenges(challenges, oldKeys, newKeys)
debug("test: full flow building proof envelope encrypted with SA's X25519 key")
const envelope = encryptPayload(JSON.stringify(proofs), getOwnEncryptionPublicKey())
debug("test: full flow confirming")
const confirmRes = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope,
}
})
expect(confirmRes.status()).toBe(200)
expect(await confirmRes.json()).toMatchObject({ message: /confirmed/ })
debug("test: full flow verifying keys were rotated in DB")
const server = await getServerByUrl(SERVER_URL)
expect(server).toBeDefined()
expect(server!.publicKey).toBe(newKeys.signingPublicKey)
expect(server!.encryptionPublicKey).toBe(newKeys.encryptionPublicKey)
})