feat: implement server discovery and key rotation functionality

- Added new routes for server discovery and key rotation, including challenge issuance and confirmation processes.
- Introduced database schema for managing server registrations and rotation challenges.
- Implemented encryption and decryption utilities for secure communication between servers.
- Updated package dependencies and added new client and server plugins for social features.
- Enhanced user management with additional fields and relations in the database schema.
This commit is contained in:
Nixyan 2026-03-09 21:37:59 -03:00
parent b1b80dd75b
commit ea172050a6
23 changed files with 1313 additions and 139 deletions

View file

@ -14,11 +14,12 @@
"bullmq": "^5.70.4", "bullmq": "^5.70.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"debug": "^4.4.3",
"dexie": "^4.3.0", "dexie": "^4.3.0",
"dexie-react-hooks": "^4.2.0", "dexie-react-hooks": "^4.2.0",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"framer-motion": "^12.35.0", "framer-motion": "^12.35.2",
"http-signature": "^1.4.0", "http-signature": "^1.4.0",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
@ -26,7 +27,7 @@
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.2",
"pg": "^8.20.0", "pg": "^8.20.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
@ -42,7 +43,9 @@
"@react-email/preview-server": "5.2.9", "@react-email/preview-server": "5.2.9",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"@types/bun": "^1.3.10", "@types/bun": "^1.3.10",
"@types/node": "^20.19.37", "@types/debug": "^4.1.12",
"@types/node": "^25.3.5",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@ -52,7 +55,7 @@
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"react-email": "5.2.9", "react-email": "5.2.9",
"shadcn": "^3.8.5", "shadcn": "^4.0.2",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@ -620,7 +623,13 @@
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
"@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="],
"@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="], "@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="],
@ -922,7 +931,7 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"framer-motion": ["framer-motion@12.35.0", "", { "dependencies": { "motion-dom": "^12.35.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-w8hghCMQ4oq10j6aZh3U2yeEQv5K69O/seDI/41PK4HtgkLrcBovUNc0ayBC3UyyU7V1mrY2yLzvYdWJX9pGZQ=="], "framer-motion": ["framer-motion@12.35.2", "", { "dependencies": { "motion-dom": "^12.35.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
@ -1166,7 +1175,7 @@
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="], "mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="],
"motion-dom": ["motion-dom@12.35.0", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-FFMLEnIejK/zDABn+vqGVAUN4T0+3fw+cVAY8MMT65yR+j5uMuvWdd4npACWhh94OVWQs79CrBBuwOwGRZAQiA=="], "motion-dom": ["motion-dom@12.35.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg=="],
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
@ -1208,7 +1217,7 @@
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="], "nodemailer": ["nodemailer@8.0.2", "", {}, "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@ -1406,7 +1415,7 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shadcn": ["shadcn@3.8.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-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="], "shadcn": ["shadcn@4.0.2", "", { "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-U6BxywcVt5Vy+iLdjoyWTPcuz2RQNad09tjJsW8hyKtxlx7VAvzHIvoNxvXN+gxWt/HX8kt/cVNk8AXcmPUaNQ=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
@ -1520,7 +1529,7 @@
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
@ -1634,6 +1643,8 @@
"@types/cors/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], "@types/cors/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
"@types/node-forge/@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="],
"@types/nodemailer/@types/node": ["@types/node@20.19.36", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-+3TQ+XhRjbmeKGHMhmUZfKlkF2/mAc+PpO2B90PBI7hRpkgPCSo5PaJ8tfWBJ4LMIuqrnKLD5TveeGMy+curtg=="], "@types/nodemailer/@types/node": ["@types/node@20.19.36", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-+3TQ+XhRjbmeKGHMhmUZfKlkF2/mAc+PpO2B90PBI7hRpkgPCSo5PaJ8tfWBJ4LMIuqrnKLD5TveeGMy+curtg=="],
"@types/pg/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], "@types/pg/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
@ -1776,10 +1787,20 @@
"@prisma/config/c12/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], "@prisma/config/c12/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"@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=="],
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"bullmq/ioredis/@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="], "bullmq/ioredis/@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="],
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@ -1838,6 +1859,8 @@
"drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"engine.io/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"giget/nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], "giget/nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],

View file

@ -16,6 +16,7 @@
"dev": "cross-env NODE_ENV=development tsx src/server.ts", "dev": "cross-env NODE_ENV=development tsx src/server.ts",
"email:dev": "cross-env NODE_ENV=development email dev --dir src/lib/mail/templates --port 3001", "email:dev": "cross-env NODE_ENV=development email dev --dir src/lib/mail/templates --port 3001",
"test": "cross-env NODE_ENV=test playwright test", "test": "cross-env NODE_ENV=test playwright test",
"test:key": "cross-env NODE_ENV=test playwright test tests/key.test.ts",
"build": "next build", "build": "next build",
"start": "cross-env NODE_ENV=production node src/server.ts", "start": "cross-env NODE_ENV=production node src/server.ts",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
@ -33,11 +34,12 @@
"bullmq": "^5.70.4", "bullmq": "^5.70.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"debug": "^4.4.3",
"dexie": "^4.3.0", "dexie": "^4.3.0",
"dexie-react-hooks": "^4.2.0", "dexie-react-hooks": "^4.2.0",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"framer-motion": "^12.35.0", "framer-motion": "^12.35.2",
"http-signature": "^1.4.0", "http-signature": "^1.4.0",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
@ -45,7 +47,7 @@
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.2",
"pg": "^8.20.0", "pg": "^8.20.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
@ -61,7 +63,9 @@
"@react-email/preview-server": "5.2.9", "@react-email/preview-server": "5.2.9",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"@types/bun": "^1.3.10", "@types/bun": "^1.3.10",
"@types/node": "^20.19.37", "@types/debug": "^4.1.12",
"@types/node": "^25.3.5",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@ -71,7 +75,7 @@
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"react-email": "5.2.9", "react-email": "5.2.9",
"shadcn": "^3.8.5", "shadcn": "^4.0.2",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",

View file

@ -0,0 +1,133 @@
import db from "@/lib/db";
import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
import { decryptPayload } from "@/lib/federation/keytools";
import { eq, sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import createDebug from "debug";
const debug = createDebug("app:discover:rotate:confirm");
/**
* Confirms a key rotation challenge issued by /discover/rotate/init.
*
* 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.
*
* 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.)
*/
export async function POST(request: NextRequest) {
const body = await request.json();
debug("POST /discover/rotate/confirm confirmation request for %s", body?.serverUrl);
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(),
}).safeParse(body);
if (!validated.success) {
debug("POST /discover/rotate/confirm validation failed: %o", validated.error.message);
return NextResponse.json({ error: validated.error.message }, { status: 400 });
}
debug("POST /discover/rotate/confirm fetching pending challenge for %s", validated.data.serverUrl);
const [challenge] = await db.select().from(rotateChallengeTokens)
.where(eq(rotateChallengeTokens.serverUrl, validated.data.serverUrl));
if (!challenge) {
debug("POST /discover/rotate/confirm no pending challenge found");
return NextResponse.json({ error: "No pending rotation challenge found for this server." }, { status: 404 });
}
if (challenge.expiresAt < new Date()) {
debug("POST /discover/rotate/confirm challenge expired at %s", challenge.expiresAt.toISOString());
await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, challenge.id));
return NextResponse.json({ error: "Challenge token has expired." }, { status: 400 });
}
if (challenge.attemptsLeft <= 0) {
debug("POST /discover/rotate/confirm no attempts left, blacklisting %s", challenge.serverUrl);
await db.insert(blacklistedServers).values({
id: crypto.randomUUID(),
serverUrl: challenge.serverUrl,
reason: "Too many failed attempts to confirm key rotation challenge",
createdAt: new Date(),
});
await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, challenge.id));
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;
try {
decryptedOld = decryptPayload(validated.data.signedOldChallenge, process.env.FEDERATION_PRIVATE_KEY!);
decryptedNew = decryptPayload(validated.data.signedNewChallenge, process.env.FEDERATION_PRIVATE_KEY!);
} catch {
debug("POST /discover/rotate/confirm decryption failed, decrementing attempts");
await db.update(rotateChallengeTokens).set({
attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`,
}).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.`,
}, { status: 400 });
}
// Both plaintexts must match their stored tokens.
// A mismatch on oldKeyChallenge means the requester does not hold the registered private key.
// A mismatch on newKeyChallenge means the requester does not actually own the new key.
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",
);
await db.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.`,
}, { status: 400 });
}
// Both challenges passed:
// — SA holds the old private key (they are who they claim to be)
// — SA holds the new private key (they own the key they want to rotate to)
// — SA knows our public key (they fetched our identity to re-encrypt)
debug("POST /discover/rotate/confirm both challenges passed, rotating key for %s", challenge.serverUrl);
await db.update(serverRegistry).set({
publicKey: challenge.newPublicKey,
updatedAt: new Date(),
}).where(eq(serverRegistry.url, challenge.serverUrl));
await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, challenge.id));
debug("POST /discover/rotate/confirm key rotation complete for %s", challenge.serverUrl);
return NextResponse.json({ message: "Key rotation confirmed successfully." });
}

View file

@ -0,0 +1,96 @@
import db from "@/lib/db";
import { 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) => {
try {
const pub = forge.pki.publicKeyFromPem(key);
return pub.n.bitLength() >= 4096;
} catch {
return false;
}
}, { message: "Invalid public key" });
const schema = z.object({
url: z.url(),
newPublicKey: publicKeySchema,
});
/**
* 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.
*
* 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.
*/
export async function POST(request: NextRequest) {
const body = await request.json();
debug("POST /discover/rotate/init rotation request for %s", body?.url);
const validated = schema.safeParse(body);
if (!validated.success) {
debug("POST /discover/rotate/init validation failed: %o", validated.error.message);
return NextResponse.json({ error: validated.error.message }, { status: 400 });
}
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) {
debug("POST /discover/rotate/init server not found");
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 });
}
// 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();
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);
await db.insert(rotateChallengeTokens).values({
id: crypto.randomUUID(),
oldKeyToken: oldKeyPlaintext,
newKeyToken: newKeyPlaintext,
newPublicKey: validated.data.newPublicKey,
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 });
}

137
src/app/discover/route.ts Normal file
View file

@ -0,0 +1,137 @@
import db from "@/lib/db";
import { serverRegistry } from "@/lib/db/schema";
import { decryptPayload } 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");
export async function GET() {
debug("GET /discover fetching healthy peers");
const peers = await db.select().from(serverRegistry).where(eq(serverRegistry.isHealthy, true));
debug("GET /discover found %d peer(s)", peers.length);
return NextResponse.json({
url: process.env.NEXT_PUBLIC_APP_URL,
publicKey: process.env.FEDERATION_PUBLIC_KEY,
peers
});
}
async function upsertServer(url: string, publicKey: string) {
return await db.insert(serverRegistry).values({
id: crypto.randomUUID(),
url: url,
publicKey: publicKey,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
isHealthy: true,
}).onConflictDoNothing();
}
const publicKeySchema = z.string().refine((key) => {
try {
const pub = forge.pki.publicKeyFromPem(key);
return pub.n.bitLength() >= 4096;
} catch {
return false;
}
}, { message: "Invalid public key" });
const schema = z.discriminatedUnion("method", [
z.object({
method: z.literal("DISCOVER"),
publicKey: publicKeySchema,
signature: z.string().refine((signature) => {
try {
const sig = decryptPayload(signature, process.env.FEDERATION_PRIVATE_KEY!);
const data = JSON.parse(sig);
return data.publicKey != null && data.url != null;
} catch {
return false;
}
}, { message: "Invalid signature" }),
}),
z.object({
method: z.literal("REGISTER"),
url: z.url(),
publicKey: publicKeySchema,
})
]);
async function discoverServer(validated: Extract<z.infer<typeof schema>, { method: "DISCOVER" }>) {
debug("DISCOVER looking up server by public key");
const server = await db.select().from(serverRegistry).where(eq(serverRegistry.publicKey, validated.publicKey));
if (server.length === 0) {
debug("DISCOVER server not found");
return NextResponse.json({ error: "Server not found" }, { status: 404 });
}
const confirmations = {
sameKeyOnServer: false,
sameKeyOnFetch: false,
}
if (server[0].publicKey === validated.publicKey) confirmations.sameKeyOnServer = true;
debug("DISCOVER fetching public key from federation server %s", server[0].url);
const federationResponse = await (await fetch(server[0].url + "/discover")).json();
if (federationResponse.publicKey === validated.publicKey) confirmations.sameKeyOnFetch = true;
debug("DISCOVER confirmations: %o", confirmations);
return NextResponse.json(confirmations);
}
async function registerServer(validated: Extract<z.infer<typeof schema>, { method: "REGISTER" }>) {
debug("REGISTER fetching /discover from %s to validate server", validated.url);
const response = await (await fetch(validated.url + "/discover")).json();
if (!response.publicKey) {
debug("REGISTER remote server returned no public key");
return NextResponse.json({ error: "Invalid server" }, { status: 400 });
} else if (response.publicKey !== validated.publicKey) {
debug("REGISTER public key mismatch: provided vs fetched");
return NextResponse.json({ error: "Invalid public key" }, { 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 });
}
debug("REGISTER upserting server %s", validated.url);
await upsertServer(validated.url.toString(), validated.publicKey);
debug("REGISTER server registered successfully");
return NextResponse.json({
message: "Server registered successfully", echo: {
url: process.env.NEXT_PUBLIC_APP_URL,
publicKey: process.env.FEDERATION_PUBLIC_KEY,
}
});
}
export async function POST(request: NextRequest) {
const body = await request.json();
debug("POST /discover method: %s", body?.method);
const validated = schema.safeParse(body);
if (!validated.success) {
debug("POST /discover validation failed: %o", validated.error.message);
return NextResponse.json({ error: validated.error.message }, { status: 400 });
}
switch (validated.data.method) {
case "DISCOVER":
return await discoverServer(validated.data);
case "REGISTER":
return await registerServer(validated.data);
default:
return NextResponse.json({ error: "Invalid method" }, { status: 400 });
}
}

View file

@ -1,10 +1,12 @@
import { twoFactorClient, usernameClient } from "better-auth/client/plugins"; import { twoFactorClient, usernameClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
import { sipherSocialClientPlugin } from "./plugins/client/social";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
fetchOptions: {}, fetchOptions: {},
plugins: [ plugins: [
usernameClient(), usernameClient(),
twoFactorClient(), twoFactorClient(),
sipherSocialClientPlugin(),
] ]
}) })

View file

@ -1,7 +1,9 @@
import { federation } from "@/plugins/server/federation";
import { sipherSocial } from '@/plugins/server/social';
import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { createAuthMiddleware } from "better-auth/api"; import { createAuthMiddleware } from "better-auth/api";
import { bearer, haveIBeenPwned, testUtils, twoFactor, username } from "better-auth/plugins"; import { bearer, haveIBeenPwned, openAPI, testUtils, twoFactor, username } from "better-auth/plugins";
import db from "./db"; import db from "./db";
import * as schema from "./db/schema"; import * as schema from "./db/schema";
import EmailService from "./mail"; import EmailService from "./mail";
@ -54,11 +56,24 @@ export const auth = betterAuth({
twoFactor(), twoFactor(),
bearer(), bearer(),
haveIBeenPwned(), haveIBeenPwned(),
sipherSocial(),
federation(),
openAPI(),
testUtils() // TODO: Add a conditional plugin for test utils in development testUtils() // TODO: Add a conditional plugin for test utils in development
], ],
// This is disabled by default, but I'll keep this here for ease of mind. // This is disabled by default, but I'll keep this here for ease of mind.
// You never know when companies will change their minds and decide to start tracking you. // You never know when companies will change their minds and decide to start tracking you.
telemetry: { telemetry: {
enabled: false enabled: false
},
user: {
additionalFields: {
isPrivate: {
type: "boolean",
defaultValue: false,
required: false,
index: false,
}
}
} }
}); });

View file

@ -1,5 +1,14 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; import {
pgTable,
text,
timestamp,
boolean,
integer,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", { export const user = pgTable("user", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@ -15,6 +24,7 @@ export const user = pgTable("user", {
username: text("username").unique(), username: text("username").unique(),
displayUsername: text("display_username"), displayUsername: text("display_username"),
twoFactorEnabled: boolean("two_factor_enabled").default(false), twoFactorEnabled: boolean("two_factor_enabled").default(false),
isPrivate: boolean("is_private").default(false),
}); });
export const session = pgTable( export const session = pgTable(
@ -92,10 +102,116 @@ export const twoFactor = pgTable(
], ],
); );
export const serverRegistry = pgTable(
"server_registry",
{
id: text("id").primaryKey(),
url: text("url").notNull().unique(),
publicKey: text("public_key").notNull().unique(),
lastSeen: timestamp("last_seen").notNull(),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
isHealthy: boolean("is_healthy").notNull(),
},
(table) => [
uniqueIndex("serverRegistry_publicKey_uidx").on(table.publicKey),
index("serverRegistry_lastSeen_idx").on(table.lastSeen),
],
);
export const posts = pgTable("posts", {
id: text("id").primaryKey(),
content: jsonb("content").notNull(),
authorId: text("author_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
published: timestamp("published").notNull(),
isLocal: boolean("is_local").default(false).notNull(),
isPrivate: boolean("is_private").default(false),
createdAt: timestamp("created_at").notNull(),
});
export const follows = pgTable("follows", {
id: text("id").primaryKey(),
followerId: text("follower_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
followingId: text("following_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accepted: boolean("accepted").default(false).notNull(),
createdAt: timestamp("created_at").notNull(),
});
export const deliveryJobs = pgTable("delivery_jobs", {
id: text("id").primaryKey(),
targetUrl: text("target_url").notNull(),
payload: text("payload").notNull(),
attempts: integer("attempts").default(0).notNull(),
lastAttemptedAt: timestamp("last_attempted_at"),
nextAttemptAt: timestamp("next_attempt_at"),
createdAt: timestamp("created_at").notNull(),
});
export const mutes = pgTable("mutes", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
mutedUserId: text("muted_user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull(),
});
export const blocks = pgTable("blocks", {
id: text("id").primaryKey(),
blockerId: text("blocker_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
blockedUserId: text("blocked_user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull(),
});
export const rotateChallengeTokens = pgTable(
"rotate_challenge_tokens",
{
id: text("id").primaryKey(),
oldKeyToken: text("old_key_token").notNull(),
newKeyToken: text("new_key_token").notNull().unique(),
newPublicKey: text("new_public_key").notNull(),
serverUrl: text("server_url").notNull(),
createdAt: timestamp("created_at").notNull(),
attemptsLeft: integer("attempts_left").default(3).notNull(),
expiresAt: timestamp("expires_at").notNull(),
},
(table) => [
uniqueIndex("rotateChallengeTokens_newKeyToken_uidx").on(table.newKeyToken),
index("rotateChallengeTokens_serverUrl_idx").on(table.serverUrl),
],
);
export const blacklistedServers = pgTable(
"blacklisted_servers",
{
id: text("id").primaryKey(),
serverUrl: text("server_url").notNull(),
createdAt: timestamp("created_at").notNull(),
reason: text("reason").notNull(),
},
(table) => [index("blacklistedServers_serverUrl_idx").on(table.serverUrl)],
);
export const userRelations = relations(user, ({ many }) => ({ export const userRelations = relations(user, ({ many }) => ({
sessions: many(session), sessions: many(session),
accounts: many(account), accounts: many(account),
twoFactors: many(twoFactor), twoFactors: many(twoFactor),
postss: many(posts),
followss: many(follows),
mutess: many(mutes),
blockss: many(blocks),
})); }));
export const sessionRelations = relations(session, ({ one }) => ({ export const sessionRelations = relations(session, ({ one }) => ({
@ -118,3 +234,52 @@ export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
references: [user.id], references: [user.id],
}), }),
})); }));
export const postsRelations = relations(posts, ({ one }) => ({
user: one(user, {
fields: [posts.authorId],
references: [user.id],
}),
}));
export const followsFollowerIdRelations = relations(follows, ({ one }) => ({
user: one(user, {
fields: [follows.followerId],
references: [user.id],
}),
}));
export const followsFollowingIdRelations = relations(follows, ({ one }) => ({
user: one(user, {
fields: [follows.followingId],
references: [user.id],
}),
}));
export const mutesUserIdRelations = relations(mutes, ({ one }) => ({
user: one(user, {
fields: [mutes.userId],
references: [user.id],
}),
}));
export const mutesMutedUserIdRelations = relations(mutes, ({ one }) => ({
user: one(user, {
fields: [mutes.mutedUserId],
references: [user.id],
}),
}));
export const blocksBlockerIdRelations = relations(blocks, ({ one }) => ({
user: one(user, {
fields: [blocks.blockerId],
references: [user.id],
}),
}));
export const blocksBlockedUserIdRelations = relations(blocks, ({ one }) => ({
user: one(user, {
fields: [blocks.blockedUserId],
references: [user.id],
}),
}));

View file

@ -1,120 +0,0 @@
import { relations } from "drizzle-orm";
import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
username: text("username").unique(),
displayUsername: text("display_username"),
twoFactorEnabled: boolean("two_factor_enabled").default(false),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const twoFactor = pgTable(
"two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("twoFactor_secret_idx").on(table.secret),
index("twoFactor_userId_idx").on(table.userId),
],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
twoFactors: many(twoFactor),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
user: one(user, {
fields: [twoFactor.userId],
references: [user.id],
}),
}));

View file

@ -0,0 +1,30 @@
import Bun from "bun";
import forge from "node-forge";
export async function generateKeyPair() {
// Check if .env file exists
if (!Bun.file(".env.local").exists()) { throw new Error("No .env.local file found"); }
const keypair = forge.pki.rsa.generateKeyPair(4096);
const keys = {
publicKey: forge.pki.publicKeyToPem(keypair.publicKey),
privateKey: forge.pki.privateKeyToPem(keypair.privateKey)
}
// Sanity check to make sure the keys are not already in the file
const env = await Bun.file(".env.local").text();
if (env.includes("FEDERATION_PUBLIC_KEY") && env.includes("FEDERATION_PRIVATE_KEY")) {
throw new Error("Keys already exist in .env.local file, if you wish to regenerate them, please delete the keys and run the command again");
}
// Read the .env.local file and append the keys to the file without overwriting the existing keys
// Escape newlines for .env format (single-line value that expands to PEM when loaded)
const publicKey = keys.publicKey.replace(/\r?\n/g, "\\n");
const privateKey = keys.privateKey.replace(/\r?\n/g, "\\n");
const separator = "#------------- SERVER KEYS -------------\n# DO NOT EDIT THIS SECTION, THIS IS AUTOMATICALLY GENERATED BY THE SERVER\nDO NOT PUBLISH THE PRIVATE KEY TO THE PUBLIC!";
await Bun.write(".env.local", `${env}\n\n${separator}\n\nFEDERATION_PUBLIC_KEY="${publicKey}"\nFEDERATION_PRIVATE_KEY="${privateKey}"`);
console.log("Keys generated successfully");
return keys;
}

View file

@ -0,0 +1,49 @@
import forge from "node-forge";
export function encryptPayload(payload: string, recipientPublicKey: string) {
const pub = forge.pki.publicKeyFromPem(recipientPublicKey);
return forge.util.encode64(
pub.encrypt(
forge.util.encodeUtf8(payload),
"RSA-OAEP"
)
)
}
export function decryptPayload(payload: string, privateKey: string) {
const priv = forge.pki.privateKeyFromPem(privateKey);
try {
return forge.util.decodeUtf8(
priv.decrypt(
forge.util.decode64(payload),
"RSA-OAEP"
)
)
} catch (error) {
console.error("Failed to decrypt payload", error);
throw error;
}
}
export function verifyChallenge(
challenge: string,
signedChallenge: string,
publicKeyPem: string
): boolean {
try {
const pub = forge.pki.publicKeyFromPem(publicKeyPem)
const md = forge.md.sha256.create()
md.update(challenge, 'utf8')
const sig = forge.util.decode64(signedChallenge)
return pub.verify(md.digest().bytes(), sig)
} catch {
return false
}
}
export function signChallenge(challenge: string, privateKeyPem: string): string {
const priv = forge.pki.privateKeyFromPem(privateKeyPem)
const md = forge.md.sha256.create()
md.update(challenge, 'utf8')
return forge.util.encode64(priv.sign(md.digest().bytes()))
}

View file

@ -0,0 +1,26 @@
import type { BetterAuthClientPlugin } from "better-auth/client";
import { z } from "zod";
import { postContentSchema } from "../server/helpers/social/social";
import type { sipherSocial } from "../server/social";
type SipherSocialPlugin = typeof sipherSocial;
export const sipherSocialClientPlugin = () => {
return {
id: "sipher-social",
$InferServerPlugin: {} as ReturnType<SipherSocialPlugin>,
getActions($fetch, $store, options) {
return {
createPost: async (content: z.infer<typeof postContentSchema>) => {
const response = await $fetch("/social/posts", {
method: "POST",
body: {
content,
},
});
return response;
}
}
},
} satisfies BetterAuthClientPlugin;
};

View file

@ -0,0 +1,105 @@
import { BetterAuthPlugin } from "better-auth";
export const federation = () => {
return {
id: "sipher-federation",
schema: {
serverRegistry: {
fields: {
url: {
type: "string",
required: true,
unique: true,
index: false
},
publicKey: {
type: "string",
required: true,
unique: true,
index: true
},
lastSeen: {
type: "date",
required: true,
index: true
},
createdAt: {
type: "date",
required: true,
index: false
},
updatedAt: {
type: "date",
required: true,
index: false
},
isHealthy: {
type: "boolean",
required: true,
index: false
}
}
},
rotateChallengeTokens: {
fields: {
oldKeyToken: {
type: "string",
required: true,
index: false
},
newKeyToken: {
type: "string",
required: true,
unique: true,
index: true
},
newPublicKey: {
type: "string",
required: true,
index: false
},
serverUrl: {
type: "string",
required: true,
index: true
},
createdAt: {
type: "date",
required: true,
index: false
},
attemptsLeft: {
type: "number",
required: true,
index: false,
defaultValue: 3
},
expiresAt: {
type: "date",
required: true,
index: false
}
}
},
blacklistedServers: {
fields: {
serverUrl: {
type: "string",
required: true,
index: true
},
createdAt: {
type: "date",
required: true,
index: false
},
reason: {
type: "string",
required: true,
index: false
}
}
}
}
} satisfies BetterAuthPlugin;
}

View file

@ -0,0 +1,17 @@
import { createAuthEndpoint } from "better-auth/api"
import { z } from "zod"
export const createBlock = createAuthEndpoint("/social/blocks", {
method: "POST",
}, async (context) => { })
export const deleteBlock = createAuthEndpoint("/social/blocks/:id", {
method: "DELETE",
params: z.object({
id: z.string(),
}),
}, async (context) => { })
export const getBlocks = createAuthEndpoint("/social/blocks", {
method: "GET",
}, async (context) => { })

View file

@ -0,0 +1,21 @@
import { createAuthEndpoint } from "better-auth/api"
import { z } from "zod"
export const followUser = createAuthEndpoint("/social/follows", {
method: "POST",
}, async (context) => { })
export const unfollowUser = createAuthEndpoint("/social/follows/:id", {
method: "DELETE",
params: z.object({
id: z.string(),
}),
}, async (context) => { })
export const getFollows = createAuthEndpoint("/social/follows/following", {
method: "GET",
}, async (context) => { })
export const getFollowers = createAuthEndpoint("/social/follows/followers", {
method: "GET",
}, async (context) => { })

View file

@ -0,0 +1,7 @@
import { createBlock, deleteBlock, getBlocks } from "./blocks";
import { followUser, getFollowers, getFollows, unfollowUser } from "./follows";
import { createMute, deleteMute, getMutes } from "./mutes";
import { createPost, getPost } from "./posts";
export { createBlock, createMute, createPost, deleteBlock, deleteMute, followUser, getBlocks, getFollowers, getFollows, getMutes, getPost, unfollowUser };

View file

@ -0,0 +1,18 @@
import { createAuthEndpoint } from "better-auth/api"
import { z } from "zod"
export const createMute = createAuthEndpoint("/social/mutes", {
method: "POST",
}, async (context) => { })
export const deleteMute = createAuthEndpoint("/social/mutes/:id", {
method: "DELETE",
params: z.object({
id: z.string(),
}),
}, async (context) => { })
export const getMutes = createAuthEndpoint("/social/mutes", {
method: "GET",
}, async (context) => { })

View file

@ -0,0 +1,25 @@
import { createAuthEndpoint, getSessionFromCtx } from "better-auth/api";
import { z } from "zod";
import { postContentSchema } from "../social";
export const createPost = createAuthEndpoint("/social/posts", {
method: "POST",
body: postContentSchema,
}, async (context) => {
const content = context.body;
const user = getSessionFromCtx(context)
if (!user) {
return context.json({ error: "Unauthorized" }, { status: 401 });
}
console.log(content);
return context.json({ message: "Hello, world!" }, { status: 200 });
});
export const getPost = createAuthEndpoint("/social/posts/:id", {
method: "GET",
params: z.object({
id: z.string(),
}),
}, async (context) => { })

View file

@ -0,0 +1,219 @@
import { BetterAuthPluginDBSchema } from "better-auth";
import { z } from "zod";
const postContentBlockSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("text"),
value: z.string().min(1, "Text content cannot be empty"),
}),
z.object({
type: z.literal("image"),
url: z.url("Image must be a valid URL"),
index: z.number().min(0, "Index must be a positive number"),
}),
z.object({
type: z.literal("video"),
url: z.url("Video must be a valid URL"),
}),
z.object({
type: z.literal("audio"),
url: z.url("Audio must be a valid URL"),
}),
z.object({
type: z.literal("link"),
url: z.url("Link must be a valid URL"),
}),
], { error: 'Block "type" must be one of: text, image, video, audio, link' });
export const postContentSchema = z
.array(postContentBlockSchema, { error: "Post content must be an array of blocks" })
.min(1, "Post must contain at least one content block");
export default {
posts: {
fields: {
content: {
type: "json",
required: true,
index: false,
transform: {
output: (value) => {
let parsed: unknown;
try {
parsed = typeof value === "string" ? JSON.parse(value) : value;
} catch {
throw new Error("Post content is not valid JSON");
}
const validated = postContentSchema.safeParse(parsed);
if (!validated.success) {
const issues = validated.error.issues
.map((i) => `[${i.path.join(".")}] ${i.message}`)
.join("; ");
throw new Error(`Invalid post content: ${issues}`);
}
return validated.data;
}
}
},
authorId: {
type: "string",
required: true,
index: false,
references: {
model: "user",
field: "id"
}
},
published: {
type: "date",
required: true,
index: false,
},
// "isLocal" will be used to determine if the post should only exist
// on the local server or if it should be propagated to other servers
isLocal: {
type: "boolean",
required: true,
index: false,
defaultValue: false,
},
// "isPrivate" will be used to determine if the post should be visible only for the user's followers
isPrivate: {
type: "boolean",
required: false,
index: false,
defaultValue: false,
},
createdAt: {
type: "date",
required: true,
index: false
}
}
},
follows: {
fields: {
followerId: {
type: "string",
required: true,
index: false,
references: {
model: "user",
field: "id"
}
},
followingId: {
type: "string",
required: true,
index: false,
references: {
model: "user",
field: "id"
}
},
accepted: {
type: "boolean",
required: true,
index: false,
defaultValue: false,
},
createdAt: {
type: "date",
required: true,
index: false
}
}
},
deliveryJobs: {
fields: {
targetUrl: {
type: "string",
required: true,
index: false
},
// This could be encrypted, so we're not using a transform function to check for validity
payload: {
type: "string",
required: true,
index: false
},
attempts: {
type: "number",
required: true,
index: false,
defaultValue: 0,
},
lastAttemptedAt: {
type: "date",
required: false,
index: false,
},
nextAttemptAt: {
type: "date",
required: false,
index: false,
},
createdAt: {
type: "date",
required: true,
index: false
}
}
},
mutes: {
fields: {
userId: {
type: "string",
required: true,
index: false,
references: {
model: "user",
field: "id"
}
},
mutedUserId: {
type: "string",
required: true,
index: false,
references: {
model: "user",
field: "id"
}
},
createdAt: {
type: "date",
required: true,
index: false
}
}
},
blocks: {
fields: {
blockerId: {
type: "string",
required: true,
index: false,
references: {
model: "user",
field: "id"
}
},
blockedUserId: {
type: "string",
required: true,
index: false,
references: {
model: "user",
field: "id"
}
},
createdAt: {
type: "date",
required: true,
index: false
}
}
}
} satisfies BetterAuthPluginDBSchema

View file

@ -0,0 +1,14 @@
import type { BetterAuthPlugin } from "better-auth";
import * as socialEndpoints from "./helpers/social/endpoints";
import socialSchema from "./helpers/social/social";
export const sipherSocial = () => {
return {
id: "sipher-social",
schema: socialSchema,
endpoints: {
...socialEndpoints,
}
} satisfies BetterAuthPlugin;
}

46
tests/helpers/db.ts Normal file
View file

@ -0,0 +1,46 @@
// tests/helpers/db.ts
import db from "@/lib/db";
import { rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
import forge from "node-forge";
export function generateKeypair() {
const keypair = forge.pki.rsa.generateKeyPair(2048);
return {
publicKey: forge.pki.publicKeyToPem(keypair.publicKey),
privateKey: forge.pki.privateKeyToPem(keypair.privateKey),
}
}
export async function seedServer(url: string, publicKey: string) {
await db.insert(serverRegistry).values({
id: crypto.randomUUID(),
url,
publicKey,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
isHealthy: true,
}).onConflictDoNothing()
}
export async function seedChallenge(overrides?: Partial<typeof rotateChallengeTokens.$inferInsert>) {
const { publicKey: defaultNewPublicKey } = generateKeypair()
const defaults = {
id: crypto.randomUUID(),
serverUrl: "https://test-server.com",
oldKeyToken: crypto.randomUUID(),
newKeyToken: crypto.randomUUID(),
newPublicKey: defaultNewPublicKey,
attemptsLeft: 3,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 1000 * 60 * 5),
}
const row = { ...defaults, ...overrides }
await db.insert(rotateChallengeTokens).values(row)
return row
}
export async function clearTables() {
await db.delete(rotateChallengeTokens)
await db.delete(serverRegistry)
}

142
tests/key.test.ts Normal file
View file

@ -0,0 +1,142 @@
/**
* Tests the key rotation flow.
*
* This test covers:
* - Missing challenge
* - Expired challenge
* - Wrong challenge plaintext
* - Blacklists server after too many failed attempts
* - Confirms valid challenge and rotates key
*/
import { expect, test } from "@playwright/test"
import createDebug from "debug"
import forge from "node-forge"
import { clearTables, generateKeypair, seedChallenge, seedServer } from "./helpers/db"
const debug = createDebug("test:key")
test.beforeEach(async ({ }, testInfo) => {
debug("beforeEach clearing tables for: %s", testInfo.title)
await clearTables()
})
test.afterEach(async ({ }, testInfo) => {
debug("afterEach clearing tables after: %s", testInfo.title)
await clearTables()
})
function encryptPayload(payload: string, recipientPublicKey: string) {
const pub = forge.pki.publicKeyFromPem(recipientPublicKey);
return forge.util.encode64(
pub.encrypt(
forge.util.encodeUtf8(payload),
"RSA-OAEP"
)
)
}
test("rejects missing challenge", async ({ request }) => {
debug("test: rejects missing challenge posting with unknown serverUrl")
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://ghost-server.com",
signedOldChallenge: "fake",
signedNewChallenge: "fake",
}
})
debug("test: rejects missing challenge status %d", res.status())
expect(res.status()).toBe(404)
})
test("rejects expired challenge", async ({ request }) => {
debug("test: rejects expired challenge seeding expired challenge")
await seedChallenge({ expiresAt: new Date(Date.now() - 1000) })
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://test-server.com",
signedOldChallenge: "fake",
signedNewChallenge: "fake",
}
})
debug("test: rejects expired challenge status %d", res.status())
expect(res.status()).toBe(400)
expect(await res.json()).toMatchObject({ error: /expired/ })
})
test("rejects wrong challenge plaintext", async ({ request }) => {
debug("test: rejects wrong challenge plaintext seeding valid challenge")
await seedChallenge()
debug("test: rejects wrong challenge plaintext posting with incorrect plaintexts")
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://test-server.com",
// encrypt wrong plaintexts with your server's public key
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
}
})
debug("test: rejects wrong challenge plaintext status %d", res.status())
expect(res.status()).toBe(400)
expect(await res.json()).toMatchObject({ error: /mismatch/ })
})
test("blacklists server after too many failed attempts", async ({ request }) => {
debug("test: blacklists server after too many failed attempts seeding server and challenge (attemptsLeft=3)")
await seedServer("https://test-server.com", process.env.FEDERATION_PUBLIC_KEY!)
await seedChallenge({ expiresAt: new Date(Date.now() + 1000 * 60) })
// 3 wrong attempts exhaust attemptsLeft (3 → 0), each returning 400 mismatch
for (let i = 0; i < 3; i++) {
debug("test: blacklists server after too many failed attempts wrong attempt %d/3", i + 1)
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://test-server.com",
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
}
})
debug("test: blacklists server after too many failed attempts status %d", res.status())
expect(res.status()).toBe(400)
expect(await res.json()).toMatchObject({ error: /mismatch/ })
}
// 4th attempt: attemptsLeft is now 0, server gets blacklisted
debug("test: blacklists server after too many failed attempts 4th attempt should trigger blacklist (403)")
const finalRes = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://test-server.com",
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
}
})
debug("test: blacklists server after too many failed attempts final status %d", finalRes.status())
expect(finalRes.status()).toBe(403)
expect(await finalRes.json()).toMatchObject({ error: /blacklisted/ })
})
test("confirms valid challenge and rotates key", async ({ request }) => {
debug("test: confirms valid challenge generating old and new keypairs")
// SB's old keypair — what is currently registered
const { publicKey: oldPublicKey } = generateKeypair()
// SB's new keypair — what SB wants to rotate to
const { publicKey: newPublicKey } = generateKeypair()
debug("test: confirms valid challenge seeding server and challenge")
await seedServer("https://test-server.com", oldPublicKey)
const challenge = await seedChallenge({ newPublicKey })
// Simulate SB: re-encrypt the plaintext tokens with SA's public key
debug("test: confirms valid challenge re-encrypting tokens with SA public key")
const signedOldChallenge = encryptPayload(challenge.oldKeyToken, process.env.FEDERATION_PUBLIC_KEY!)
const signedNewChallenge = encryptPayload(challenge.newKeyToken, process.env.FEDERATION_PUBLIC_KEY!)
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://test-server.com",
signedOldChallenge,
signedNewChallenge,
}
})
debug("test: confirms valid challenge status %d", res.status())
expect(res.status()).toBe(200)
expect(await res.json()).toMatchObject({ message: /confirmed/ })
})

View file

@ -26,9 +26,9 @@
"@/*": [ "@/*": [
"./src/*" "./src/*"
], ],
"@app": [ "@/plugins/*": [
"./src/app" "./src/lib/plugins/*"
] ],
} }
}, },
"include": [ "include": [