Repository navigation
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>
…de (#37) * docs: add an automated access guide for service accounts Adds Automated Access (docs/using-source/automated-access.md), the guide for software that reaches Source Cooperative with nobody at the keyboard: what a service account is; creating one and granting it products; issuing an API key and the five variables the AWS CLI or an SDK needs to exchange it at the data proxy; the refusal users see and the request id to quote; keeping the key out of URLs and debug output; rotation and editable expiry; tools that keep their first credentials; GDAL; revocation, the emergency stop and revoking a found key; and GitHub Actions as coming soon. The page is added to sidebars.ts. Upload Your Data's Option 3 walked through trusting an IAM role in the uploader's own AWS account to write straight to Source's bucket. Service accounts supersede it, so the section is now a pointer to the new page, as are the two references to it earlier on that page and the "contact us" line for automated access. Access Data gains one sentence pointing unattended software at service accounts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd * docs: say what the malformed-key error means 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 * docs: document GitHub Actions sign-in, ReadOnly, and the current service account UI GitHub Actions trust shipped in data.source.coop#237 (which implements #222 and #223), so the "coming soon" note becomes instructions: trusting a workflow, the step from Example usage, why a job with an environment or a pull_request trigger carries a different subject, role-duration-seconds for long jobs, and the AccessDenied a refused sign-in gets. The API-key section now says how to choose ReadOnly, and that any other role name is refused. The create and issue steps follow the UI on source.coop main: sign-ins are added under "How software signs in" at creation or from "Add sign-in" under "Signs in with", and the variables, Change expiry and Revoke live in each row's menu. The stop table gains removing a workflow, and disabling now names workflow sign-ins too (authenticateWithOidcToken refuses a disabled account). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rVE6YPLaG5BrUoC76r3fp * docs: list an API key's variables from where to how The example now matches the order source.coop prints (source-cooperative/source.coop#619): region, S3 endpoint, STS endpoint, role, key file. The table below it follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rVE6YPLaG5BrUoC76r3fp * docs: split Upload Your Data into browser, CLI and service account pages Upload Your Data becomes a sidebar category. Its index keeps /data-upload with what every method shares (how the proxy works, where to upload, the controls) and a table for choosing a method. Option 1 becomes Upload in the Browser (/upload-in-the-browser), Option 2 becomes Upload with the Source CLI (/upload-with-the-cli) with View Credentials as its no-CLI fallback, and Automated Access moves in as With a Service Account, keeping /automated-access. The index carries HTML anchors for the old Option headings and the CLI heading Access Data linked to, so existing links still land. Content moves unchanged apart from headings and a sentence of glue per page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rVE6YPLaG5BrUoC76r3fp * docs: explain AWS_REGION, and drop the note that proxy uploads are disabled The data proxy verifies signatures with whatever region the client signed for and uses it for nothing else, but many SDKs refuse to sign without one, so both credential pages now say to set it anyway. Access Data's closing section said uploading through the proxy was disabled, which the Upload Your Data pages contradict; it is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rVE6YPLaG5BrUoC76r3fp * docs: note that only owners and maintainers manage service accounts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: keep the IAM-role upload instructions at /direct-uploads, marked deprecated Pipelines that write straight to the bucket with their own IAM role still need these instructions until they move to service accounts. The page stays out of the sidebar; the upload index links to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * tools: mark the IAM policy wizard deprecated and link it to /direct-uploads Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: drop the upload index's advice against permanent keys Service account API keys can be issued without an expiry, so the index shouldn't forbid them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: set AWS_DEFAULT_REGION, which boto3 reads, instead of AWS_REGION botocore reads only AWS_DEFAULT_REGION, so with AWS_REGION alone boto3 had no region. Note that the JavaScript and Java SDKs read AWS_REGION. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: give the key file as a full path, not $HOME Cron and systemd, where the page tells readers to copy these variables, do not expand $HOME, so the job would look for a literal "$HOME/..." path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: keep a found key off the command line when revoking it The example put the key in curl's arguments, where it lands in shell history and is visible in ps, against the page's own advice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: create the key file private from the start mkdir -m leaves an existing directory's mode alone, and the key file was readable by others between cat and chmod. chmod the directory, and create the file under umask 077 in a subshell so the shell's umask is untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: clarify how software signs in, name the Grant button, keep old anchors - Explain in the intro that a service account signs in with a GitHub workflow token or an API key, and what 'nothing else has to run' meant. - The grant dialog's button is labelled Grant, not a check mark. - Keep the old page's sub-section anchors so links into it land beside the page that now covers them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: keep AWS_REGION, and note that boto3 needs AWS_DEFAULT_REGION AWS_REGION is what most SDKs and the app's example usage read; boto3 is the one that reads only AWS_DEFAULT_REGION. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: drop the files/folders/objects list from the upload index Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: drop the 'Why we use these controls' section from the upload index Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: drop the 'What not to do' section from the upload index Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: list Direct Uploads (Deprecated) in the sidebar Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Simplify * Prefer account rather than org * Add windows focused examples * Rename file * Reorganize, format * Simplify * line-wrap plain-text code blocks * Add more windows friendly docs * Prefer account-id / product-id --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
main has since landed the decisions this branch proposed, in different form: ADR-014 service accounts with per-account trusts (#232, #237), ADR-013 opaque API keys (#234, #242), and the FullAccess/ReadOnly roles (#236). Take main's ADR-009, 010, 013 and RFC-001; drop this branch's federated-subject-resolution and service-accounts ADRs. That also resolves both review findings on ADR-013 (the signing-key contradiction and the per-Role API-key opt-in), since main's text replaces them. The delete ADR has no counterpart on main and answers a decision the epic still lists as owed, so it stays as ADR-015. Its Context no longer claims GetObjectVersion is already a read, and the classifier fix now covers the ReadOnly ceiling, which omits it too. 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