Relationships
#1382 Collective-token scope cache is keyed on a 12-char token prefix — only 2 chars distinguish org tokens, so a rotated token silently inherits another token's scopes
Opened by magistr · 7/24/2026
Summary
The collective-token scope cache is keyed on apiKeyFingerprint(), which is the literal first 12 characters of the token. Every collective token begins with the 10-character constant swamp_org_, so exactly two characters distinguish any two collective tokens' cache entries. A rotated or re-minted token whose first two post-prefix characters match a previously cached token silently inherits that token's scopes — in either direction. No whoami is performed on a cache hit, so this persists indefinitely.
Where (source)
src/domain/auth/auth_credentials.ts:28-30export function apiKeyFingerprint(apiKey: string): string { return apiKey.slice(0, 12); }src/cli/auth_context.ts:22—COLLECTIVE_TOKEN_PREFIX = "swamp_org_"(10 chars).src/infrastructure/persistence/auth_repository.ts:198-214—loadScopeCachereturns the cached scopes whencached.fingerprint === fingerprint, and that is the only validity check.src/cli/mod.ts:1345-1348— on a cache hit, the livewhoamiis skipped entirely.- The same 12-char value also gates the identity-cache merge at
auth_repository.ts:99-106, which carriesusernameandcollectives(the latter feeds extension trust atmod.ts:1402-1408).
The project's own test fixtures use "swamp_org_te" as a whole fingerprint (auth_repository_test.ts:437,454,501,517), which shows the effective key size directly.
Deterministic repro
Fake tokens throughout; no network is required or used.
export SWAMP_HOME="$(mktemp -d)"; mkdir -p "$SWAMP_HOME/config"
# cache belongs to token A, which was granted vault:*
printf '{"fingerprint":"swamp_org_ab","scopes":["vault:*"]}\n' \
> "$SWAMP_HOME/config/scope_cache.json"
# token B is a DIFFERENT token that merely shares the first 12 chars.
# It was never granted vault:*.
export SWAMP_API_KEY="[REDACTED-SECRET-1]" # any string with this 12-char head
export SWAMP_CLUB_URL="https://[IP-1]:9" # unroutable: proves no lookup happens
swamp vault create @swamp/aws-sm v1Observed: the command proceeds past the vault:* scope gate (it then fails downstream on Unknown vault type, which is unrelated — the gate is what matters).
Control, identical in every respect except the cache:
rm "$SWAMP_HOME/config/scope_cache.json"
swamp vault create @swamp/aws-sm v1
# -> Error: Your collective token lacks the vault:* scope.The gate blocks without the cache and passes with it, for a token that never held the scope. The cache is the cause.
The inverse is equally reproducible: cache ["serve:*"] under the same fingerprint and a token that genuinely holds vault:* is denied.
Impact
Two failure directions, both silent:
- Fail-open. A token is denied a scope server-side but passes the client pre-flight, so the user proceeds into an operation that will fail later, or (for the gates that are client-side only) succeeds locally when policy says it should not.
- Fail-closed with a wrong diagnosis. A correctly scoped token is told it "lacks the X scope" — the same misleading message as #1381, from a different cause.
Both survive rotation, which is exactly when a fingerprint collision becomes likely: rotating a token is the moment you mint a new one on a machine that already has a cache entry. On a machine holding several org tokens the birthday bound is unkind — around 40 distinct tokens gives roughly even odds of some pair colliding.
Expected
- Derive the fingerprint from a hash, not a prefix — e.g. the first 12 hex chars of
SHA-256(token). This is a drop-in change that both removes the collision and stops persisting 12 characters of live secret to disk (see below). - Alternatively key the cache on the server-returned token id (
apiKeyId) rather than anything derived from the secret.
Related
- #1381 — same user-visible symptom ("lacks the X scope"), different cause (swallowed
whoamierrors). Worth fixing together since both land on the same error string. - Sibling issue on cache lifecycle (no TTL, no way to clear) filed separately.
Note on secret material
Because the "fingerprint" is a prefix rather than a digest, 12 real characters of the live bearer token are written in plaintext to both auth.json and scope_cache.json. Files are mode 0o600, but mode is a no-op on Windows in Deno. Hashing fixes this incidentally.
Environment
- Verified against source
main@10943ef. The account+scope gate shipped ina519305(#1934), 2026-07-23; the installed stable20260722.210030.0-sha.7ff1e2f0predates it, so repros must be run from source or a newer build. - macOS 14.
Closed
No activity in this phase yet.
magistr commented 7/24/2026, 9:40:01 PM
Clarification: the redactor masked the token literal on the SWAMP_API_KEY line of the repro. The value there is not a real secret — it is any synthetic string whose first 12 characters are swamp_org_ab, i.e. the same 12-char head as the fingerprint seeded into scope_cache.json on the line above, with arbitrary differing bytes after it. That identity of the first 12 chars, and nothing else, is what makes the cache hit.
Sign in to post a ripple.