import { webcrypto } from "node:crypto"; import { field, isMapping } from "#common"; import type { BotProfile } from "#profiles"; // The model a profile runs when profiles.json names none. function defaultModel(type: BotProfile["type"]): string { switch (type) { case "CLAUDE": return "claude-sonnet-5"; case "CODEX": return "gpt-5.6-terra"; case "OPENCODE": return "openrouter/qwen/qwen3.8-27b"; } } export interface BotCredentials { reviewerToken: string; profiles: BotProfile[]; } function decodeBase64Url(value: string): Uint8Array { const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); return Uint8Array.from( atob(padded), (character) => character.codePointAt(0) ?? 0, ); } function encodeBase64Url(value: Uint8Array): string { const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join( "", ); return btoa(binary) .replaceAll("+", "-") .replaceAll("/", "_") .replaceAll("=", ""); } const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer; async function importMasterKey( encoded: string, usage: webcrypto.KeyUsage, ): Promise { const bytes = decodeBase64Url(encoded); if (bytes.length !== 32) { throw new Error("master-key must be 32 bytes encoded as base64url"); } const key = await webcrypto.subtle.importKey( "raw", buffer(bytes), "AES-GCM", false, [usage], ); return key; } export async function decryptCredential( encrypted: string, masterKey: string, ): Promise { const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":"); if ( version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "" ) { throw new Error("credential must use v1::"); } const nonce = decodeBase64Url(encodedNonce); if (nonce.length !== 12) { throw new Error("credential nonce must be 12 bytes"); } const plaintext = await webcrypto.subtle.decrypt( { name: "AES-GCM", iv: buffer(nonce) }, await importMasterKey(masterKey, "decrypt"), buffer(decodeBase64Url(encodedCiphertext)), ); return new TextDecoder().decode(plaintext); } export async function encryptCredential( plaintext: string, masterKey: string, nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)), ): Promise { if (nonce.length !== 12) { throw new Error("AES-GCM nonce must be 12 bytes"); } const ciphertext = await webcrypto.subtle.encrypt( { name: "AES-GCM", iv: buffer(nonce) }, await importMasterKey(masterKey, "encrypt"), new TextEncoder().encode(plaintext), ); return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`; } // profiles.json files written before the UPPER_SNAKE switch spell types in lowercase. const OLD_PROFILE_TYPES = new Map([ ["claude", "CLAUDE"], ["codex", "CODEX"], ["opencode", "OPENCODE"], ]); function currentSpelling(type: unknown): unknown { return OLD_PROFILE_TYPES.get(type) ?? type; } async function parseProfile( entry: unknown, index: number, masterKey: string, ): Promise { const at = `profiles[${index}]`; if (!isMapping(entry)) { throw new TypeError(`${at}: expected a mapping`); } for (const key of Object.keys(entry)) { if (key !== "type" && key !== "model" && key !== "token") { throw new Error(`${at}.${key}: unknown field`); } } const type = currentSpelling(field(entry, "type")); const model = field(entry, "model"); const token = field(entry, "token"); if (type !== "CLAUDE" && type !== "CODEX" && type !== "OPENCODE") { throw new Error(`${at}.type: expected "CLAUDE", "CODEX", or "OPENCODE"`); } if (model !== undefined && (typeof model !== "string" || model === "")) { throw new Error(`${at}.model: expected a model name`); } if ((type === "CLAUDE" || type === "OPENCODE") && typeof token !== "string") { throw new Error(`${at}.token: expected an encrypted credential`); } if (token !== undefined && typeof token !== "string") { throw new Error(`${at}.token: expected an encrypted credential`); } return { type, model: model ?? defaultModel(type), ...(typeof token === "string" && { token: await decryptCredential(token, masterKey), }), }; } export async function parseCredentials( text: string, masterKey: string, ): Promise { const value: unknown = JSON.parse(text); if (!isMapping(value)) { throw new TypeError("credentials must be a mapping"); } for (const key of Object.keys(value)) { if (key !== "reviewer-token" && key !== "profiles") { throw new Error(`${key}: unknown field`); } } const reviewerToken = field(value, "reviewer-token"); const profiles = field(value, "profiles"); if (typeof reviewerToken !== "string") { throw new TypeError("reviewer-token: expected an encrypted credential"); } if (!Array.isArray(profiles) || profiles.length === 0) { throw new Error("profiles must contain at least one profile"); } const parsed = await Promise.all( profiles.map(async (entry: unknown, index) => { const profile = await parseProfile(entry, index, masterKey); return profile; }), ); return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed, }; }