063eb09750
Co-authored-by: Danny Kim <temeddix@gmail.com> Co-committed-by: Danny Kim <temeddix@gmail.com>
186 lines
5.4 KiB
TypeScript
186 lines
5.4 KiB
TypeScript
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<webcrypto.CryptoKey> {
|
|
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<string> {
|
|
const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] =
|
|
encrypted.split(":");
|
|
if (
|
|
version !== "v1" ||
|
|
encodedNonce === "" ||
|
|
encodedCiphertext === "" ||
|
|
extra !== ""
|
|
) {
|
|
throw new Error("credential must use v1:<nonce>:<ciphertext>");
|
|
}
|
|
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<string> {
|
|
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<unknown, BotProfile["type"]>([
|
|
["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<BotProfile> {
|
|
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<BotCredentials> {
|
|
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,
|
|
};
|
|
}
|