Files
claude-tokenization-proxy/src/store.ts
T
rootiest 5462d0fee3 feat: stateless AES-256-GCM crypto tokens with SSE-aware detokenization
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.
2026-06-20 02:29:38 -04:00

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;
}
}