diff --git a/Overlord-Server/src/config.ts b/Overlord-Server/src/config.ts index 7637d04..a560d2a 100644 --- a/Overlord-Server/src/config.ts +++ b/Overlord-Server/src/config.ts @@ -75,6 +75,9 @@ export interface Config { maxConcurrentPerUser: number; globalMaxConcurrent: number; }; + buildSigning: { + banlist: string[]; + }; } const DEFAULT_CONFIG: Config = { @@ -148,6 +151,9 @@ const DEFAULT_CONFIG: Config = { maxConcurrentPerUser: 1, globalMaxConcurrent: 3, }, + buildSigning: { + banlist: [], + }, }; type SaveSecrets = { @@ -156,6 +162,15 @@ type SaveSecrets = { agentToken?: string; bootstrapPassword?: string; }; + buildSigning?: { + privateKey?: string; + publicKey?: string; + }; +}; + +export type BuildSigningSecrets = { + privateKey: string; + publicKey: string; }; function generateRandomSecret(length = 48): string { @@ -520,6 +535,11 @@ export function loadConfig(): Config { fileConfig.buildRateLimit?.globalMaxConcurrent || DEFAULT_CONFIG.buildRateLimit.globalMaxConcurrent, }, + buildSigning: { + banlist: Array.isArray(fileConfig.buildSigning?.banlist) + ? fileConfig.buildSigning.banlist.filter((v: unknown): v is string => typeof v === "string" && v.length > 0) + : [...DEFAULT_CONFIG.buildSigning.banlist], + }, }; if (saveChanged) { @@ -820,8 +840,51 @@ export async function updateBuildRateLimitConfig( return next; } +export function getBuildSigningSecrets(): BuildSigningSecrets | null { + const dataDir = ensureDataDir(); + const savePath = resolve(dataDir, "save.json"); + const saved = loadSaveSecrets(savePath); + const priv = saved.buildSigning?.privateKey; + const pub = saved.buildSigning?.publicKey; + if (typeof priv === "string" && priv && typeof pub === "string" && pub) { + return { privateKey: priv, publicKey: pub }; + } + return null; +} + +export function setBuildSigningSecrets(secrets: BuildSigningSecrets): void { + const dataDir = ensureDataDir(); + const savePath = resolve(dataDir, "save.json"); + const saved = loadSaveSecrets(savePath); + saved.buildSigning = { privateKey: secrets.privateKey, publicKey: secrets.publicKey }; + persistSaveSecrets(savePath, saved); +} + +export function getBuildBanlist(): string[] { + return getConfig().buildSigning.banlist; +} + +export function setBuildBanlist(banlist: string[]): void { + const current = getConfig(); + const cleaned = Array.from( + new Set(banlist.filter((v) => typeof v === "string" && v.length > 0)), + ); + const next: Config["buildSigning"] = { banlist: cleaned }; + + configCache = { ...current, buildSigning: next }; + + const fileConfig = readFileConfigForUpdate(); + fileConfig.buildSigning = next; + try { + writeFileSync(getPersistentConfigPath(), JSON.stringify(fileConfig, null, 2)); + } catch (err) { + logger.warn("Failed to persist buildSigning banlist", err); + } +} + export function getExportableConfig(serverVersion: string): Record { const config = getConfig(); + const buildSigningSecrets = getBuildSigningSecrets(); return { _meta: { exportedAt: new Date().toISOString(), @@ -840,6 +903,11 @@ export function getExportableConfig(serverVersion: string): Record): Promise<{ app } } + if (data.buildSigning && typeof data.buildSigning === "object") { + const incomingPriv = typeof data.buildSigning.privateKey === "string" ? data.buildSigning.privateKey : ""; + const incomingPub = typeof data.buildSigning.publicKey === "string" ? data.buildSigning.publicKey : ""; + let importedKeypair = false; + + if (incomingPriv && incomingPub) { + const dataDir = ensureDataDir(); + const savePath = resolve(dataDir, "save.json"); + const savedSecrets = loadSaveSecrets(savePath); + savedSecrets.buildSigning = { privateKey: incomingPriv, publicKey: incomingPub }; + persistSaveSecrets(savePath, savedSecrets); + importedKeypair = true; + } else if (incomingPriv || incomingPub) { + warnings.push("buildSigning import skipped: both privateKey and publicKey are required."); + } + + if (Array.isArray(data.buildSigning.banlist)) { + const cleaned = data.buildSigning.banlist.filter( + (v: unknown): v is string => typeof v === "string" && v.length > 0, + ); + setBuildBanlist(cleaned); + } + + applied.push( + importedKeypair + ? "buildSigning (keypair updated in save.json — restart required, banlist applied)" + : "buildSigning (banlist applied)", + ); + } + return { applied, warnings }; } diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 23cdb6f..babc31b 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "url"; import path from "path"; import fs from "fs/promises"; import { existsSync } from "node:fs"; -import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages, recordBuildClaim, getClientMetricsSummary, pruneOldNotifications } from "./db"; +import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages, recordBuildClaim, getClientMetricsSummary, pruneOldNotifications } from "./db"; import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers"; import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation"; import { ClientInfo, ClientRole } from "./types"; @@ -512,30 +512,30 @@ async function startServer() { registration: {}, }; - function handleBuildTagConnection(clientId: string, buildTagValue: string, keyFingerprint: string) { - if (!buildTagValue) return; - const build = getBuildByTag(buildTagValue); - if (!build || !build.builtByUserId) return; + function handleBuildTagConnection( + clientId: string, + buildId: string | null, + builtByUserId: number | undefined, + keyFingerprint: string, + ) { + if (!buildId || !builtByUserId) return; - if (build.blocked) return; - - const isFirstClaim = recordBuildClaim(build.id, keyFingerprint); + const isFirstClaim = recordBuildClaim(buildId, keyFingerprint); if (!isFirstClaim) return; - const userId = build.builtByUserId; - const user = getUserById(userId); + const user = getUserById(builtByUserId); if (!user) return; if (user.role === "admin") return; - const currentScope = getUserClientAccessScope(userId); + const currentScope = getUserClientAccessScope(builtByUserId); if (currentScope === "none") { - setUserClientAccessScope(userId, "allowlist"); + setUserClientAccessScope(builtByUserId, "allowlist"); } if (currentScope === "none" || currentScope === "allowlist") { - setUserClientAccessRule(userId, clientId, "allow"); - logger.info(`[build-tag] Auto-added client ${clientId} to user ${user.username}'s allowlist (build: ${build.id.substring(0, 8)}, fp: ${keyFingerprint.substring(0, 12)}...)`); + setUserClientAccessRule(builtByUserId, clientId, "allow"); + logger.info(`[build-tag] Auto-added client ${clientId} to user ${user.username}'s allowlist (build: ${buildId.substring(0, 8)}, fp: ${keyFingerprint.substring(0, 12)}...)`); } } diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts index c3bc9a8..d339f8c 100644 --- a/Overlord-Server/src/server/build-process.ts +++ b/Overlord-Server/src/server/build-process.ts @@ -5,6 +5,7 @@ import { v4 as uuidv4 } from "uuid"; import { saveBuild } from "../db"; import { logger } from "../logger"; import { getConfig } from "../config"; +import { signBuildToken } from "./build-signing"; import { ensureDataDir } from "../paths"; import * as buildManager from "../build/buildManager"; import type { BuildStream } from "../build/types"; @@ -322,8 +323,13 @@ export async function startBuildProcess( sendToStream({ type: "output", text: "Mutex: disabled\n", level: "info" }); } - const buildTag = uuidv4(); - sendToStream({ type: "output", text: `Build tag: ${buildTag}\n`, level: "info" }); + const buildTag = await signBuildToken({ + v: 1, + bid: buildId, + uid: config.builtByUserId ?? null, + iat: Math.floor(Date.now() / 1000), + }); + sendToStream({ type: "output", text: `Build tag: signed (bid=${buildId.substring(0, 8)})\n`, level: "info" }); if (config.outputName) { sendToStream({ type: "output", text: `Custom output name: ${config.outputName}\n`, level: "info" }); diff --git a/Overlord-Server/src/server/build-signing.test.ts b/Overlord-Server/src/server/build-signing.test.ts new file mode 100644 index 0000000..10668f8 --- /dev/null +++ b/Overlord-Server/src/server/build-signing.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test, beforeAll, beforeEach } from "bun:test"; +import { mkdtemp } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; + +let signBuildToken: typeof import("./build-signing").signBuildToken; +let verifyBuildToken: typeof import("./build-signing").verifyBuildToken; +let isBuildBanned: typeof import("./build-signing").isBuildBanned; +let addBuildToBanlist: typeof import("./build-signing").addBuildToBanlist; +let removeBuildFromBanlist: typeof import("./build-signing").removeBuildFromBanlist; +let resetCache: () => void; + +beforeAll(async () => { + process.env.DATA_DIR = await mkdtemp(join(tmpdir(), "overlord-build-signing-test-")); + const mod = await import("./build-signing"); + signBuildToken = mod.signBuildToken; + verifyBuildToken = mod.verifyBuildToken; + isBuildBanned = mod.isBuildBanned; + addBuildToBanlist = mod.addBuildToBanlist; + removeBuildFromBanlist = mod.removeBuildFromBanlist; + resetCache = mod.resetBuildSigningCacheForTests; +}); + +describe("build-signing", () => { + test("sign/verify roundtrip preserves payload", async () => { + const payload = { v: 1 as const, bid: "build-abc-123", uid: 42, iat: 1700000000 }; + const token = await signBuildToken(payload); + expect(typeof token).toBe("string"); + expect(token).toContain("."); + + const verified = await verifyBuildToken(token); + expect(verified).not.toBeNull(); + expect(verified!.v).toBe(1); + expect(verified!.bid).toBe("build-abc-123"); + expect(verified!.uid).toBe(42); + expect(verified!.iat).toBe(1700000000); + }); + + test("sign/verify works with null uid", async () => { + const payload = { v: 1 as const, bid: "build-no-owner", uid: null, iat: 1700000001 }; + const token = await signBuildToken(payload); + const verified = await verifyBuildToken(token); + expect(verified).not.toBeNull(); + expect(verified!.uid).toBe(null); + }); + + test("tampered payload fails verification", async () => { + const token = await signBuildToken({ v: 1, bid: "real-build", uid: 1, iat: 1700000000 }); + const [payloadB64, sigB64] = token.split("."); + const tamperedPayload = Buffer.from( + JSON.stringify({ v: 1, bid: "evil-build", uid: 1, iat: 1700000000 }), + ) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + expect(await verifyBuildToken(`${tamperedPayload}.${sigB64}`)).toBeNull(); + }); + + test("tampered signature fails verification", async () => { + const token = await signBuildToken({ v: 1, bid: "real-build", uid: 1, iat: 1700000000 }); + const [payloadB64] = token.split("."); + const fakeSig = "A".repeat(86); + expect(await verifyBuildToken(`${payloadB64}.${fakeSig}`)).toBeNull(); + }); + + test("legacy UUID-style buildTag returns null (so DB fallback path triggers)", async () => { + expect(await verifyBuildToken("550e8400-e29b-41d4-a716-446655440000")).toBeNull(); + }); + + test("garbage input returns null", async () => { + expect(await verifyBuildToken("")).toBeNull(); + expect(await verifyBuildToken("not-a-token")).toBeNull(); + expect(await verifyBuildToken(".")).toBeNull(); + expect(await verifyBuildToken("a.")).toBeNull(); + expect(await verifyBuildToken(".b")).toBeNull(); + }); + + test("payload missing required fields returns null", async () => { + const badPayloadB64 = Buffer.from(JSON.stringify({ hello: "world" })) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + expect(await verifyBuildToken(`${badPayloadB64}.AAAA`)).toBeNull(); + }); + + test("token signed with a different key fails verification", async () => { + const token = await signBuildToken({ v: 1, bid: "build-x", uid: 1, iat: 1700000000 }); + const verifiedFirst = await verifyBuildToken(token); + expect(verifiedFirst).not.toBeNull(); + + process.env.DATA_DIR = await mkdtemp(join(tmpdir(), "overlord-build-signing-test-rotated-")); + resetCache(); + + expect(await verifyBuildToken(token)).toBeNull(); + }); + + test("banlist add/remove/check", () => { + expect(isBuildBanned("nope")).toBe(false); + addBuildToBanlist("ban-me"); + expect(isBuildBanned("ban-me")).toBe(true); + addBuildToBanlist("ban-me"); + expect(isBuildBanned("ban-me")).toBe(true); + removeBuildFromBanlist("ban-me"); + expect(isBuildBanned("ban-me")).toBe(false); + removeBuildFromBanlist("ban-me"); + expect(isBuildBanned("ban-me")).toBe(false); + }); +}); diff --git a/Overlord-Server/src/server/build-signing.ts b/Overlord-Server/src/server/build-signing.ts new file mode 100644 index 0000000..6ef25e9 --- /dev/null +++ b/Overlord-Server/src/server/build-signing.ts @@ -0,0 +1,155 @@ +import logger from "../logger"; +import type { BuildTokenPayload } from "../types"; +import { + getBuildSigningSecrets, + setBuildSigningSecrets, + getBuildBanlist, + setBuildBanlist, +} from "../config"; + +let cachedPrivateKey: CryptoKey | null = null; +let cachedPublicKey: CryptoKey | null = null; +let cachedPublicKeyB64: string | null = null; + +function toBase64Url(bytes: Uint8Array): string { + return Buffer.from(bytes) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function fromBase64Url(s: string): Uint8Array { + const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4)); + const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad; + return new Uint8Array(Buffer.from(b64, "base64")); +} + +async function generateKeypair(): Promise<{ privateKeyB64: string; publicKeyB64: string }> { + const kp = (await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"])) as CryptoKeyPair; + const privRaw = await crypto.subtle.exportKey("pkcs8", kp.privateKey); + const pubRaw = await crypto.subtle.exportKey("raw", kp.publicKey); + return { + privateKeyB64: Buffer.from(privRaw).toString("base64"), + publicKeyB64: Buffer.from(pubRaw).toString("base64"), + }; +} + +async function importKeys(privateKeyB64: string, publicKeyB64: string) { + const privBytes = Buffer.from(privateKeyB64, "base64"); + const pubBytes = Buffer.from(publicKeyB64, "base64"); + cachedPrivateKey = await crypto.subtle.importKey( + "pkcs8", + privBytes, + { name: "Ed25519" }, + false, + ["sign"], + ); + cachedPublicKey = await crypto.subtle.importKey( + "raw", + pubBytes, + { name: "Ed25519" }, + false, + ["verify"], + ); + cachedPublicKeyB64 = publicKeyB64; +} + +async function ensureKeysLoaded(): Promise { + if (cachedPrivateKey && cachedPublicKey) return; + + let secrets = getBuildSigningSecrets(); + if (!secrets || !secrets.privateKey || !secrets.publicKey) { + const generated = await generateKeypair(); + secrets = { privateKey: generated.privateKeyB64, publicKey: generated.publicKeyB64 }; + setBuildSigningSecrets(secrets); + logger.info("[build-signing] generated new Ed25519 build-signing keypair"); + } + + await importKeys(secrets.privateKey, secrets.publicKey); +} + +export function resetBuildSigningCacheForTests(): void { + cachedPrivateKey = null; + cachedPublicKey = null; + cachedPublicKeyB64 = null; +} + +export async function getBuildSigningPublicKey(): Promise { + await ensureKeysLoaded(); + return cachedPublicKeyB64 || ""; +} + +export async function signBuildToken(payload: BuildTokenPayload): Promise { + await ensureKeysLoaded(); + const payloadJson = JSON.stringify(payload); + const payloadBytes = new TextEncoder().encode(payloadJson); + const payloadB64 = toBase64Url(payloadBytes); + const sigBuf = await crypto.subtle.sign("Ed25519", cachedPrivateKey!, payloadBytes as any); + const sigB64 = toBase64Url(new Uint8Array(sigBuf)); + return `${payloadB64}.${sigB64}`; +} + +export async function verifyBuildToken(token: string): Promise { + if (!token || typeof token !== "string") return null; + const dot = token.indexOf("."); + if (dot <= 0 || dot === token.length - 1) return null; + + try { + await ensureKeysLoaded(); + } catch (err) { + logger.warn("[build-signing] failed to load signing keys", err); + return null; + } + + try { + const payloadB64 = token.slice(0, dot); + const sigB64 = token.slice(dot + 1); + const payloadBytes = fromBase64Url(payloadB64); + const sigBytes = fromBase64Url(sigB64); + if (sigBytes.length !== 64) return null; + + const ok = await crypto.subtle.verify( + "Ed25519", + cachedPublicKey!, + sigBytes as any, + payloadBytes as any, + ); + if (!ok) return null; + + const parsed = JSON.parse(new TextDecoder().decode(payloadBytes)); + if ( + !parsed || + typeof parsed !== "object" || + parsed.v !== 1 || + typeof parsed.bid !== "string" || + !parsed.bid || + typeof parsed.iat !== "number" || + (parsed.uid !== null && typeof parsed.uid !== "number") + ) { + return null; + } + return { v: 1, bid: parsed.bid, uid: parsed.uid ?? null, iat: parsed.iat }; + } catch { + return null; + } +} + +export function isBuildBanned(buildId: string): boolean { + if (!buildId) return false; + return getBuildBanlist().includes(buildId); +} + +export function addBuildToBanlist(buildId: string): void { + if (!buildId) return; + const list = getBuildBanlist(); + if (list.includes(buildId)) return; + setBuildBanlist([...list, buildId]); +} + +export function removeBuildFromBanlist(buildId: string): void { + if (!buildId) return; + const list = getBuildBanlist(); + const next = list.filter((id) => id !== buildId); + if (next.length !== list.length) setBuildBanlist(next); +} diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts index b4123b5..b32f826 100644 --- a/Overlord-Server/src/server/routes/build-routes.ts +++ b/Overlord-Server/src/server/routes/build-routes.ts @@ -24,6 +24,7 @@ import { logger } from "../../logger"; import { normalizeClientOs } from "../deploy-utils"; import { canUserBuild, recordBuildStart, recordBuildEnd } from "../../build-rate-limit"; import { getConfig } from "../../config"; +import { addBuildToBanlist, removeBuildFromBanlist } from "../build-signing"; import path from "path"; import fs from "fs"; import { resolveRuntimeRoot } from "../runtime-paths"; @@ -552,6 +553,12 @@ export async function handleBuildRoutes( return Response.json({ error: "Build not found" }, { status: 404 }); } + if (blocked) { + addBuildToBanlist(buildId); + } else { + removeBuildFromBanlist(buildId); + } + let disconnected = 0; if (blocked && build.buildTag) { const clientIds = listClientIdsByBuildTag(build.buildTag); diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index d66e761..d9047a6 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -19,6 +19,7 @@ import type { ClientInfo } from "../../types"; import { clearClientSyncState, handleFrame, handleHello, handlePing, handlePong } from "../../wsHandlers"; import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation"; import { stopAllProxiesForClient } from "../socks5-proxy-manager"; +import { verifyBuildToken, isBuildBanned } from "../build-signing"; const OFFLINE_GRACE_MS = (() => { const raw = process.env.OVERLORD_OFFLINE_GRACE_MS; @@ -164,7 +165,12 @@ type WsLifecycleDeps = { clearPendingNotificationScreenshots: (clientId: string) => void; clearClientPluginState: (clientId: string) => void; notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void; - handleBuildTagConnection: (clientId: string, buildTag: string, keyFingerprint: string) => void; + handleBuildTagConnection: ( + clientId: string, + buildId: string | null, + builtByUserId: number | undefined, + keyFingerprint: string, + ) => void; notifyDashboard: () => void; notifyDashboardClientEvent: ( event: "client_online" | "client_offline" | "client_purgatory", @@ -360,11 +366,30 @@ export async function handleWebSocketMessage( const buildTag = typeof (payload as any).buildTag === "string" ? (payload as any).buildTag.trim() : ""; - const build = buildTag ? getBuildByTag(buildTag) : null; - const builtByUserId = build?.builtByUserId; - if (buildTag && build?.blocked) { - logger.info(`[build-block] rejecting agent ${resolvedId} from blocked build ${build.id.substring(0, 8)}`); + let resolvedBuildId: string | null = null; + let builtByUserId: number | undefined; + let isRevoked = false; + + if (buildTag) { + const verified = await verifyBuildToken(buildTag); + if (verified) { + resolvedBuildId = verified.bid; + builtByUserId = verified.uid ?? undefined; + isRevoked = isBuildBanned(verified.bid); + } else { + const build = getBuildByTag(buildTag); + if (build) { + resolvedBuildId = build.id; + builtByUserId = build.builtByUserId; + isRevoked = !!build.blocked || isBuildBanned(build.id); + } + } + } + + if (buildTag && isRevoked) { + const shortId = resolvedBuildId ? resolvedBuildId.substring(0, 8) : ""; + logger.info(`[build-block] rejecting agent ${resolvedId} from revoked build ${shortId}`); try { ws.send( encodeMessage({ @@ -559,7 +584,7 @@ export async function handleWebSocketMessage( (ws as any).data.wasKnown = true; if (buildTag) { - deps.handleBuildTagConnection(infoObj.id, buildTag, keyFingerprint); + deps.handleBuildTagConnection(infoObj.id, resolvedBuildId, builtByUserId, keyFingerprint); } } break; diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts index f611b8a..38c227d 100644 --- a/Overlord-Server/src/types.ts +++ b/Overlord-Server/src/types.ts @@ -2,6 +2,13 @@ export type ClientRole = "client" | "viewer"; export type EnrollmentStatus = "pending" | "approved" | "denied"; +export type BuildTokenPayload = { + v: 1; + bid: string; + uid: number | null; + iat: number; +}; + export type ClientInfo = { id: string; lastSeen: number;