{server.name}
{server.enabled ? "Enabled" : "Disabled"}{server.host}:{server.port}
+Updated {server.updatedAt.toISOString()}
+Server operations
+Run commands through the portal backend. RCON endpoints remain internal and credentials are never sent to the browser.
+{savedMessages[saved] ?? "RCON settings saved."}
} + {error &&{errorMessages[error] ?? "The RCON operation failed."}
} + +Command proxy
+Only the latest bounded response is shown. Commands and responses are not saved as console history.
+Configuration
+Saved endpoints
{server.host}:{server.port}
+Updated {server.updatedAt.toISOString()}
+No RCON connections configured.
} +Enable an RCON connection before opening the console.
; + } + + return ( + + ); +} diff --git a/apps/web/src/lib/rcon-credentials.test.ts b/apps/web/src/lib/rcon-credentials.test.ts new file mode 100644 index 0000000..64bbe6f --- /dev/null +++ b/apps/web/src/lib/rcon-credentials.test.ts @@ -0,0 +1,38 @@ +import { randomBytes } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "./rcon-credentials"; + +const key = randomBytes(32).toString("base64"); +const otherKey = randomBytes(32).toString("base64"); +const connectionId = "11111111-1111-4111-8111-111111111111"; + +describe("RCON credential encryption", () => { + it("round trips with randomized authenticated encryption", () => { + const first = encryptRconPassword("super-secret", connectionId, key); + const second = encryptRconPassword("super-secret", connectionId, key); + + expect(first).not.toBe(second); + expect(first).not.toContain("super-secret"); + expect(decryptRconPassword(first, connectionId, key)).toBe("super-secret"); + expect(decryptRconPassword(second, connectionId, key)).toBe("super-secret"); + }); + + it("fails closed for tampering, another connection, or another key", () => { + const encrypted = encryptRconPassword("super-secret", connectionId, key); + expect(() => decryptRconPassword(`${encrypted}x`, connectionId, key)).toThrow("RCON credential unavailable"); + expect(() => decryptRconPassword(encrypted, "22222222-2222-4222-8222-222222222222", key)).toThrow("RCON credential unavailable"); + expect(() => decryptRconPassword(encrypted, connectionId, otherKey)).toThrow("RCON credential unavailable"); + }); + + it("requires an exact 32-byte deployment key", () => { + expect(() => encryptRconPassword("secret", connectionId, "not-base64")).toThrow("RCON credential key is not configured"); + }); + + it("creates a keyed, versioned command digest", () => { + const digest = rconCommandDigest("say secret message", key); + expect(digest).toMatch(/^hmac-sha256:v1:[a-f0-9]{64}$/u); + expect(digest).not.toContain("secret message"); + expect(rconCommandDigest("say secret message", key)).toBe(digest); + expect(rconCommandDigest("say secret message", otherKey)).not.toBe(digest); + }); +}); diff --git a/apps/web/src/lib/rcon-credentials.ts b/apps/web/src/lib/rcon-credentials.ts new file mode 100644 index 0000000..e994010 --- /dev/null +++ b/apps/web/src/lib/rcon-credentials.ts @@ -0,0 +1,74 @@ +import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto"; + +const VERSION = "v1"; +const KEY_BYTES = 32; +const IV_BYTES = 12; + +function explicitKey(encoded: string) { + const key = Buffer.from(encoded, "base64"); + if (key.length !== KEY_BYTES || key.toString("base64").replace(/=+$/u, "") !== encoded.trim().replace(/=+$/u, "")) { + throw new Error("invalid key"); + } + return key; +} + +function credentialKey(encoded: string | undefined, purpose: "credential" | "audit" = "credential") { + if (encoded !== undefined) return explicitKey(encoded); + const configured = purpose === "credential" ? process.env.RCON_CREDENTIAL_KEY : process.env.RCON_AUDIT_KEY; + if (configured) return explicitKey(configured); + const authSecret = process.env.AUTH_SECRET; + if (!authSecret) throw new Error("missing key"); + return createHash("sha256").update(`minecraft-account-manager:rcon:${purpose}:v1\0${authSecret}`, "utf8").digest(); +} + +function additionalData(connectionId: string) { + return Buffer.from(`${VERSION}:${connectionId}`, "utf8"); +} + +function decodeBase64url(value: string) { + const decoded = Buffer.from(value, "base64url"); + if (decoded.toString("base64url") !== value) throw new Error("invalid envelope"); + return decoded; +} + +export function encryptRconPassword(password: string, connectionId: string, encodedKey?: string) { + let key: Buffer; + try { + key = credentialKey(encodedKey); + } catch { + throw new Error("RCON credential key is not configured"); + } + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: 16 }); + cipher.setAAD(additionalData(connectionId)); + const ciphertext = Buffer.concat([cipher.update(password, "utf8"), cipher.final()]); + return [VERSION, iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), ciphertext.toString("base64url")].join(":"); +} + +export function rconCommandDigest(command: string, encodedKey?: string) { + let key: Buffer; + try { + key = credentialKey(encodedKey, "audit"); + } catch { + throw new Error("RCON audit key is not configured"); + } + return `hmac-sha256:v1:${createHmac("sha256", key).update(command, "utf8").digest("hex")}`; +} + +export function decryptRconPassword(envelope: string, connectionId: string, encodedKey?: string) { + try { + const key = credentialKey(encodedKey); + const [version, ivValue, tagValue, ciphertextValue, extra] = envelope.split(":"); + if (version !== VERSION || !ivValue || !tagValue || !ciphertextValue || extra) throw new Error("invalid envelope"); + const iv = decodeBase64url(ivValue); + const tag = decodeBase64url(tagValue); + const ciphertext = decodeBase64url(ciphertextValue); + if (iv.length !== IV_BYTES || tag.length !== 16) throw new Error("invalid envelope"); + const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 }); + decipher.setAAD(additionalData(connectionId)); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); + } catch { + throw new Error("RCON credential unavailable"); + } +} diff --git a/apps/web/src/lib/rcon-gateway.test.ts b/apps/web/src/lib/rcon-gateway.test.ts new file mode 100644 index 0000000..11af071 --- /dev/null +++ b/apps/web/src/lib/rcon-gateway.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; +import { executeRcon, testRconConnection, type RconTransport } from "./rcon-gateway"; + +function transport(overrides: Partial