diff --git a/.env.local.example b/.env.local.example
index 26dc819..af95c0d 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -7,4 +7,13 @@ EMAIL_HOST=
EMAIL_PORT=
EMAIL_SECURE=
EMAIL_USER=
-EMAIL_PASSWORD=
\ No newline at end of file
+EMAIL_PASSWORD=
+
+DEBUG=
+
+MINIO_BUCKET=
+MINIO_ENDPOINT=
+MINIO_PORT=
+MINIO_USE_SSL=
+MINIO_ACCESS_KEY=
+MINIO_SECRET_KEY=
\ No newline at end of file
diff --git a/README.md b/README.md
index 33e0827..8f051a1 100644
--- a/README.md
+++ b/README.md
@@ -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
+
+
+Rotating Federation Keys
+
+Federation identity is tied to two keypairs (Ed25519 for signing, X25519 for encryption). The `rotateKeys.ts` script walks through every known federation, proves ownership of both the old and new keys via a challenge-response protocol, and updates `.env.local` when all federations confirm.
+
+You **need** the old keys in order to run this script, if you lost them, 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 '' --only ''
+```
+
+- `--resume ` — Reuse the new keys from the previous run instead of generating fresh ones (required because successful federations already registered them).
+- `--only ` — 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 ''
+```
+
+
+
+
## Author
**Marcello Brito** (Tocka) — [tockanest.com](https://tockanest.com)
diff --git a/bun.lock b/bun.lock
index dd21680..6bd50dd 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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=="],
diff --git a/next.config.ts b/next.config.ts
index 66e1566..a4bef11 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,8 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
- reactCompiler: true,
+ /* config options here */
+ reactCompiler: true,
+ allowedDevOrigins: ["172.21.157.201", "172.21.144.1"]
};
export default nextConfig;
diff --git a/package.json b/package.json
index 612eea2..f4fe0fd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/rotateKeys.ts b/rotateKeys.ts
new file mode 100644
index 0000000..cdb3ee6
--- /dev/null
+++ b/rotateKeys.ts
@@ -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 — retry all federations with previously generated keys
+ * bun run rotateKeys.ts --resume --only — 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 {
+ 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 {
+ 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 | 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);
diff --git a/src/app/discover/rotate/confirm/route.ts b/src/app/discover/rotate/confirm/route.ts
index 2d24853..4927830 100644
--- a/src/app/discover/rotate/confirm/route.ts
+++ b/src/app/discover/rotate/confirm/route.ts
@@ -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));
diff --git a/src/app/discover/rotate/init/route.ts b/src/app/discover/rotate/init/route.ts
index b70b945..702133a 100644
--- a/src/app/discover/rotate/init/route.ts
+++ b/src/app/discover/rotate/init/route.ts
@@ -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);
}
diff --git a/src/app/discover/route.ts b/src/app/discover/route.ts
index 8d50fb3..64af7fb 100644
--- a/src/app/discover/route.ts
+++ b/src/app/discover/route.ts
@@ -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) {
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) {
}
try {
- assertSafeUrl(server[0].url);
+ 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) {
async function registerServer(validated: z.infer) {
try {
- assertSafeUrl(validated.url);
+ 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) {
}
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) {
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,
}
});
}
@@ -196,4 +211,4 @@ export async function POST(request: NextRequest) {
}
return NextResponse.json({ error: "Invalid method" }, { status: 400 });
-}
\ No newline at end of file
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 5aadb89..d8684db 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -6,206 +6,216 @@
/* SiPher brand palette — Silent Whisper */
:root {
- /* Brand tokens */
- --black: #080808;
- --surface: #0f0f0f;
- --card: #141414;
- --border: #1f1f1f;
- --muted: #2a2a2a;
- --dim: #555;
- --text: #e8e8e8;
- --subtle: #888;
- --acid: #c8f000;
- --static: #ff3c3c;
- --ghost: #9b9b9b;
- --void: #080808;
- --signal: #00e5ff;
+ /* Brand tokens */
+ --black: #080808;
+ --surface: #0f0f0f;
+ --card: #141414;
+ --border: #1f1f1f;
+ --muted: #2a2a2a;
+ --dim: #555;
+ --text: #e8e8e8;
+ --subtle: #888;
+ --acid: #c8f000;
+ --static: #ff3c3c;
+ --ghost: #9b9b9b;
+ --void: #080808;
+ --signal: #00e5ff;
- /* Light mode — minimal variant for system preference */
- --background: #f5f5f5;
- --foreground: #0a0a0a;
- --card: #ffffff;
- --card-foreground: #0a0a0a;
- --popover: #ffffff;
- --popover-foreground: #0a0a0a;
- --primary: #8a9a00;
- --primary-foreground: #080808;
- --secondary: #e8e8e8;
- --secondary-foreground: #0a0a0a;
- --muted: #e5e5e5;
- --muted-foreground: #555;
- --accent: #c8f000;
- --accent-foreground: #080808;
- --destructive: #ff3c3c;
- --destructive-foreground: #ffffff;
- --border: #e5e5e5;
- --input: #e5e5e5;
- --ring: #8a9a00;
- --chart-1: #8a9a00;
- --chart-2: #c8f000;
- --chart-3: #00e5ff;
- --chart-4: #9b9b9b;
- --chart-5: #555;
- --sidebar: #fafafa;
- --sidebar-foreground: #0a0a0a;
- --sidebar-primary: #8a9a00;
- --sidebar-primary-foreground: #080808;
- --sidebar-accent: #e5e5e5;
- --sidebar-accent-foreground: #0a0a0a;
- --sidebar-border: #e5e5e5;
- --sidebar-ring: #8a9a00;
+ /* Light mode — minimal variant for system preference */
+ --background: #f5f5f5;
+ --foreground: #0a0a0a;
+ --card: #ffffff;
+ --card-foreground: #0a0a0a;
+ --popover: #ffffff;
+ --popover-foreground: #0a0a0a;
+ --primary: #8a9a00;
+ --primary-foreground: #080808;
+ --secondary: #e8e8e8;
+ --secondary-foreground: #0a0a0a;
+ --muted: #e5e5e5;
+ --muted-foreground: #555;
+ --accent: #c8f000;
+ --accent-foreground: #080808;
+ --destructive: #ff3c3c;
+ --destructive-foreground: #ffffff;
+ --border: #e5e5e5;
+ --input: #e5e5e5;
+ --ring: #8a9a00;
+ --chart-1: #8a9a00;
+ --chart-2: #c8f000;
+ --chart-3: #00e5ff;
+ --chart-4: #9b9b9b;
+ --chart-5: #555;
+ --sidebar: #fafafa;
+ --sidebar-foreground: #0a0a0a;
+ --sidebar-primary: #8a9a00;
+ --sidebar-primary-foreground: #080808;
+ --sidebar-accent: #e5e5e5;
+ --sidebar-accent-foreground: #0a0a0a;
+ --sidebar-border: #e5e5e5;
+ --sidebar-ring: #8a9a00;
- --font-sans: "DM Sans", sans-serif;
- --font-mono: "Space Mono", monospace;
- --font-display: "Bebas Neue", sans-serif;
+ --font-sans: "DM Sans", sans-serif;
+ --font-mono: "Space Mono", monospace;
+ --font-display: "Bebas Neue", sans-serif;
- --radius: 0.2rem;
- --tracking-normal: -0.01em;
- --spacing: 0.25rem;
+ --radius: 0.2rem;
+ --tracking-normal: -0.01em;
+ --spacing: 0.25rem;
- --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-2xl: 0px 2px 8px 0px rgb(0 0 0 / 0.15);
+ --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-2xl: 0px 2px 8px 0px rgb(0 0 0 / 0.15);
}
.dark {
- /* SiPher dark theme — primary brand identity */
- --background: var(--black);
- --foreground: var(--text);
- --card: #141414;
- --card-foreground: var(--text);
- --popover: var(--surface);
- --popover-foreground: var(--text);
- --primary: var(--acid);
- --primary-foreground: var(--void);
- --secondary: var(--muted);
- --secondary-foreground: var(--text);
- --muted: var(--muted);
- --muted-foreground: var(--subtle);
- --accent: var(--acid);
- --accent-foreground: var(--void);
- --destructive: var(--static);
- --destructive-foreground: #ffffff;
- --border: var(--border);
- --input: var(--border);
- --ring: var(--acid);
- --chart-1: var(--acid);
- --chart-2: var(--signal);
- --chart-3: var(--static);
- --chart-4: var(--ghost);
- --chart-5: var(--subtle);
- --sidebar: var(--surface);
- --sidebar-foreground: var(--text);
- --sidebar-primary: var(--acid);
- --sidebar-primary-foreground: var(--void);
- --sidebar-accent: var(--muted);
- --sidebar-accent-foreground: var(--acid);
- --sidebar-border: var(--border);
- --sidebar-ring: var(--acid);
+ /* SiPher dark theme — primary brand identity */
+ --background: var(--black);
+ --foreground: var(--text);
+ --card: #141414;
+ --card-foreground: var(--text);
+ --popover: var(--surface);
+ --popover-foreground: var(--text);
+ --primary: var(--acid);
+ --primary-foreground: var(--void);
+ --secondary: var(--muted);
+ --secondary-foreground: var(--text);
+ --muted: var(--muted);
+ --muted-foreground: var(--subtle);
+ --accent: var(--acid);
+ --accent-foreground: var(--void);
+ --destructive: var(--static);
+ --destructive-foreground: #ffffff;
+ --border: var(--border);
+ --input: var(--border);
+ --ring: var(--acid);
+ --chart-1: var(--acid);
+ --chart-2: var(--signal);
+ --chart-3: var(--static);
+ --chart-4: var(--ghost);
+ --chart-5: var(--subtle);
+ --sidebar: var(--surface);
+ --sidebar-foreground: var(--text);
+ --sidebar-primary: var(--acid);
+ --sidebar-primary-foreground: var(--void);
+ --sidebar-accent: var(--muted);
+ --sidebar-accent-foreground: var(--acid);
+ --sidebar-border: var(--border);
+ --sidebar-ring: var(--acid);
- --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-2xl: 0px 4px 12px 0px rgb(0 0 0 / 0.6);
+ --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-2xl: 0px 4px 12px 0px rgb(0 0 0 / 0.6);
}
@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --color-card: var(--card);
- --color-card-foreground: var(--card-foreground);
- --color-popover: var(--popover);
- --color-popover-foreground: var(--popover-foreground);
- --color-primary: var(--primary);
- --color-primary-foreground: var(--primary-foreground);
- --color-secondary: var(--secondary);
- --color-secondary-foreground: var(--secondary-foreground);
- --color-muted: var(--muted);
- --color-muted-foreground: var(--muted-foreground);
- --color-accent: var(--accent);
- --color-accent-foreground: var(--accent-foreground);
- --color-destructive: var(--destructive);
- --color-destructive-foreground: var(--destructive-foreground);
- --color-border: var(--border);
- --color-input: var(--input);
- --color-ring: var(--ring);
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
- --color-sidebar: var(--sidebar);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
- /* Brand palette — use as bg-acid, text-signal, etc. */
- --color-acid: var(--acid);
- --color-static: var(--static);
- --color-ghost: var(--ghost);
- --color-void: var(--void);
- --color-signal: var(--signal);
- --color-surface: var(--surface);
- --color-dim: var(--dim);
- --color-subtle: var(--subtle);
+ /* Brand palette — use as bg-acid, text-signal, etc. */
+ --color-acid: var(--acid);
+ --color-static: var(--static);
+ --color-ghost: var(--ghost);
+ --color-void: var(--void);
+ --color-signal: var(--signal);
+ --color-surface: var(--surface);
+ --color-dim: var(--dim);
+ --color-subtle: var(--subtle);
- --font-sans: var(--font-sans);
- --font-mono: var(--font-mono);
- --font-display: var(--font-display);
+ --font-sans: var(--font-sans);
+ --font-mono: var(--font-mono);
+ --font-display: var(--font-display);
- --radius-sm: calc(var(--radius) - 2px);
- --radius-md: var(--radius);
- --radius-lg: calc(var(--radius) + 2px);
- --radius-xl: calc(var(--radius) + 4px);
+ --radius-sm: calc(var(--radius) - 2px);
+ --radius-md: var(--radius);
+ --radius-lg: calc(var(--radius) + 2px);
+ --radius-xl: calc(var(--radius) + 4px);
- --shadow-2xs: var(--shadow-2xs);
- --shadow-xs: var(--shadow-xs);
- --shadow-sm: var(--shadow-sm);
- --shadow: var(--shadow);
- --shadow-md: var(--shadow-md);
- --shadow-lg: var(--shadow-lg);
- --shadow-xl: var(--shadow-xl);
- --shadow-2xl: var(--shadow-2xl);
+ --shadow-2xs: var(--shadow-2xs);
+ --shadow-xs: var(--shadow-xs);
+ --shadow-sm: var(--shadow-sm);
+ --shadow: var(--shadow);
+ --shadow-md: var(--shadow-md);
+ --shadow-lg: var(--shadow-lg);
+ --shadow-xl: var(--shadow-xl);
+ --shadow-2xl: var(--shadow-2xl);
- --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
- --tracking-tight: calc(var(--tracking-normal) - 0.025em);
- --tracking-normal: var(--tracking-normal);
- --tracking-wide: calc(var(--tracking-normal) + 0.025em);
- --tracking-wider: calc(var(--tracking-normal) + 0.05em);
- --tracking-widest: calc(var(--tracking-normal) + 0.1em);
+ --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
+ --tracking-tight: calc(var(--tracking-normal) - 0.025em);
+ --tracking-normal: var(--tracking-normal);
+ --tracking-wide: calc(var(--tracking-normal) + 0.025em);
+ --tracking-wider: calc(var(--tracking-normal) + 0.05em);
+ --tracking-widest: calc(var(--tracking-normal) + 0.1em);
}
@layer base {
- * {
- @apply border-border outline-ring/50;
- }
+ * {
+ @apply border-border outline-ring/50;
+ }
- body {
- @apply bg-background text-foreground font-sans antialiased;
- letter-spacing: var(--tracking-normal);
- }
+ body {
+ @apply bg-background text-foreground font-sans antialiased;
+ letter-spacing: var(--tracking-normal);
+ }
- /* Section labels — Space Mono, uppercase, letter-spacing */
- .section-label {
- @apply font-mono text-[10px] text-muted-foreground uppercase tracking-[0.2em];
- }
+ /* Section labels — Space Mono, uppercase, letter-spacing */
+ .section-label {
+ @apply font-mono text-[10px] text-muted-foreground uppercase tracking-[0.2em];
+ }
- /* Display typography — Bebas Neue for headings */
- .font-display {
- font-family: var(--font-display), sans-serif;
- letter-spacing: 0.04em;
- }
-}
\ No newline at end of file
+ /* Display typography — Bebas Neue for headings */
+ .font-display {
+ font-family: var(--font-display), sans-serif;
+ letter-spacing: 0.04em;
+ }
+}
diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx
index 1252874..88411f4 100644
--- a/src/components/login-form.tsx
+++ b/src/components/login-form.tsx
@@ -1,95 +1,95 @@
-import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
} from "@/components/ui/card"
import {
- Field,
- FieldDescription,
- FieldGroup,
- FieldLabel,
- FieldSeparator,
+ Field,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
+import { cn } from "@/lib/utils"
export function LoginForm({
- className,
- ...props
+ className,
+ ...props
}: React.ComponentProps<"div">) {
- return (
-
-
-
- Welcome back
-
- Login with your Apple or Google account
-
-
-
-
-
-
-
- By clicking continue, you agree to our Terms of Service{" "}
- and Privacy Policy.
-
-
- )
+ return (
+
+
+
+ Welcome back
+
+ Login with your Apple or Google account
+
+
+
+
+
+
+
+ By clicking continue, you agree to our Terms of Service{" "}
+ and Privacy Policy.
+
+