|
| 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 |
0 commit comments