docs(adr): ADR-013 keys end in a CRC-32 checksum - #242
Conversation
An API key becomes `sck_`, 30 random base62 characters and a six-character base62 CRC-32 of those 30: 40 characters matching `^sck_[0-9A-Za-z]{36}$`, GitHub's own token layout. The checksum follows GitHub's secret-scanning partner guidance (a unique prefix, high entropy, a 32-bit checksum), so a scanner, the proxy or the CLI can reject a look-alike, truncated or mistyped key without a lookup; it adds no security. Base62 keeps `-` out of the key, so a double-click selects all of it. The proxy's step 2 now checks the checksum and refuses a malformed key with a message of its own. ADR-013 is revised in place because nothing implementing it has shipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
|
Claude finished @alukach's task in 14s —— View job ✅ No blocking issues — safe to merge. The ADR is internally consistent. I checked these points:
I did not read the implementing PRs, so I haven't confirmed they use the same CRC and base62 encoding. The spec's encoding choices are ones implementers can get wrong. The IEEE CRC-32 is the same one as zlib's Simplify (ponytail)
💰 Estimated review cost: $0.10 · 0m13s · 5 turns |
|
🚀 Latest commit deployed to https://source-data-proxy-pr-242.source-coop.workers.dev
|
Source API keys are now `sck_`, 30 random base62 characters and a six-character base62 CRC-32 of those 30 (ADR-013, source-cooperative/data.source.coop#242). The CLI checks the shape and the checksum before sending a key anywhere, so a key file holding a key that was cut short or mistyped fails at once with "does not hold a Source API key (sck_ and 36 letters and digits); check that it was copied whole", instead of reaching the proxy. The error still never echoes the file's contents. The CRC-32 is the IEEE one zlib computes, in a few lines rather than a new crate. The tests' keys are still built at run time so scanners don't flag the file, with checksums computed independently in Python. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
An API key now ends in a six-character checksum of the rest (ADR-013, source-cooperative/data.source.coop#242), so the data proxy and the Source CLI refuse a key that was cut short or mistyped before anything is looked up, with "API key is malformed; check that it was copied whole". The guide's "If the key is refused" section shows that error and what to do about it, and no longer says a value that isn't a key gets "API key was not accepted". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
## What I'm changing
A service-account API key now ends in a checksum. It is `sck_`, 30
random base62 characters, and six more that are the CRC-32 of those 30
(IEEE, as zlib computes it) written in base62: a fixed 40 characters
matching `^sck_[0-9A-Za-z]{36}$`, in place of `sck_` and 43 base64url
characters. This is GitHub's own token layout (`ghp_` + 30 random + 6
checksum), and it is what ADR-013 now specifies
(source-cooperative/data.source.coop#242).
The hint a key is listed by becomes that checksum, the key's last six
characters (`sck_…Xy9QeT`), instead of its last four.
## Why
GitHub's [secret-scanning partner
program](https://docs.github.com/en/code-security/tutorials/secret-scanning-partner-program#identify-your-secrets-and-create-regular-expressions)
recommends a unique prefix, high entropy and a 32-bit checksum. With the
checksum, anything that holds a key — a scanner, the data proxy, the
Source CLI, #581's revocation routes — can tell it from a look-alike, a
truncated key or a mistyped one without a lookup, so a mistyped key gets
an error that says so instead of "not accepted". It adds no security:
anyone can compute a CRC-32. Base62 keeps `-` out of the key, so a
double-click selects all of it; about half of the base64url keys
contained one.
The random part is 178 bits, down from 256. The hash needs no salt at
that size and enumeration stays infeasible.
## How
- `src/types/service-account-key.ts` holds the whole format:
`API_KEY_PATTERN`, `API_KEY_ALPHABET`, `apiKeyChecksum` and `isApiKey`.
The CRC-32 is a few lines of TypeScript rather than `zlib.crc32` because
client components import this module and it is bundled for the browser.
- `issueApiKey` draws the 30 characters with `crypto.randomInt(62)`,
which is uniform, and appends their checksum.
- The hint is the checksum. Six base62 characters are a 32-bit
fingerprint where four were 24 bits, and a checksum of 178 random bits
narrows them by 32, which leaves far too many to guess. The schema still
accepts the four-character hints of keys issued before this change, so
their rows keep rendering.
- Tests and the Storybook mock build their keys at run time, so secret
scanners don't flag the files once the pattern is registered.
Keys issued since #570 merged are in the old format and will be refused
as malformed by the proxy. No deployed proxy can exchange a key yet
(source-cooperative/data.source.coop#235 is open), so none of them has
ever worked; their owners issue new ones.
## Stories
-
[ApiKeyList](https://source-coop-ui-git-feat-api-key-checksum-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeylist--default):
each row's hint is the six-character checksum.
-
[IssueApiKeyDialog](https://source-coop-ui-git-feat-api-key-checksum-radiantearth.vercel.app/?path=/story/features-service-accounts-issueapikeydialog--default):
issue a key to see a 40-character key, listed "by its last six
characters".


## How you can test it
I ran these on the branch as pushed (8d0a99e):
- `npm run type-check` passes.
- `npx jest src/types/service-account-key.test.ts
src/lib/actions/service-account-keys.test.ts
src/components/features/service-accounts
src/app/api/v1/service-account-keys --forceExit`: 4 suites and 25 tests
pass. The new suite pins `apiKeyChecksum` to vectors computed
independently with Python's `zlib.crc32`, including a CRC above 2^31
(which a signed shift would get wrong) and one whose checksum keeps a
leading zero, and it refuses a cut-short key, a mistyped character, a
mistyped checksum, a character outside base62 and the old 47-character
format.
- `npm run lint` reports nothing in the files this PR touches.
- The screenshots are from a static Storybook build of this branch; the
dialog story issues `sck_storyFixtureNotARealKey12345671oUR38`, whose
checksum holds.
## Docs and ADRs
- ADR-013 states the key format and is revised in place for it by
source-cooperative/data.source.coop#242, since nothing implementing it
has shipped.
- source-cooperative/docs.source.coop#37, the automated-access guide,
now says what the proxy's new "API key is malformed; check that it was
copied whole" error means.
## Related
Part of #491 and #561. The same format is checked by
source-cooperative/data.source.coop#235 (proxy),
source-cooperative/source-coop-cli#20 (CLI) and #581 (revocation routes
and GitHub's secret-scanning endpoint), which also documents what to
send GitHub to enroll.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Supersedes #233. Implements the proxy half of API keys as recorded in the revised ADR-013 (#234, stacked on #232). Pairs with source-cooperative/source.coop#580 (into source-cooperative/source.coop#570's branch), which serves the route this calls. Closes #231. Part of source-cooperative/source.coop#491. ## What I'm changing A service account's API key is an opaque `sck_` secret, 30 random base62 characters and a six-character CRC-32 checksum of them (ADR-013, #242), that source.coop stores only as a SHA-256 hash. Nothing signs it. `/.sts` now accepts one as `WebIdentityToken` and resolves it by asking source.coop: - **Body only.** A key is read from the POST form body. One in the query string is refused before any lookup with `API key must be sent in the request body, not the URL (request id …)`, because Cloudflare logs request URLs. A JWT in the query string still goes to the STS route as before. - **Local checks first.** Trim surrounding whitespace, since every hand-made token file ends in a newline, then require exactly `sck_` + 36 base62 characters whose last six are the CRC-32 of the thirty before them (IEEE, as zlib computes it, written out in a few lines rather than a crate). Anything else with the `sck_` prefix is refused without a lookup. - **One lookup, cached.** `POST {SOURCE_API_URL}/api/v1/service-account-keys/exchanges` with `{"key_hash"}`, authenticated as the proxy itself (sentinel subject `urn:source:data-proxy`, the one route ADR-005 now lets the proxy call as itself). The route always answers 200: `{account_id, key_id, active: true}` or `{active: false}`. Both are cached for 60 seconds, so revocation takes effect within a minute and an unknown key costs one lookup a minute. A non-200 or network failure fails closed as a 500 `InternalError`, which SDKs retry, and caches nothing. - **One refusal for the key, and one for a mangled one.** A key that fails its shape or checksum was cut short or mistyped, and reads `InvalidIdentityToken: API key is malformed; check that it was copied whole (request id …)`; the format is public, so this reveals nothing, and it still counts against the rate limit. Unknown, revoked, expired and disabled all read `InvalidIdentityToken: API key was not accepted (request id …)`, with the id also in `x-amzn-requestid`. The id is in the message because SDKs show the user nothing else. The proxy logs one WARN line per refusal with the reason it knows (`malformed`, `inactive`, `query_string`, `rate_limited`) plus the key id and an 8-hex hash prefix; source.coop logs which of unknown, revoked, expired or disabled under the same request id. - **Minting.** Credentials for the named account under the `_default` role, sealed like every other session, with the same 900s floor, 3600s default and `STS_MAX_SESSION_DURATION_SECS` cap. `RoleArn`'s account segment is ignored, as for an Ory token. `ReadOnly`/`FullAccess` arrive with #221. - **Rate limit.** A new `KEY_EXCHANGE_LIMIT` ratelimit binding, 100 attempts a minute per client IP, in every wrangler config. A client exchanges about once a session, so a cluster behind one NAT stays far under it. A deployment without the binding logs an error and does not refuse traffic. **Decisions to flag** - **The limit applies to every `sck_` attempt, not only cache misses.** The cache sits inside the fetch helper, and at 100 a minute per IP the distinction makes no difference to legitimate traffic. ADR-013's wording is updated in #234 to match. - **No `<RequestId>` element in the STS error XML.** That lives in multistore-sts's `build_sts_error_response`; the header plus the message cover what SDKs surface, so no upstream change is needed now. - **`namespace_id`s `1001` (production), `1002` (staging) and `1003` (previews)** for the ratelimit binding. They only need to be unique within the Cloudflare account. - **CodeQL flags `key_hash` as weak password hashing (`src/keys.rs`), and it should be dismissed as a false positive.** The rule matches on the name: it takes the key for a password. A key is 256 random bits, so a slow hash or salt adds nothing, and the lookup is a key get, so there is no comparison to time. This is how GitHub stores its own tokens, and ADR-013 (#234) records it so nobody later "fixes" it to bcrypt. I have not dismissed the alert; that is the repo owner's call. - **Security Audit.** It failed here because `main`'s lockfile carried a rustls advisory (RUSTSEC-2026-0285). #238 fixed that on `main`, and this branch is rebased onto the fix. ## How I did it - `src/keys.rs` (new, wasm-free): `parse_api_key`, `looks_like_api_key`, `key_hash`, `KeyStanding`, `credentials_for`. - `src/lib.rs`: `api_key_exchange` runs after `ApiAuth` is built and before the router, and returns `None` for anything that isn't an `sck_` exchange so the STS route handles it unchanged. `exchange_api_key` does the lookup and minting; `key_refusal` builds the uniform refusal; `finish` adds CORS and the request-id headers to a pre-gateway response; `within_rate_limit` wraps the binding. - `src/source_api/auth.rs`: `PROXY_SELF_SUBJECT`, `ApiCaller` (anonymous, an account, or the proxy), `authorization_header_as_self`; `authorization_header` refuses the sentinel so no request can claim it. - `src/source_api/cache.rs`: `get_or_fetch_key_standing`; `cached_fetch` takes a method, an optional JSON body and an `ApiCaller`. The cache key is `{api_url}?key_hash={hash}`, so it is a URL, as the Cache API requires, and is scoped to the environment's API. - `src/sts.rs`: `default_role` factored out of `StsCredentialRegistry::new`. - `wrangler.toml`, `wrangler.preview.toml`: the binding per environment. `README.md`: a Bindings table and an API keys section. From #233 this keeps `finish`, the shape of `api_key_exchange`/`exchange_api_key`, `credentials_for` and the method argument to `cached_fetch`. It drops `/.keys`, minting, self-verification against the proxy's own JWKS, the `api_key` role, `chrono`/`rsa` and the `[patch.crates-io]` pin on multistore; developmentseed/multistore#147 is not needed. ## How to test it - `cargo test`: all suites, including the new `tests/keys.rs` (a key's shape, trimming `\n` and `\r\n`, rejection of short, long, wrong-case, mistyped, wrong-checksum, non-base62, checksum-less 47-character and JWT tokens, checksum vectors computed independently with Python's zlib (a CRC above 2^31, one with a leading zero), assembled with `concat!` so scanners don't flag the file, the SHA-256 test vector, and credentials sealed for the account within floor, default and cap). Run by the pre-commit hook, along with `cargo clippy --target wasm32-unknown-unknown -- -D warnings` and `cargo check --target wasm32-unknown-unknown`. - `pytest tests/test_api_keys.py` against `wrangler dev` and `tests/stub_api.py`, which gains the exchanges route keyed by the hash of fixed test keys, with a per-hash call counter. Nine tests, all passing locally with wrangler 3.114: a live key exchanges; **an unmodified boto3, configured only by `AWS_ROLE_ARN`, `AWS_WEB_IDENTITY_TOKEN_FILE` (a file holding the key and a trailing newline) and `AWS_ENDPOINT_URL_STS`, acquires credentials**; the second exchange within 60s never reaches the API; a trailing newline is harmless; unknown and revoked keys get byte-identical refusals apart from the id, and the refusal is cached; a key in the query string is refused without a lookup; malformed keys, including a mistyped one whose checksum fails, are refused without a lookup and with the malformed message; a wrong role is reported as such; an API 500 fails closed and is not cached. `test_control_plane.py` and `test_writes.py` still pass; their credentialed tests need CI's GitHub token and were skipped locally. The checksum commits (d6745e0, 30ad22f) were run by CI's Integration Tests job, which exchanges the new-format keys against the worker; it passed on both. - End to end, once source.coop#580 is on a deployment this preview points at: issue a key from a service account's page, save it to a file, then ```sh AWS_WEB_IDENTITY_TOKEN_FILE=./key AWS_ROLE_ARN=arn:aws:iam::000000000000:role/_default \ AWS_ENDPOINT_URL_STS=https://<preview>/.sts AWS_ENDPOINT_URL_S3=https://<preview> AWS_REGION=us-east-1 \ aws s3 ls s3://<owner>/<product>/ ``` Revoke the key and see the next exchange refused within 60s, then quote the printed request id to find the proxy's and source.coop's log lines. Not run here: it needs source-cooperative/source.coop#580 deployed. ## PR Checklist - [x] This PR has **no** breaking changes. (JWT exchanges at `/.sts` are unchanged; the new binding is additive.) - [x] I have updated or added new tests to cover the changes in this PR. - [x] This PR affects the [Source Cooperative Frontend & API](https://github.com/source-cooperative/source.coop), and I have opened issue/PR source-cooperative/source.coop#580 to track the change. ## Related Issues Closes #231. Supersedes #233. ADR: #234 (revises ADR-013, amends ADR-005). Route: source-cooperative/source.coop#580, into source-cooperative/source.coop#570. Epic: source-cooperative/source.coop#491. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
What I'm changing
ADR-013 now specifies an API key as
sck_, 30 random base62 characters and a six-character checksum: a fixed 40 characters matching^sck_[0-9A-Za-z]{36}$, in place ofsck_and 43 base64url characters with no checksum. The checksum is the CRC-32 of the 30 random characters (IEEE, as zlib computes it), written in base62 with the digits0-9A-Za-z, most significant first, padded with0to six. This is GitHub's own token layout.Why
GitHub's secret-scanning partner program recommends three things for a secret format: a unique prefix, high entropy, and a 32-bit checksum. The ADR already had the first two. The checksum lets a scanner, the proxy or the CLI tell a real key from a look-alike, a truncated key or a mistyped one without asking source.coop, and so gives a mistyped key a refusal of its own. It adds no security, since anyone can compute it; the ADR says so. Base62 keeps
-out of the key, so a double-click selects all of it: about half of the base64url keys contained one.The random part is 178 bits, down from 256; the ADR's "no salt or KDF" and "enumeration is infeasible" arguments still hold at that size, and the text now says 178.
The proxy's step 2 gains the checksum check and the distinct refusal, "API key is malformed; check that it was copied whole", which reveals nothing because the format is public.
ADR-013 is revised in place, as it was on 2026-09-25, because nothing implementing it has shipped: source.coop issues keys since source-cooperative/source.coop#570 merged, but no deployed proxy can exchange one until #235 lands.
Implementing PRs
/.sts(commits d6745e0 and 30ad22f); feat(sts): serve the FullAccess and ReadOnly roles #236 and feat(sts): let platform IdP tokens act as accounts that trust them #237 are rebased onto it.Docs and ADRs
Only ADR-013 states the key format; I checked the other ADRs with
git grep sck_and none mention it. ADR-014's amendment of ADR-013 is about ownership, not format, and still holds.🤖 Generated with Claude Code
https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd