diff --git a/bun.lock b/bun.lock index 922f399..45664aa 100644 --- a/bun.lock +++ b/bun.lock @@ -14,11 +14,12 @@ "bullmq": "^5.70.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "debug": "^4.4.3", "dexie": "^4.3.0", "dexie-react-hooks": "^4.2.0", "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", - "framer-motion": "^12.35.0", + "framer-motion": "^12.35.2", "http-signature": "^1.4.0", "ioredis": "^5.10.0", "lucide-react": "^0.577.0", @@ -26,7 +27,7 @@ "next": "16.1.6", "next-themes": "^0.4.6", "node-forge": "^1.3.3", - "nodemailer": "^8.0.1", + "nodemailer": "^8.0.2", "pg": "^8.20.0", "radix-ui": "^1.4.3", "react": "19.2.4", @@ -42,7 +43,9 @@ "@react-email/preview-server": "5.2.9", "@tailwindcss/postcss": "^4.2.1", "@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/pg": "^8.18.0", "@types/react": "^19.2.14", @@ -52,7 +55,7 @@ "cross-env": "^10.1.0", "drizzle-kit": "^0.31.9", "react-email": "5.2.9", - "shadcn": "^3.8.5", + "shadcn": "^4.0.2", "tailwindcss": "^4.2.1", "tsx": "^4.21.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/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=="], @@ -922,7 +931,7 @@ "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=="], @@ -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=="], - "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=="], @@ -1208,7 +1217,7 @@ "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=="], @@ -1406,7 +1415,7 @@ "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=="], @@ -1520,7 +1529,7 @@ "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=="], @@ -1634,6 +1643,8 @@ "@types/cors/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], + "@types/node-forge/@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="], + "@types/nodemailer/@types/node": ["@types/node@20.19.36", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-+3TQ+XhRjbmeKGHMhmUZfKlkF2/mAc+PpO2B90PBI7hRpkgPCSo5PaJ8tfWBJ4LMIuqrnKLD5TveeGMy+curtg=="], "@types/pg/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], @@ -1776,10 +1787,20 @@ "@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=="], "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=="], "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=="], + "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=="], "giget/nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], diff --git a/package.json b/package.json index cac9cc2..9d6311b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "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", "test": "cross-env NODE_ENV=test playwright test", + "test:key": "cross-env NODE_ENV=test playwright test tests/key.test.ts", "build": "next build", "start": "cross-env NODE_ENV=production node src/server.ts", "db:push": "drizzle-kit push", @@ -33,11 +34,12 @@ "bullmq": "^5.70.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "debug": "^4.4.3", "dexie": "^4.3.0", "dexie-react-hooks": "^4.2.0", "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", - "framer-motion": "^12.35.0", + "framer-motion": "^12.35.2", "http-signature": "^1.4.0", "ioredis": "^5.10.0", "lucide-react": "^0.577.0", @@ -45,7 +47,7 @@ "next": "16.1.6", "next-themes": "^0.4.6", "node-forge": "^1.3.3", - "nodemailer": "^8.0.1", + "nodemailer": "^8.0.2", "pg": "^8.20.0", "radix-ui": "^1.4.3", "react": "19.2.4", @@ -61,7 +63,9 @@ "@react-email/preview-server": "5.2.9", "@tailwindcss/postcss": "^4.2.1", "@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/pg": "^8.18.0", "@types/react": "^19.2.14", @@ -71,7 +75,7 @@ "cross-env": "^10.1.0", "drizzle-kit": "^0.31.9", "react-email": "5.2.9", - "shadcn": "^3.8.5", + "shadcn": "^4.0.2", "tailwindcss": "^4.2.1", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", diff --git a/src/app/discover/rotate/confirm/route.ts b/src/app/discover/rotate/confirm/route.ts new file mode 100644 index 0000000..2c30ae7 --- /dev/null +++ b/src/app/discover/rotate/confirm/route.ts @@ -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." }); +} diff --git a/src/app/discover/rotate/init/route.ts b/src/app/discover/rotate/init/route.ts new file mode 100644 index 0000000..2c80841 --- /dev/null +++ b/src/app/discover/rotate/init/route.ts @@ -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 }); +} diff --git a/src/app/discover/route.ts b/src/app/discover/route.ts new file mode 100644 index 0000000..798eb9f --- /dev/null +++ b/src/app/discover/route.ts @@ -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, { 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, { 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 }); + } +} \ No newline at end of file diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index c741d8c..5821fd8 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,10 +1,12 @@ import { twoFactorClient, usernameClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; +import { sipherSocialClientPlugin } from "./plugins/client/social"; export const authClient = createAuthClient({ fetchOptions: {}, plugins: [ usernameClient(), twoFactorClient(), + sipherSocialClientPlugin(), ] }) \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 6fd22e4..b5166a7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,7 +1,9 @@ +import { federation } from "@/plugins/server/federation"; +import { sipherSocial } from '@/plugins/server/social'; import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { betterAuth } from "better-auth"; 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 * as schema from "./db/schema"; import EmailService from "./mail"; @@ -54,11 +56,24 @@ export const auth = betterAuth({ twoFactor(), bearer(), haveIBeenPwned(), + sipherSocial(), + federation(), + openAPI(), 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. // You never know when companies will change their minds and decide to start tracking you. telemetry: { enabled: false + }, + user: { + additionalFields: { + isPrivate: { + type: "boolean", + defaultValue: false, + required: false, + index: false, + } + } } }); \ No newline at end of file diff --git a/src/lib/db/schema/index.ts b/src/lib/db/schema/index.ts index bea36f7..8cee988 100644 --- a/src/lib/db/schema/index.ts +++ b/src/lib/db/schema/index.ts @@ -1,5 +1,14 @@ 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", { id: text("id").primaryKey(), @@ -15,6 +24,7 @@ export const user = pgTable("user", { username: text("username").unique(), displayUsername: text("display_username"), twoFactorEnabled: boolean("two_factor_enabled").default(false), + isPrivate: boolean("is_private").default(false), }); 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 }) => ({ sessions: many(session), accounts: many(account), twoFactors: many(twoFactor), + postss: many(posts), + followss: many(follows), + mutess: many(mutes), + blockss: many(blocks), })); export const sessionRelations = relations(session, ({ one }) => ({ @@ -118,3 +234,52 @@ export const twoFactorRelations = relations(twoFactor, ({ one }) => ({ 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], + }), +})); diff --git a/src/lib/db/schema/user/index.ts b/src/lib/db/schema/user/index.ts deleted file mode 100644 index f748f06..0000000 --- a/src/lib/db/schema/user/index.ts +++ /dev/null @@ -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], - }), -})); diff --git a/src/lib/federation/keygen.ts b/src/lib/federation/keygen.ts new file mode 100644 index 0000000..6e9960f --- /dev/null +++ b/src/lib/federation/keygen.ts @@ -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; +} \ No newline at end of file diff --git a/src/lib/federation/keytools.ts b/src/lib/federation/keytools.ts new file mode 100644 index 0000000..13e51c0 --- /dev/null +++ b/src/lib/federation/keytools.ts @@ -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())) +} \ No newline at end of file diff --git a/src/lib/plugins/client/social.ts b/src/lib/plugins/client/social.ts new file mode 100644 index 0000000..cdf7076 --- /dev/null +++ b/src/lib/plugins/client/social.ts @@ -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, + getActions($fetch, $store, options) { + return { + createPost: async (content: z.infer) => { + const response = await $fetch("/social/posts", { + method: "POST", + body: { + content, + }, + }); + return response; + } + } + }, + } satisfies BetterAuthClientPlugin; +}; \ No newline at end of file diff --git a/src/lib/plugins/server/federation.ts b/src/lib/plugins/server/federation.ts new file mode 100644 index 0000000..4ac5af8 --- /dev/null +++ b/src/lib/plugins/server/federation.ts @@ -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; +} \ No newline at end of file diff --git a/src/lib/plugins/server/helpers/social/endpoints/blocks.ts b/src/lib/plugins/server/helpers/social/endpoints/blocks.ts new file mode 100644 index 0000000..cb6bf05 --- /dev/null +++ b/src/lib/plugins/server/helpers/social/endpoints/blocks.ts @@ -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) => { }) \ No newline at end of file diff --git a/src/lib/plugins/server/helpers/social/endpoints/follows.ts b/src/lib/plugins/server/helpers/social/endpoints/follows.ts new file mode 100644 index 0000000..408b757 --- /dev/null +++ b/src/lib/plugins/server/helpers/social/endpoints/follows.ts @@ -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) => { }) \ No newline at end of file diff --git a/src/lib/plugins/server/helpers/social/endpoints/index.ts b/src/lib/plugins/server/helpers/social/endpoints/index.ts new file mode 100644 index 0000000..b2b9262 --- /dev/null +++ b/src/lib/plugins/server/helpers/social/endpoints/index.ts @@ -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 }; + diff --git a/src/lib/plugins/server/helpers/social/endpoints/mutes.ts b/src/lib/plugins/server/helpers/social/endpoints/mutes.ts new file mode 100644 index 0000000..c061fe2 --- /dev/null +++ b/src/lib/plugins/server/helpers/social/endpoints/mutes.ts @@ -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) => { }) + diff --git a/src/lib/plugins/server/helpers/social/endpoints/posts.ts b/src/lib/plugins/server/helpers/social/endpoints/posts.ts new file mode 100644 index 0000000..64f8d8e --- /dev/null +++ b/src/lib/plugins/server/helpers/social/endpoints/posts.ts @@ -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) => { }) \ No newline at end of file diff --git a/src/lib/plugins/server/helpers/social/social.ts b/src/lib/plugins/server/helpers/social/social.ts new file mode 100644 index 0000000..fb3bddc --- /dev/null +++ b/src/lib/plugins/server/helpers/social/social.ts @@ -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 \ No newline at end of file diff --git a/src/lib/plugins/server/social.ts b/src/lib/plugins/server/social.ts new file mode 100644 index 0000000..d10c7bc --- /dev/null +++ b/src/lib/plugins/server/social.ts @@ -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; +} \ No newline at end of file diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts new file mode 100644 index 0000000..4d7911f --- /dev/null +++ b/tests/helpers/db.ts @@ -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) { + 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) +} \ No newline at end of file diff --git a/tests/key.test.ts b/tests/key.test.ts new file mode 100644 index 0000000..40055f0 --- /dev/null +++ b/tests/key.test.ts @@ -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/ }) +}) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index a19c151..06d4df8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,9 +26,9 @@ "@/*": [ "./src/*" ], - "@app": [ - "./src/app" - ] + "@/plugins/*": [ + "./src/lib/plugins/*" + ], } }, "include": [