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_SECURE=
EMAIL_USER= EMAIL_USER=
EMAIL_PASSWORD= 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. - - [ ] — 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. - - [ ] — Two servers can fetch posts, follows and other data from their users, including DMs.
- **Phase 2** — Server trust scoring and a public vouch ledger. - **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 3** — Opt-in relay network for censorship resistance.
- **Phase 4** — End-to-end encryption via TBD. - **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 ## Author
**Marcello Brito** (Tocka) — [tockanest.com](https://tockanest.com) **Marcello Brito** (Tocka) — [tockanest.com](https://tockanest.com)

View file

@ -20,14 +20,12 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"framer-motion": "^12.35.2", "framer-motion": "^12.35.2",
"http-signature": "^1.4.0",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"minio": "^8.0.7", "minio": "^8.0.7",
"nanostores": "^1.1.1", "nanostores": "^1.1.1",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-forge": "^1.3.3",
"nodemailer": "^8.0.2", "nodemailer": "^8.0.2",
"pg": "^8.20.0", "pg": "^8.20.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@ -38,6 +36,8 @@
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"zod": "^4.3.6", "zod": "^4.3.6",
}, },
"devDependencies": { "devDependencies": {
@ -45,8 +45,7 @@
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"@types/bun": "^1.3.10", "@types/bun": "^1.3.10",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^25.3.5", "@types/node": "^25.4.0",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@ -56,7 +55,7 @@
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"react-email": "5.2.9", "react-email": "5.2.9",
"shadcn": "^4.0.2", "shadcn": "^4.0.5",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"tw-animate-css": "^1.4.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": ["@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/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=="], "@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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debounce": ["debounce@2.2.0", "", {}, "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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-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-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], "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=="], "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=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], "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-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-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=="], "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
@ -1446,7 +1417,7 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "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=="], "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=="], "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=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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/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/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=="], "@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/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/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=="], "@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],

View file

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

View file

@ -43,14 +43,12 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"framer-motion": "^12.35.2", "framer-motion": "^12.35.2",
"http-signature": "^1.4.0",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"minio": "^8.0.7", "minio": "^8.0.7",
"nanostores": "^1.1.1", "nanostores": "^1.1.1",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-forge": "^1.3.3",
"nodemailer": "^8.0.2", "nodemailer": "^8.0.2",
"pg": "^8.20.0", "pg": "^8.20.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@ -61,6 +59,8 @@
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@ -69,7 +69,6 @@
"@types/bun": "^1.3.10", "@types/bun": "^1.3.10",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^25.4.0", "@types/node": "^25.4.0",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@ -79,7 +78,7 @@
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"react-email": "5.2.9", "react-email": "5.2.9",
"shadcn": "^4.0.3", "shadcn": "^4.0.5",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"tw-animate-css": "^1.4.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 db from "@/lib/db";
import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema"; 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 createDebug from "debug";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server"; 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). * Terminology: SA = this server (Server A), SB = the server rotating its keys (Server B).
* *
* Full rotation flow: * Full rotation flow:
* 1. SB generates a new keypair. It keeps the old private key accessible until rotation is complete. * 1. SB generates new Ed25519 + X25519 keypairs.
* 2. SB sends { url, newPublicKey } to SA's /discover/rotate/init. * 2. SB sends { url, newSigningPublicKey, newEncryptionPublicKey } to SA's /discover/rotate/init.
* 3. SA issues two independent challenges and returns them to SB: * 3. SA issues 4 challenges:
* - oldKeyChallenge: a random token encrypted with SB's CURRENT (old) public key. * - signingOldChallenge: plaintext nonce (SB signs with old Ed25519 key)
* - newKeyChallenge: a random token encrypted with SB's NEW public key. * - signingNewChallenge: plaintext nonce (SB signs with new Ed25519 key)
* 4. SB decrypts both challenges using the respective private keys: * - encryptionOldChallenge: nonce encrypted with SB's current X25519 key
* - oldKeyChallenge decrypted with old private key oldPlaintext * - encryptionNewChallenge: nonce encrypted with SB's new X25519 key
* - newKeyChallenge decrypted with new private key newPlaintext * 4. SB solves all 4 challenges:
* 5. SB fetches SA's public key from /discover, then re-encrypts both plaintexts with it: * - Signs the signing challenges with respective Ed25519 keys
* - signedOldChallenge = encrypt(oldPlaintext, SA.publicKey) * - Decrypts the encryption challenges with respective X25519 keys
* - signedNewChallenge = encrypt(newPlaintext, SA.publicKey) * 5. SB fetches SA's /discover to get SA's X25519 public key, then encrypts
* 6. SB sends { serverUrl, signedOldChallenge, signedNewChallenge } to this route. * all 4 proof values into a single EncryptedEnvelope using SA's X25519 key.
* 7. SA decrypts both with its own private key and compares to the stored tokens. * 6. SA decrypts the envelope and verifies all 4 proofs.
* - If either mismatches: decrement attemptsLeft; blacklist server at 0 attempts.
* - If both match: update serverRegistry with newPublicKey and delete the challenge.
* *
* What each check proves: * What each check proves:
* - signedOldChallenge match SB holds the old private key (identity proof: "they are who they say they are") * - signingOldSignature: SB holds the old Ed25519 private key (identity proof)
* - signedNewChallenge match SB holds the new private key (ownership proof: "they own the key they want to rotate to") * - signingNewSignature: SB holds the new Ed25519 private key (ownership proof)
* - re-encryption with SA's public key → SB fetched SA's identity from /discover * - encryptionOldPlaintext: SB holds the old X25519 private key (encryption identity proof)
* * - encryptionNewPlaintext: SB holds the new X25519 private key (encryption ownership proof)
* TODO: on success, announce the completed rotation to other known federation peers * - Envelope encrypted with SA's X25519 key: SB fetched SA's /discover (identity binding)
* 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.) * - 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) { export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
@ -44,12 +42,12 @@ export async function POST(request: NextRequest) {
const validated = z.object({ const validated = z.object({
serverUrl: z.url(), serverUrl: z.url(),
// SA decrypted oldKeyChallenge with their OLD private key, envelope: z.object({
// then re-encrypted the plaintext with OUR public key. ephemeralPublicKey: z.string(),
signedOldChallenge: z.string(), iv: z.string(),
// SA decrypted newKeyChallenge with their NEW private key, ciphertext: z.string(),
// then re-encrypted the plaintext with OUR public key. authTag: z.string(),
signedNewChallenge: z.string(), }),
}).safeParse(body); }).safeParse(body);
if (!validated.success) { if (!validated.success) {
@ -57,9 +55,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: validated.error.message }, { status: 400 }); 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); 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) => { return await db.transaction(async (tx) => {
const [challenge] = await tx.select().from(rotateChallengeTokens) const [challenge] = await tx.select().from(rotateChallengeTokens)
.where(eq(rotateChallengeTokens.serverUrl, validated.data.serverUrl)) .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 }); 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); debug("POST /discover/rotate/confirm %d attempt(s) left, decrypting envelope", challenge.attemptsLeft);
let decryptedOld: string;
let decryptedNew: string; const ownEncryptionSecretKey = new Uint8Array(
Buffer.from(process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY!, "base64"),
);
let proofs: {
signingOldSignature: string;
signingNewSignature: string;
encryptionOldPlaintext: string;
encryptionNewPlaintext: string;
};
try { try {
decryptedOld = decryptPayload(validated.data.signedOldChallenge, process.env.FEDERATION_PRIVATE_KEY!); const decrypted = decryptPayload(validated.data.envelope, ownEncryptionSecretKey);
decryptedNew = decryptPayload(validated.data.signedNewChallenge, process.env.FEDERATION_PRIVATE_KEY!); proofs = JSON.parse(decrypted);
} catch { } 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({ await tx.update(rotateChallengeTokens).set({
attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`, attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`,
}).where(eq(rotateChallengeTokens.id, challenge.id)) }).where(eq(rotateChallengeTokens.id, challenge.id));
return NextResponse.json({ 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 }); }, { status: 400 });
} }
if (decryptedOld !== challenge.oldKeyToken || decryptedNew !== challenge.newKeyToken) { const [server] = await tx.select().from(serverRegistry)
debug("POST /discover/rotate/confirm token mismatch (old=%s, new=%s), decrementing attempts", .where(eq(serverRegistry.url, challenge.serverUrl));
decryptedOld === challenge.oldKeyToken ? "ok" : "MISMATCH",
decryptedNew === challenge.newKeyToken ? "ok" : "MISMATCH", 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({ await tx.update(rotateChallengeTokens).set({
attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`, attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`,
}).where(eq(rotateChallengeTokens.id, challenge.id)); }).where(eq(rotateChallengeTokens.id, challenge.id));
return NextResponse.json({ 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 }); }, { 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({ await tx.update(serverRegistry).set({
publicKey: challenge.newPublicKey, publicKey: challenge.newSigningPublicKey,
encryptionPublicKey: challenge.newEncryptionPublicKey,
updatedAt: new Date(), updatedAt: new Date(),
}).where(eq(serverRegistry.url, challenge.serverUrl)); }).where(eq(serverRegistry.url, challenge.serverUrl));

View file

@ -1,42 +1,47 @@
import db from "@/lib/db"; 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 { encryptPayload } from "@/lib/federation/keytools";
import createDebug from "debug"; import createDebug from "debug";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import forge from "node-forge";
import { z } from "zod"; import { z } from "zod";
const debug = createDebug("app:discover:rotate:init"); 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 { try {
const pub = forge.pki.publicKeyFromPem(key); const decoded = Buffer.from(key, "base64");
return pub.n.bitLength() >= 4096; return decoded.length === expectedBytes;
} catch { } catch {
return false; return false;
} }
}, { message: "Invalid public key" }); }
const schema = z.object({ const schema = z.object({
url: z.url(), 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. * Initializes a key rotation challenge for a server.
* *
* This route is used to initiate the key rotation process. It will issue two independent challenges: * Issues 4 independent challenges:
* - oldKeyChallenge: a random token encrypted with the server's current public key. * - signingOldChallenge: plaintext nonce (SB signs with old Ed25519 key)
* - newKeyChallenge: a random token encrypted with the server's new public 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. * Challenges expire in 5 minutes. SB confirms via /discover/rotate/confirm.
*
* 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.
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
@ -48,6 +53,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: validated.error.message }, { status: 400 }); 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); 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())); const server = await db.select().from(serverRegistry).where(eq(serverRegistry.url, validated.data.url.toString()));
if (server.length === 0) { 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 }); return NextResponse.json({ error: "Server not found, please register your server first." }, { status: 404 });
} }
if (server[0].publicKey === validated.data.newPublicKey) { if (
debug("POST /discover/rotate/init new key is identical to current key, rejecting"); server[0].publicKey === validated.data.newSigningPublicKey &&
return NextResponse.json({ error: "Your server is already registered with this public key." }, { status: 400 }); 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) const [existing] = await db.select().from(rotateChallengeTokens)
.where(eq(rotateChallengeTokens.serverUrl, validated.data.url.toString())); .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)); await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, existing.id));
} }
// Issue two independent challenges: const signingOldPlaintext = crypto.randomUUID();
// const signingNewPlaintext = crypto.randomUUID();
// oldKeyChallenge — encrypted with the SA's CURRENT registered public key. const encryptionOldPlaintext = crypto.randomUUID();
// Only the holder of the current private key can decrypt this. const encryptionNewPlaintext = crypto.randomUUID();
// 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();
debug("POST /discover/rotate/init issuing challenges for server %s", validated.data.url); debug("POST /discover/rotate/init issuing 4 challenges for server %s", validated.data.url);
const oldKeyChallenge = encryptPayload(oldKeyPlaintext, server[0].publicKey);
const newKeyChallenge = encryptPayload(newKeyPlaintext, validated.data.newPublicKey); 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({ await db.insert(rotateChallengeTokens).values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
oldKeyToken: oldKeyPlaintext, signingOldToken: signingOldPlaintext,
newKeyToken: newKeyPlaintext, signingNewToken: signingNewPlaintext,
newPublicKey: validated.data.newPublicKey, encryptionOldToken: encryptionOldPlaintext,
encryptionNewToken: encryptionNewPlaintext,
newSigningPublicKey: validated.data.newSigningPublicKey,
newEncryptionPublicKey: validated.data.newEncryptionPublicKey,
serverUrl: validated.data.url.toString(), serverUrl: validated.data.url.toString(),
createdAt: new Date(), createdAt: new Date(),
expiresAt: new Date(Date.now() + 1000 * 60 * 5), expiresAt: new Date(Date.now() + 1000 * 60 * 5),
}); });
debug("POST /discover/rotate/init challenges issued, expires in 5 minutes"); 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 db from "@/lib/db";
import { serverRegistry } from "@/lib/db/schema"; 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 { assertSafeUrl, UrlGuardError } from "@/lib/federation/url-guard";
import createDebug from "debug"; import createDebug from "debug";
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import forge from "node-forge";
import { z } from "zod"; import { z } from "zod";
const debug = createDebug("app:discover"); 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() { export async function GET() {
debug("GET /discover fetching healthy peers"); debug("GET /discover fetching healthy peers");
const peers = await db.select({ const peers = await db.select({
@ -19,17 +79,19 @@ export async function GET() {
debug("GET /discover found %d peer(s)", peers.length); debug("GET /discover found %d peer(s)", peers.length);
return NextResponse.json({ return NextResponse.json({
url: process.env.BETTER_AUTH_URL, url: process.env.BETTER_AUTH_URL!,
publicKey: process.env.FEDERATION_PUBLIC_KEY, 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({ return await db.insert(serverRegistry).values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
url: url, url,
publicKey: publicKey, publicKey,
encryptionPublicKey,
lastSeen: new Date(), lastSeen: new Date(),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@ -37,56 +99,6 @@ async function upsertServer(url: string, publicKey: string) {
}).onConflictDoNothing(); }).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>) { async function discoverServer(validated: z.infer<typeof discoverSchema>) {
debug("DISCOVER looking up server by public key"); debug("DISCOVER looking up server by public key");
const server = await db.select().from(serverRegistry).where(eq(serverRegistry.publicKey, validated.publicKey)); 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 { try {
assertSafeUrl(server[0].url); if (process.env.NODE_ENV !== "development") {
assertSafeUrl(server[0].url);
}
} catch (err) { } catch (err) {
debug("DISCOVER stored URL failed SSRF check: %s", server[0].url); debug("DISCOVER stored URL failed SSRF check: %s", server[0].url);
if (err instanceof UrlGuardError) { if (err instanceof UrlGuardError) {
@ -126,7 +140,9 @@ async function discoverServer(validated: z.infer<typeof discoverSchema>) {
async function registerServer(validated: z.infer<typeof registerSchema>) { async function registerServer(validated: z.infer<typeof registerSchema>) {
try { try {
assertSafeUrl(validated.url); if (process.env.NODE_ENV !== "development") {
assertSafeUrl(validated.url);
}
} catch (err) { } catch (err) {
debug("REGISTER URL failed SSRF check: %s", validated.url); debug("REGISTER URL failed SSRF check: %s", validated.url);
if (err instanceof UrlGuardError) { 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); debug("REGISTER fetching /discover from %s to validate server", validated.url);
let response: { publicKey?: string }; let response: { publicKey?: string; encryptionPublicKey?: string };
try { try {
response = await (await fetch(validated.url + "/discover")).json(); response = await (await fetch(validated.url + "/discover")).json();
} catch (err) { } 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 }); return NextResponse.json({ error: "Failed to reach the server" }, { status: 502 });
} }
if (!response.publicKey) { if (!response.publicKey || !response.encryptionPublicKey) {
debug("REGISTER remote server returned no public key"); debug("REGISTER remote server returned incomplete keys");
return NextResponse.json({ error: "Invalid server" }, { status: 400 }); return NextResponse.json({ error: "Invalid server" }, { status: 400 });
} else if (response.publicKey !== validated.publicKey) { } else if (response.publicKey !== validated.publicKey || response.encryptionPublicKey !== validated.encryptionPublicKey) {
debug("REGISTER public key mismatch: provided vs fetched"); debug("REGISTER key mismatch: provided vs fetched");
debug("REGISTER provided public key: %s", validated.publicKey); return NextResponse.json({ error: "Public keys do not match the ones reported by the server" }, { status: 400 });
debug("REGISTER fetched public key: %s", response.publicKey);
return NextResponse.json({ error: "Invalid public key" }, { status: 400 });
} }
debug("REGISTER checking for existing registration at %s", validated.url); debug("REGISTER checking for existing registration at %s", validated.url);
const server = await db.select().from(serverRegistry).where(eq(serverRegistry.url, validated.url.toString())); const server = await db.select().from(serverRegistry).where(eq(serverRegistry.url, validated.url.toString()));
if (server.length > 0 && server[0].publicKey !== validated.publicKey) { if (server.length > 0 && server[0].publicKey !== validated.publicKey) {
debug("REGISTER key mismatch against existing registration"); 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); 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"); debug("REGISTER server registered successfully");
return NextResponse.json({ return NextResponse.json({
message: "Server registered successfully", echo: { message: "Server registered successfully", echo: {
url: process.env.NEXT_PUBLIC_APP_URL, url: process.env.BETTER_AUTH_URL!,
publicKey: process.env.FEDERATION_PUBLIC_KEY, publicKey: process.env.FEDERATION_PUBLIC_KEY,
encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY,
} }
}); });
} }

View file

@ -6,206 +6,216 @@
/* SiPher brand palette — Silent Whisper */ /* SiPher brand palette — Silent Whisper */
:root { :root {
/* Brand tokens */ /* Brand tokens */
--black: #080808; --black: #080808;
--surface: #0f0f0f; --surface: #0f0f0f;
--card: #141414; --card: #141414;
--border: #1f1f1f; --border: #1f1f1f;
--muted: #2a2a2a; --muted: #2a2a2a;
--dim: #555; --dim: #555;
--text: #e8e8e8; --text: #e8e8e8;
--subtle: #888; --subtle: #888;
--acid: #c8f000; --acid: #c8f000;
--static: #ff3c3c; --static: #ff3c3c;
--ghost: #9b9b9b; --ghost: #9b9b9b;
--void: #080808; --void: #080808;
--signal: #00e5ff; --signal: #00e5ff;
/* Light mode — minimal variant for system preference */ /* Light mode — minimal variant for system preference */
--background: #f5f5f5; --background: #f5f5f5;
--foreground: #0a0a0a; --foreground: #0a0a0a;
--card: #ffffff; --card: #ffffff;
--card-foreground: #0a0a0a; --card-foreground: #0a0a0a;
--popover: #ffffff; --popover: #ffffff;
--popover-foreground: #0a0a0a; --popover-foreground: #0a0a0a;
--primary: #8a9a00; --primary: #8a9a00;
--primary-foreground: #080808; --primary-foreground: #080808;
--secondary: #e8e8e8; --secondary: #e8e8e8;
--secondary-foreground: #0a0a0a; --secondary-foreground: #0a0a0a;
--muted: #e5e5e5; --muted: #e5e5e5;
--muted-foreground: #555; --muted-foreground: #555;
--accent: #c8f000; --accent: #c8f000;
--accent-foreground: #080808; --accent-foreground: #080808;
--destructive: #ff3c3c; --destructive: #ff3c3c;
--destructive-foreground: #ffffff; --destructive-foreground: #ffffff;
--border: #e5e5e5; --border: #e5e5e5;
--input: #e5e5e5; --input: #e5e5e5;
--ring: #8a9a00; --ring: #8a9a00;
--chart-1: #8a9a00; --chart-1: #8a9a00;
--chart-2: #c8f000; --chart-2: #c8f000;
--chart-3: #00e5ff; --chart-3: #00e5ff;
--chart-4: #9b9b9b; --chart-4: #9b9b9b;
--chart-5: #555; --chart-5: #555;
--sidebar: #fafafa; --sidebar: #fafafa;
--sidebar-foreground: #0a0a0a; --sidebar-foreground: #0a0a0a;
--sidebar-primary: #8a9a00; --sidebar-primary: #8a9a00;
--sidebar-primary-foreground: #080808; --sidebar-primary-foreground: #080808;
--sidebar-accent: #e5e5e5; --sidebar-accent: #e5e5e5;
--sidebar-accent-foreground: #0a0a0a; --sidebar-accent-foreground: #0a0a0a;
--sidebar-border: #e5e5e5; --sidebar-border: #e5e5e5;
--sidebar-ring: #8a9a00; --sidebar-ring: #8a9a00;
--font-sans: "DM Sans", sans-serif; --font-sans: "DM Sans", sans-serif;
--font-mono: "Space Mono", monospace; --font-mono: "Space Mono", monospace;
--font-display: "Bebas Neue", sans-serif; --font-display: "Bebas Neue", sans-serif;
--radius: 0.2rem; --radius: 0.2rem;
--tracking-normal: -0.01em; --tracking-normal: -0.01em;
--spacing: 0.25rem; --spacing: 0.25rem;
--shadow-2xs: 0px 2px 4px 0px rgb(0 0 0 / 0.04); --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-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-sm:
--shadow: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 1px 2px -1px rgb(0 0 0 / 0.08); 0px 2px 8px 0px rgb(0 0 0 / 0.06), 0px 1px 2px -1px rgb(0 0 0 / 0.06);
--shadow-md: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 2px 4px -1px rgb(0 0 0 / 0.08); --shadow:
--shadow-lg: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 4px 6px -1px rgb(0 0 0 / 0.08); 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 1px 2px -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-md:
--shadow-2xl: 0px 2px 8px 0px rgb(0 0 0 / 0.15); 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 { .dark {
/* SiPher dark theme — primary brand identity */ /* SiPher dark theme — primary brand identity */
--background: var(--black); --background: var(--black);
--foreground: var(--text); --foreground: var(--text);
--card: #141414; --card: #141414;
--card-foreground: var(--text); --card-foreground: var(--text);
--popover: var(--surface); --popover: var(--surface);
--popover-foreground: var(--text); --popover-foreground: var(--text);
--primary: var(--acid); --primary: var(--acid);
--primary-foreground: var(--void); --primary-foreground: var(--void);
--secondary: var(--muted); --secondary: var(--muted);
--secondary-foreground: var(--text); --secondary-foreground: var(--text);
--muted: var(--muted); --muted: var(--muted);
--muted-foreground: var(--subtle); --muted-foreground: var(--subtle);
--accent: var(--acid); --accent: var(--acid);
--accent-foreground: var(--void); --accent-foreground: var(--void);
--destructive: var(--static); --destructive: var(--static);
--destructive-foreground: #ffffff; --destructive-foreground: #ffffff;
--border: var(--border); --border: var(--border);
--input: var(--border); --input: var(--border);
--ring: var(--acid); --ring: var(--acid);
--chart-1: var(--acid); --chart-1: var(--acid);
--chart-2: var(--signal); --chart-2: var(--signal);
--chart-3: var(--static); --chart-3: var(--static);
--chart-4: var(--ghost); --chart-4: var(--ghost);
--chart-5: var(--subtle); --chart-5: var(--subtle);
--sidebar: var(--surface); --sidebar: var(--surface);
--sidebar-foreground: var(--text); --sidebar-foreground: var(--text);
--sidebar-primary: var(--acid); --sidebar-primary: var(--acid);
--sidebar-primary-foreground: var(--void); --sidebar-primary-foreground: var(--void);
--sidebar-accent: var(--muted); --sidebar-accent: var(--muted);
--sidebar-accent-foreground: var(--acid); --sidebar-accent-foreground: var(--acid);
--sidebar-border: var(--border); --sidebar-border: var(--border);
--sidebar-ring: var(--acid); --sidebar-ring: var(--acid);
--shadow-2xs: 0px 4px 12px 0px rgb(0 0 0 / 0.25); --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-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-sm:
--shadow: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 1px 2px -1px rgb(0 0 0 / 0.4); 0px 4px 12px 0px rgb(0 0 0 / 0.35), 0px 1px 2px -1px rgb(0 0 0 / 0.35);
--shadow-md: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 2px 4px -1px rgb(0 0 0 / 0.4); --shadow:
--shadow-lg: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 4px 6px -1px rgb(0 0 0 / 0.4); 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 1px 2px -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-md:
--shadow-2xl: 0px 4px 12px 0px rgb(0 0 0 / 0.6); 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 { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
/* Brand palette — use as bg-acid, text-signal, etc. */ /* Brand palette — use as bg-acid, text-signal, etc. */
--color-acid: var(--acid); --color-acid: var(--acid);
--color-static: var(--static); --color-static: var(--static);
--color-ghost: var(--ghost); --color-ghost: var(--ghost);
--color-void: var(--void); --color-void: var(--void);
--color-signal: var(--signal); --color-signal: var(--signal);
--color-surface: var(--surface); --color-surface: var(--surface);
--color-dim: var(--dim); --color-dim: var(--dim);
--color-subtle: var(--subtle); --color-subtle: var(--subtle);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-mono); --font-mono: var(--font-mono);
--font-display: var(--font-display); --font-display: var(--font-display);
--radius-sm: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius); --radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px); --radius-lg: calc(var(--radius) + 2px);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs); --shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs); --shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm); --shadow-sm: var(--shadow-sm);
--shadow: var(--shadow); --shadow: var(--shadow);
--shadow-md: var(--shadow-md); --shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg); --shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl); --shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl); --shadow-2xl: var(--shadow-2xl);
--tracking-tighter: calc(var(--tracking-normal) - 0.05em); --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em); --tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-normal: var(--tracking-normal); --tracking-normal: var(--tracking-normal);
--tracking-wide: calc(var(--tracking-normal) + 0.025em); --tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em); --tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em); --tracking-widest: calc(var(--tracking-normal) + 0.1em);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground font-sans antialiased; @apply bg-background text-foreground font-sans antialiased;
letter-spacing: var(--tracking-normal); letter-spacing: var(--tracking-normal);
} }
/* Section labels — Space Mono, uppercase, letter-spacing */ /* Section labels — Space Mono, uppercase, letter-spacing */
.section-label { .section-label {
@apply font-mono text-[10px] text-muted-foreground uppercase tracking-[0.2em]; @apply font-mono text-[10px] text-muted-foreground uppercase tracking-[0.2em];
} }
/* Display typography — Bebas Neue for headings */ /* Display typography — Bebas Neue for headings */
.font-display { .font-display {
font-family: var(--font-display), sans-serif; font-family: var(--font-display), sans-serif;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
} }

View file

@ -1,95 +1,95 @@
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import { import {
Field, Field,
FieldDescription, FieldDescription,
FieldGroup, FieldGroup,
FieldLabel, FieldLabel,
FieldSeparator, FieldSeparator,
} from "@/components/ui/field" } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
export function LoginForm({ export function LoginForm({
className, className,
...props ...props
}: React.ComponentProps<"div">) { }: React.ComponentProps<"div">) {
return ( return (
<div className={cn("flex flex-col gap-6", className)} {...props}> <div className={cn("flex flex-col gap-6", className)} {...props}>
<Card> <Card>
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle className="text-xl">Welcome back</CardTitle> <CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription> <CardDescription>
Login with your Apple or Google account Login with your Apple or Google account
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form> <form>
<FieldGroup> <FieldGroup>
<Field> <Field>
<Button variant="outline" type="button"> <Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path <path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
Login with Apple Login with Apple
</Button> </Button>
<Button variant="outline" type="button"> <Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path <path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
Login with Google Login with Google
</Button> </Button>
</Field> </Field>
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card"> <FieldSeparator className="*:data-[slot=field-separator-content]:bg-card">
Or continue with Or continue with
</FieldSeparator> </FieldSeparator>
<Field> <Field>
<FieldLabel htmlFor="email">Email</FieldLabel> <FieldLabel htmlFor="email">Email</FieldLabel>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="m@example.com" placeholder="m@example.com"
required required
/> />
</Field> </Field>
<Field> <Field>
<div className="flex items-center"> <div className="flex items-center">
<FieldLabel htmlFor="password">Password</FieldLabel> <FieldLabel htmlFor="password">Password</FieldLabel>
<a <a
href="#" href="#"
className="ml-auto text-sm underline-offset-4 hover:underline" className="ml-auto text-sm underline-offset-4 hover:underline"
> >
Forgot your password? Forgot your password?
</a> </a>
</div> </div>
<Input id="password" type="password" required /> <Input id="password" type="password" required />
</Field> </Field>
<Field> <Field>
<Button type="submit">Login</Button> <Button type="submit">Login</Button>
<FieldDescription className="text-center"> <FieldDescription className="text-center">
Don&apos;t have an account? <a href="#">Sign up</a> Don&apos;t have an account? <a href="#">Sign up</a>
</FieldDescription> </FieldDescription>
</Field> </Field>
</FieldGroup> </FieldGroup>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
<FieldDescription className="px-6 text-center"> <FieldDescription className="px-6 text-center">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "} By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>. and <a href="#">Privacy Policy</a>.
</FieldDescription> </FieldDescription>
</div> </div>
) )
} }

View file

@ -1,64 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui" import { Slot } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9", icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8", "icon-sm": "size-8",
"icon-lg": "size-10", "icon-lg": "size-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} }
) )
function Button({ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button"
return ( return (
<Comp <Comp
data-slot="button" data-slot="button"
data-variant={variant} data-variant={variant}
data-size={size} data-size={size}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) )
} }
export { Button, buttonVariants } export { Button, buttonVariants }

View file

@ -3,90 +3,85 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", "flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-title" data-slot="card-title"
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) )
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) )
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-content" data-slot="card-content"
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) )
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) )
} }
export { export {
Card, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
} }

View file

@ -1,158 +1,159 @@
"use client" "use client"
import * as React from "react"
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui" import { Dialog as DialogPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
} }
function DialogOverlay({ function DialogOverlay({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function DialogContent({ function DialogContent({
className, className,
children, children,
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none 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 sm:max-w-lg", "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none 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 sm:max-w-lg",
className className
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-close" data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) )
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="dialog-header" data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) )
} }
function DialogFooter({ function DialogFooter({
className, className,
showCloseButton = false, showCloseButton = false,
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean showCloseButton?: boolean
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close asChild> <DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button> <Button variant="outline">Close</Button>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
) )
} }
function DialogTitle({ function DialogTitle({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) { }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) )
} }
function DialogDescription({ function DialogDescription({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) { }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) )
} }
export { export {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger
} }

View file

@ -1,257 +1,249 @@
"use client" "use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
) )
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return ( return (
<DropdownMenuPrimitive.Trigger <DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
{...props} {...props}
/> />
) )
} }
function DropdownMenuContent({ function DropdownMenuContent({
className, className,
sideOffset = 4, sideOffset = 4,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return ( return (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) )
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
) )
} }
function DropdownMenuItem({ function DropdownMenuItem({
className, className,
inset, inset,
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean
variant?: "default" | "destructive" variant?: "default" | "destructive"
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item" data-slot="dropdown-menu-item"
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( 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 className
)} )}
{...props} {...props}
/> />
) )
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
className, className,
children, children,
checked, checked,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return ( return (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( 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 className
)} )}
checked={checked} checked={checked}
{...props} {...props}
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) )
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return ( return (
<DropdownMenuPrimitive.RadioGroup <DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
) )
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return ( return (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( 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 className
)} )}
{...props} {...props}
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" /> <CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) )
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
className, className,
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn( 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 className
)} )}
{...props} {...props}
/> />
) )
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
className, className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)} className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} {...props}
/> />
) )
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
className, className,
...props ...props
}: React.ComponentProps<"span">) { }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground", "ml-auto text-xs tracking-widest text-muted-foreground",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
className, className,
inset, inset,
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( 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 className
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) )
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
className, className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( 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 className
)} )}
{...props} {...props}
/> />
) )
} }
export { export {
DropdownMenu, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent,
DropdownMenuPortal, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup,
DropdownMenuTrigger, DropdownMenuRadioItem,
DropdownMenuContent, DropdownMenuSeparator,
DropdownMenuGroup, DropdownMenuShortcut,
DropdownMenuLabel, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} }

View file

@ -1,248 +1,244 @@
"use client" "use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { useMemo } from "react"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return ( return (
<fieldset <fieldset
data-slot="field-set" data-slot="field-set"
className={cn( className={cn(
"flex flex-col gap-6", "flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function FieldLegend({ function FieldLegend({
className, className,
variant = "legend", variant = "legend",
...props ...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return ( return (
<legend <legend
data-slot="field-legend" data-slot="field-legend"
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"mb-3 font-medium", "mb-3 font-medium",
"data-[variant=legend]:text-base", "data-[variant=legend]:text-base",
"data-[variant=label]:text-sm", "data-[variant=label]:text-sm",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="field-group" data-slot="field-group"
className={cn( 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 className
)} )}
{...props} {...props}
/> />
) )
} }
const fieldVariants = cva( const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive", "group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{ {
variants: { variants: {
orientation: { orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], vertical: ["flex-col *:w-full [&>.sr-only]:w-auto"],
horizontal: [ horizontal: [
"flex-row items-center", "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", "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
], ],
responsive: [ responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-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:*: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", "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
], ],
}, },
}, },
defaultVariants: { defaultVariants: {
orientation: "vertical", orientation: "vertical",
}, },
} }
) )
function Field({ function Field({
className, className,
orientation = "vertical", orientation = "vertical",
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return ( return (
<div <div
role="group" role="group"
data-slot="field" data-slot="field"
data-orientation={orientation} data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)} className={cn(fieldVariants({ orientation }), className)}
{...props} {...props}
/> />
) )
} }
function FieldContent({ className, ...props }: React.ComponentProps<"div">) { function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="field-content" data-slot="field-content"
className={cn( className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug", "group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function FieldLabel({ function FieldLabel({
className, className,
...props ...props
}: React.ComponentProps<typeof Label>) { }: React.ComponentProps<typeof Label>) {
return ( return (
<Label <Label
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", "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", "has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50", "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return ( return (
<p <p
data-slot="field-description" data-slot="field-description"
className={cn( 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", "last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", "[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function FieldSeparator({ function FieldSeparator({
children, children,
className, className,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
children?: React.ReactNode children?: React.ReactNode
}) { }) {
return ( return (
<div <div
data-slot="field-separator" data-slot="field-separator"
data-content={!!children} data-content={!!children}
className={cn( className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className className
)} )}
{...props} {...props}
> >
<Separator className="absolute inset-0 top-1/2" /> <Separator className="absolute inset-0 top-1/2" />
{children && ( {children && (
<span <span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground" className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content" data-slot="field-separator-content"
> >
{children} {children}
</span> </span>
)} )}
</div> </div>
) )
} }
function FieldError({ function FieldError({
className, className,
children, children,
errors, errors,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined> errors?: Array<{ message?: string } | undefined>
}) { }) {
const content = useMemo(() => { const content = useMemo(() => {
if (children) { if (children) {
return children return children
} }
if (!errors?.length) { if (!errors?.length) {
return null return null
} }
const uniqueErrors = [ const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(), ...new Map(errors.map((error) => [error?.message, error])).values(),
] ]
if (uniqueErrors?.length == 1) { if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message return uniqueErrors[0]?.message
} }
return ( return (
<ul className="ml-4 flex list-disc flex-col gap-1"> <ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map( {uniqueErrors.map(
(error, index) => (error, index) =>
error?.message && <li key={index}>{error.message}</li> error?.message && <li key={index}>{error.message}</li>
)} )}
</ul> </ul>
) )
}, [children, errors]) }, [children, errors])
if (!content) { if (!content) {
return null return null
} }
return ( return (
<div <div
role="alert" role="alert"
data-slot="field-error" data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)} className={cn("text-sm font-normal text-destructive", className)}
{...props} {...props}
> >
{content} {content}
</div> </div>
) )
} }
export { export {
Field, Field, FieldContent, FieldDescription,
FieldLabel, FieldError,
FieldDescription, FieldGroup, FieldLabel, FieldLegend,
FieldError, FieldSeparator,
FieldGroup, FieldSet, FieldTitle
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
} }

View file

@ -1,167 +1,163 @@
"use client" "use client"
import * as React from "react"
import type { Label as LabelPrimitive } from "radix-ui" import type { Label as LabelPrimitive } from "radix-ui"
import { Slot } from "radix-ui" import { Slot } from "radix-ui"
import * as React from "react"
import { import {
Controller, Controller,
FormProvider, FormProvider,
useFormContext, useFormContext,
useFormState, useFormState,
type ControllerProps, type ControllerProps,
type FieldPath, type FieldPath,
type FieldValues, type FieldValues,
} from "react-hook-form" } from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
const Form = FormProvider const Form = FormProvider
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { > = {
name: TName name: TName
} }
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue {} as FormFieldContextValue
) )
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ >({
...props ...props
}: ControllerProps<TFieldValues, TName>) => { }: ControllerProps<TFieldValues, TName>) => {
return ( return (
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
) )
} }
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext) const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext) const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext() const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name }) const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState) const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) { if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>") throw new Error("useFormField should be used within <FormField>")
} }
const { id } = itemContext const { id } = itemContext
return { return {
id, id,
name: fieldContext.name, name: fieldContext.name,
formItemId: `${id}-form-item`, formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, formMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
} }
} }
type FormItemContextValue = { type FormItemContextValue = {
id: string id: string
} }
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue {} as FormItemContextValue
) )
function FormItem({ className, ...props }: React.ComponentProps<"div">) { function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId() const id = React.useId()
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div <div
data-slot="form-item" data-slot="form-item"
className={cn("grid gap-2", className)} className={cn("grid gap-2", className)}
{...props} {...props}
/> />
</FormItemContext.Provider> </FormItemContext.Provider>
) )
} }
function FormLabel({ function FormLabel({
className, className,
...props ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField() const { error, formItemId } = useFormField()
return ( return (
<Label <Label
data-slot="form-label" data-slot="form-label"
data-error={!!error} data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)} className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) )
} }
function FormControl({ ...props }: React.ComponentProps<typeof Slot.Root>) { function FormControl({ ...props }: React.ComponentProps<typeof Slot.Root>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return ( return (
<Slot.Root <Slot.Root
data-slot="form-control" data-slot="form-control"
id={formItemId} id={formItemId}
aria-describedby={ aria-describedby={
!error !error
? `${formDescriptionId}` ? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}` : `${formDescriptionId} ${formMessageId}`
} }
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
) )
} }
function FormDescription({ className, ...props }: React.ComponentProps<"p">) { function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField()
return ( return (
<p <p
data-slot="form-description" data-slot="form-description"
id={formDescriptionId} id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) )
} }
function FormMessage({ className, ...props }: React.ComponentProps<"p">) { function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children const body = error ? String(error?.message ?? "") : props.children
if (!body) { if (!body) {
return null return null
} }
return ( return (
<p <p
data-slot="form-message" data-slot="form-message"
id={formMessageId} id={formMessageId}
className={cn("text-sm text-destructive", className)} className={cn("text-sm text-destructive", className)}
{...props} {...props}
> >
{body} {body}
</p> </p>
) )
} }
export { export {
useFormField, Form, FormControl,
Form, FormDescription, FormField, FormItem,
FormItem, FormLabel, FormMessage, useFormField
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
} }

View file

@ -3,19 +3,19 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
<input <input
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30", "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className className
)} )}
{...props} {...props}
/> />
) )
} }
export { Input } export { Input }

View file

@ -1,24 +1,24 @@
"use client" "use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui" import { Label as LabelPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Label({ function Label({
className, className,
...props ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className
)} )}
{...props} {...props}
/> />
) )
} }
export { Label } export { Label }

View file

@ -1,28 +1,28 @@
"use client" "use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui" import { Separator as SeparatorPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Separator({ function Separator({
className, className,
orientation = "horizontal", orientation = "horizontal",
decorative = true, decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return ( return (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
data-slot="separator" data-slot="separator"
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className className
)} )}
{...props} {...props}
/> />
) )
} }
export { Separator } export { Separator }

View file

@ -1,40 +1,40 @@
"use client" "use client"
import { import {
CircleCheckIcon, CircleCheckIcon,
InfoIcon, InfoIcon,
Loader2Icon, Loader2Icon,
OctagonXIcon, OctagonXIcon,
TriangleAlertIcon, TriangleAlertIcon,
} from "lucide-react" } from "lucide-react"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme()
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
icons={{ icons={{
success: <CircleCheckIcon className="size-4" />, success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />, info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />, warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />, error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />, loading: <Loader2Icon className="size-4 animate-spin" />,
}} }}
style={ style={
{ {
"--normal-bg": "var(--popover)", "--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)", "--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)", "--normal-border": "var(--border)",
"--border-radius": "var(--radius)", "--border-radius": "var(--radius)",
} as React.CSSProperties } as React.CSSProperties
} }
{...props} {...props}
/> />
) )
} }
export { Toaster } export { Toaster }

View file

@ -12,9 +12,17 @@ import minioClient from "./plugins/server/storage/minio.client";
const isTest = process.env.NODE_ENV === "test"; const isTest = process.env.NODE_ENV === "test";
const emailService: EmailService | undefined = isTest ? undefined : new EmailService(); 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) { 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({ const bAuth = betterAuth({

View file

@ -4,7 +4,7 @@ import { Pool } from "pg";
import * as schema from "./schema"; import * as schema from "./schema";
const pool = new Pool({ const pool = new Pool({
connectionString: process.env.DATABASE_URL!, connectionString: process.env.DATABASE_URL!,
}); });
const db = drizzle({ client: pool, schema }); const db = drizzle({ client: pool, schema });

View file

@ -1,285 +1,289 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import {
pgTable, boolean,
text, index,
timestamp, integer,
boolean, jsonb,
integer, pgTable,
jsonb, text,
index, timestamp,
uniqueIndex, uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
export const user = pgTable("user", { export const user = pgTable("user", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
email: text("email").notNull().unique(), email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(), emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"), image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.defaultNow() .defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
username: text("username").unique(), username: text("username").unique(),
displayUsername: text("display_username"), displayUsername: text("display_username"),
twoFactorEnabled: boolean("two_factor_enabled").default(false), twoFactorEnabled: boolean("two_factor_enabled").default(false),
isPrivate: boolean("is_private").default(false), isPrivate: boolean("is_private").default(false),
}); });
export const session = pgTable( export const session = pgTable(
"session", "session",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(), expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(), token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
ipAddress: text("ip_address"), ipAddress: text("ip_address"),
userAgent: text("user_agent"), userAgent: text("user_agent"),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
}, },
(table) => [index("session_userId_idx").on(table.userId)], (table) => [index("session_userId_idx").on(table.userId)],
); );
export const account = pgTable( export const account = pgTable(
"account", "account",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
accountId: text("account_id").notNull(), accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(), providerId: text("provider_id").notNull(),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"), accessToken: text("access_token"),
refreshToken: text("refresh_token"), refreshToken: text("refresh_token"),
idToken: text("id_token"), idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"), accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"), scope: text("scope"),
password: text("password"), password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
}, },
(table) => [index("account_userId_idx").on(table.userId)], (table) => [index("account_userId_idx").on(table.userId)],
); );
export const verification = pgTable( export const verification = pgTable(
"verification", "verification",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
identifier: text("identifier").notNull(), identifier: text("identifier").notNull(),
value: text("value").notNull(), value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(), expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.defaultNow() .defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
}, },
(table) => [index("verification_identifier_idx").on(table.identifier)], (table) => [index("verification_identifier_idx").on(table.identifier)],
); );
export const twoFactor = pgTable( export const twoFactor = pgTable(
"two_factor", "two_factor",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
secret: text("secret").notNull(), secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(), backupCodes: text("backup_codes").notNull(),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
}, },
(table) => [ (table) => [
index("twoFactor_secret_idx").on(table.secret), index("twoFactor_secret_idx").on(table.secret),
index("twoFactor_userId_idx").on(table.userId), index("twoFactor_userId_idx").on(table.userId),
], ],
); );
export const posts = pgTable("posts", { export const posts = pgTable("posts", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
content: jsonb("content").notNull(), content: jsonb("content").notNull(),
authorId: text("author_id") authorId: text("author_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
published: timestamp("published").notNull(), published: timestamp("published").notNull(),
isLocal: boolean("is_local").default(false).notNull(), isLocal: boolean("is_local").default(false).notNull(),
isPrivate: boolean("is_private").default(false), isPrivate: boolean("is_private").default(false),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
}); });
export const follows = pgTable("follows", { export const follows = pgTable("follows", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
followerId: text("follower_id") followerId: text("follower_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
followingId: text("following_id") followingId: text("following_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
accepted: boolean("accepted").default(false).notNull(), accepted: boolean("accepted").default(false).notNull(),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
}); });
export const deliveryJobs = pgTable("delivery_jobs", { export const deliveryJobs = pgTable("delivery_jobs", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
targetUrl: text("target_url").notNull(), targetUrl: text("target_url").notNull(),
payload: text("payload").notNull(), payload: text("payload").notNull(),
attempts: integer("attempts").default(0).notNull(), attempts: integer("attempts").default(0).notNull(),
lastAttemptedAt: timestamp("last_attempted_at"), lastAttemptedAt: timestamp("last_attempted_at"),
nextAttemptAt: timestamp("next_attempt_at"), nextAttemptAt: timestamp("next_attempt_at"),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
}); });
export const mutes = pgTable("mutes", { export const mutes = pgTable("mutes", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
mutedUserId: text("muted_user_id") mutedUserId: text("muted_user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
}); });
export const blocks = pgTable("blocks", { export const blocks = pgTable("blocks", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
blockerId: text("blocker_id") blockerId: text("blocker_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
blockedUserId: text("blocked_user_id") blockedUserId: text("blocked_user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
}); });
export const serverRegistry = pgTable( export const serverRegistry = pgTable(
"server_registry", "server_registry",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
url: text("url").notNull().unique(), url: text("url").notNull().unique(),
publicKey: text("public_key").notNull().unique(), publicKey: text("public_key").notNull().unique(),
lastSeen: timestamp("last_seen").notNull(), encryptionPublicKey: text("encryption_public_key").notNull().unique(),
createdAt: timestamp("created_at").notNull(), lastSeen: timestamp("last_seen").notNull(),
updatedAt: timestamp("updated_at").notNull(), createdAt: timestamp("created_at").notNull(),
isHealthy: boolean("is_healthy").notNull(), updatedAt: timestamp("updated_at").notNull(),
}, isHealthy: boolean("is_healthy").notNull(),
(table) => [ },
uniqueIndex("serverRegistry_publicKey_uidx").on(table.publicKey), (table) => [
index("serverRegistry_lastSeen_idx").on(table.lastSeen), uniqueIndex("serverRegistry_publicKey_uidx").on(table.publicKey),
], uniqueIndex("serverRegistry_encryptionPublicKey_uidx").on(
table.encryptionPublicKey,
),
index("serverRegistry_lastSeen_idx").on(table.lastSeen),
],
); );
export const rotateChallengeTokens = pgTable( export const rotateChallengeTokens = pgTable(
"rotate_challenge_tokens", "rotate_challenge_tokens",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
oldKeyToken: text("old_key_token").notNull(), signingOldToken: text("signing_old_token").notNull(),
newKeyToken: text("new_key_token").notNull().unique(), signingNewToken: text("signing_new_token").notNull(),
newPublicKey: text("new_public_key").notNull(), encryptionOldToken: text("encryption_old_token").notNull(),
serverUrl: text("server_url").notNull(), encryptionNewToken: text("encryption_new_token").notNull(),
createdAt: timestamp("created_at").notNull(), newSigningPublicKey: text("new_signing_public_key").notNull(),
attemptsLeft: integer("attempts_left").default(3).notNull(), newEncryptionPublicKey: text("new_encryption_public_key").notNull(),
expiresAt: timestamp("expires_at").notNull(), serverUrl: text("server_url").notNull(),
}, createdAt: timestamp("created_at").notNull(),
(table) => [ attemptsLeft: integer("attempts_left").default(3).notNull(),
uniqueIndex("rotateChallengeTokens_newKeyToken_uidx").on(table.newKeyToken), expiresAt: timestamp("expires_at").notNull(),
index("rotateChallengeTokens_serverUrl_idx").on(table.serverUrl), },
], (table) => [index("rotateChallengeTokens_serverUrl_idx").on(table.serverUrl)],
); );
export const blacklistedServers = pgTable( export const blacklistedServers = pgTable(
"blacklisted_servers", "blacklisted_servers",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
serverUrl: text("server_url").notNull(), serverUrl: text("server_url").notNull(),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
reason: text("reason").notNull(), reason: text("reason").notNull(),
}, },
(table) => [index("blacklistedServers_serverUrl_idx").on(table.serverUrl)], (table) => [index("blacklistedServers_serverUrl_idx").on(table.serverUrl)],
); );
export const userRelations = relations(user, ({ many }) => ({ export const userRelations = relations(user, ({ many }) => ({
sessions: many(session), sessions: many(session),
accounts: many(account), accounts: many(account),
twoFactors: many(twoFactor), twoFactors: many(twoFactor),
postss: many(posts), postss: many(posts),
followss: many(follows), followss: many(follows),
mutess: many(mutes), mutess: many(mutes),
blockss: many(blocks), blockss: many(blocks),
})); }));
export const sessionRelations = relations(session, ({ one }) => ({ export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [session.userId], fields: [session.userId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const accountRelations = relations(account, ({ one }) => ({ export const accountRelations = relations(account, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [account.userId], fields: [account.userId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({ export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [twoFactor.userId], fields: [twoFactor.userId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const postsRelations = relations(posts, ({ one }) => ({ export const postsRelations = relations(posts, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [posts.authorId], fields: [posts.authorId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const followsFollowerIdRelations = relations(follows, ({ one }) => ({ export const followsFollowerIdRelations = relations(follows, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [follows.followerId], fields: [follows.followerId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const followsFollowingIdRelations = relations(follows, ({ one }) => ({ export const followsFollowingIdRelations = relations(follows, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [follows.followingId], fields: [follows.followingId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const mutesUserIdRelations = relations(mutes, ({ one }) => ({ export const mutesUserIdRelations = relations(mutes, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [mutes.userId], fields: [mutes.userId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const mutesMutedUserIdRelations = relations(mutes, ({ one }) => ({ export const mutesMutedUserIdRelations = relations(mutes, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [mutes.mutedUserId], fields: [mutes.mutedUserId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const blocksBlockerIdRelations = relations(blocks, ({ one }) => ({ export const blocksBlockerIdRelations = relations(blocks, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [blocks.blockerId], fields: [blocks.blockerId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const blocksBlockedUserIdRelations = relations(blocks, ({ one }) => ({ export const blocksBlockedUserIdRelations = relations(blocks, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [blocks.blockedUserId], fields: [blocks.blockedUserId],
references: [user.id], references: [user.id],
}), }),
})); }));

View file

@ -1,32 +1,43 @@
import Bun from "bun"; import Bun from "bun";
import forge from "node-forge"; import nacl from "tweetnacl";
export async function generateKeyPair() { export async function generateKeyPair() {
const envFile = Bun.file(".env.local");
// Check if .env file exists if (!await envFile.exists()) {
if (!Bun.file(".env.local").exists()) { throw new Error("No .env.local file found"); } 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)
} }
// Sanity check to make sure the keys are not already in the file const signing = nacl.sign.keyPair();
const env = await Bun.file(".env.local").text(); const encryption = nacl.box.keyPair();
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 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 const signingPublicKey = Buffer.from(signing.publicKey).toString("base64");
// Escape newlines for .env format (single-line value that expands to PEM when loaded) const signingPrivateKey = Buffer.from(signing.secretKey).toString("base64");
const publicKey = keys.publicKey.replace(/\r?\n/g, "\\n"); const encryptionPublicKey = Buffer.from(encryption.publicKey).toString("base64");
const privateKey = keys.privateKey.replace(/\r?\n/g, "\\n"); const encryptionPrivateKey = Buffer.from(encryption.secretKey).toString("base64");
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}"`);
console.log("Keys generated successfully"); const block = [
return keys; "",
"# 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(); 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) { export interface EncryptedEnvelope {
const pub = forge.pki.publicKeyFromPem(recipientPublicKey); ephemeralPublicKey: string;
return forge.util.encode64( iv: string;
pub.encrypt( ciphertext: string;
forge.util.encodeUtf8(payload), authTag: string;
"RSA-OAEP"
)
)
} }
export function decryptPayload(payload: string, privateKey: string) { function toBase64(buf: Uint8Array): string {
const priv = forge.pki.privateKeyFromPem(privateKey); 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 { try {
return forge.util.decodeUtf8( const msgBytes = new TextEncoder().encode(message);
priv.decrypt( const sigBytes = fromBase64(signature);
forge.util.decode64(payload), return nacl.sign.detached.verify(msgBytes, sigBytes, ed25519PublicKey);
"RSA-OAEP" } 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) { } catch (error) {
console.error("Failed to decrypt payload", error);
throw error; throw error;
} }
} }
export function verifyChallenge( export function decryptPayload(envelope: EncryptedEnvelope, ownX25519SecretKey: Uint8Array): string {
challenge: string,
signedChallenge: string,
publicKeyPem: string
): boolean {
try { try {
const pub = forge.pki.publicKeyFromPem(publicKeyPem) const ephemeralPub = fromBase64(envelope.ephemeralPublicKey);
const md = forge.md.sha256.create() const sharedPoint = nacl.box.before(ephemeralPub, ownX25519SecretKey);
md.update(challenge, 'utf8') const aesKey = deriveAesKey(sharedPoint);
const sig = forge.util.decode64(signedChallenge) const iv = fromBase64(envelope.iv);
return pub.verify(md.digest().bytes(), sig) const ciphertext = fromBase64(envelope.ciphertext);
} catch { const authTag = fromBase64(envelope.authTag);
return false
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 { import { createHash } from "node:crypto";
const priv = forge.pki.privateKeyFromPem(privateKeyPem) export function fingerprintKey(keyBase64: string): string {
const md = forge.md.sha256.create() const hash = createHash("sha256").update(fromBase64(keyBase64)).digest("hex");
md.update(challenge, 'utf8') return hash;
return forge.util.encode64(priv.sign(md.digest().bytes()))
} }

View file

@ -1,5 +1,5 @@
import { pixelBasedPreset } from "@react-email/tailwind";
import type { TailwindConfig } 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. * 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, unique: true,
index: true index: true
}, },
encryptionPublicKey: {
type: "string",
required: true,
unique: true,
index: true
},
lastSeen: { lastSeen: {
type: "date", type: "date",
required: true, required: true,
@ -42,18 +48,32 @@ export const federation = () => {
}, },
rotateChallengeTokens: { rotateChallengeTokens: {
fields: { fields: {
oldKeyToken: { signingOldToken: {
type: "string", type: "string",
required: true, required: true,
index: false index: false
}, },
newKeyToken: { signingNewToken: {
type: "string", type: "string",
required: true, required: true,
unique: true, index: false
index: true
}, },
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", type: "string",
required: true, required: true,
index: false index: false

View file

@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }

View file

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

View file

@ -19,6 +19,7 @@ test("discover server", async ({ request, page }) => {
method: "REGISTER", method: "REGISTER",
url: new URL(url).toString(), url: new URL(url).toString(),
publicKey: process.env.FEDERATION_PUBLIC_KEY!, publicKey: process.env.FEDERATION_PUBLIC_KEY!,
encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!,
} }
}) })
const status = response.status() const status = response.status()
@ -27,12 +28,14 @@ test("discover server", async ({ request, page }) => {
debug("response body: %o", body); debug("response body: %o", body);
expect(status).toBe(200) expect(status).toBe(200)
expect(body).toMatchObject({ message: "Server registered successfully" }) 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(
await insertServerEcho("http://192.168.3.26:3000", body.echo.publicKey as string); "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"); const server = await getServerByUrl("http://192.168.3.26:3000");
expect(server).toBeDefined() expect(server).toBeDefined()
expect(server?.publicKey).toBe(body.echo.publicKey as string) expect(server?.publicKey).toBe(body.echo.publicKey as string)

View file

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

View file

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