feat(accounts): end API keys in a CRC-32 checksum - #596
Merged
Merged
Conversation
A key is now `sck_`, 30 random base62 characters drawn with `crypto.randomInt`, and six more that are the CRC-32 of those 30 (IEEE, as zlib computes it) in base62: 40 characters matching `^sck_[0-9A-Za-z]{36}$`, GitHub's own token layout, as ADR-013 now specifies (source-cooperative/data.source.coop#242). GitHub's secret-scanning partner program recommends a 32-bit checksum so that a scanner, the data proxy or the CLI can reject a look-alike, truncated or mistyped key without a lookup. Base62 keeps `-` out of the key, so a double-click selects all of it.
`src/types/service-account-key.ts` holds the whole format: the pattern, the alphabet, `apiKeyChecksum` and `isApiKey`. The CRC-32 is written out in a few lines rather than taken from `zlib`, because client components import this module and it is bundled for the browser.
The hint the UI shows is now the checksum, the key's last six characters (`sck_…Xy9QeT`): a 32-bit fingerprint that narrows the key's 178 random bits by 32, where four characters were 24 bits. The schema still accepts the four-character hints of keys issued before this change.
Stories show six-character hints, and the Storybook mock builds its sample key at run time with a real checksum; tests build their keys at run time too, so secret scanners don't flag the files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
The show-once view told the user a key is listed by "its last four characters", and the key list's story said the same; both now say six, the checksum the hint has become. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
|
Claude finished @alukach's task in 14s —— View job ✅ No blocking issues — safe to merge. I read the diff for correctness and security, and found nothing blocking.
Simplify (ponytail)
DocsThe description names ADR-013 (data.source.coop#242) and the automated-access guide (docs.source.coop#37). It also says why nothing else is affected. This meets the CLAUDE.md requirement. 💰 Estimated review cost: $0.13 · 0m13s · 4 turns |
alukach
added a commit
that referenced
this pull request
Sep 29, 2026
Keys now end in a CRC-32 checksum of their random part (#596). Both revocation routes select keys with `isApiKey` rather than the bare pattern, so a token GitHub reports whose checksum fails is left out of the answer without a lookup, like any other non-key, and the self-revoke route answers 400 for a cut-short or mistyped key before looking anything up. The OpenAPI descriptions say what a whole key is. The tests build their keys at run time with `apiKeyChecksum`, so secret scanners don't flag them, and each suite gains a mistyped key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
This was referenced Sep 29, 2026
alukach
added this pull request to stack #597
September 30, 2026 00:24
alukach
marked this pull request as ready for review
September 30, 2026 00:25
alukach
added a commit
that referenced
this pull request
Sep 30, 2026
Keys now end in a CRC-32 checksum of their random part (#596). Both revocation routes select keys with `isApiKey` rather than the bare pattern, so a token GitHub reports whose checksum fails is left out of the answer without a lookup, like any other non-key, and the self-revoke route answers 400 for a cut-short or mistyped key before looking anything up. The OpenAPI descriptions say what a whole key is. The tests build their keys at run time with `apiKeyChecksum`, so secret scanners don't flag them, and each suite gains a mistyped key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
alukach
added a commit
that referenced
this pull request
Sep 30, 2026
…ret scanning (#581) ## What I'm changing This adds two public routes that revoke a leaked service-account API key. It is the code half of #561. It builds on #596, merged, which ends every key in a CRC-32 checksum; #570 and #580's opaque `sck_` keys and `key_hash`-keyed records, both merged, are what these routes look up. - `POST /api/v1/service-account-keys/revocations` lets anyone who holds a key, its owner or a stranger who found it, revoke it without signing in, by sending `{"key": "sck_…"}`. It always answers 204, whether the key was live, already revoked or unknown. - `POST /api/v1/secret-scanning/github` is the endpoint for GitHub's secret-scanning partner program. It verifies GitHub's signature, revokes every reported token that `isApiKey` accepts (the pattern and the checksum), and answers with the feedback body the program defines. Both routes call one function, `revokeLeakedKey` in `src/lib/accounts/service-account-keys.ts`. It hashes the key, calls `fetchByHash`, revokes the key if it isn't revoked already, and logs `key_id` and `account_id`, never the key. `hashApiKey` moves there from `src/lib/actions/service-account-keys.ts`, which is a `use server` module and can't export it, so issuing a key and revoking one hash the same way. A key's record also says who revoked it. A new optional field, `revoked_via`, is `owner` when the key was revoked from settings, `holder` when someone presented it to the self-revoke route, and `github` when secret scanning reported it. A new table method, `serviceAccountKeysTable.revoke(key_hash, via)`, writes `revoked_via` and `revoked_at` in the same update, and all three ways of revoking a key call it; `set` no longer accepts `revoked_at`. The key list's tooltip adds who revoked the key after the date, for example "Revoked Sep 24, 2026 after GitHub found it in public". Keys revoked before this change have no `revoked_via`, and their tooltip is unchanged. ## How I did it **Self-revoke.** The key is read only from a JSON body. If `sck_` appears in the query string, the route answers 400 before any lookup, because request URLs are logged; this is the proxy's `/.sts` rule from ADR-013. The key is trimmed (a key copied out of a token file ends in a newline), hashed and looked up. The route never calls `getApiSession` and reads no cookies. A body without a well-formed key gets 400 rather than 204. The key format is public, so refusing a malformed key says nothing about any real key, and it tells someone who pasted the wrong thing that nothing was revoked. The 204 is the same for live, revoked and unknown keys, and presenting a live key revokes it, so a key can't be tested here without being killed. **GitHub endpoint.** This follows the [partner-program docs](https://docs.github.com/en/code-security/tutorials/secret-scanning-partner-program). - Before parsing anything, the route checks `Github-Public-Key-Signature` (a base64 DER ECDSA signature, P-256 with SHA-256) over the raw body bytes using `crypto.verify`. The public key is the one `Github-Public-Key-Identifier` names at `https://api.github.com/meta/public_keys/secret_scanning`. - Every failure to verify gets 401 and revokes nothing: a missing header, an unknown identifier, a bad signature, or a check that can't finish because the key fetch failed or a key or signature won't parse. The check is one function, `signedByGitHub`, which never throws and logs each refusal at warn with the key identifier. - GitHub's public keys are cached in module scope for the life of the function instance. GitHub rotates keys, so an identifier not in the cache triggers a refetch, but at most once every ten minutes. The keys endpoint allows 60 unauthenticated requests an hour per IP, Vercel's egress IPs are shared, and anyone can forge an identifier; without the throttle, a few dozen forged requests could use up the budget and stop an instance from ever verifying a real report. The attempt time is recorded before the fetch, so a failed or concurrent fetch counts against the throttle as well. - There is one cost. If GitHub starts signing with a newly published key within ten minutes of an instance's last fetch, that instance refuses the report with 401 until the window passes, so the report only gets through if GitHub sends it again. The partner-program docs don't say whether GitHub retries refused deliveries, so this is worth confirming during registration. - Tokens are selected by `isApiKey`, not by `type`. The type name is only fixed at registration, and the pattern is what gets registered. A token that fails the pattern or its checksum is never looked up and is left out of the answer, so a look-alike or a mangled key costs no database read. - The docs define a feedback body, and the route returns it: `[{token_raw, token_type, label}]`, with `true_positive` for a key whose record exists (whether it was revoked now or before) and `false_positive` otherwise. I chose `token_raw` over `token_hash` because GitHub already sent us the raw token, and the docs don't say how `token_hash` is encoded. The route answers 200; the docs don't specify status codes. - A signed body that isn't an array of `{token, type}` gets 400. `url` and `source` are only logged, so they aren't validated: an unexpected value in one of them shouldn't stop a batch of keys from being revoked. **What gets revoked.** An expired key is revoked too, and so is a key whose service account is disabled, because extending the expiry or enabling the account would bring the key back. An already-revoked key keeps its original `revoked_at`. Each revocation is stored with its `revoked_via` and logged at warn with the same `via`. The GitHub log line also carries the `url` and `source` where the key was found; they are logged, not stored. **Route paths.** `revocations` sits beside the existing `service-account-keys/exchanges`: a caller creates a revocation of a key in that collection, just as the proxy creates an exchange. The key can't go in the path because URLs are logged, and whoever finds a key doesn't know its public `key_id`. `secret-scanning/github` names the program and the partner, so an endpoint for another scanner, with its own signature scheme, can sit beside it. It isn't under `service-account-keys` because its body is GitHub's report format, not ours. **No config.** The keys URL belongs to GitHub and is the same in every environment, so it is a constant in the route rather than a `CONFIG` value. **Departure from the issue text.** #561 on GitHub still says to choose a marker in source-cooperative/data.source.coop#230, and that it blocks #548. Under the revised scope, the marker is fixed by ADR-013 (revised by source-cooperative/data.source.coop#234 and #242) as `^sck_[0-9A-Za-z]{36}$`, the last six characters a CRC-32 checksum of the rest, and #561 no longer blocks #548. This PR follows the revised scope. ## How you can test it On 581f0da, which adds `revoked_via`: `npm run type-check` passes, and `npx jest --forceExit src/app/api/v1/secret-scanning src/app/api/v1/service-account-keys src/lib/actions/service-account-keys.test.ts` passes (4 suites, 24 tests). Those suites now expect `revoke(hash, "github" | "holder" | "owner")`. I did not repeat the mutation pass or the random-order runs below. After the rebase onto `main` (d7fd522), type-check is clean and all 82 suites pass. Earlier, as of 9e9bd56 on #596's branch: - `npm run type-check` passes. - `npx jest src/types/service-account-key.test.ts src/lib/actions/service-account-keys.test.ts src/app/api/v1/secret-scanning src/app/api/v1/service-account-keys src/components/features/service-accounts --forceExit`: 6 suites and 37 tests pass. These are this PR's two suites, which now include a key whose checksum fails, and the suites #596 changed; the key actions import `hashApiKey` from the new module. - `npm run lint` reports nothing in the files this PR touches. - A hand mutation pass, run before the rebase onto #596 (dd9fc72): each of ten deliberate breaks makes at least one test fail. They are disabling signature verification, dropping the refetch throttle, never refetching for an unseen identifier, recording the fetch time only after a successful fetch, letting a failed check escape as a 500, loosening the token filter, dropping the query-string refusal, dropping the trim, rewriting an already-revoked key, and logging the key. - The GitHub route keeps its key cache between requests, so I also ran its suite in eight random orders (`--randomize`, seeds 1 to 8) and ran each of its seven tests on its own, on 9e9bd56. All passed. The GitHub suite signs requests with a throwaway P-256 key from `node:crypto` against a mocked key fetch, and it mocks `Date.now` to show the ten-minute throttle. It also replays the signed sample request from GitHub's docs with GitHub's published key, which pins the signature encoding to what GitHub actually sends. Not tested: a live delivery from GitHub, which needs the registration listed under follow-ups. To try the self-revoke route by hand against a local server or a preview: ``` curl -i -X POST <host>/api/v1/service-account-keys/revocations -H 'content-type: application/json' -d '{"key":"sck_…"}' ``` It answers 204, and the key's service-account page shows it as revoked. The proxy refuses new exchanges of the key once its 60-second cache expires. The same request with `?key=sck_…` in the URL answers 400. The only UI change is the tooltip text. The [ApiKeyList Default story](https://source-coop-ui-git-feat-api-key-leak-revocation-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeylist--default) now includes a key revoked by an owner and a key revoked by GitHub; hover over each key's dates to see who revoked it. ## Docs and ADRs - ADR-013 as revised (source-cooperative/data.source.coop#234) already describes the self-revoke route: "the same hash lookup, body-only, a uniform response, the same rate limit". This PR implements all of that except the rate limit, which is the WAF follow-up below, so the ADR still holds. The GitHub endpoint follows from registering the ADR's marker. It accepts raw keys only in GitHub-signed reports and changes nothing the proxy decides, so I read it as within ADR-013 rather than a new decision. I also checked ADR-005 (authorization) and ADR-014 (service accounts); neither is affected. - docs.source.coop: no page on `main` covers API keys or revocation yet. The unattended workflow guide, source-cooperative/docs.source.coop#34, should say what to do when a key leaks: POST it to this route, or disable the service account. ## Follow-ups - **GitHub partner registration** is external and takes weeks. What to send GitHub, the steps, the questions to ask, and the equivalent steps for GitLab, gitleaks and TruffleHog are logged on #561: #561 (comment) - **Rate limiting.** source.coop has no rate-limit infrastructure, and 256-bit keys make guessing infeasible, so this PR adds no dependency. A Vercel WAF rate-limit rule on both paths would give the self-revoke route the limit ADR-013 describes, and would cap floods of either route. Forged GitHub key identifiers no longer need it, because the ten-minute throttle holds each instance to six key fetches an hour. If other tenants on Vercel's shared egress IPs use up GitHub's unauthenticated limit anyway, GitHub's docs suggest a personal access token with no scopes, or conditional requests. A token would be a new secret in `CONFIG`, so it is left out until it's needed. - **Telling the owner.** The app has no email or notification mechanism, so a revocation is only logged and shown in the key list. Telling the owner that their key leaked, and where (the GitHub `url` is in the log), is left for a follow-up. ## Related Part of #561 (epic #491). Builds on #570 as reworked by #580, both merged. 🤖 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
to source-cooperative/data.source.coop
that referenced
this pull request
Sep 30, 2026
## 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](https://docs.github.com/en/code-security/tutorials/secret-scanning-partner-program#identify-your-secrets-and-create-regular-expressions)
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
- #235 checks the checksum at `/.sts`
(commits d6745e0 and 30ad22f); #236 and #237 are rebased onto it.
- source-cooperative/source.coop#596 generates keys in the new format
and shows the checksum as a key's hint;
source-cooperative/source.coop#581, stacked on it, validates leaked keys
by checksum. What to send GitHub, and the equivalent steps for other
scanners, are logged on source-cooperative/source.coop#561.
- source-cooperative/source-coop-cli#20 checks a key file's format and
checksum before exchanging 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.com/claude-code)
https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ofsck_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 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.tsholds the whole format:API_KEY_PATTERN,API_KEY_ALPHABET,apiKeyChecksumandisApiKey. The CRC-32 is a few lines of TypeScript rather thanzlib.crc32because client components import this module and it is bundled for the browser.issueApiKeydraws the 30 characters withcrypto.randomInt(62), which is uniform, and appends their checksum.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
How you can test it
I ran these on the branch as pushed (8d0a99e):
npm run type-checkpasses.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 pinsapiKeyChecksumto vectors computed independently with Python'szlib.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 lintreports nothing in the files this PR touches.sck_storyFixtureNotARealKey12345671oUR38, whose checksum holds.Docs and ADRs
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.ai/code/session_01R1eiTse4416N6uTgAy4Ddd