This commit is contained in:
+138
@@ -0,0 +1,138 @@
|
||||
import { parse } from "jsr:@std/yaml@1";
|
||||
import type { BotProfile } from "./profiles.ts";
|
||||
|
||||
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
|
||||
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<CryptoKey> {
|
||||
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<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 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<string> {
|
||||
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<BotCredentials> {
|
||||
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<string, unknown>;
|
||||
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<BotProfile> => {
|
||||
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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user