diff --git a/.env.local.example b/.env.local.example index 26dc819..af95c0d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -7,4 +7,13 @@ EMAIL_HOST= EMAIL_PORT= EMAIL_SECURE= EMAIL_USER= -EMAIL_PASSWORD= \ No newline at end of file +EMAIL_PASSWORD= + +DEBUG= + +MINIO_BUCKET= +MINIO_ENDPOINT= +MINIO_PORT= +MINIO_USE_SSL= +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= \ No newline at end of file diff --git a/README.md b/README.md index 33e0827..8f051a1 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,60 @@ Your identity is `you@yourserver.com`. Your server, your data, your rules. - - [ ] — One server can create posts, have users following each other and dms (unencrypted for now) works. - - [ ] — Two servers can fetch posts, follows and other data from their users, including DMs. - **Phase 2** — Server trust scoring and a public vouch ledger. +- - [ ] — Add a "nuke" endpoint where if a federation loses their old keys and cannot rotate them, it'll nuke everything and make the other federations reset that federation score. - **Phase 3** — Opt-in relay network for censorship resistance. - **Phase 4** — End-to-end encryption via TBD. --- +## Instructions + +
+Rotating Federation Keys + +Federation identity is tied to two keypairs (Ed25519 for signing, X25519 for encryption). The `rotateKeys.ts` script walks through every known federation, proves ownership of both the old and new keys via a challenge-response protocol, and updates `.env.local` when all federations confirm. + +You **need** the old keys in order to run this script, if you lost them, you'll have to use the nuke endpoint. (Yet to be made) + +### Prerequisites + +- A running database with the server registry populated (at least one peer federation). +- `.env.local` with valid `FEDERATION_*` keys and `BETTER_AUTH_URL`. + +### Basic rotation + +```sh +bun run rotateKeys.ts +``` + +The script will: + +1. List all federations in the registry. +2. Ask for confirmation before proceeding. +3. For each federation: request a challenge, solve it, and confirm. +4. On full success: back up `.env.local` and write the new keys. +5. On any failure: print a retry command and exit without writing keys. + +### Retrying after partial failure + +If some federations failed while others succeeded, the script prints a ready-to-copy command targeting only the failures: + +```sh +bun run rotateKeys.ts --resume '' --only '' +``` + +- `--resume ` — Reuse the new keys from the previous run instead of generating fresh ones (required because successful federations already registered them). +- `--only ` — Comma-separated list of federation URLs to retry. Federations not in this list are skipped. + +You can also retry all federations with just `--resume`: + +```sh +bun run rotateKeys.ts --resume '' +``` + +
+ + ## Author **Marcello Brito** (Tocka) — [tockanest.com](https://tockanest.com) diff --git a/bun.lock b/bun.lock index dd21680..6bd50dd 100644 --- a/bun.lock +++ b/bun.lock @@ -20,14 +20,12 @@ "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", "framer-motion": "^12.35.2", - "http-signature": "^1.4.0", "ioredis": "^5.10.0", "lucide-react": "^0.577.0", "minio": "^8.0.7", "nanostores": "^1.1.1", "next": "16.1.6", "next-themes": "^0.4.6", - "node-forge": "^1.3.3", "nodemailer": "^8.0.2", "pg": "^8.20.0", "radix-ui": "^1.4.3", @@ -38,6 +36,8 @@ "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", "zod": "^4.3.6", }, "devDependencies": { @@ -45,8 +45,7 @@ "@tailwindcss/postcss": "^4.2.1", "@types/bun": "^1.3.10", "@types/debug": "^4.1.12", - "@types/node": "^25.3.5", - "@types/node-forge": "^1.3.14", + "@types/node": "^25.4.0", "@types/nodemailer": "^7.0.11", "@types/pg": "^8.18.0", "@types/react": "^19.2.14", @@ -56,7 +55,7 @@ "cross-env": "^10.1.0", "drizzle-kit": "^0.31.9", "react-email": "5.2.9", - "shadcn": "^4.0.2", + "shadcn": "^4.0.5", "tailwindcss": "^4.2.1", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", @@ -630,8 +629,6 @@ "@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], - "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="], - "@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="], "@types/pg": ["@types/pg@8.18.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q=="], @@ -666,10 +663,6 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], - - "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], - "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -688,8 +681,6 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], - "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], - "better-auth": ["better-auth@1.5.4", "", { "dependencies": { "@better-auth/core": "1.5.4", "@better-auth/drizzle-adapter": "1.5.4", "@better-auth/kysely-adapter": "1.5.4", "@better-auth/memory-adapter": "1.5.4", "@better-auth/mongo-adapter": "1.5.4", "@better-auth/prisma-adapter": "1.5.4", "@better-auth/telemetry": "1.5.4", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-ReykcEKx6Kp9560jG1wtlDBnftA7L7xb3ZZdDWm5yGXKKe2pUf+oBjH0fqekrkRII0m4XBVQbQ0mOrFv+3FdYg=="], "better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="], @@ -778,8 +769,6 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], @@ -794,8 +783,6 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="], - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "debounce": ["debounce@2.2.0", "", {}, "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw=="], @@ -854,8 +841,6 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="], - "eciesjs": ["eciesjs@0.4.17", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -916,8 +901,6 @@ "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], - "extsprintf": ["extsprintf@1.3.0", "", {}, "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g=="], - "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -986,8 +969,6 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], - "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="], - "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -1018,8 +999,6 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "http-signature": ["http-signature@1.4.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^2.0.2", "sshpk": "^1.18.0" } }, "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg=="], - "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -1088,14 +1067,10 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="], - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -1104,8 +1079,6 @@ "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], - "jsprim": ["jsprim@2.0.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ=="], - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], @@ -1232,8 +1205,6 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], - "node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="], - "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], @@ -1446,7 +1417,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shadcn": ["shadcn@4.0.3", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-dWN9aw+fszDEMPdRqAVmv5xgrsO60r5XDinHvrCjyfSW/FOiOrJJpYHGDI67+ItIwhTGHK1cXCM5YPAWVOsUtw=="], + "shadcn": ["shadcn@4.0.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-z0SOHEU1+ADam1UJHrgxJhUsOb0/jBoYc+u9mhWs071KrnORq48X7uCwG3mD2ysQEBtOfeK/MxMGsmzL5Jt+Jg=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -1490,8 +1461,6 @@ "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], - "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], - "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -1564,7 +1533,9 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], - "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + + "tweetnacl-util": ["tweetnacl-util@0.15.1", "", {}, "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw=="], "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], @@ -1602,8 +1573,6 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="], - "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], @@ -1692,8 +1661,6 @@ "@types/cors/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], - "@types/node-forge/@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="], - "@types/nodemailer/@types/node": ["@types/node@20.19.36", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-+3TQ+XhRjbmeKGHMhmUZfKlkF2/mAc+PpO2B90PBI7hRpkgPCSo5PaJ8tfWBJ4LMIuqrnKLD5TveeGMy+curtg=="], "@types/pg/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], @@ -1842,8 +1809,6 @@ "@types/cors/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@types/node-forge/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@types/nodemailer/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/next.config.ts b/next.config.ts index 66e1566..a4bef11 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ - reactCompiler: true, + /* config options here */ + reactCompiler: true, + allowedDevOrigins: ["172.21.157.201", "172.21.144.1"] }; export default nextConfig; diff --git a/package.json b/package.json index 612eea2..f4fe0fd 100644 --- a/package.json +++ b/package.json @@ -43,14 +43,12 @@ "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", "framer-motion": "^12.35.2", - "http-signature": "^1.4.0", "ioredis": "^5.10.0", "lucide-react": "^0.577.0", "minio": "^8.0.7", "nanostores": "^1.1.1", "next": "16.1.6", "next-themes": "^0.4.6", - "node-forge": "^1.3.3", "nodemailer": "^8.0.2", "pg": "^8.20.0", "radix-ui": "^1.4.3", @@ -61,6 +59,8 @@ "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", "zod": "^4.3.6" }, "devDependencies": { @@ -69,7 +69,6 @@ "@types/bun": "^1.3.10", "@types/debug": "^4.1.12", "@types/node": "^25.4.0", - "@types/node-forge": "^1.3.14", "@types/nodemailer": "^7.0.11", "@types/pg": "^8.18.0", "@types/react": "^19.2.14", @@ -79,7 +78,7 @@ "cross-env": "^10.1.0", "drizzle-kit": "^0.31.9", "react-email": "5.2.9", - "shadcn": "^4.0.3", + "shadcn": "^4.0.5", "tailwindcss": "^4.2.1", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", diff --git a/rotateKeys.ts b/rotateKeys.ts new file mode 100644 index 0000000..cdb3ee6 --- /dev/null +++ b/rotateKeys.ts @@ -0,0 +1,379 @@ +/** + * This script is used to rotate the keys of the federation. + * It will go through all known federations and request the key rotation challenge one by one. + * It will then solve the challenges and send the proofs to the federation that we are who we say we are. + * + * This script is meant to be run manually and should not under any circumstances be run automatically under an endpoint. + * + * Usage: + * bun run rotateKeys.ts — generate fresh keys and rotate all federations + * bun run rotateKeys.ts --resume — retry all federations with previously generated keys + * bun run rotateKeys.ts --resume --only — retry only specific federations (comma-separated URLs) + */ + +import db from "@/lib/db"; +import { serverRegistry } from "@/lib/db/schema"; +import { decryptPayload, EncryptedEnvelope, encryptPayload, signMessage } from "@/lib/federation/keytools"; +import { config } from "dotenv"; +import nacl from "tweetnacl"; + +config({ path: ".env.local" }); + +const FETCH_TIMEOUT_MS = 30_000; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface FedKeys { + signingPublicKey: string; + signingSecretKey: string; + encryptionPublicKey: string; + encryptionSecretKey: string; +} + +function generateKeypair(): FedKeys { + const signing = nacl.sign.keyPair(); + const encryption = nacl.box.keyPair(); + return { + signingPublicKey: Buffer.from(signing.publicKey).toString("base64"), + signingSecretKey: Buffer.from(signing.secretKey).toString("base64"), + encryptionPublicKey: Buffer.from(encryption.publicKey).toString("base64"), + encryptionSecretKey: Buffer.from(encryption.secretKey).toString("base64"), + }; +} + +function printKeys(label: string, keys: FedKeys) { + console.log(label); + console.log(` FEDERATION_PUBLIC_KEY=${keys.signingPublicKey}`); + console.log(` FEDERATION_PRIVATE_KEY=${keys.signingSecretKey}`); + console.log(` FEDERATION_ENCRYPTION_PUBLIC_KEY=${keys.encryptionPublicKey}`); + console.log(` FEDERATION_ENCRYPTION_PRIVATE_KEY=${keys.encryptionSecretKey}`); +} + +async function readErrorBody(response: Response): Promise { + try { + const body = await response.json(); + return body?.error ?? body?.message ?? JSON.stringify(body); + } catch { + try { + return await response.text(); + } catch { + return response.statusText; + } + } +} + +async function confirm(prompt: string): Promise { + process.stdout.write(`${prompt} [y/N] `); + for await (const line of console) { + return line.trim().toLowerCase() === "y"; + } + return false; +} + +// --------------------------------------------------------------------------- +// Validate environment +// --------------------------------------------------------------------------- + +const REQUIRED_ENV = [ + "FEDERATION_PUBLIC_KEY", + "FEDERATION_PRIVATE_KEY", + "FEDERATION_ENCRYPTION_PUBLIC_KEY", + "FEDERATION_ENCRYPTION_PRIVATE_KEY", + "BETTER_AUTH_URL", +] as const; + +const missing = REQUIRED_ENV.filter((k) => !process.env[k]); +if (missing.length > 0) { + console.error("Missing required environment variables:"); + missing.forEach((k) => console.error(` - ${k}`)); + console.error("Ensure .env.local is present and populated."); + process.exit(1); +} + +const oldFedKeys: FedKeys = { + signingPublicKey: process.env.FEDERATION_PUBLIC_KEY!, + signingSecretKey: process.env.FEDERATION_PRIVATE_KEY!, + encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!, + encryptionSecretKey: process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY!, +}; + +const ORIGIN = process.env.BETTER_AUTH_URL!; + +// --------------------------------------------------------------------------- +// Parse --resume flag +// --------------------------------------------------------------------------- + +let newFedKeys: FedKeys; + +const resumeIdx = process.argv.indexOf("--resume"); +if (resumeIdx !== -1) { + const raw = process.argv[resumeIdx + 1]; + if (!raw) { + console.error("--resume requires a JSON string argument containing the new keys."); + process.exit(1); + } + try { + const parsed = JSON.parse(raw); + if ( + !parsed.signingPublicKey || + !parsed.signingSecretKey || + !parsed.encryptionPublicKey || + !parsed.encryptionSecretKey + ) { + throw new Error("Missing key fields"); + } + newFedKeys = parsed as FedKeys; + console.log("Resuming rotation with previously generated keys."); + } catch (err) { + console.error("Failed to parse --resume keys:", (err as Error).message); + process.exit(1); + } +} else { + newFedKeys = generateKeypair(); +} + +// --------------------------------------------------------------------------- +// Parse --only filter +// --------------------------------------------------------------------------- + +const onlyIdx = process.argv.indexOf("--only"); +let onlyUrls: Set | null = null; +if (onlyIdx !== -1) { + const raw = process.argv[onlyIdx + 1]; + if (!raw) { + console.error("--only requires a comma-separated list of federation URLs."); + process.exit(1); + } + onlyUrls = new Set(raw.split(",").map((u) => u.trim()).filter(Boolean)); + if (onlyUrls.size === 0) { + console.error("--only list is empty."); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// Fetch federations +// --------------------------------------------------------------------------- + +const allFederations = await db.select().from(serverRegistry); + +if (allFederations.length === 0) { + console.log("No federations found in the registry. Nothing to rotate."); + process.exit(0); +} + +const federations = onlyUrls + ? allFederations.filter((f) => onlyUrls!.has(f.url)) + : allFederations; + +if (federations.length === 0) { + console.error("None of the --only URLs matched federations in the registry:"); + onlyUrls!.forEach((u) => console.error(` - ${u}`)); + process.exit(1); +} + +if (onlyUrls) { + const unmatched = [...onlyUrls].filter((u) => !federations.some((f) => f.url === u)); + if (unmatched.length > 0) { + console.warn("Warning: these --only URLs were not found in the registry and will be skipped:"); + unmatched.forEach((u) => console.warn(` - ${u}`)); + } +} + +console.log(`Targeting ${federations.length} federation(s) for key rotation:`); +federations.forEach((f) => console.log(` - ${f.url}`)); + +if (!await confirm("\nProceed with key rotation?")) { + console.log("Aborted."); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Solve init challenges +// --------------------------------------------------------------------------- + +interface InitChallenges { + signingOldChallenge: string; + signingNewChallenge: string; + encryptionOldChallenge: EncryptedEnvelope; + encryptionNewChallenge: EncryptedEnvelope; +} + +function solveInitChallenges(challenges: InitChallenges, oldKeys: FedKeys, newKeys: FedKeys) { + const oldSigningSecret = new Uint8Array(Buffer.from(oldKeys.signingSecretKey, "base64")); + const newSigningSecret = new Uint8Array(Buffer.from(newKeys.signingSecretKey, "base64")); + const oldEncSecret = new Uint8Array(Buffer.from(oldKeys.encryptionSecretKey, "base64")); + const newEncSecret = new Uint8Array(Buffer.from(newKeys.encryptionSecretKey, "base64")); + + return { + signingOldSignature: signMessage(challenges.signingOldChallenge, oldSigningSecret), + signingNewSignature: signMessage(challenges.signingNewChallenge, newSigningSecret), + encryptionOldPlaintext: decryptPayload(challenges.encryptionOldChallenge, oldEncSecret), + encryptionNewPlaintext: decryptPayload(challenges.encryptionNewChallenge, newEncSecret), + }; +} + +// --------------------------------------------------------------------------- +// Rotate each federation +// --------------------------------------------------------------------------- + +const transactions: Array<{ + url: string; + success: boolean; + message: string; +}> = []; + +for (const federation of federations) { + const tag = federation.url; + console.log(`\n[${tag}] Requesting rotation challenge...`); + + try { + // Step 1 — Init challenge + const initResponse = await fetch(`${federation.url}/discover/rotate/init`, { + method: "POST", + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { + "Content-Type": "application/json", + "Origin": ORIGIN, + "x-federation-origin": ORIGIN, + }, + body: JSON.stringify({ + url: ORIGIN, + newSigningPublicKey: newFedKeys.signingPublicKey, + newEncryptionPublicKey: newFedKeys.encryptionPublicKey, + }), + }); + + if (!initResponse.ok) { + const detail = await readErrorBody(initResponse); + console.error(`[${tag}] Init failed (${initResponse.status}): ${detail}`); + transactions.push({ url: tag, success: false, message: detail }); + continue; + } + + const challenges: InitChallenges = await initResponse.json(); + + // Step 2 — Fetch the federation's public encryption key + const discoverResponse = await fetch(`${federation.url}/discover`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { + "Content-Type": "application/json", + "Origin": ORIGIN, + "x-federation-origin": ORIGIN, + }, + }); + + if (!discoverResponse.ok) { + const detail = await readErrorBody(discoverResponse); + console.error(`[${tag}] Discover failed (${discoverResponse.status}): ${detail}`); + transactions.push({ url: tag, success: false, message: detail }); + continue; + } + + const discoverData: { + url: string; + publicKey: string; + encryptionPublicKey: string; + } = await discoverResponse.json(); + + // Step 3 — Solve challenges + const proofs = solveInitChallenges(challenges, oldFedKeys, newFedKeys); + + // Step 4 — Encrypt proofs with the federation's encryption public key + const encPubKey = new Uint8Array(Buffer.from(discoverData.encryptionPublicKey, "base64")); + const encryptedProofs = encryptPayload(JSON.stringify(proofs), encPubKey); + + // Step 5 — Confirm + const confirmResponse = await fetch(`${federation.url}/discover/rotate/confirm`, { + method: "POST", + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { + "Content-Type": "application/json", + "Origin": ORIGIN, + "x-federation-origin": ORIGIN, + }, + body: JSON.stringify({ + serverUrl: ORIGIN, + envelope: encryptedProofs, + }), + }); + + if (!confirmResponse.ok) { + const detail = await readErrorBody(confirmResponse); + console.error(`[${tag}] Confirm failed (${confirmResponse.status}): ${detail}`); + transactions.push({ url: tag, success: false, message: detail }); + continue; + } + + const confirmData = await confirmResponse.json(); + console.log(`[${tag}] ${confirmData.message}`); + transactions.push({ url: tag, success: true, message: confirmData.message }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[${tag}] Unexpected error: ${message}`); + transactions.push({ url: tag, success: false, message }); + } +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +const successes = transactions.filter((t) => t.success); +const failures = transactions.filter((t) => !t.success); + +console.log("\n================================"); +console.log(`Results: ${successes.length} succeeded, ${failures.length} failed out of ${transactions.length}`); + +if (failures.length > 0) { + console.error("\nFailed federations:"); + failures.forEach((f) => console.error(` - ${f.url}: ${f.message}`)); + + const resumePayload = JSON.stringify(newFedKeys); + const failedUrls = failures.map((f) => f.url).join(","); + + if (successes.length > 0) { + console.error("\nKeys NOT written to .env.local (some federations succeeded, some failed)."); + console.error("Retry ONLY the failed federations with:\n"); + console.log(` bun run rotateKeys.ts --resume '${resumePayload}' --only '${failedUrls}'\n`); + } else { + console.error("\nKeys NOT written to .env.local. Retry with:\n"); + console.log(` bun run rotateKeys.ts --resume '${resumePayload}'\n`); + } + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Write new keys to .env.local (with backup) +// --------------------------------------------------------------------------- + +const envPath = ".env.local"; +const envContent = await Bun.file(envPath).text(); + +const backupPath = `.env.local.bak.${Date.now()}`; +await Bun.write(backupPath, envContent); +console.log(`\nBacked up .env.local → ${backupPath}`); + +const envReplacements: [RegExp, string][] = [ + [/FEDERATION_PUBLIC_KEY=.*/, `FEDERATION_PUBLIC_KEY="${newFedKeys.signingPublicKey}"`], + [/FEDERATION_PRIVATE_KEY=.*/, `FEDERATION_PRIVATE_KEY="${newFedKeys.signingSecretKey}"`], + [/FEDERATION_ENCRYPTION_PUBLIC_KEY=.*/, `FEDERATION_ENCRYPTION_PUBLIC_KEY="${newFedKeys.encryptionPublicKey}"`], + [/FEDERATION_ENCRYPTION_PRIVATE_KEY=.*/, `FEDERATION_ENCRYPTION_PRIVATE_KEY="${newFedKeys.encryptionSecretKey}"`], +]; + +let updatedEnv = envContent; +for (const [pattern, replacement] of envReplacements) { + if (!pattern.test(updatedEnv)) { + console.error(`Warning: ${pattern.source.split("=")[0]} not found in .env.local — appending.`); + updatedEnv += `\n${replacement}`; + } else { + updatedEnv = updatedEnv.replace(pattern, replacement); + } +} + +await Bun.write(envPath, updatedEnv); + +console.log("New keys written to .env.local successfully."); +printKeys("\nOld keys (displayed once, not stored anywhere):", oldFedKeys); diff --git a/src/app/discover/rotate/confirm/route.ts b/src/app/discover/rotate/confirm/route.ts index 2d24853..4927830 100644 --- a/src/app/discover/rotate/confirm/route.ts +++ b/src/app/discover/rotate/confirm/route.ts @@ -1,6 +1,6 @@ import db from "@/lib/db"; import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema"; -import { decryptPayload } from "@/lib/federation/keytools"; +import { decryptPayload, verifySignature } from "@/lib/federation/keytools"; import createDebug from "debug"; import { eq, sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; @@ -14,29 +14,27 @@ const debug = createDebug("app:discover:rotate:confirm"); * Terminology: SA = this server (Server A), SB = the server rotating its keys (Server B). * * Full rotation flow: - * 1. SB generates a new keypair. It keeps the old private key accessible until rotation is complete. - * 2. SB sends { url, newPublicKey } to SA's /discover/rotate/init. - * 3. SA issues two independent challenges and returns them to SB: - * - oldKeyChallenge: a random token encrypted with SB's CURRENT (old) public key. - * - newKeyChallenge: a random token encrypted with SB's NEW public key. - * 4. SB decrypts both challenges using the respective private keys: - * - oldKeyChallenge → decrypted with old private key → oldPlaintext - * - newKeyChallenge → decrypted with new private key → newPlaintext - * 5. SB fetches SA's public key from /discover, then re-encrypts both plaintexts with it: - * - signedOldChallenge = encrypt(oldPlaintext, SA.publicKey) - * - signedNewChallenge = encrypt(newPlaintext, SA.publicKey) - * 6. SB sends { serverUrl, signedOldChallenge, signedNewChallenge } to this route. - * 7. SA decrypts both with its own private key and compares to the stored tokens. - * - If either mismatches: decrement attemptsLeft; blacklist server at 0 attempts. - * - If both match: update serverRegistry with newPublicKey and delete the challenge. + * 1. SB generates new Ed25519 + X25519 keypairs. + * 2. SB sends { url, newSigningPublicKey, newEncryptionPublicKey } to SA's /discover/rotate/init. + * 3. SA issues 4 challenges: + * - signingOldChallenge: plaintext nonce (SB signs with old Ed25519 key) + * - signingNewChallenge: plaintext nonce (SB signs with new Ed25519 key) + * - encryptionOldChallenge: nonce encrypted with SB's current X25519 key + * - encryptionNewChallenge: nonce encrypted with SB's new X25519 key + * 4. SB solves all 4 challenges: + * - Signs the signing challenges with respective Ed25519 keys + * - Decrypts the encryption challenges with respective X25519 keys + * 5. SB fetches SA's /discover to get SA's X25519 public key, then encrypts + * all 4 proof values into a single EncryptedEnvelope using SA's X25519 key. + * 6. SA decrypts the envelope and verifies all 4 proofs. * * What each check proves: - * - signedOldChallenge match → SB holds the old private key (identity proof: "they are who they say they are") - * - signedNewChallenge match → SB holds the new private key (ownership proof: "they own the key they want to rotate to") - * - re-encryption with SA's public key → SB fetched SA's identity from /discover - * - * TODO: on success, announce the completed rotation to other known federation peers - * so they can treat SA as a trusted proxy for confirming SB's new key. (Other federation servers could ignore this information and force the challenge to be completed for themselves.) + * - signingOldSignature: SB holds the old Ed25519 private key (identity proof) + * - signingNewSignature: SB holds the new Ed25519 private key (ownership proof) + * - encryptionOldPlaintext: SB holds the old X25519 private key (encryption identity proof) + * - encryptionNewPlaintext: SB holds the new X25519 private key (encryption ownership proof) + * - Envelope encrypted with SA's X25519 key: SB fetched SA's /discover (identity binding) + * - Discover being fetched: SB fetched SA's /discover endpoint (liveliness proof) <- Not accounted for but it is a proof that the other federation is alive and responsive. */ export async function POST(request: NextRequest) { const body = await request.json(); @@ -44,12 +42,12 @@ export async function POST(request: NextRequest) { const validated = z.object({ serverUrl: z.url(), - // SA decrypted oldKeyChallenge with their OLD private key, - // then re-encrypted the plaintext with OUR public key. - signedOldChallenge: z.string(), - // SA decrypted newKeyChallenge with their NEW private key, - // then re-encrypted the plaintext with OUR public key. - signedNewChallenge: z.string(), + envelope: z.object({ + ephemeralPublicKey: z.string(), + iv: z.string(), + ciphertext: z.string(), + authTag: z.string(), + }), }).safeParse(body); if (!validated.success) { @@ -57,9 +55,15 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: validated.error.message }, { status: 400 }); } + const [blacklisted] = await db.select().from(blacklistedServers) + .where(eq(blacklistedServers.serverUrl, validated.data.serverUrl)); + if (blacklisted) { + debug("POST /discover/rotate/confirm – server %s is blacklisted", validated.data.serverUrl); + return NextResponse.json({ error: "Your server has been blacklisted. Please contact support to unblacklist your server." }, { status: 403 }); + } + debug("POST /discover/rotate/confirm – fetching pending challenge for %s", validated.data.serverUrl); - // transaction to ensure that the challenge is deleted and the server registry is updated atomically and that there's no race condition. return await db.transaction(async (tx) => { const [challenge] = await tx.select().from(rotateChallengeTokens) .where(eq(rotateChallengeTokens.serverUrl, validated.data.serverUrl)) @@ -88,38 +92,75 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Your server has been blacklisted. Please contact support to unblacklist your server." }, { status: 403 }); } - debug("POST /discover/rotate/confirm – %d attempt(s) left, decrypting challenges", challenge.attemptsLeft); - let decryptedOld: string; - let decryptedNew: string; + debug("POST /discover/rotate/confirm – %d attempt(s) left, decrypting envelope", challenge.attemptsLeft); + + const ownEncryptionSecretKey = new Uint8Array( + Buffer.from(process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY!, "base64"), + ); + + let proofs: { + signingOldSignature: string; + signingNewSignature: string; + encryptionOldPlaintext: string; + encryptionNewPlaintext: string; + }; try { - decryptedOld = decryptPayload(validated.data.signedOldChallenge, process.env.FEDERATION_PRIVATE_KEY!); - decryptedNew = decryptPayload(validated.data.signedNewChallenge, process.env.FEDERATION_PRIVATE_KEY!); + const decrypted = decryptPayload(validated.data.envelope, ownEncryptionSecretKey); + proofs = JSON.parse(decrypted); } catch { - debug("POST /discover/rotate/confirm – decryption failed, decrementing attempts"); + debug("POST /discover/rotate/confirm – envelope decryption failed, decrementing attempts"); await tx.update(rotateChallengeTokens).set({ attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`, - }).where(eq(rotateChallengeTokens.id, challenge.id)) + }).where(eq(rotateChallengeTokens.id, challenge.id)); return NextResponse.json({ - error: `Failed to decrypt one or both challenges. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`, + error: `Failed to decrypt envelope. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`, }, { status: 400 }); } - if (decryptedOld !== challenge.oldKeyToken || decryptedNew !== challenge.newKeyToken) { - debug("POST /discover/rotate/confirm – token mismatch (old=%s, new=%s), decrementing attempts", - decryptedOld === challenge.oldKeyToken ? "ok" : "MISMATCH", - decryptedNew === challenge.newKeyToken ? "ok" : "MISMATCH", + const [server] = await tx.select().from(serverRegistry) + .where(eq(serverRegistry.url, challenge.serverUrl)); + + if (!server) { + debug("POST /discover/rotate/confirm – server not found in registry"); + return NextResponse.json({ error: "Server not found in registry." }, { status: 404 }); + } + + const currentSigningPub = new Uint8Array(Buffer.from(server.publicKey, "base64")); + const newSigningPub = new Uint8Array(Buffer.from(challenge.newSigningPublicKey, "base64")); + + const signingOldValid = verifySignature( + challenge.signingOldToken, + proofs.signingOldSignature, + currentSigningPub, + ); + const signingNewValid = verifySignature( + challenge.signingNewToken, + proofs.signingNewSignature, + newSigningPub, + ); + const encOldValid = proofs.encryptionOldPlaintext === challenge.encryptionOldToken; + const encNewValid = proofs.encryptionNewPlaintext === challenge.encryptionNewToken; + + if (!signingOldValid || !signingNewValid || !encOldValid || !encNewValid) { + debug( + "POST /discover/rotate/confirm – proof mismatch (sigOld=%s, sigNew=%s, encOld=%s, encNew=%s), decrementing", + signingOldValid ? "ok" : "FAIL", + signingNewValid ? "ok" : "FAIL", + encOldValid ? "ok" : "FAIL", + encNewValid ? "ok" : "FAIL", ); await tx.update(rotateChallengeTokens).set({ attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`, }).where(eq(rotateChallengeTokens.id, challenge.id)); return NextResponse.json({ - error: `Challenge mismatch. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`, + error: `Challenge verification failed. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`, }, { status: 400 }); } - debug("POST /discover/rotate/confirm – both challenges passed, rotating key for %s", challenge.serverUrl); + debug("POST /discover/rotate/confirm – all 4 proofs passed, rotating keys for %s", challenge.serverUrl); await tx.update(serverRegistry).set({ - publicKey: challenge.newPublicKey, + publicKey: challenge.newSigningPublicKey, + encryptionPublicKey: challenge.newEncryptionPublicKey, updatedAt: new Date(), }).where(eq(serverRegistry.url, challenge.serverUrl)); diff --git a/src/app/discover/rotate/init/route.ts b/src/app/discover/rotate/init/route.ts index b70b945..702133a 100644 --- a/src/app/discover/rotate/init/route.ts +++ b/src/app/discover/rotate/init/route.ts @@ -1,42 +1,47 @@ import db from "@/lib/db"; -import { rotateChallengeTokens, serverRegistry } from "@/lib/db/schema"; +import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema"; import { encryptPayload } from "@/lib/federation/keytools"; import createDebug from "debug"; import { eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; -import forge from "node-forge"; import { z } from "zod"; const debug = createDebug("app:discover:rotate:init"); -const publicKeySchema = z.string().refine((key) => { +const ED25519_PUBLIC_KEY_BYTES = 32; +const X25519_PUBLIC_KEY_BYTES = 32; + +function isValidBase64Key(key: string, expectedBytes: number): boolean { try { - const pub = forge.pki.publicKeyFromPem(key); - return pub.n.bitLength() >= 4096; + const decoded = Buffer.from(key, "base64"); + return decoded.length === expectedBytes; } catch { return false; } -}, { message: "Invalid public key" }); +} const schema = z.object({ url: z.url(), - newPublicKey: publicKeySchema, + newSigningPublicKey: z.string().refine( + (key) => isValidBase64Key(key, ED25519_PUBLIC_KEY_BYTES), + { message: "Invalid Ed25519 signing public key" }, + ), + newEncryptionPublicKey: z.string().refine( + (key) => isValidBase64Key(key, X25519_PUBLIC_KEY_BYTES), + { message: "Invalid X25519 encryption public key" }, + ), }); /** * Initializes a key rotation challenge for a server. * - * This route is used to initiate the key rotation process. It will issue two independent challenges: - * - oldKeyChallenge: a random token encrypted with the server's current public key. - * - newKeyChallenge: a random token encrypted with the server's new public key. + * Issues 4 independent challenges: + * - signingOldChallenge: plaintext nonce (SB signs with old Ed25519 key) + * - signingNewChallenge: plaintext nonce (SB signs with new Ed25519 key) + * - encryptionOldChallenge: nonce encrypted with SB's current X25519 key (SB decrypts) + * - encryptionNewChallenge: nonce encrypted with SB's new X25519 key (SB decrypts) * - * The challenges are stored in the database and will expire in 5 minutes. - * - * The challenges are returned to the client and must be decrypted using the respective private keys. - * - * The client must then send the challenges to the server's /discover/rotate/confirm route to confirm the key rotation. - * - * The server will not send his current public key to the client, the client must fetch it from the server's /discover route as a part of the challenge validation. + * Challenges expire in 5 minutes. SB confirms via /discover/rotate/confirm. */ export async function POST(request: NextRequest) { const body = await request.json(); @@ -48,6 +53,13 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: validated.error.message }, { status: 400 }); } + const [blacklisted] = await db.select().from(blacklistedServers) + .where(eq(blacklistedServers.serverUrl, validated.data.url.toString())); + if (blacklisted) { + debug("POST /discover/rotate/init – server %s is blacklisted", validated.data.url); + return NextResponse.json({ error: "Your server has been blacklisted." }, { status: 403 }); + } + debug("POST /discover/rotate/init – looking up server %s", validated.data.url); const server = await db.select().from(serverRegistry).where(eq(serverRegistry.url, validated.data.url.toString())); if (server.length === 0) { @@ -55,13 +67,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Server not found, please register your server first." }, { status: 404 }); } - if (server[0].publicKey === validated.data.newPublicKey) { - debug("POST /discover/rotate/init – new key is identical to current key, rejecting"); - return NextResponse.json({ error: "Your server is already registered with this public key." }, { status: 400 }); + if ( + server[0].publicKey === validated.data.newSigningPublicKey && + server[0].encryptionPublicKey === validated.data.newEncryptionPublicKey + ) { + debug("POST /discover/rotate/init – keys are identical to current keys, rejecting"); + return NextResponse.json({ error: "Your server is already registered with these keys." }, { status: 400 }); } - // Check for existing pending challenges, only one active challenge per server is allowed. - // This got removed by accident on a previous commit. const [existing] = await db.select().from(rotateChallengeTokens) .where(eq(rotateChallengeTokens.serverUrl, validated.data.url.toString())); @@ -77,37 +90,39 @@ export async function POST(request: NextRequest) { await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, existing.id)); } - // Issue two independent challenges: - // - // oldKeyChallenge — encrypted with the SA's CURRENT registered public key. - // Only the holder of the current private key can decrypt this. - // This is the identity proof: it shows the requester really is the - // registered server and not someone who merely knows its URL. - // - // newKeyChallenge — encrypted with the submitted new public key. - // Only the holder of the new private key can decrypt this. - // This proves the SA actually owns the key they want to rotate to. - // - // Both plaintexts are stored. On confirm the SA must re-encrypt both - // with OUR public key so we can decrypt and compare — proving they - // fetched our identity as well. - const oldKeyPlaintext = crypto.randomUUID(); - const newKeyPlaintext = crypto.randomUUID(); + const signingOldPlaintext = crypto.randomUUID(); + const signingNewPlaintext = crypto.randomUUID(); + const encryptionOldPlaintext = crypto.randomUUID(); + const encryptionNewPlaintext = crypto.randomUUID(); - debug("POST /discover/rotate/init – issuing challenges for server %s", validated.data.url); - const oldKeyChallenge = encryptPayload(oldKeyPlaintext, server[0].publicKey); - const newKeyChallenge = encryptPayload(newKeyPlaintext, validated.data.newPublicKey); + debug("POST /discover/rotate/init – issuing 4 challenges for server %s", validated.data.url); + + const currentEncPubKey = new Uint8Array(Buffer.from(server[0].encryptionPublicKey, "base64")); + const newEncPubKey = new Uint8Array(Buffer.from(validated.data.newEncryptionPublicKey, "base64")); + + const encryptionOldChallenge = encryptPayload(encryptionOldPlaintext, currentEncPubKey); + const encryptionNewChallenge = encryptPayload(encryptionNewPlaintext, newEncPubKey); await db.insert(rotateChallengeTokens).values({ id: crypto.randomUUID(), - oldKeyToken: oldKeyPlaintext, - newKeyToken: newKeyPlaintext, - newPublicKey: validated.data.newPublicKey, + signingOldToken: signingOldPlaintext, + signingNewToken: signingNewPlaintext, + encryptionOldToken: encryptionOldPlaintext, + encryptionNewToken: encryptionNewPlaintext, + newSigningPublicKey: validated.data.newSigningPublicKey, + newEncryptionPublicKey: validated.data.newEncryptionPublicKey, serverUrl: validated.data.url.toString(), createdAt: new Date(), expiresAt: new Date(Date.now() + 1000 * 60 * 5), }); debug("POST /discover/rotate/init – challenges issued, expires in 5 minutes"); - return NextResponse.json({ oldKeyChallenge, newKeyChallenge }); + const response = { + signingOldChallenge: signingOldPlaintext, + signingNewChallenge: signingNewPlaintext, + encryptionOldChallenge, + encryptionNewChallenge, + } + debug("POST /discover/rotate/init – response: %o", response); + return NextResponse.json(response); } diff --git a/src/app/discover/route.ts b/src/app/discover/route.ts index 8d50fb3..64af7fb 100644 --- a/src/app/discover/route.ts +++ b/src/app/discover/route.ts @@ -1,15 +1,75 @@ import db from "@/lib/db"; import { serverRegistry } from "@/lib/db/schema"; -import { decryptPayload } from "@/lib/federation/keytools"; +import { decryptPayload, fingerprintKey } from "@/lib/federation/keytools"; import { assertSafeUrl, UrlGuardError } from "@/lib/federation/url-guard"; import createDebug from "debug"; import { desc, eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; -import forge from "node-forge"; import { z } from "zod"; const debug = createDebug("app:discover"); +const ED25519_PUBLIC_KEY_BYTES = 32; +const X25519_PUBLIC_KEY_BYTES = 32; + +function isValidBase64Key(key: string, expectedBytes: number): boolean { + try { + const decoded = Buffer.from(key, "base64"); + return decoded.length === expectedBytes; + } catch { + return false; + } +} + +const signingKeySchema = z.string().refine( + (key) => isValidBase64Key(key, ED25519_PUBLIC_KEY_BYTES), + { message: `Signing public key must be a base64-encoded Ed25519 key (${ED25519_PUBLIC_KEY_BYTES} bytes)` }, +); + +const encryptionKeySchema = z.string().refine( + (key) => isValidBase64Key(key, X25519_PUBLIC_KEY_BYTES), + { message: `Encryption public key must be a base64-encoded X25519 key (${X25519_PUBLIC_KEY_BYTES} bytes)` }, +); + +function getOwnEncryptionSecretKey(): Uint8Array { + return new Uint8Array(Buffer.from(process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY!, "base64")); +} + +const discoverSchema = z.object({ + method: z.literal("DISCOVER"), + publicKey: signingKeySchema, + encryptionPublicKey: encryptionKeySchema, + envelope: z.object({ + ephemeralPublicKey: z.string(), + iv: z.string(), + ciphertext: z.string(), + authTag: z.string(), + }), +}).superRefine((data, ctx) => { + try { + const decrypted = decryptPayload(data.envelope, getOwnEncryptionSecretKey()); + const parsed = JSON.parse(decrypted); + if (parsed.publicKeyFingerprint !== fingerprintKey(data.publicKey)) { + ctx.addIssue({ code: "custom", message: "Envelope does not match the provided signing public key" }); + } + if (parsed.encryptionPublicKeyFingerprint !== fingerprintKey(data.encryptionPublicKey)) { + ctx.addIssue({ code: "custom", message: "Envelope does not match the provided encryption public key" }); + } + if (!parsed.url) { + ctx.addIssue({ code: "custom", message: "Envelope is missing the url field" }); + } + } catch { + ctx.addIssue({ code: "custom", message: "Invalid envelope" }); + } +}); + +const registerSchema = z.object({ + method: z.literal("REGISTER"), + url: z.url(), + publicKey: signingKeySchema, + encryptionPublicKey: encryptionKeySchema, +}); + export async function GET() { debug("GET /discover – fetching healthy peers"); const peers = await db.select({ @@ -19,17 +79,19 @@ export async function GET() { debug("GET /discover – found %d peer(s)", peers.length); return NextResponse.json({ - url: process.env.BETTER_AUTH_URL, + url: process.env.BETTER_AUTH_URL!, publicKey: process.env.FEDERATION_PUBLIC_KEY, - peers + encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY, + peers, }); } -async function upsertServer(url: string, publicKey: string) { +async function upsertServer(url: string, publicKey: string, encryptionPublicKey: string) { return await db.insert(serverRegistry).values({ id: crypto.randomUUID(), - url: url, - publicKey: publicKey, + url, + publicKey, + encryptionPublicKey, lastSeen: new Date(), createdAt: new Date(), updatedAt: new Date(), @@ -37,56 +99,6 @@ async function upsertServer(url: string, publicKey: string) { }).onConflictDoNothing(); } -const publicKeySchema = z.string().superRefine((key, ctx) => { - let pub: forge.pki.rsa.PublicKey; - try { - pub = forge.pki.publicKeyFromPem(key) as forge.pki.rsa.PublicKey; - } catch { - ctx.addIssue({ code: "custom", message: "Public key is not a valid PEM-encoded RSA key", input: key }); - return; - } - if (!pub.n) { - ctx.addIssue({ code: "custom", message: "Public key is not an RSA key", input: key }); - return; - } - if (pub.n.bitLength() < 2048) { - ctx.addIssue({ code: "custom", message: `RSA key must be at least 2048 bits (got ${pub.n.bitLength()})`, input: key }); - } -}); - -function fingerprintKey(pem: string): string { - const md = forge.md.sha256.create(); - md.update(pem, "utf8"); - return md.digest().toHex(); -} - -const discoverSchema = z.object({ - method: z.literal("DISCOVER"), - publicKey: publicKeySchema, - signature: z.string(), -}).superRefine((data, ctx) => { - try { - const decrypted = decryptPayload(data.signature, process.env.FEDERATION_PRIVATE_KEY!); - const parsed = JSON.parse(decrypted); - // The signature contains a SHA-256 fingerprint of the public key - // (since the full PEM exceeds RSA-OAEP's size limit) plus a url. - if (parsed.publicKeyFingerprint !== fingerprintKey(data.publicKey)) { - ctx.addIssue({ code: "custom", message: "Signature does not match the provided public key" }); - } - if (!parsed.url) { - ctx.addIssue({ code: "custom", message: "Signature is missing the url field" }); - } - } catch { - ctx.addIssue({ code: "custom", message: "Invalid signature" }); - } -}); - -const registerSchema = z.object({ - method: z.literal("REGISTER"), - url: z.url(), - publicKey: publicKeySchema, -}); - async function discoverServer(validated: z.infer) { debug("DISCOVER – looking up server by public key"); const server = await db.select().from(serverRegistry).where(eq(serverRegistry.publicKey, validated.publicKey)); @@ -96,7 +108,9 @@ async function discoverServer(validated: z.infer) { } try { - assertSafeUrl(server[0].url); + if (process.env.NODE_ENV !== "development") { + assertSafeUrl(server[0].url); + } } catch (err) { debug("DISCOVER – stored URL failed SSRF check: %s", server[0].url); if (err instanceof UrlGuardError) { @@ -126,7 +140,9 @@ async function discoverServer(validated: z.infer) { async function registerServer(validated: z.infer) { try { - assertSafeUrl(validated.url); + if (process.env.NODE_ENV !== "development") { + assertSafeUrl(validated.url); + } } catch (err) { debug("REGISTER – URL failed SSRF check: %s", validated.url); if (err instanceof UrlGuardError) { @@ -136,7 +152,7 @@ async function registerServer(validated: z.infer) { } debug("REGISTER – fetching /discover from %s to validate server", validated.url); - let response: { publicKey?: string }; + let response: { publicKey?: string; encryptionPublicKey?: string }; try { response = await (await fetch(validated.url + "/discover")).json(); } catch (err) { @@ -144,31 +160,30 @@ async function registerServer(validated: z.infer) { return NextResponse.json({ error: "Failed to reach the server" }, { status: 502 }); } - if (!response.publicKey) { - debug("REGISTER – remote server returned no public key"); + if (!response.publicKey || !response.encryptionPublicKey) { + debug("REGISTER – remote server returned incomplete keys"); return NextResponse.json({ error: "Invalid server" }, { status: 400 }); - } else if (response.publicKey !== validated.publicKey) { - debug("REGISTER – public key mismatch: provided vs fetched"); - debug("REGISTER – provided public key: %s", validated.publicKey); - debug("REGISTER – fetched public key: %s", response.publicKey); - return NextResponse.json({ error: "Invalid public key" }, { status: 400 }); + } else if (response.publicKey !== validated.publicKey || response.encryptionPublicKey !== validated.encryptionPublicKey) { + debug("REGISTER – key mismatch: provided vs fetched"); + return NextResponse.json({ error: "Public keys do not match the ones reported by the server" }, { status: 400 }); } debug("REGISTER – checking for existing registration at %s", validated.url); const server = await db.select().from(serverRegistry).where(eq(serverRegistry.url, validated.url.toString())); if (server.length > 0 && server[0].publicKey !== validated.publicKey) { debug("REGISTER – key mismatch against existing registration"); - return NextResponse.json({ error: "Your public key does not match the one registered on the server, to update your public key, please send a PATCH request instead." }, { status: 400 }); + return NextResponse.json({ error: "Your public key does not match the one registered on the server, to update your public key, please use the key rotation flow instead." }, { status: 400 }); } debug("REGISTER – upserting server %s", validated.url); - await upsertServer(validated.url.toString(), validated.publicKey); + await upsertServer(validated.url.toString(), validated.publicKey, validated.encryptionPublicKey); debug("REGISTER – server registered successfully"); return NextResponse.json({ message: "Server registered successfully", echo: { - url: process.env.NEXT_PUBLIC_APP_URL, + url: process.env.BETTER_AUTH_URL!, publicKey: process.env.FEDERATION_PUBLIC_KEY, + encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY, } }); } @@ -196,4 +211,4 @@ export async function POST(request: NextRequest) { } return NextResponse.json({ error: "Invalid method" }, { status: 400 }); -} \ No newline at end of file +} diff --git a/src/app/globals.css b/src/app/globals.css index 5aadb89..d8684db 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,206 +6,216 @@ /* SiPher brand palette — Silent Whisper */ :root { - /* Brand tokens */ - --black: #080808; - --surface: #0f0f0f; - --card: #141414; - --border: #1f1f1f; - --muted: #2a2a2a; - --dim: #555; - --text: #e8e8e8; - --subtle: #888; - --acid: #c8f000; - --static: #ff3c3c; - --ghost: #9b9b9b; - --void: #080808; - --signal: #00e5ff; + /* Brand tokens */ + --black: #080808; + --surface: #0f0f0f; + --card: #141414; + --border: #1f1f1f; + --muted: #2a2a2a; + --dim: #555; + --text: #e8e8e8; + --subtle: #888; + --acid: #c8f000; + --static: #ff3c3c; + --ghost: #9b9b9b; + --void: #080808; + --signal: #00e5ff; - /* Light mode — minimal variant for system preference */ - --background: #f5f5f5; - --foreground: #0a0a0a; - --card: #ffffff; - --card-foreground: #0a0a0a; - --popover: #ffffff; - --popover-foreground: #0a0a0a; - --primary: #8a9a00; - --primary-foreground: #080808; - --secondary: #e8e8e8; - --secondary-foreground: #0a0a0a; - --muted: #e5e5e5; - --muted-foreground: #555; - --accent: #c8f000; - --accent-foreground: #080808; - --destructive: #ff3c3c; - --destructive-foreground: #ffffff; - --border: #e5e5e5; - --input: #e5e5e5; - --ring: #8a9a00; - --chart-1: #8a9a00; - --chart-2: #c8f000; - --chart-3: #00e5ff; - --chart-4: #9b9b9b; - --chart-5: #555; - --sidebar: #fafafa; - --sidebar-foreground: #0a0a0a; - --sidebar-primary: #8a9a00; - --sidebar-primary-foreground: #080808; - --sidebar-accent: #e5e5e5; - --sidebar-accent-foreground: #0a0a0a; - --sidebar-border: #e5e5e5; - --sidebar-ring: #8a9a00; + /* Light mode — minimal variant for system preference */ + --background: #f5f5f5; + --foreground: #0a0a0a; + --card: #ffffff; + --card-foreground: #0a0a0a; + --popover: #ffffff; + --popover-foreground: #0a0a0a; + --primary: #8a9a00; + --primary-foreground: #080808; + --secondary: #e8e8e8; + --secondary-foreground: #0a0a0a; + --muted: #e5e5e5; + --muted-foreground: #555; + --accent: #c8f000; + --accent-foreground: #080808; + --destructive: #ff3c3c; + --destructive-foreground: #ffffff; + --border: #e5e5e5; + --input: #e5e5e5; + --ring: #8a9a00; + --chart-1: #8a9a00; + --chart-2: #c8f000; + --chart-3: #00e5ff; + --chart-4: #9b9b9b; + --chart-5: #555; + --sidebar: #fafafa; + --sidebar-foreground: #0a0a0a; + --sidebar-primary: #8a9a00; + --sidebar-primary-foreground: #080808; + --sidebar-accent: #e5e5e5; + --sidebar-accent-foreground: #0a0a0a; + --sidebar-border: #e5e5e5; + --sidebar-ring: #8a9a00; - --font-sans: "DM Sans", sans-serif; - --font-mono: "Space Mono", monospace; - --font-display: "Bebas Neue", sans-serif; + --font-sans: "DM Sans", sans-serif; + --font-mono: "Space Mono", monospace; + --font-display: "Bebas Neue", sans-serif; - --radius: 0.2rem; - --tracking-normal: -0.01em; - --spacing: 0.25rem; + --radius: 0.2rem; + --tracking-normal: -0.01em; + --spacing: 0.25rem; - --shadow-2xs: 0px 2px 4px 0px rgb(0 0 0 / 0.04); - --shadow-xs: 0px 2px 4px 0px rgb(0 0 0 / 0.04); - --shadow-sm: 0px 2px 8px 0px rgb(0 0 0 / 0.06), 0px 1px 2px -1px rgb(0 0 0 / 0.06); - --shadow: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 1px 2px -1px rgb(0 0 0 / 0.08); - --shadow-md: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 2px 4px -1px rgb(0 0 0 / 0.08); - --shadow-lg: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 4px 6px -1px rgb(0 0 0 / 0.08); - --shadow-xl: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 8px 10px -1px rgb(0 0 0 / 0.08); - --shadow-2xl: 0px 2px 8px 0px rgb(0 0 0 / 0.15); + --shadow-2xs: 0px 2px 4px 0px rgb(0 0 0 / 0.04); + --shadow-xs: 0px 2px 4px 0px rgb(0 0 0 / 0.04); + --shadow-sm: + 0px 2px 8px 0px rgb(0 0 0 / 0.06), 0px 1px 2px -1px rgb(0 0 0 / 0.06); + --shadow: + 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 1px 2px -1px rgb(0 0 0 / 0.08); + --shadow-md: + 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 2px 4px -1px rgb(0 0 0 / 0.08); + --shadow-lg: + 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 4px 6px -1px rgb(0 0 0 / 0.08); + --shadow-xl: + 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 8px 10px -1px rgb(0 0 0 / 0.08); + --shadow-2xl: 0px 2px 8px 0px rgb(0 0 0 / 0.15); } .dark { - /* SiPher dark theme — primary brand identity */ - --background: var(--black); - --foreground: var(--text); - --card: #141414; - --card-foreground: var(--text); - --popover: var(--surface); - --popover-foreground: var(--text); - --primary: var(--acid); - --primary-foreground: var(--void); - --secondary: var(--muted); - --secondary-foreground: var(--text); - --muted: var(--muted); - --muted-foreground: var(--subtle); - --accent: var(--acid); - --accent-foreground: var(--void); - --destructive: var(--static); - --destructive-foreground: #ffffff; - --border: var(--border); - --input: var(--border); - --ring: var(--acid); - --chart-1: var(--acid); - --chart-2: var(--signal); - --chart-3: var(--static); - --chart-4: var(--ghost); - --chart-5: var(--subtle); - --sidebar: var(--surface); - --sidebar-foreground: var(--text); - --sidebar-primary: var(--acid); - --sidebar-primary-foreground: var(--void); - --sidebar-accent: var(--muted); - --sidebar-accent-foreground: var(--acid); - --sidebar-border: var(--border); - --sidebar-ring: var(--acid); + /* SiPher dark theme — primary brand identity */ + --background: var(--black); + --foreground: var(--text); + --card: #141414; + --card-foreground: var(--text); + --popover: var(--surface); + --popover-foreground: var(--text); + --primary: var(--acid); + --primary-foreground: var(--void); + --secondary: var(--muted); + --secondary-foreground: var(--text); + --muted: var(--muted); + --muted-foreground: var(--subtle); + --accent: var(--acid); + --accent-foreground: var(--void); + --destructive: var(--static); + --destructive-foreground: #ffffff; + --border: var(--border); + --input: var(--border); + --ring: var(--acid); + --chart-1: var(--acid); + --chart-2: var(--signal); + --chart-3: var(--static); + --chart-4: var(--ghost); + --chart-5: var(--subtle); + --sidebar: var(--surface); + --sidebar-foreground: var(--text); + --sidebar-primary: var(--acid); + --sidebar-primary-foreground: var(--void); + --sidebar-accent: var(--muted); + --sidebar-accent-foreground: var(--acid); + --sidebar-border: var(--border); + --sidebar-ring: var(--acid); - --shadow-2xs: 0px 4px 12px 0px rgb(0 0 0 / 0.25); - --shadow-xs: 0px 4px 12px 0px rgb(0 0 0 / 0.25); - --shadow-sm: 0px 4px 12px 0px rgb(0 0 0 / 0.35), 0px 1px 2px -1px rgb(0 0 0 / 0.35); - --shadow: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 1px 2px -1px rgb(0 0 0 / 0.4); - --shadow-md: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 2px 4px -1px rgb(0 0 0 / 0.4); - --shadow-lg: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 4px 6px -1px rgb(0 0 0 / 0.4); - --shadow-xl: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 8px 10px -1px rgb(0 0 0 / 0.4); - --shadow-2xl: 0px 4px 12px 0px rgb(0 0 0 / 0.6); + --shadow-2xs: 0px 4px 12px 0px rgb(0 0 0 / 0.25); + --shadow-xs: 0px 4px 12px 0px rgb(0 0 0 / 0.25); + --shadow-sm: + 0px 4px 12px 0px rgb(0 0 0 / 0.35), 0px 1px 2px -1px rgb(0 0 0 / 0.35); + --shadow: + 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 1px 2px -1px rgb(0 0 0 / 0.4); + --shadow-md: + 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 2px 4px -1px rgb(0 0 0 / 0.4); + --shadow-lg: + 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 4px 6px -1px rgb(0 0 0 / 0.4); + --shadow-xl: + 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 8px 10px -1px rgb(0 0 0 / 0.4); + --shadow-2xl: 0px 4px 12px 0px rgb(0 0 0 / 0.6); } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); - /* Brand palette — use as bg-acid, text-signal, etc. */ - --color-acid: var(--acid); - --color-static: var(--static); - --color-ghost: var(--ghost); - --color-void: var(--void); - --color-signal: var(--signal); - --color-surface: var(--surface); - --color-dim: var(--dim); - --color-subtle: var(--subtle); + /* Brand palette — use as bg-acid, text-signal, etc. */ + --color-acid: var(--acid); + --color-static: var(--static); + --color-ghost: var(--ghost); + --color-void: var(--void); + --color-signal: var(--signal); + --color-surface: var(--surface); + --color-dim: var(--dim); + --color-subtle: var(--subtle); - --font-sans: var(--font-sans); - --font-mono: var(--font-mono); - --font-display: var(--font-display); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-display: var(--font-display); - --radius-sm: calc(var(--radius) - 2px); - --radius-md: var(--radius); - --radius-lg: calc(var(--radius) + 2px); - --radius-xl: calc(var(--radius) + 4px); + --radius-sm: calc(var(--radius) - 2px); + --radius-md: var(--radius); + --radius-lg: calc(var(--radius) + 2px); + --radius-xl: calc(var(--radius) + 4px); - --shadow-2xs: var(--shadow-2xs); - --shadow-xs: var(--shadow-xs); - --shadow-sm: var(--shadow-sm); - --shadow: var(--shadow); - --shadow-md: var(--shadow-md); - --shadow-lg: var(--shadow-lg); - --shadow-xl: var(--shadow-xl); - --shadow-2xl: var(--shadow-2xl); + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); - --tracking-tighter: calc(var(--tracking-normal) - 0.05em); - --tracking-tight: calc(var(--tracking-normal) - 0.025em); - --tracking-normal: var(--tracking-normal); - --tracking-wide: calc(var(--tracking-normal) + 0.025em); - --tracking-wider: calc(var(--tracking-normal) + 0.05em); - --tracking-widest: calc(var(--tracking-normal) + 0.1em); + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-normal: var(--tracking-normal); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); } @layer base { - * { - @apply border-border outline-ring/50; - } + * { + @apply border-border outline-ring/50; + } - body { - @apply bg-background text-foreground font-sans antialiased; - letter-spacing: var(--tracking-normal); - } + body { + @apply bg-background text-foreground font-sans antialiased; + letter-spacing: var(--tracking-normal); + } - /* Section labels — Space Mono, uppercase, letter-spacing */ - .section-label { - @apply font-mono text-[10px] text-muted-foreground uppercase tracking-[0.2em]; - } + /* Section labels — Space Mono, uppercase, letter-spacing */ + .section-label { + @apply font-mono text-[10px] text-muted-foreground uppercase tracking-[0.2em]; + } - /* Display typography — Bebas Neue for headings */ - .font-display { - font-family: var(--font-display), sans-serif; - letter-spacing: 0.04em; - } -} \ No newline at end of file + /* Display typography — Bebas Neue for headings */ + .font-display { + font-family: var(--font-display), sans-serif; + letter-spacing: 0.04em; + } +} diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx index 1252874..88411f4 100644 --- a/src/components/login-form.tsx +++ b/src/components/login-form.tsx @@ -1,95 +1,95 @@ -import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "@/components/ui/card" import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, - FieldSeparator, + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldSeparator, } from "@/components/ui/field" import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" export function LoginForm({ - className, - ...props + className, + ...props }: React.ComponentProps<"div">) { - return ( -
- - - Welcome back - - Login with your Apple or Google account - - - -
- - - - - - - Or continue with - - - Email - - - - - - - - - - Don't have an account? Sign up - - - -
-
-
- - By clicking continue, you agree to our Terms of Service{" "} - and Privacy Policy. - -
- ) + return ( +
+ + + Welcome back + + Login with your Apple or Google account + + + +
+ + + + + + + Or continue with + + + Email + + + + + + + + + + Don't have an account? Sign up + + + +
+
+
+ + By clicking continue, you agree to our Terms of Service{" "} + and Privacy Policy. + +
+ ) } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 4d38506..b56c4cc 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,64 +1,64 @@ -import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" +import * as React from "react" import { cn } from "@/lib/utils" 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", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", - 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", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - 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", - 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", - icon: "size-9", - "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8", - "icon-lg": "size-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } + "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: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", + 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", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + 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", + 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", + icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } ) function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props + className, + variant = "default", + size = "default", + asChild = false, + ...props }: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot.Root : "button" + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" - return ( - - ) + return ( + + ) } export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index acf57dc..b84c672 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -3,90 +3,85 @@ import * as React from "react" import { cn } from "@/lib/utils" function Card({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ) } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ) } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ) } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ) } function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ) } function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ) } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ) } export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, + Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } + diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 84bdef4..6b43725 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,158 +1,159 @@ "use client" -import * as React from "react" import { XIcon } from "lucide-react" import { Dialog as DialogPrimitive } from "radix-ui" +import * as React from "react" -import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" function Dialog({ - ...props + ...props }: React.ComponentProps) { - return + return } function DialogTrigger({ - ...props + ...props }: React.ComponentProps) { - return + return } function DialogPortal({ - ...props + ...props }: React.ComponentProps) { - return + return } function DialogClose({ - ...props + ...props }: React.ComponentProps) { - return + return } function DialogOverlay({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } function DialogContent({ - className, - children, - showCloseButton = true, - ...props + className, + children, + showCloseButton = true, + ...props }: React.ComponentProps & { - showCloseButton?: boolean + showCloseButton?: boolean }) { - return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - - ) + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) } function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ) } function DialogFooter({ - className, - showCloseButton = false, - children, - ...props + className, + showCloseButton = false, + children, + ...props }: React.ComponentProps<"div"> & { - showCloseButton?: boolean + showCloseButton?: boolean }) { - return ( -
- {children} - {showCloseButton && ( - - - - )} -
- ) + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) } function DialogTitle({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } function DialogDescription({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } export { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogOverlay, - DialogPortal, - DialogTitle, - DialogTrigger, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger } + diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index ae1fcf6..61a6b37 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,257 +1,249 @@ "use client" -import * as React from "react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" +import * as React from "react" import { cn } from "@/lib/utils" function DropdownMenu({ - ...props + ...props }: React.ComponentProps) { - return + return } function DropdownMenuPortal({ - ...props + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } function DropdownMenuTrigger({ - ...props + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } function DropdownMenuContent({ - className, - sideOffset = 4, - ...props + className, + sideOffset = 4, + ...props }: React.ComponentProps) { - return ( - - - - ) + return ( + + + + ) } function DropdownMenuGroup({ - ...props + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } function DropdownMenuItem({ - className, - inset, - variant = "default", - ...props + className, + inset, + variant = "default", + ...props }: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" + inset?: boolean + variant?: "default" | "destructive" }) { - return ( - - ) + return ( + + ) } function DropdownMenuCheckboxItem({ - className, - children, - checked, - ...props + className, + children, + checked, + ...props }: React.ComponentProps) { - return ( - - - - - - - {children} - - ) + return ( + + + + + + + {children} + + ) } function DropdownMenuRadioGroup({ - ...props + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } function DropdownMenuRadioItem({ - className, - children, - ...props + className, + children, + ...props }: React.ComponentProps) { - return ( - - - - - - - {children} - - ) + return ( + + + + + + + {children} + + ) } function DropdownMenuLabel({ - className, - inset, - ...props + className, + inset, + ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean }) { - return ( - - ) + return ( + + ) } function DropdownMenuSeparator({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } function DropdownMenuShortcut({ - className, - ...props + className, + ...props }: React.ComponentProps<"span">) { - return ( - - ) + return ( + + ) } function DropdownMenuSub({ - ...props + ...props }: React.ComponentProps) { - return + return } function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props + className, + inset, + children, + ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean }) { - return ( - - {children} - - - ) + return ( + + {children} + + + ) } function DropdownMenuSubContent({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } export { - DropdownMenu, - DropdownMenuPortal, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, + DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } + diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx index ec849da..1a2da38 100644 --- a/src/components/ui/field.tsx +++ b/src/components/ui/field.tsx @@ -1,248 +1,244 @@ "use client" -import { useMemo } from "react" import { cva, type VariantProps } from "class-variance-authority" +import { useMemo } from "react" -import { cn } from "@/lib/utils" import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" +import { cn } from "@/lib/utils" function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { - return ( -
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", - className - )} - {...props} - /> - ) + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) } function FieldLegend({ - className, - variant = "legend", - ...props + className, + variant = "legend", + ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { - return ( - - ) + return ( + + ) } function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
[data-slot=field-group]]:gap-4", - className - )} - {...props} - /> - ) + return ( +
+ ) } const fieldVariants = cva( - "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", - { - variants: { - orientation: { - vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], - horizontal: [ - "flex-row items-center", - "[&>[data-slot=field-label]]:flex-auto", - "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", - ], - responsive: [ - "flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto", - "@md/field-group:[&>[data-slot=field-label]]:flex-auto", - "@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: { - orientation: "vertical", - }, - } + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col *:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "*:data-[slot=field-label]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col @md/field-group:flex-row @md/field-group:items-center *:w-full @md/field-group:*:w-auto [&>.sr-only]:w-auto", + "@md/field-group:*:data-[slot=field-label]:flex-auto", + "@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: { + orientation: "vertical", + }, + } ) function Field({ - className, - orientation = "vertical", - ...props + className, + orientation = "vertical", + ...props }: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ) + return ( +
+ ) } function FieldContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ) } function FieldLabel({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( -