import { parse } from "jsr:@std/yaml@1"; import type { BotProfile } from "./profiles.ts"; const DEFAULT_MODELS: Record = { claude: "claude-sonnet-5", codex: "gpt-5.6-terra", }; export type 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.charCodeAt(0)); } function encodeBase64Url(value: Uint8Array): string { const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join( "", ); return btoa(binary).replaceAll("+", "-").replaceAll("/", "_") .replaceAll("=", ""); } const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer; async function importMasterKey( encoded: string, usage: KeyUsage, ): Promise { const bytes = decodeBase64Url(encoded); if (bytes.length !== 32) { throw new Error("master-key must be 32 bytes encoded as base64url"); } return await crypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [ usage, ]); } 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 crypto.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 = crypto.getRandomValues(new Uint8Array(12)), ): Promise { if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes"); const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv: buffer(nonce) }, await importMasterKey(masterKey, "encrypt"), new TextEncoder().encode(plaintext), ); return `v1:${encodeBase64Url(nonce)}:${ encodeBase64Url(new Uint8Array(ciphertext)) }`; } export async function parseCredentials( text: string, masterKey: string, ): Promise { const value: unknown = parse(text); if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error("credentials must be a mapping"); } const record = value as Record; for (const key of Object.keys(record)) { if (key !== "reviewer-token" && key !== "profiles") { throw new Error(`${key}: unknown field`); } } if (typeof record["reviewer-token"] !== "string") { throw new Error("reviewer-token: expected an encrypted credential"); } if (!Array.isArray(record.profiles) || record.profiles.length === 0) { throw new Error("profiles must contain at least one profile"); } const profiles = await Promise.all( record.profiles.map(async (entry: unknown, index): Promise => { const at = `profiles[${index}]`; if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { throw new Error(`${at}: expected a mapping`); } const profile = entry as Record; for (const key of Object.keys(profile)) { if (key !== "type" && key !== "model" && key !== "token") { throw new Error(`${at}.${key}: unknown field`); } } const { type, model, token } = profile; if (type !== "claude" && type !== "codex") { throw new Error(`${at}.type: expected \"claude\" or \"codex\"`); } if (model !== undefined && (typeof model !== "string" || model === "")) { throw new Error(`${at}.model: expected a model name`); } if (type === "claude" && 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 ?? DEFAULT_MODELS[type], ...(typeof token === "string" && { token: await decryptCredential(token, masterKey), }), }; }), ); return { reviewerToken: await decryptCredential(record["reviewer-token"], masterKey), profiles, }; }