Skip to content

Commit 5554034

Browse files
author
RJ
committed
fix(onboarding): sanitize + validate Claude token at ingestion (client rr connect + server /team/cred) — corrupt decorated-blob storage was bug #23; shared claude_cred oracle
2 parents c0e2dc7 + 4416623 commit 5554034

5 files changed

Lines changed: 293 additions & 2 deletions

File tree

bin/lib/claude_cred.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# claude_cred.py — SANITIZE + VALIDATE a Claude BYO credential at the INGESTION
2+
# boundary. The single shape oracle shared by BOTH the client (`rr connect`) and the
3+
# server (POST /team/cred), so the two sides agree on exactly one token grammar.
4+
#
5+
# WHY THIS EXISTS (bug #23). `claude setup-token` prints a DECORATED INTERACTIVE UI —
6+
# spinner frames, "Welcome to Claude Code", "Your OAuth token:", an authorize URL, all
7+
# wrapped in ANSI/terminal control sequences — NOT a clean token on stdout. An operator
8+
# onboarded with `export CLAUDE_CODE_OAUTH_TOKEN="$(claude setup-token)"`, so the captured
9+
# value was the whole ~2199-char decorated dump with the real 108-char sk-ant-oat token
10+
# buried inside. That blob became the per-team Secret Manager cred -> `claude` received
11+
# `Bearer <ansi junk>` -> "Header has invalid value" -> interactive-login fallback ->
12+
# GATE_FAILED, 0 files, every run. Defense at the boundary: the client EXTRACTS the clean
13+
# token before signing/posting; the server REFUSES a secret that is not a clean single
14+
# token regardless. Neither side ever trusts a decorated blob.
15+
16+
from __future__ import annotations
17+
18+
import re
19+
20+
# ── the two Claude BYO credential shapes (design §1) ───────────────────────────
21+
# oauth : sk-ant-oat<NN>-<body> (`claude setup-token` — the user's subscription)
22+
# apikey: sk-ant-api<NN>-<body> (a metered console key)
23+
# <NN> is a two-digit version; <body> is base64url-ish [A-Za-z0-9_-]. A real oauth token
24+
# is ~108 chars (a 13-char "sk-ant-oat01-" prefix + a ~95-char body). We bound the body
25+
# 20..180 so a too-short fragment never validates and an over-long blob never masquerades
26+
# as one whole token.
27+
_BODY = r"[A-Za-z0-9_-]{20,180}"
28+
_OAUTH_RE = re.compile(r"\Ask-ant-oat[0-9]{2}-%s\Z" % _BODY)
29+
_API_RE = re.compile(r"\Ask-ant-api[0-9]{2}-%s\Z" % _BODY)
30+
_ANY_RE = re.compile(r"\Ask-ant-(?:oat|api)[0-9]{2}-%s\Z" % _BODY)
31+
# The UN-anchored finder that pulls a token candidate OUT of a decorated blob.
32+
_FIND_RE = re.compile(r"sk-ant-(?:oat|api)[0-9]{2}-%s" % _BODY)
33+
34+
# ANSI / terminal control sequences the setup-token UI emits, stripped before matching:
35+
# CSI ESC [ ... final byte — color SGR, cursor moves, line clears, spinner frames.
36+
# OSC ESC ] ... BEL|ST — window-title sets.
37+
# ESC ESC <2nd byte> — other two-byte escapes.
38+
_CSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
39+
_OSC_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
40+
_ESC_RE = re.compile(r"\x1b[@-_]")
41+
# Any remaining C0 control char or DEL (includes newlines/tabs/carriage returns and a
42+
# stray lone ESC) — turned into a space so adjacent tokens split cleanly.
43+
_CTRL_RE = re.compile(r"[\x00-\x1f\x7f]")
44+
45+
# The hard ceiling for a stored Claude secret. A real token is ~108 chars; nothing
46+
# legitimate approaches this. Anything longer is a decorated blob or garbage — refused.
47+
MAX_SECRET_LEN = 200
48+
49+
# The kinds this oracle knows (mirrors cp_team_creds.ENV_FOR_KIND).
50+
KIND_OAUTH_TOKEN = "oauth_token"
51+
KIND_API_KEY = "api_key"
52+
53+
54+
def _sanitize(raw):
55+
"""Strip ANSI/OSC/ESC control sequences from `raw` and turn every remaining control
56+
character (newlines, tabs, stray ESC) into a space, so token candidates are cleanly
57+
delimited. Returns "" for a non-str input."""
58+
if not isinstance(raw, str):
59+
return ""
60+
s = _OSC_RE.sub("", raw) # OSC first (its BEL/ST terminator is itself a control char).
61+
s = _CSI_RE.sub("", s)
62+
s = _ESC_RE.sub("", s)
63+
s = _CTRL_RE.sub(" ", s)
64+
return s
65+
66+
67+
def extract_claude_token(raw):
68+
"""Return the single clean sk-ant-* token recoverable from `raw`, else None.
69+
70+
Strips the setup-token UI's ANSI/terminal control sequences + banner, then finds every
71+
sk-ant-(oat|api)NN-<body> candidate. Returns the token iff EXACTLY ONE distinct valid
72+
token is present; None when zero (junk / no token) or two-or-more DISTINCT tokens
73+
(ambiguous) are found. A single token repeated is fine (a banner may echo it once).
74+
NEVER returns a value that still carries junk — the result is a whole, clean token."""
75+
found = _FIND_RE.findall(_sanitize(raw))
76+
if not found:
77+
return None
78+
if len(set(found)) != 1: # zero handled above; >1 distinct == ambiguous.
79+
return None
80+
tok = found[0]
81+
return tok if _ANY_RE.match(tok) else None # belt-and-braces: whole, clean token only.
82+
83+
84+
def is_valid_claude_secret(secret, kind):
85+
"""Server-side shape gate (defense-in-depth): True iff `secret` is a CLEAN single token
86+
of the right shape for `kind`. Refuses anything that carries a control char, exceeds
87+
MAX_SECRET_LEN, or does not match the sk-ant-oat (oauth_token) / sk-ant-api (api_key)
88+
grammar. The server refuses a decorated/oversized/garbage secret even though the client
89+
already sanitizes — so a bad value is NEVER stored regardless of how it arrived."""
90+
if not isinstance(secret, str) or not secret:
91+
return False
92+
if len(secret) > MAX_SECRET_LEN:
93+
return False
94+
if _CTRL_RE.search(secret): # any control char (ANSI/newline/tab) -> not a clean token.
95+
return False
96+
if kind == KIND_OAUTH_TOKEN:
97+
return bool(_OAUTH_RE.match(secret))
98+
if kind == KIND_API_KEY:
99+
return bool(_API_RE.match(secret))
100+
return False

bin/lib/cp_publicsurface.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
# a bounded, scrubbed row to the CALLER'S OWN team partition and STOPS; the
6262
# gated worker (cp_maintainer_runner) is the only side that dispatches.
6363
import cp_team_creds # the PER-TEAM MODEL-CREDENTIAL store (BYO-credential onboarding — POST /team/cred).
64+
import claude_cred # the shared SANITIZE+VALIDATE oracle for the Claude BYO cred (bug #23). The server
65+
# REFUSES a decorated/oversized/control-char secret at the boundary (structured 4xx,
66+
# no store) even though the client already sanitizes — defense-in-depth.
6467
# THE INV-6 REASONING (write-forward ≠ "holding a cred"): the signed handler
6568
# WRITE-FORWARDS the caller's OWN cred into EXACTLY the caller's server-derived
6669
# team_id partition (Secret Manager in cloud) and STOPS. It NEVER reads a cred back
@@ -708,6 +711,16 @@ def register_team_cred(identity, request, *, home=None, now=None):
708711
return (422, {"error": "bad_kind", "team_id": team_id})
709712
if not isinstance(secret, str) or not secret:
710713
return (422, {"error": "missing_secret", "team_id": team_id})
714+
# BUG #23 — SHAPE GATE (defense-in-depth, runs on BOTH the public and gated surface, BEFORE any
715+
# forward or store). The corrupt-cred incident began with a secret that was the whole ~2199-char
716+
# `claude setup-token` decorated dump (ANSI escapes + banner) rather than the clean token, so the
717+
# store held junk and every maintainer run failed `claude` auth. We REFUSE, right here, any secret
718+
# that carries a control char, exceeds 200 chars, or does not match the sk-ant-oat (oauth_token) /
719+
# sk-ant-api (api_key) grammar — a STRUCTURED 422, never stored, never the secret in the body/log.
720+
# The value only TRANSITS this check (INV-4); nothing of it is echoed. FALSIFIER: drop this gate and
721+
# a control-char/oversized secret sails through to put_team_cred and IS stored (test 10 goes red).
722+
if not claude_cred.is_valid_claude_secret(secret, kind):
723+
return (422, {"error": "bad_secret_shape", "team_id": team_id})
711724
# LEAST-PRIVILEGE WRITE PLACEMENT (the /team/cred 503 fix). On the internet-facing PUBLIC surface
712725
# with the Secret-Manager cred store, the public SA (heimdall-cp-public-run@) is DELIBERATELY
713726
# least-privilege and holds NO secretmanager.admin/create — so a DIRECT put_team_cred here (which

bin/rr

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,23 @@ do_connect() {
691691
fi
692692
[ -n "$cred_secret" ] || die "no credential provided — set CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY or paste when prompted"
693693

694+
# BUG #23 — SANITIZE + VALIDATE the Claude cred at the CLIENT boundary before it is ever
695+
# signed/posted. `claude setup-token` prints a DECORATED interactive UI (ANSI escape
696+
# sequences + a "Welcome to Claude Code" / "Your OAuth token:" banner + the authorize URL),
697+
# so `export CLAUDE_CODE_OAUTH_TOKEN="$(claude setup-token)"` captures the whole ~2199-char
698+
# decorated dump, NOT the clean token. Stored as-is, `claude` gets `Bearer <ansi junk>` ->
699+
# "Header has invalid value" -> interactive fallback -> GATE_FAILED, 0 files. Strip ANSI/
700+
# control, collapse whitespace, EXTRACT the one sk-ant-oat…/sk-ant-api… token by shape.
701+
# Zero / junk / two distinct tokens -> REFUSE (never post a non-clean value). The secret
702+
# crosses to python via the ENV ONLY (never argv/log); nothing of the token is printed.
703+
local clean_secret
704+
clean_secret="$(RR_RAW_SECRET="$cred_secret" cp_extract_token_py)"
705+
if [ -z "$clean_secret" ]; then
706+
unset cred_secret
707+
die "the token looks wrong — paste ONLY the sk-ant-oat… value from \`claude setup-token\` (the token itself, not the whole decorated output)"
708+
fi
709+
cred_secret="$clean_secret"; unset clean_secret # use the CLEAN extracted single token.
710+
694711
say "signing + posting the tenant credential to the control plane ($CP_URL/team/cred)…"
695712
local resp kind a b
696713
resp="$(CP_REG_PATH=/team/cred CP_REG_KIND="$cred_kind" CP_CRED_SECRET="$cred_secret" cp_register_py)"
@@ -749,6 +766,22 @@ do_connect() {
749766
# seed read / network (the dry-run plan uses this). Emits one tab-record: `OK\t<a>\t<team_id>` on 2xx
750767
# (a = kind for cred, installation_id for install), `HTTP\t<code>\t<err>` on an HTTP refusal, or
751768
# `ERR\t<reason>` on no-seed/transport.
769+
# cp_extract_token_py — SANITIZE + EXTRACT the clean Claude token from RR_RAW_SECRET (bug #23).
770+
# Reuses the SHIPPED claude_cred oracle (the SAME shape grammar the server enforces). The raw
771+
# value crosses via the ENV ONLY (never argv). Prints the ONE clean sk-ant-oat…/sk-ant-api…
772+
# token on success, or NOTHING (empty) when zero / junk / ambiguous — so the caller refuses.
773+
# Nothing of the token is otherwise emitted (no log/echo/repr).
774+
cp_extract_token_py() {
775+
LIB="$LIB" "$PY" - <<'PYEOF'
776+
import os, sys
777+
sys.path.insert(0, os.environ["LIB"])
778+
import claude_cred
779+
tok = claude_cred.extract_claude_token(os.environ.get("RR_RAW_SECRET") or "")
780+
if tok:
781+
sys.stdout.write(tok)
782+
PYEOF
783+
}
784+
752785
cp_register_py() {
753786
LIB="$LIB" CP_URL="$CP_URL" CP_HAID="$CP_HAID" CP_SEED_FILE="$CP_SEED_FILE" \
754787
"$PY" - <<'PYEOF'

test/heimdall-rr-cp.test.sh

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,56 @@ else
330330
echo " SKIP (11) dedup-notice round-trip — no crypto backend (cryptography|pynacl)"
331331
fi
332332

333+
# ── (12) BUG #23 — the CLIENT sanitizes/extracts the Claude token BEFORE signing/posting ──────────
334+
# `claude setup-token` prints a DECORATED interactive UI (ANSI + banner), so
335+
# `export CLAUDE_CODE_OAUTH_TOKEN="$(claude setup-token)"` captures the whole dump, not the token.
336+
# `rr connect` runs it through the SHIPPED claude_cred oracle (the same shape grammar the server
337+
# enforces): extract the ONE sk-ant-oat…/sk-ant-api… token, or REFUSE. This proves both the oracle
338+
# contract the client relies on AND that bin/rr is wired to call it + refuse (grep-falsifiable).
339+
echo "(12) BUG #23 client token sanitize/extract — decorated setup-token dump -> the clean token, or refuse"
340+
C12_OUT="$WORK/c12.out"
341+
"$PY" - >"$C12_OUT" 2>/dev/null <<PYEOF
342+
import json, os, secrets, sys
343+
sys.path.insert(0, "$ROOT/bin/lib")
344+
import claude_cred as C
345+
ESC = "\x1b"
346+
tok = "sk-ant-oat01-" + secrets.token_urlsafe(70)
347+
# (a) the ~2199-char decorated \`claude setup-token\` dump: ANSI + banner + the token buried.
348+
spinner = "".join(ESC + "[2K" + ESC + "[1G" + f for f in ["-", "\\\\", "|", "/"] * 60)
349+
banner = (ESC + "]0;claude\x07" + ESC + "[1mWelcome to Claude Code" + ESC + "[0m\r\n"
350+
"Your OAuth token:\r\n" + ESC + "[32m" + tok + ESC + "[0m\r\n"
351+
"Visit https://console.anthropic.com/oauth/authorize?x=1 to continue\r\n")
352+
blob = spinner + banner + (ESC + "[2K") * 200
353+
out = {
354+
"blob_big": len(blob) >= 2199,
355+
"a_decorated_extracts_clean": C.extract_claude_token(blob) == tok,
356+
"b_clean_unchanged": C.extract_claude_token(tok) == tok,
357+
"c_junk_none": C.extract_claude_token("no token here\x1b[0m just words") is None,
358+
"d_two_tokens_none": C.extract_claude_token(
359+
tok + " " + "sk-ant-api03-" + secrets.token_urlsafe(70)) is None,
360+
"d_same_twice_ok": C.extract_claude_token(tok + "\n" + tok) == tok,
361+
}
362+
sys.stdout.write(json.dumps(out))
363+
PYEOF
364+
c12() { "$PY" -c "import json;print(json.load(open('$C12_OUT')).get('$1'))" 2>/dev/null; }
365+
[ "$(c12 blob_big)" = "True" ] || bad "12.0 fixture broken: the reconstructed dump is < 2199 chars"
366+
[ "$(c12 a_decorated_extracts_clean)" = "True" ] \
367+
&& ok "12.1 the 2199-char decorated dump -> the CLEAN buried sk-ant-oat token (a)" || bad "12.1 decorated dump did not extract clean (see $C12_OUT)"
368+
[ "$(c12 b_clean_unchanged)" = "True" ] \
369+
&& ok "12.2 a clean token is returned UNCHANGED (b)" || bad "12.2 clean token altered"
370+
[ "$(c12 c_junk_none)" = "True" ] \
371+
&& ok "12.3 junk with no token -> None (client refuses) (c)" || bad "12.3 junk not rejected"
372+
[ "$(c12 d_two_tokens_none)" = "True" ] && [ "$(c12 d_same_twice_ok)" = "True" ] \
373+
&& ok "12.4 two DISTINCT tokens -> None (ambiguous, refuse); the same token twice -> that token (d)" || bad "12.4 ambiguity handling wrong"
374+
# bin/rr WIRING (grep-falsifiable): do_connect extracts via cp_extract_token_py and REFUSES on empty.
375+
if grep -q 'cp_extract_token_py' "$RR" \
376+
&& grep -q 'the token looks wrong — paste ONLY the sk-ant-oat' "$RR" \
377+
&& grep -q 'claude_cred.extract_claude_token' "$RR"; then
378+
ok "12.5 bin/rr connect is WIRED to sanitize+extract the cred (cp_extract_token_py) and REFUSE a non-clean value"
379+
else
380+
bad "12.5 bin/rr connect is missing the sanitize/extract/refuse wiring"
381+
fi
382+
333383
echo
334384
echo "════════════════════════════════════════════════════════════════════════════"
335385
printf "rr-cp: \033[32m%d passed\033[0m, " "$PASS"

0 commit comments

Comments
 (0)