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:
parent
b1b80dd75b
commit
ea172050a6
23 changed files with 1313 additions and 139 deletions
43
bun.lock
43
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
12
package.json
12
package.json
|
|
@ -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",
|
||||||
|
|
|
||||||
133
src/app/discover/rotate/confirm/route.ts
Normal file
133
src/app/discover/rotate/confirm/route.ts
Normal 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." });
|
||||||
|
}
|
||||||
96
src/app/discover/rotate/init/route.ts
Normal file
96
src/app/discover/rotate/init/route.ts
Normal 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
137
src/app/discover/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
30
src/lib/federation/keygen.ts
Normal file
30
src/lib/federation/keygen.ts
Normal 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;
|
||||||
|
}
|
||||||
49
src/lib/federation/keytools.ts
Normal file
49
src/lib/federation/keytools.ts
Normal 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()))
|
||||||
|
}
|
||||||
26
src/lib/plugins/client/social.ts
Normal file
26
src/lib/plugins/client/social.ts
Normal 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;
|
||||||
|
};
|
||||||
105
src/lib/plugins/server/federation.ts
Normal file
105
src/lib/plugins/server/federation.ts
Normal 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;
|
||||||
|
}
|
||||||
17
src/lib/plugins/server/helpers/social/endpoints/blocks.ts
Normal file
17
src/lib/plugins/server/helpers/social/endpoints/blocks.ts
Normal 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) => { })
|
||||||
21
src/lib/plugins/server/helpers/social/endpoints/follows.ts
Normal file
21
src/lib/plugins/server/helpers/social/endpoints/follows.ts
Normal 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) => { })
|
||||||
7
src/lib/plugins/server/helpers/social/endpoints/index.ts
Normal file
7
src/lib/plugins/server/helpers/social/endpoints/index.ts
Normal 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 };
|
||||||
|
|
||||||
18
src/lib/plugins/server/helpers/social/endpoints/mutes.ts
Normal file
18
src/lib/plugins/server/helpers/social/endpoints/mutes.ts
Normal 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) => { })
|
||||||
|
|
||||||
25
src/lib/plugins/server/helpers/social/endpoints/posts.ts
Normal file
25
src/lib/plugins/server/helpers/social/endpoints/posts.ts
Normal 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) => { })
|
||||||
219
src/lib/plugins/server/helpers/social/social.ts
Normal file
219
src/lib/plugins/server/helpers/social/social.ts
Normal 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
|
||||||
14
src/lib/plugins/server/social.ts
Normal file
14
src/lib/plugins/server/social.ts
Normal 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
46
tests/helpers/db.ts
Normal 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
142
tests/key.test.ts
Normal 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/ })
|
||||||
|
})
|
||||||
|
|
@ -26,9 +26,9 @@
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
],
|
],
|
||||||
"@app": [
|
"@/plugins/*": [
|
||||||
"./src/app"
|
"./src/lib/plugins/*"
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue