Skip to content

feat(accounts): end API keys in a CRC-32 checksum - #596

Merged
alukach merged 2 commits into
mainfrom
feat/api-key-checksum
Sep 30, 2026
Merged

alukach merged 2 commits into
mainfrom
feat/api-key-checksum

Conversation

@alukach

@alukach alukach commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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 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: each row's hint is the six-character checksum.
  • IssueApiKeyDialog: issue a key to see a 40-character key, listed "by its last six characters".

The key list with six-character hints

The show-once view with a 40-character key

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

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

alukach and others added 2 commits September 29, 2026 16:35
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
@vercel

vercel Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
source-coop-ui Ready Ready Preview Sep 29, 2026 11:39pm UTC
source-cooperative Ready Ready Preview Sep 29, 2026 11:39pm UTC

Request Review

@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.

I read the diff for correctness and security, and found nothing blocking.

  • apiKeyChecksum is a correct reflected CRC-32. It starts at ~0, uses polynomial 0xedb88320, and applies the final ~crc >>> 0, so the value is unsigned before the base62 conversion. Math.floor(n / 62) avoids signed-shift problems. Six base62 digits cover 62^6 ≈ 5.7e10, which is above 2^32, so six digits are always enough.
  • issueApiKey draws each character with randomInt(62), which is uniform. There is no modulo bias.
  • key.slice(-6) and token.slice(4, 34) match the fixed 40-character layout. isApiKey checks the pattern before it slices.
  • The hint schema still accepts the old four-character hints, so existing rows keep rendering.
  • Storybook and test keys are built at run time. The CLAUDE.md Storybook and comment conventions are followed.

Simplify (ponytail)

Docs

The 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
@alukach
alukach added this pull request to stack #597 September 30, 2026 00:24
@alukach
alukach marked this pull request as ready for review September 30, 2026 00:25
@alukach
alukach merged commit cdada60 into main Sep 30, 2026
12 checks passed
@alukach
alukach deleted the feat/api-key-checksum branch September 30, 2026 03:12
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

2 active deployments
Preview – source-cooperative — 8d0a99eb Deployed Sep 29, 2026 by vercel[bot]
Preview – source-coop-ui — 8d0a99eb Deployed Sep 29, 2026 by vercel[bot]
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