Skip to main content
← Back to list
01Issue
BugClosedSwamp CLITeam
AssigneesNone

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-30
    export function apiKeyFingerprint(apiKey: string): string {
      return apiKey.slice(0, 12);
    }
  • src/cli/auth_context.ts:22COLLECTIVE_TOKEN_PREFIX = "swamp_org_" (10 chars).
  • src/infrastructure/persistence/auth_repository.ts:198-214loadScopeCache returns the cached scopes when cached.fingerprint === fingerprint, and that is the only validity check.
  • src/cli/mod.ts:1345-1348 — on a cache hit, the live whoami is skipped entirely.
  • The same 12-char value also gates the identity-cache merge at auth_repository.ts:99-106, which carries username and collectives (the latter feeds extension trust at mod.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 v1

Observed: 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.
  • #1381 — same user-visible symptom ("lacks the X scope"), different cause (swallowed whoami errors). 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 in a519305 (#1934), 2026-07-23; the installed stable 20260722.210030.0-sha.7ff1e2f0 predates it, so repros must be run from source or a newer build.
  • macOS 14.
02Bog Flow
OPENTRIAGEDIN PROGRESSCLOSED

Closed

7/24/2026, 10:45:02 PM

No activity in this phase yet.

03Sludge Pulse
Editable. Press Enter to edit.

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.

stack72 commented 7/24/2026, 10:45:02 PM

@magistr Thanks for the detailed reports — we appreciate the work you're putting in to make the tool better. Fixes are in #1956 and will be released shortly.

Sign in to post a ripple.