5462d0fee3
Replace the in-memory token map with self-contained cryptographic tokens. ggshield-flagged secrets are encrypted with AES-256-GCM under a persistent 256-bit master key; the placeholder carries IV ++ authTag ++ ciphertext as hex, so the proxy is stateless and resolves tokens across restarts and replayed chat histories. The streaming detokenizer now parses SSE content_block_delta events and reassembles placeholders fragmented across many deltas — a raw byte scan can't bridge the interleaved event/JSON framing. Plain JSON responses keep the simpler contiguous byte scan. The master key is generated 0600 on first run at ~/.config/claude-tokenization-proxy/master.key (override GG_MASTER_KEY_FILE); deleting it makes existing tokens unrecoverable.
74 lines
2.7 KiB
TypeScript
74 lines
2.7 KiB
TypeScript
// Copyright (C) 2026 Rootiest
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import { createDecipheriv, randomBytes } from "node:crypto";
|
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
|
|
// Cryptographic self-contained tokens: the placeholder carries its own
|
|
// ciphertext, so the proxy is stateless. The only persistent state is the
|
|
// master key, kept in a 0600 file so tokens survive restarts.
|
|
const KEY_PATH =
|
|
process.env.GG_MASTER_KEY_FILE ||
|
|
join(homedir(), ".config", "claude-tokenization-proxy", "master.key");
|
|
|
|
// AES-256-GCM layout inside the hex blob: 12-byte IV, 16-byte auth tag, then
|
|
// the ciphertext. detokenize slices on these fixed boundaries.
|
|
export const IV_BYTES = 12;
|
|
export const TAG_BYTES = 16;
|
|
const IV_HEX = IV_BYTES * 2;
|
|
const TAG_HEX = TAG_BYTES * 2;
|
|
|
|
let masterKey: Buffer | undefined;
|
|
|
|
/**
|
|
* Load the 256-bit master key from disk, generating and persisting one (0600)
|
|
* on first run. Call once at startup before serving traffic. Idempotent.
|
|
*/
|
|
export function initMasterKey(): Buffer {
|
|
if (masterKey) return masterKey;
|
|
try {
|
|
const key = readFileSync(KEY_PATH);
|
|
if (key.length !== 32) {
|
|
throw new Error(`master key at ${KEY_PATH} is ${key.length} bytes, expected 32`);
|
|
}
|
|
masterKey = key;
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
const key = randomBytes(32);
|
|
mkdirSync(dirname(KEY_PATH), { recursive: true, mode: 0o700 });
|
|
writeFileSync(KEY_PATH, key, { mode: 0o600 });
|
|
console.warn(`[proxy] generated new master key at ${KEY_PATH}`);
|
|
masterKey = key;
|
|
}
|
|
return masterKey;
|
|
}
|
|
|
|
export function getMasterKey(): Buffer {
|
|
if (!masterKey) throw new Error("master key not initialized — call initMasterKey() first");
|
|
return masterKey;
|
|
}
|
|
|
|
/**
|
|
* Decrypt a full placeholder token back to its secret. Returns undefined for
|
|
* anything that isn't one of our tokens or that fails authentication, so
|
|
* unknown/corrupt placeholders pass through the stream untouched.
|
|
*/
|
|
export function resolveToken(token: string): string | undefined {
|
|
const m = /^<<<gg_token_([0-9a-f]+)>>>$/.exec(token);
|
|
if (!m) return undefined;
|
|
const blob = m[1];
|
|
if (blob.length <= IV_HEX + TAG_HEX) return undefined;
|
|
try {
|
|
const iv = Buffer.from(blob.slice(0, IV_HEX), "hex");
|
|
const tag = Buffer.from(blob.slice(IV_HEX, IV_HEX + TAG_HEX), "hex");
|
|
const ciphertext = Buffer.from(blob.slice(IV_HEX + TAG_HEX), "hex");
|
|
const decipher = createDecipheriv("aes-256-gcm", getMasterKey(), iv);
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|