diff --git a/bun.lock b/bun.lock index ba8ee245..50053776 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@hono/zod-openapi": "^1.2.2", "drizzle-orm": "^0.45.1", "hono": "^4.12.8", + "node-forge": "^1.4.0", "zod": "^4.3.6", }, "devDependencies": { @@ -18,6 +19,7 @@ "@semantic-release/git": "^10.0.1", "@semantic-release/npm": "^13.1.5", "@types/node": "^25.5.0", + "@types/node-forge": "^1.3.14", "drizzle-kit": "^0.31.10", "prettier": "3.8.1", "semantic-release": "^25.0.3", @@ -245,6 +247,8 @@ "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="], + "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], "@types/parse-path": ["@types/parse-path@7.0.3", "", {}, "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg=="], @@ -529,6 +533,8 @@ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + "normalize-package-data": ["normalize-package-data@8.0.0", "", { "dependencies": { "hosted-git-info": "^9.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ=="], "normalize-url": ["normalize-url@9.0.0", "", {}, "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ=="], diff --git a/package.json b/package.json index 66bc72bf..40d46f7a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@hono/zod-openapi": "^1.2.2", "drizzle-orm": "^0.45.1", "hono": "^4.12.8", + "node-forge": "^1.4.0", "zod": "^4.3.6" }, "devDependencies": { @@ -32,6 +33,7 @@ "@semantic-release/git": "^10.0.1", "@semantic-release/npm": "^13.1.5", "@types/node": "^25.5.0", + "@types/node-forge": "^1.3.14", "drizzle-kit": "^0.31.10", "prettier": "3.8.1", "semantic-release": "^25.0.3", diff --git a/src/routes/manager.ts b/src/routes/manager.ts index c989b974..56226fcf 100644 --- a/src/routes/manager.ts +++ b/src/routes/manager.ts @@ -4,9 +4,13 @@ import { ErrorResponseSchema } from '../schemas/common'; import { ReleaseResponseSchema, VersionResponseSchema, - HistoryResponseSchema + HistoryResponseSchema, + SignedReleaseResponseSchema, + SignedVersionResponseSchema, + SignedHistoryResponseSchema } from '../schemas/releases'; import * as managerService from '../services/manager'; +import { signResponse } from "../services/signature"; const app = new OpenAPIHono<{ Bindings: Env }>(); @@ -20,7 +24,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: ReleaseResponseSchema } + 'application/json': { schema: SignedReleaseResponseSchema } }, description: 'The latest manager release.' }, @@ -33,7 +37,8 @@ app.openapi( } }), async (c) => { - return c.json(await managerService.getRelease(c.env, false), 200); + const data = await managerService.getRelease(c.env, false); + return c.json( signResponse(data, c.env.MANAGER_PRIVATE_KEY, c.env.MANAGER_CERT), 200); } ); @@ -47,7 +52,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: ReleaseResponseSchema } + 'application/json': { schema: SignedReleaseResponseSchema } }, description: 'The latest manager prerelease.' }, @@ -60,7 +65,8 @@ app.openapi( } }), async (c) => { - return c.json(await managerService.getRelease(c.env, true), 200); + const data = await managerService.getRelease(c.env, true); + return c.json( signResponse(data, c.env.MANAGER_PRIVATE_KEY, c.env.MANAGER_CERT), 200); } ); @@ -74,7 +80,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: VersionResponseSchema } + 'application/json': { schema: SignedVersionResponseSchema } }, description: 'The current manager release version.' }, @@ -87,7 +93,8 @@ app.openapi( } }), async (c) => { - return c.json(await managerService.getVersion(c.env, false), 200); + const data = await managerService.getVersion(c.env, false); + return c.json( signResponse(data, c.env.MANAGER_PRIVATE_KEY, c.env.MANAGER_CERT), 200); } ); @@ -101,7 +108,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: VersionResponseSchema } + 'application/json': { schema: SignedVersionResponseSchema } }, description: 'The current manager prerelease version.' }, @@ -114,7 +121,8 @@ app.openapi( } }), async (c) => { - return c.json(await managerService.getVersion(c.env, true), 200); + const data = await managerService.getVersion(c.env, true); + return c.json( signResponse(data, c.env.MANAGER_PRIVATE_KEY, c.env.MANAGER_CERT), 200); } ); @@ -128,7 +136,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: HistoryResponseSchema } + 'application/json': { schema: SignedHistoryResponseSchema } }, description: 'The manager release history.' }, @@ -141,7 +149,8 @@ app.openapi( } }), async (c) => { - return c.json(await managerService.getHistory(c.env, false), 200); + const data = await managerService.getHistory(c.env, false); + return c.json( signResponse(data, c.env.MANAGER_PRIVATE_KEY, c.env.MANAGER_CERT), 200); } ); @@ -155,7 +164,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: HistoryResponseSchema } + 'application/json': { schema: SignedHistoryResponseSchema } }, description: 'The manager prerelease history.' }, @@ -168,7 +177,8 @@ app.openapi( } }), async (c) => { - return c.json(await managerService.getHistory(c.env, true), 200); + const data = await managerService.getHistory(c.env, true); + return c.json( signResponse(data, c.env.MANAGER_PRIVATE_KEY, c.env.MANAGER_CERT), 200); } ); @@ -182,7 +192,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: ReleaseResponseSchema } + 'application/json': { schema: SignedReleaseResponseSchema } }, description: 'The latest manager downloaders release.' }, @@ -195,10 +205,11 @@ app.openapi( } }), async (c) => { + const data = await managerService.getDownloadersRelease(c.env, false); return c.json( - await managerService.getDownloadersRelease(c.env, false), + signResponse(data, c.env.DOWNLOADER_PRIVATE_KEY, c.env.DOWNLOADER_CERT), 200 - ); + ); } ); @@ -212,7 +223,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: ReleaseResponseSchema } + 'application/json': { schema: SignedReleaseResponseSchema } }, description: 'The latest manager downloaders prerelease.' }, @@ -225,10 +236,11 @@ app.openapi( } }), async (c) => { + const data = await managerService.getDownloadersRelease(c.env, true); return c.json( - await managerService.getDownloadersRelease(c.env, true), + signResponse(data, c.env.DOWNLOADER_PRIVATE_KEY, c.env.DOWNLOADER_CERT), 200 - ); + ); } ); @@ -243,7 +255,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: VersionResponseSchema } + 'application/json': { schema: SignedVersionResponseSchema } }, description: 'The current manager downloaders release version.' }, @@ -256,10 +268,11 @@ app.openapi( } }), async (c) => { + const data = await managerService.getDownloadersRelease(c.env, false); return c.json( - await managerService.getDownloadersVersion(c.env, false), + signResponse(data, c.env.DOWNLOADER_PRIVATE_KEY, c.env.DOWNLOADER_CERT), 200 - ); + ); } ); @@ -273,7 +286,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: VersionResponseSchema } + 'application/json': { schema: SignedVersionResponseSchema } }, description: 'The current manager downloaders prerelease version.' @@ -287,10 +300,11 @@ app.openapi( } }), async (c) => { + const data = await managerService.getDownloadersRelease(c.env, true); return c.json( - await managerService.getDownloadersVersion(c.env, true), + signResponse(data, c.env.DOWNLOADER_PRIVATE_KEY, c.env.DOWNLOADER_CERT), 200 - ); + ); } ); diff --git a/src/routes/patches.ts b/src/routes/patches.ts index 8adc5c4b..7c67a977 100644 --- a/src/routes/patches.ts +++ b/src/routes/patches.ts @@ -5,9 +5,13 @@ import { ReleaseResponseSchema, VersionResponseSchema, HistoryResponseSchema, - PublicKeyResponseSchema + PublicKeyResponseSchema, + SignedReleaseResponseSchema, + SignedVersionResponseSchema, + SignedHistoryResponseSchema } from '../schemas/releases'; import * as patchesService from '../services/patches'; +import { signResponse } from "../services/signature"; const app = new OpenAPIHono<{ Bindings: Env }>(); @@ -21,7 +25,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: ReleaseResponseSchema } + 'application/json': { schema: SignedReleaseResponseSchema } }, description: 'The current patches release.' }, @@ -34,7 +38,8 @@ app.openapi( } }), async (c) => { - return c.json(await patchesService.getRelease(c.env, false), 200); + const data = await patchesService.getRelease(c.env, false); + return c.json(signResponse(data, c.env.PATCHES_PRIVATE_KEY, c.env.PATCHES_CERT), 200); } ); @@ -48,7 +53,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: ReleaseResponseSchema } + 'application/json': { schema: SignedReleaseResponseSchema } }, description: 'The current patches prerelease.' }, @@ -61,7 +66,8 @@ app.openapi( } }), async (c) => { - return c.json(await patchesService.getRelease(c.env, true), 200); + const data = await patchesService.getRelease(c.env, true); + return c.json(signResponse(data, c.env.PATCHES_PRIVATE_KEY, c.env.PATCHES_CERT), 200); } ); @@ -77,7 +83,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: VersionResponseSchema } + 'application/json': { schema: SignedVersionResponseSchema } }, description: 'The current patches release version.' }, @@ -90,7 +96,8 @@ app.openapi( } }), async (c) => { - return c.json(await patchesService.getVersion(c.env, false), 200); + const data = await patchesService.getVersion(c.env, false); + return c.json(signResponse(data, c.env.PATCHES_PRIVATE_KEY, c.env.PATCHES_CERT), 200); } ); @@ -104,7 +111,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: VersionResponseSchema } + 'application/json': { schema: SignedVersionResponseSchema } }, description: 'The current patches prerelease version.' }, @@ -117,7 +124,8 @@ app.openapi( } }), async (c) => { - return c.json(await patchesService.getVersion(c.env, true), 200); + const data = await patchesService.getVersion(c.env, true); + return c.json(signResponse(data, c.env.PATCHES_PRIVATE_KEY, c.env.PATCHES_CERT), 200); } ); @@ -131,7 +139,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: HistoryResponseSchema } + 'application/json': { schema: SignedHistoryResponseSchema } }, description: 'The patches release history.' }, @@ -144,7 +152,8 @@ app.openapi( } }), async (c) => { - return c.json(await patchesService.getHistory(c.env, false), 200); + const data = await patchesService.getHistory(c.env, false); + return c.json(signResponse(data, c.env.PATCHES_PRIVATE_KEY, c.env.PATCHES_CERT), 200); } ); @@ -158,7 +167,7 @@ app.openapi( responses: { 200: { content: { - 'application/json': { schema: HistoryResponseSchema } + 'application/json': { schema: SignedHistoryResponseSchema } }, description: 'The patches prerelease history.' }, @@ -171,7 +180,8 @@ app.openapi( } }), async (c) => { - return c.json(await patchesService.getHistory(c.env, true), 200); + const data = await patchesService.getHistory(c.env, true); + return c.json(signResponse(data, c.env.PATCHES_PRIVATE_KEY, c.env.PATCHES_CERT), 200); } ); diff --git a/src/schemas/releases.ts b/src/schemas/releases.ts index 1a37ef23..d25965d5 100644 --- a/src/schemas/releases.ts +++ b/src/schemas/releases.ts @@ -49,3 +49,21 @@ export const PublicKeyResponseSchema = z }) }) .openapi('PublicKey'); + +export const SignedReleaseResponseSchema = z.object({ + data: ReleaseResponseSchema, + signature: z.string(), + certificate: z.string(), +}).openapi("SignedRelease"); + +export const SignedVersionResponseSchema = z.object({ + data: VersionResponseSchema, + signature: z.string(), + certificate: z.string(), +}).openapi("SignedVersion"); + +export const SignedHistoryResponseSchema = z.object({ + data: HistoryResponseSchema, + signature: z.string(), + certificate: z.string(), +}).openapi("SignedHistory"); diff --git a/src/services/signature.ts b/src/services/signature.ts new file mode 100644 index 00000000..7a55f2fe --- /dev/null +++ b/src/services/signature.ts @@ -0,0 +1,26 @@ + +import forge from "node-forge"; + +export const signResponse = ( + data: any, + privateKeyPem: string | undefined, + certPem: string | undefined, +) => { + if (!privateKeyPem || !certPem) { + console.warn("Signing keys not found. Returning unsigned data."); + return data; + } + + const body = JSON.stringify(data, Object.keys(data).sort()); + + const md = forge.md.sha256.create(); + md.update(body, "utf8"); + const privateKey = forge.pki.privateKeyFromPem(privateKeyPem); + const signature = privateKey.sign(md); + + return { + data, + signature: forge.util.encode64(signature), + certificate: certPem, + }; +}; diff --git a/src/types.ts b/src/types.ts index e0052e08..fd9619b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,4 +19,10 @@ export interface Env { PATCHES_PUBLIC_KEY_FILE: string; CONTRIBUTORS_REPOS: string; API_VERSION: string; + PATCHES_PRIVATE_KEY: string; + PATCHES_CERT: string; + MANAGER_PRIVATE_KEY: string; + MANAGER_CERT: string; + DOWNLOADER_PRIVATE_KEY: string; + DOWNLOADER_CERT: string; }