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.
This commit is contained in:
2026-06-20 02:29:38 -04:00
parent afb739f061
commit 5462d0fee3
6 changed files with 316 additions and 81 deletions
+4
View File
@@ -18,3 +18,7 @@ node_modules/
# ggshield local cache
.cache_ggshield
# Master key — never commit (default lives in ~/.config, but guard the project root)
master.key
*.master.key
+23 -12
View File
@@ -18,17 +18,28 @@ claude-code ◄──SSE (detokenized)─── proxy ◄──SSE stream──
## How it works
1. **Request interception** — the JSON body of `POST /v1/messages` is scanned by
`ggshield`. Every detected secret is replaced with a unique
`<<<gg_token_[hex]>>>` placeholder, and the `token → secret` mapping is kept
in memory (`src/store.ts`). The redacted body is forwarded upstream.
2. **Streaming detokenization** — the Anthropic SSE response is piped through a
stateful `Transform` (`src/detokenize.ts`) that scans the byte stream for
placeholders and substitutes the original secret back. It holds back only the
few characters that could still become a token, so a placeholder split across
chunk boundaries (`…<<<gg_to` / `ken_abc>>>…`) is reassembled correctly.
`ggshield`. Every detected secret is encrypted with **AES-256-GCM** under a
persistent master key and replaced with a self-contained
`<<<gg_token_[hex]>>>` placeholder, where the hex payload is `IV ++ authTag ++
ciphertext`. No server-side mapping is kept — the token carries everything
needed to restore it (`src/tokenize.ts`, `src/store.ts`). The redacted body is
forwarded upstream.
2. **Streaming detokenization** — the response is piped through a stateful
`Transform` (`src/detokenize.ts`) that finds placeholders, decrypts the hex
payload, and substitutes the original secret back. For SSE streams it parses
`content_block_delta` events and reassembles the reconstructed text, because
the model emits a long placeholder fragmented across many deltas
(`…"text":"<<<gg_to"}` / `{"text":"ken_abc>>>"…`) with event framing wedged
between the fragments — a raw byte scan can't bridge that. Plain JSON
responses, where the token is contiguous, take a simpler direct byte scan.
Secrets live only in memory for the lifetime of the process — restart the proxy
and the mapping is gone.
The proxy is **stateless**: tokens are cryptographically self-contained, so they
resolve correctly even after a restart or when replaying older chat histories.
The only persistent state is the 256-bit master key, generated on first run and
stored `0600` at `~/.config/claude-tokenization-proxy/master.key` (override with
`GG_MASTER_KEY_FILE`). Anyone with that key file can decrypt captured tokens —
treat it like any other secret, and deleting it makes existing tokens
unrecoverable.
## Requirements
@@ -160,8 +171,8 @@ that session. To make it stick:
keys/tokens). A secret literally containing a backslash or double-quote could
mis-escape inside the response JSON. See the `ponytail:` note in
`src/detokenize.ts`.
- The token map is in-memory only; it is not shared across proxy restarts or
multiple proxy processes.
- Tokens are decryptable by anyone holding the master key file. Multiple proxy
instances must share the same `master.key` to resolve each other's tokens.
## License
+195 -50
View File
@@ -8,10 +8,10 @@ import { resolveToken as storeResolve } from "./store.ts";
// Token shape: <<<gg_token_[0-9a-f]+>>> (hex, because that's what we generate).
const PREFIX = "<<<gg_token_";
const SUFFIX = ">>>";
const COMPLETE_RE = /^<<<gg_token_[0-9a-f]+>>>/;
type Resolver = (token: string) => string | undefined;
// Could `s` (which starts with '<') still grow into a token with more input?
// True for any strict prefix of a well-formed token.
function isPartialPrefix(s: string): boolean {
@@ -23,33 +23,30 @@ function isPartialPrefix(s: string): boolean {
}
/**
* Stateful SSE detokenizer. Scans the raw upstream byte stream for our
* placeholder tokens and swaps them back to the original secrets on the fly,
* holding back only the few characters that could still become a token so
* tokens split across chunk boundaries are reassembled correctly.
* Core token scanner. Feed it text fragments; it returns the part that is safe
* to emit (with complete tokens swapped for their secrets) and holds back only
* the trailing characters that could still grow into a token. `escape` controls
* whether a resolved secret is JSON-string-escaped on the way out: true when we
* splice it straight into a raw JSON body, false when the caller re-serializes
* the surrounding object itself (SSE deltas).
*/
export class DetokenizeStream extends Transform {
class TokenScanner {
private buf = "";
private decoder = new StringDecoder("utf8");
private resolve: (token: string) => string | undefined;
constructor(resolve: (token: string) => string | undefined = storeResolve) {
super();
private resolve: Resolver;
private escape: boolean;
constructor(resolve: Resolver, escape: boolean) {
this.resolve = resolve;
this.escape = escape;
}
private emitFor(token: string): string {
const secret = this.resolve(token);
if (secret === undefined) return token; // unknown token: leave it untouched
// ponytail: token appears inside a JSON string in the SSE `data:` line, so
// re-escape. Assumes secrets are escape-neutral (typical API keys/tokens);
// a secret literally containing a backslash or quote would double-escape —
// upgrade to context-aware splicing if that ever bites.
return JSON.stringify(secret).slice(1, -1);
if (secret === undefined) return token; // unknown/corrupt token: leave untouched
return this.escape ? JSON.stringify(secret).slice(1, -1) : secret;
}
override _transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback): void {
this.buf += this.decoder.write(chunk);
push(text: string): string {
this.buf += text;
let out = "";
while (this.buf.length > 0) {
const lt = this.buf.indexOf("<");
@@ -67,18 +64,120 @@ export class DetokenizeStream extends Transform {
this.buf = this.buf.slice(complete[0].length);
continue;
}
if (isPartialPrefix(this.buf)) break; // might finish next chunk; hold it
// This '<' can't begin a token. Emit it and keep scanning past it.
out += "<";
if (isPartialPrefix(this.buf)) break; // might finish next fragment; hold it
out += "<"; // this '<' can't begin a token
this.buf = this.buf.slice(1);
}
cb(null, out);
return out;
}
// Emit whatever is held back verbatim. Anything still buffered here is, by
// construction, an incomplete token, so it can only be passed through as-is.
flush(): string {
const out = this.buf;
this.buf = "";
return out;
}
}
/**
* Stateful detokenizer for the upstream response body. Two modes:
*
* - Raw (plain JSON responses): scan the byte stream directly. Tokens are
* contiguous in a single body, so the scanner reassembles them across TCP
* chunk boundaries and re-escapes the spliced-in secret.
*
* - SSE (streaming responses): the model emits a placeholder fragmented across
* many `content_block_delta` events, with JSON/SSE framing wedged between the
* token's characters. Scanning raw bytes can't bridge that, so we parse each
* event, run the *reconstructed* delta text through the scanner (which holds
* a partial token across deltas), and re-serialize. A token can't span a
* content block, so we flush any held tail at `content_block_stop`.
*/
export class DetokenizeStream extends Transform {
private decoder = new StringDecoder("utf8");
private sse: boolean;
private scanner: TokenScanner;
private sseBuf = ""; // bytes not yet forming a complete `\n\n`-terminated event
private currentIndex = 0; // index of the open content block (for flush deltas)
constructor(opts: { sse?: boolean; resolve?: Resolver } = {}) {
super();
this.sse = opts.sse ?? false;
// Raw mode splices into a JSON body (escape); SSE mode re-stringifies the
// delta object itself, so the secret must stay raw.
this.scanner = new TokenScanner(opts.resolve ?? storeResolve, !this.sse);
}
override _transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback): void {
const text = this.decoder.write(chunk);
cb(null, this.sse ? this.feedSSE(text) : this.scanner.push(text));
}
override _flush(cb: TransformCallback): void {
const out = this.buf + this.decoder.end();
this.buf = "";
cb(null, out);
const tail = this.decoder.end();
if (this.sse) {
let out = this.feedSSE(tail);
out += this.flushPending(); // emit any held token tail as a final delta
out += this.sseBuf; // leftover incomplete event, if any
this.sseBuf = "";
cb(null, out);
} else {
cb(null, this.scanner.push(tail) + this.scanner.flush());
}
}
private feedSSE(text: string): string {
this.sseBuf += text;
let out = "";
let idx: number;
while ((idx = this.sseBuf.indexOf("\n\n")) !== -1) {
const ev = this.sseBuf.slice(0, idx);
this.sseBuf = this.sseBuf.slice(idx + 2);
out += this.handleEvent(ev) + "\n\n";
}
return out;
}
private handleEvent(ev: string): string {
const lines = ev.split("\n");
const di = lines.findIndex((l) => l.startsWith("data:"));
if (di === -1) return ev; // no data line: passthrough
let obj: Record<string, unknown>;
try {
obj = JSON.parse(lines[di].slice(lines[di].indexOf(":") + 1).trim());
} catch {
return ev; // not JSON (e.g. `data: [DONE]`): passthrough untouched
}
const delta = obj.delta as { type?: string; text?: string } | undefined;
if (obj.type === "content_block_delta" && delta?.type === "text_delta" && typeof delta.text === "string") {
if (typeof obj.index === "number") this.currentIndex = obj.index;
delta.text = this.scanner.push(delta.text); // may hold a partial token across deltas
lines[di] = "data: " + JSON.stringify(obj);
return lines.join("\n");
}
// A token never spans a content block, so flush any held tail before the
// block closes. Pings / message_* / content_block_start must NOT flush —
// they can legitimately arrive mid-token.
if (obj.type === "content_block_stop") {
return this.flushPending() + ev;
}
return ev;
}
// Emit the scanner's held-back tail (an incomplete token) as its own
// synthesized text_delta on the current block, so it stays valid SSE.
private flushPending(): string {
const tail = this.scanner.flush();
if (!tail) return "";
const evt = {
type: "content_block_delta",
index: this.currentIndex,
delta: { type: "text_delta", text: tail },
};
return "event: content_block_delta\ndata: " + JSON.stringify(evt) + "\n\n";
}
}
@@ -93,9 +192,9 @@ function runSelfTest(): void {
]);
const resolve = (t: string) => map.get(t);
// Feed input split into arbitrary chunks, collect output.
const run = (chunks: string[]): string => {
const s = new DetokenizeStream(resolve);
// ---- raw mode (plain JSON responses) ----------------------------------
const runRaw = (chunks: string[]): string => {
const s = new DetokenizeStream({ resolve });
let out = "";
s.on("data", (d) => (out += d.toString()));
for (const c of chunks) s.write(Buffer.from(c, "utf8"));
@@ -103,36 +202,82 @@ function runSelfTest(): void {
return out;
};
// 1. Token split across a chunk boundary (the critical case).
assert(
run(["password is <<<gg_to", "ken_abc123>>> done"]) ===
"password is SUPER-SECRET-KEY done",
"split token reassembled",
runRaw(["password is <<<gg_to", "ken_abc123>>> done"]) === "password is SUPER-SECRET-KEY done",
"raw: split token reassembled",
);
assert(runRaw("x <<<gg_token_dead>>> y".split("")) === "x hunter2 y", "raw: char-by-char token");
assert(runRaw(["a < b <<< c <<<not"]) === "a < b <<< c <<<not", "raw: non-token < flushed");
assert(runRaw(["see <<<gg_token_ffff>>>!"]) === "see <<<gg_token_ffff>>>!", "raw: unknown token kept");
assert(
runRaw(["<<<gg_token_abc123>>><<<gg_token_dead>>>"]) === "SUPER-SECRET-KEYhunter2",
"raw: adjacent tokens",
);
assert(runRaw(["tail <<<gg_token_ab"]) === "tail <<<gg_token_ab", "raw: incomplete token flushed on end");
assert(runRaw(["<<<<gg_token_dead>>>"]) === "<hunter2", "raw: leading extra angle bracket");
// ---- SSE mode (streaming responses) -----------------------------------
const delta = (text: string, index = 0) =>
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index, delta: { type: "text_delta", text } })}\n\n`;
const stop = (index = 0) =>
`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index })}\n\n`;
const ping = () => `event: ping\ndata: ${JSON.stringify({ type: "ping" })}\n\n`;
const runSSE = (chunks: string[]): string => {
const s = new DetokenizeStream({ sse: true, resolve });
let out = "";
s.on("data", (d) => (out += d.toString()));
for (const c of chunks) s.write(Buffer.from(c, "utf8"));
s.end();
return out;
};
// Concatenate the text the client would reconstruct from the output deltas.
const clientText = (sse: string): string => {
let text = "";
for (const block of sse.split("\n\n")) {
const line = block.split("\n").find((l) => l.startsWith("data:"));
if (!line) continue;
try {
const o = JSON.parse(line.slice(line.indexOf(":") + 1).trim());
if (o?.delta?.type === "text_delta") text += o.delta.text;
} catch {
/* ignore non-JSON */
}
}
return text;
};
// 1. The critical case: a token fragmented across many text deltas (8 chars
// each), as the model actually streams a long placeholder.
const tok = "<<<gg_token_abc123>>>";
const frag = [];
for (let i = 0; i < tok.length; i += 4) frag.push(delta(tok.slice(i, i + 4)));
assert(
clientText(runSSE(["before "].map((t) => delta(t)).concat(frag, [delta(" after"), stop()])).valueOf()) ===
"before SUPER-SECRET-KEY after",
"sse: token fragmented across deltas reassembled",
);
// 2. Token split one char at a time.
// 2. A ping arriving mid-token must NOT break reassembly.
assert(
run("x <<<gg_token_dead>>> y".split("")) === "x hunter2 y",
"char-by-char token",
clientText(runSSE([delta("k="), delta("<<<gg_to"), ping(), delta("ken_dead>>>"), stop()])) === "k=hunter2",
"sse: ping mid-token tolerated",
);
// 3. Plain text with stray '<' and '<<<' that is NOT a token flushes promptly.
assert(run(["a < b <<< c <<<not"]) === "a < b <<< c <<<not", "non-token < flushed");
// 4. Unknown token passes through unchanged.
assert(run(["see <<<gg_token_ffff>>>!"]) === "see <<<gg_token_ffff>>>!", "unknown token kept");
// 5. Two tokens back to back.
// 3. Incomplete token at content_block_stop is flushed verbatim.
assert(
run(["<<<gg_token_abc123>>><<<gg_token_dead>>>"]) === "SUPER-SECRET-KEYhunter2",
"adjacent tokens",
clientText(runSSE([delta("oops <<<gg_token_ab"), stop()])) === "oops <<<gg_token_ab",
"sse: incomplete token flushed at block stop",
);
// 6. Incomplete token at end of stream flushed verbatim.
assert(run(["tail <<<gg_token_ab"]) === "tail <<<gg_token_ab", "incomplete token flushed on end");
// 4. Whole token in a single delta still works.
assert(
clientText(runSSE([delta("hi <<<gg_token_dead>>>!"), stop()])) === "hi hunter2!",
"sse: contiguous token in one delta",
);
// 7. Overlapping '<' runs.
assert(run(["<<<<gg_token_dead>>>"]) === "<hunter2", "leading extra angle bracket");
// 5. Non-text events pass through untouched (structure preserved).
assert(runSSE([ping()]) === ping(), "sse: ping passthrough verbatim");
console.log("detokenize self-test: all assertions passed");
}
+10 -2
View File
@@ -5,6 +5,11 @@ import http from "node:http";
import https from "node:https";
import { tokenizeBody } from "./tokenize.ts";
import { DetokenizeStream } from "./detokenize.ts";
import { initMasterKey } from "./store.ts";
// Load (or generate) the master key once, before any request can encrypt or
// decrypt a token. Fail fast if the key file is unreadable/corrupt.
initMasterKey();
const PORT = Number(process.env.PORT) || 8080;
const UPSTREAM_HOST = process.env.ANTHROPIC_UPSTREAM_HOST || "api.anthropic.com";
@@ -48,8 +53,11 @@ const server = http.createServer(async (req, res) => {
(upRes) => {
res.writeHead(upRes.statusCode ?? 502, pick(upRes.headers, STRIP_RES));
if (isTarget) {
// Detokenize the response body (SSE or JSON) on the way back.
upRes.pipe(new DetokenizeStream()).pipe(res);
// Detokenize the response on the way back. SSE streams fragment a
// placeholder across many deltas, so the detokenizer needs to parse
// events; plain JSON bodies carry the token contiguously.
const sse = String(upRes.headers["content-type"] ?? "").includes("text/event-stream");
upRes.pipe(new DetokenizeStream({ sse })).pipe(res);
} else {
upRes.pipe(res);
}
+68 -7
View File
@@ -1,12 +1,73 @@
// Copyright (C) 2026 Rootiest
// SPDX-License-Identifier: AGPL-3.0-or-later
// In-memory token <-> secret map. ponytail: a Map is enough; secrets live only
// for the lifetime of the proxy process. Swap for SQLite only if you need
// persistence across restarts (you almost certainly don't — secrets shouldn't
// outlive the session).
export const secrets = new Map<string, string>();
import { createDecipheriv, randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
export function resolveToken(token: string): string | undefined {
return secrets.get(token);
// 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;
}
}
+16 -10
View File
@@ -2,16 +2,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { createCipheriv, randomBytes } from "node:crypto";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { secrets } from "./store.ts";
import { getMasterKey, IV_BYTES } from "./store.ts";
let ggshieldWarned = false;
function newToken(): string {
return `<<<gg_token_${randomBytes(8).toString("hex")}>>>`;
// Encrypt a secret into a self-contained placeholder. The payload is a single
// hex blob (IV ++ auth tag ++ ciphertext) so it matches the stream parser's
// [0-9a-f]+ token boundary and needs no server-side state to resolve later.
function encryptToken(secret: string): string {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv("aes-256-gcm", getMasterKey(), iv);
const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
const blob = `${iv.toString("hex")}${tag.toString("hex")}${ciphertext.toString("hex")}`;
return `<<<gg_token_${blob}>>>`;
}
// Run ggshield over `text` and return the list of plaintext secret strings it
@@ -64,9 +72,9 @@ async function scanSecrets(text: string): Promise<string[]> {
}
/**
* Scan a raw request body for secrets, replace each with a placeholder token,
* record the token->secret mapping, and return the redacted body. Longest
* secrets first so a secret that contains another is replaced whole.
* Scan a raw request body for secrets and replace each with an encrypted
* self-contained placeholder. Longest secrets first so a secret that contains
* another is replaced whole. Stateless: the token carries its own ciphertext.
*/
export async function tokenizeBody(body: string): Promise<string> {
const found = await scanSecrets(body);
@@ -75,9 +83,7 @@ export async function tokenizeBody(body: string): Promise<string> {
let redacted = body;
for (const secret of found.sort((a, b) => b.length - a.length)) {
if (!redacted.includes(secret)) continue;
const token = newToken();
secrets.set(token, secret);
redacted = redacted.split(secret).join(token);
redacted = redacted.split(secret).join(encryptToken(secret));
}
return redacted;
}