Skip to content

docs(adr): ADR-013 keys end in a CRC-32 checksum - #242

Merged
alukach merged 1 commit into
mainfrom
docs/api-key-checksum
Sep 30, 2026
Merged

alukach merged 1 commit into
mainfrom
docs/api-key-checksum

Conversation

@alukach

@alukach alukach commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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 of sck_ 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 digits 0-9A-Za-z, most significant first, padded with 0 to 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

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

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

claude Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 14s —— View job


✅ No blocking issues — safe to merge.

The ADR is internally consistent. I checked these points:

  • sck_ plus 30 random characters plus 6 checksum characters is 40 characters, which matches ^sck_[0-9A-Za-z]{36}$.
  • 30 base62 characters carry about 178.6 bits, so "178 bits" is right.
  • CRC-32 tops out at 4,294,967,295, and 62^6 is about 5.68×10^10, so six base62 digits always hold it.
  • No stale references remain in the ADR: no 47, 256 bits, or base64url outside the rationale sentence.
  • The new step-2 refusal message reveals nothing, because the format is public.

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 crc32. The base62 digit order is 0-9A-Za-z, so uppercase sorts before lowercase. The output is most significant digit first, left-padded with 0. A shared test vector in the ADR would catch a mismatch between the Rust proxy, the TypeScript source.coop code and the Python CLI. This is a suggestion, not a blocker.

Simplify (ponytail)

  • adrs/013-api-keys.md:49: the rationale for the format (scanner guidance, double-click selection) is longer than the definition. Cut it to the definition plus "GitHub token layout; the checksum adds no security", and move the rest to the PR description.

💰 Estimated review cost: $0.10 · 0m13s · 5 turns

@github-actions

Copy link
Copy Markdown

🚀 Latest commit deployed to https://source-data-proxy-pr-242.source-coop.workers.dev

  • Date: 2026-09-29T23:28:11Z
  • Commit: 0b856db

alukach added a commit to source-cooperative/source-coop-cli that referenced this pull request Sep 29, 2026
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
alukach added a commit to source-cooperative/docs.source.coop that referenced this pull request Sep 29, 2026
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
@alukach
alukach marked this pull request as ready for review September 30, 2026 00:28
@alukach
alukach enabled auto-merge (squash) September 30, 2026 00:28
alukach added a commit to source-cooperative/source.coop that referenced this pull request Sep 30, 2026
## 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".

![The key list with six-character
hints](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/api-key-checksum/api-key-list.png)

![The show-once view with a 40-character
key](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/api-key-checksum/issue-api-key-show-once.png)

## 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>
alukach added a commit that referenced this pull request Sep 30, 2026
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>
@alukach
alukach disabled auto-merge September 30, 2026 04:04
@alukach
alukach merged commit b1ce76e into main Sep 30, 2026
24 checks passed
@alukach
alukach deleted the docs/api-key-checksum branch September 30, 2026 04:05

This branch was successfully deployed

1 active deployment
preview — 5426712a Deployed Sep 29, 2026 by alukach via Deploy & Test / Deploy #385
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant