Skip to content

feat(accounts): revoke leaked API keys via self-revoke and GitHub secret scanning - #581

Merged
alukach merged 5 commits into
mainfrom
feat/api-key-leak-revocation
Sep 30, 2026
Merged

alukach merged 5 commits into
mainfrom
feat/api-key-leak-revocation

Conversation

@alukach

@alukach alukach commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

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.

  • 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 feat(accounts): end API keys in a CRC-32 checksum #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 feat(accounts): end API keys in a CRC-32 checksum #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 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 (docs(adr): revise ADR-013 — API keys are opaque secrets resolved by the platform 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, 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

  • 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 Register the API-key marker with secret scanners and expose self-revoke #561: Register the API-key marker with secret scanners and expose self-revoke #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.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

@vercel

vercel Bot commented Sep 25, 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 30, 2026 3:16am UTC
source-cooperative Ready Ready Preview Sep 30, 2026 3:16am UTC

Request Review

@claude

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
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.

  • GitHub signature check: it verifies the raw bytes before anything is parsed. Every failure path returns 401 and revokes nothing.
  • Key cache: the ten-minute refetch throttle is stamped before the fetch, so failed and concurrent fetches count against it.
  • Self-revoke route: the key is read only from the body, the response is a uniform 204, and the key is never logged.
  • revoke: it writes revoked_at and revoked_via in one update, guarded by attribute_exists.

Two non-blocking notes:

  • src/lib/clients/database/service-account-keys.ts:62: the "already revoked keeps its original revoked_at" rule is check-then-write, not atomic. Two concurrent reports of one key can overwrite revoked_at and revoked_via. The effect is cosmetic, since the key is revoked either way. Making it atomic would need attribute_not_exists(revoked_at) plus handling the resulting conditional-check failure, so leaving it is reasonable.
  • src/app/api/v1/secret-scanning/github/route.ts:135: Promise.all means one DynamoDB error returns a 500 for the whole batch, after some keys may already be revoked. Revocation is idempotent, so a retry from GitHub is safe. Whether GitHub retries is already listed as a registration question in the description.

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.
· branch feat/api-key-leak-revocation


💰 Estimated review cost: $0.16 · 0m15s · 4 turns

@alukach

alukach commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main after #570 (with #580) merged and force-pushed: the branch is now this PR's three commits. Its diff is identical to before; type-check is clean and all 81 test suites pass. Description updated to drop the stacking note.

@alukach
alukach force-pushed the feat/api-key-leak-revocation branch from f76dd58 to 9e9bd56 Compare September 29, 2026 23:41
@alukach
alukach changed the base branch from main to feat/api-key-checksum September 29, 2026 23:42
@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
Base automatically changed from feat/api-key-checksum to main September 30, 2026 03:12
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".

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

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

## How you can test it

I ran these on the branch as pushed (8d0a99e):

- `npm run type-check` passes.
- `npx jest src/types/service-account-key.test.ts
src/lib/actions/service-account-keys.test.ts
src/components/features/service-accounts
src/app/api/v1/service-account-keys --forceExit`: 4 suites and 25 tests
pass. The new suite pins `apiKeyChecksum` to vectors computed
independently with Python's `zlib.crc32`, including a CRC above 2^31
(which a signed shift would get wrong) and one whose checksum keeps a
leading zero, and it refuses a cut-short key, a mistyped character, a
mistyped checksum, a character outside base62 and the old 47-character
format.
- `npm run lint` reports nothing in the files this PR touches.
- The screenshots are from a static Storybook build of this branch; the
dialog story issues `sck_storyFixtureNotARealKey12345671oUR38`, whose
checksum holds.

## Docs and ADRs

- ADR-013 states the key format and is revised in place for it by
source-cooperative/data.source.coop#242, since nothing implementing it
has shipped.
- source-cooperative/docs.source.coop#37, the automated-access guide,
now says what the proxy's new "API key is malformed; check that it was
copied whole" error means.

## Related

Part of #491 and #561. The same format is checked by
source-cooperative/data.source.coop#235 (proxy),
source-cooperative/source-coop-cli#20 (CLI) and #581 (revocation routes
and GitHub's secret-scanning endpoint), which also documents what to
send GitHub to enroll.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alukach and others added 2 commits September 29, 2026 20:14
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
alukach and others added 3 commits September 29, 2026 20:14
…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
alukach force-pushed the feat/api-key-leak-revocation branch from 581f0da to d7fd522 Compare September 30, 2026 03:15
@alukach

alukach commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main after #596's squash merge and force-pushed: the branch is now this PR's five commits. Its diff is identical to before; type-check is clean and all 82 test suites pass.

@alukach
alukach merged commit 2413d96 into main Sep 30, 2026
8 checks passed
@alukach
alukach deleted the feat/api-key-leak-revocation branch September 30, 2026 03:26
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 — d7fd5225 Deployed Sep 30, 2026 by vercel[bot]
Preview – source-coop-ui — d7fd5225 Deployed Sep 30, 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