feat(accounts): revoke leaked API keys via self-revoke and GitHub secret scanning - #581
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
|
Claude finished @alukach's task in 16s —— View job ✅ No blocking issues — safe to merge. I read the diff for correctness and security and found nothing that should block it.
Two non-blocking notes:
Simplify (ponytail)Nothing worth cutting. The custom key cache is justified by the rate-limit reasoning in the description. I did not run the tests or type-check. 💰 Estimated review cost: $0.16 · 0m15s · 4 turns |
Base automatically changed from
feat/opaque-api-keys
to
feat/service-account-keys
September 29, 2026 21:41
alukach
force-pushed
the
feat/api-key-leak-revocation
branch
from
September 29, 2026 22:55
dd9fc72 to
f76dd58
Compare
Contributor
Author
This was referenced Sep 29, 2026
alukach
force-pushed
the
feat/api-key-leak-revocation
branch
from
September 29, 2026 23:41
f76dd58 to
9e9bd56
Compare
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
## 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>
Adds `POST /api/v1/service-account-keys/revocations`, the public self-revoke route that ADR-013 as revised promises: whoever holds a service account's API key, its owner or a stranger who found it, can revoke it without signing in, because holding the key is the only proof there is. The key goes in a JSON body only; a key anywhere in the query string is refused with 400 before any lookup, since request URLs are logged. The key is trimmed, hashed with the same SHA-256 hex the record is keyed by, and its record's `revoked_at` is set if it is not already. The answer is 204 whether the key was live, already revoked or unknown, so the route says nothing about a key's validity. A body with no well-formed key is 400; the format is public, so that says nothing about any key either. The revocation itself is `revokeLeakedKey` in `src/lib/accounts/service-account-keys.ts`, which the GitHub secret-scanning endpoint will call too. It revokes an expired key and a disabled account's key as well, since both states can be undone, and logs `key_id` and `account_id`, never the key. `hashApiKey` moves there from the issue action, a `use server` module that cannot export it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
Adds `POST /api/v1/secret-scanning/github`, the endpoint GitHub's secret-scanning partner program posts to when it finds tokens matching the API-key pattern in public. Before reading anything it verifies `Github-Public-Key-Signature`, an ECDSA P-256 SHA-256 signature over the raw body, against the key that `Github-Public-Key-Identifier` names at https://api.github.com/meta/public_keys/secret_scanning. The keys are held for the life of the instance and refetched once for an identifier not yet seen, since GitHub rotates them. A missing or bad signature is 401 and revokes nothing. Each token matching `API_KEY_PATTERN` is revoked through `revokeLeakedKey`, the same path as self-revoke, and the answer is the partner program's feedback body: `{token_raw, token_type, label}` per key, `true_positive` for a key that exists and `false_positive` for one that does not. Other tokens are left out. The tests sign requests with a throwaway P-256 key against a mocked key fetch, and also replay the signed sample request from GitHub's documentation, which pins the signature encoding to what GitHub actually sends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
…orts An unseen `Github-Public-Key-Identifier` now refetches GitHub's secret-scanning keys 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, so a few dozen forged requests could otherwise use up that 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. Rotation still works: the first report after GitHub publishes a new key triggers a refetch, unless one ran in the last ten minutes. The signature check is now one function, `signedByGitHub`, that never throws. A failed key fetch, or a key or signature that `crypto.verify` cannot parse, is logged at warn with the key identifier and returns false, so the route answers 401 for every failure to verify instead of a 500 for some of them. The tests control `Date.now`. They show that the first unseen identifier refetches, that a second one within ten minutes neither fetches nor revokes anything, and that a refetch happens again once ten minutes have passed. They also cover a rejected fetch and a key that will not parse: both get 401 and revoke nothing, and a failed fetch is throttled too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
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
A key's record now carries `revoked_via`, written in the same update as `revoked_at`: `owner` when revoked from settings, `holder` when someone presents it to the revocation endpoint, `github` when secret scanning reports it. The key list's tooltip says which. Keys revoked before this carry no value and read as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach
force-pushed
the
feat/api-key-leak-revocation
branch
from
September 30, 2026 03:15
581f0da to
d7fd522
Compare
Contributor
Author
|
Rebased onto |
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
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 andkey_hash-keyed records, both merged, are what these routes look up.POST /api/v1/service-account-keys/revocationslets 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/githubis the endpoint for GitHub's secret-scanning partner program. It verifies GitHub's signature, revokes every reported token thatisApiKeyaccepts (the pattern and the checksum), and answers with the feedback body the program defines.Both routes call one function,
revokeLeakedKeyinsrc/lib/accounts/service-account-keys.ts. It hashes the key, callsfetchByHash, revokes the key if it isn't revoked already, and logskey_idandaccount_id, never the key.hashApiKeymoves there fromsrc/lib/actions/service-account-keys.ts, which is ause servermodule 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, isownerwhen the key was revoked from settings,holderwhen someone presented it to the self-revoke route, andgithubwhen secret scanning reported it. A new table method,serviceAccountKeysTable.revoke(key_hash, via), writesrevoked_viaandrevoked_atin the same update, and all three ways of revoking a key call it;setno longer acceptsrevoked_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 norevoked_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/.stsrule 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 callsgetApiSessionand 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.
Github-Public-Key-Signature(a base64 DER ECDSA signature, P-256 with SHA-256) over the raw body bytes usingcrypto.verify. The public key is the oneGithub-Public-Key-Identifiernames athttps://api.github.com/meta/public_keys/secret_scanning.signedByGitHub, which never throws and logs each refusal at warn with the key identifier.isApiKey, not bytype. 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.[{token_raw, token_type, label}], withtrue_positivefor a key whose record exists (whether it was revoked now or before) andfalse_positiveotherwise. I chosetoken_rawovertoken_hashbecause GitHub already sent us the raw token, and the docs don't say howtoken_hashis encoded. The route answers 200; the docs don't specify status codes.{token, type}gets 400.urlandsourceare 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 itsrevoked_viaand logged at warn with the samevia. The GitHub log line also carries theurlandsourcewhere the key was found; they are logged, not stored.Route paths.
revocationssits beside the existingservice-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 publickey_id.secret-scanning/githubnames the program and the partner, so an endpoint for another scanner, with its own signature scheme, can sit beside it. It isn't underservice-account-keysbecause 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
CONFIGvalue.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-checkpasses, andnpx jest --forceExit src/app/api/v1/secret-scanning src/app/api/v1/service-account-keys src/lib/actions/service-account-keys.test.tspasses (4 suites, 24 tests). Those suites now expectrevoke(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-checkpasses.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 feat(accounts): end API keys in a CRC-32 checksum #596 changed; the key actions importhashApiKeyfrom the new module.npm run lintreports nothing in the files this PR touches.--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:cryptoagainst a mocked key fetch, and it mocksDate.nowto 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:
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 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
maincovers API keys or revocation yet. The unattended workflow guide, Unattended workflow guide 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
CONFIG, so it is left out until it's needed.urlis 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.ai/code/session_01R1eiTse4416N6uTgAy4Ddd