Conversation
`creds` now takes a service account's API key, from `--api-key-file` (or `SOURCE_API_KEY_FILE`) or from `SOURCE_API_KEY`, and exchanges it at the proxy's `/.sts` without a browser. The key is trimmed and must have the issued shape, `sck_` and 43 base64url characters, before it is sent, so the contents of the wrong file never are. Its credentials are cached as `login`'s are, under a slot named for the role and a prefix of the key's SHA-256, and the key is exchanged again once they expire, so `credential_process = source-coop creds --api-key-file …` keeps an SDK, GDAL or a daemon supplied without a person. For a key the cache is best effort: one that can't be read or written costs an exchange, not the call. A per-slot lock lets callers started together share one exchange rather than each spending one against the proxy's rate limit. Every STS exchange, the Ory ID token's included, now sends `WebIdentityToken` in a form-encoded POST body instead of the query string. URLs land in access logs, reqwest prints the URL in its connection errors, and the proxy refuses an API key sent in a URL. `RoleArn` goes out in the ARN form stock SDKs send: a bare `ReadOnly` becomes `arn:aws:iam::000000000000:role/ReadOnly`, and the default `_default` becomes `arn:aws:iam::000000000000:role/_default`, which the proxy already treats as `_default`. An STS error still reads `STS error (Code): Message` with the proxy's text verbatim. When the message lacks the proxy's request id, the one in the `x-request-id` header is appended, so every failure carries an id to quote to support. The README covers the key mode, GDAL 3.12+ through `credential_process`, and the stock-SDK setup that needs no CLI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
botocore refreshes credential_process credentials with under 15 minutes left and reruns the process on every lookup until it gets ones with more; the Go and JavaScript SDKs do the same with 5 minutes. `creds` served cached credentials until 60 seconds before expiry, so for the last 15 minutes of every session it ran once per S3 request. It now replaces them once they have less than 16 minutes left, the extra minute for clock skew, or less than half the requested session if that is shorter, so a short session isn't replaced on every call. The one-minute buffer stays the floor. This applies to an API key's exchange and to `login`'s refresh-token refresh, both of which run without a person. `login` credentials with no refresh token keep the one-minute rule, because only an interactive login can replace them. Replacing credentials early never ends a session sooner: when the exchange or refresh fails and the cached credentials are still valid, `creds` serves them with a warning on stderr, as an SDK keeps its credentials when an advisory refresh fails. Without that, a proxy outage or an expired refresh token would fail a caller up to 15 minutes before its credentials ran out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
Source API keys are now `sck_`, 30 random base62 characters and a six-character base62 CRC-32 of those 30 (ADR-013, source-cooperative/data.source.coop#242). The CLI checks the shape and the checksum before sending a key anywhere, so a key file holding a key that was cut short or mistyped fails at once with "does not hold a Source API key (sck_ and 36 letters and digits); check that it was copied whole", instead of reaching the proxy. The error still never echoes the file's contents. The CRC-32 is the IEEE one zlib computes, in a few lines rather than a new crate. The tests' keys are still built at run time so scanners don't flag the file, with checksums computed independently in Python. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
This was referenced Sep 29, 2026
alukach
added a commit
to source-cooperative/source.coop
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>
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 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.
Closes #17, as rescoped on 2026-09-25 for the revised ADR-013 (see the last decision below). Part of source-cooperative/source.coop#491. The server side is source-cooperative/data.source.coop#235 (the proxy's key exchange at
/.sts) plus source-cooperative/source.coop#580 (issuing keys and answering the proxy's lookup).What and why
A service account's API key is an opaque
sck_secret (ADR-013 as revised in source-cooperative/data.source.coop#234). The proxy exchanges it at/.stsas it does an ID token, but only from a POST form body. This PR lets the CLI make that exchange for software that runs unattended.creds.source-coop creds --api-key-file <path>(orSOURCE_API_KEY_FILE, orSOURCE_API_KEYholding the key itself) exchanges the key at/.stswithout a browser and prints the usual output. That output is credential_process JSON by default, socredential_process = source-coop creds --api-key-file …in~/.aws/configserves AWS SDKs and GDAL 3.12+. Credentials are cached aslogin's are and replaced before they expire, so a daemon keeps working across expiry. I extendedcredsrather than adding a subcommand, becausecredsalready is the credential_process mode.credstherefore replaces credentials once they have less than 16 minutes left (the extra minute is for clock skew), or less than half the requested session if that is shorter. The half-session limit keeps a 15-minute session from being replaced on every call. This covers a key's exchange andlogin's refresh-token refresh. Onmain,credsserves credentials until 60 seconds before expiry, so it runs once per S3 request for the last 15 minutes of every session./.sts(checked against staging below), so nothing changes forloginusers, and this can merge before the server side lands.RoleArndefaults toarn:aws:iam::000000000000:role/_default. A bare name such as--role-arn ReadOnlyis sent asarn:aws:iam::000000000000:role/ReadOnly, and a full ARN is sent as given.CodeandMessageverbatim and exits 1, for exampleError: STS error (InvalidIdentityToken): API key was not accepted (request id a40cee47fef1c4b4). When the message carries no request id (the 500 the proxy returns when it fails closed, or any Ory-token error), the id from thex-request-idheader is appended, so every failure has one to quote to support.AWS_WEB_IDENTITY_TOKEN_FILE,AWS_ROLE_ARN,AWS_ENDPOINT_URL_STS,AWS_ENDPOINT_URL_S3,AWS_REGION). It also corrects the stale--role-arndefault in the login options table (source-coop-user→_default).Decisions to flag
--api-key-file/SOURCE_API_KEY_FILEandSOURCE_API_KEYfollow the CLI's pairing of each flag with aSOURCE_*variable (--proxy-url/SOURCE_PROXY_URL,--role-arn/SOURCE_ROLE_ARN). The_FILEsuffix mirrorsAWS_WEB_IDENTITY_TOKEN_FILE, and one key file serves both the CLI and a stock SDK. No flag takes the key itself, because a command line is visible to other users throughpsand ends up in shell history and~/.aws/config. When both are set the file wins, since it is the explicit choice a profile makes.sck_plus 36 base62 characters whose last six are the CRC-32 of the thirty before them (ADR-013, docs(adr): ADR-013 keys end in a CRC-32 checksum data.source.coop#242), the same rule as the proxy'sparse_api_key. A key cut short or mistyped therefore fails locally too, with "check that it was copied whole". Anything else fails locally, so pointing--api-key-fileat the wrong file never sends that file's contents anywhere. The error names the source (the path, orSOURCE_API_KEY) and never the value.<role>+key-<first 16 hex digits of sha256(key)>. They never share a slot with aloginsession or another key, and the key itself is never written down.loginmode stays strict, because there the cache holds the refresh token.credsthen serves them with a warning on stderr, as SDKs keep their credentials when an advisory refresh fails. Failing instead would make a one-shotaws s3 cpin a cron job fail during a proxy blip whenever the cache was in its last 16 minutes, not just its last minute. The one-minute buffer remains the floor of the refresh window.logincredentials without a refresh token keep the one-minute rule. Only an interactive login can replace them, so refreshing them early would only turn valid credentials into an error sooner.sts::assume_role, which both paths share. The proxy'sis_default_roletreats_defaultandarn:…:role/_defaultalike, sologinbehaves as before, and its cache is still keyed by the role as given, so existing caches stay valid.AWS_ROLE_ARN=arn:aws:iam::<account>:role/FullAccess, but feat(sts): exchange opaque API keys at /.sts by hash lookup data.source.coop#235 serves only_defaultuntil Add the ReadOnly Role alongside FullAccess data.source.coop#221 lands, and answers any other role withMalformedPolicyDocument: role not found. This README usesrole/_default. I'm flagging it here rather than changing either PR.AWS_WEB_IDENTITY_TOKEN_FILEfresh". Under the revised ADR-013 that file holds the key itself and never changes, so there is nothing to refresh. The rescoped issue asks forcredential_processplus an env export for tools that send STS as a GET, which is what this delivers (--format envalready existed). The rescoped text wins where the two disagree.How I tested
cargo fmt --checkandcargo clippy --all-targets -- -D warningsare clean, as is CI'scargo clippy -- -D warnings.cargo test: 33 passed, 1 ignored (the existing keyring test). All re-run on a39541d, after the checksum commit.Action,RoleArn(the bareReadOnlyexpanded),WebIdentityTokenandDurationSeconds, and no query string.\nand\r\n, accepts keys whose checksums were computed independently with Python's zlib, and rejects empty, JWT, short, long, wrong-case, mistyped, non-base62 and checksum-less 47-character input.login's.ghcr.io/osgeo/gdal:ubuntu-small-latest. The image is headless with no keyring, so the cache used its file fallback. The build ran against a local stand-in for the proxy that answers/.stsas #235 does and issues credentials for the requested duration, here 300 seconds, which puts the refresh point 150 seconds before expiry:creds --api-key-fileexchanged the key and printed only the credential_process JSON: stdout parsed as JSON, with 0 bytes on stderr. An immediate second call was served from the cache.credsexited 0, served the cached credentials, and warned on stderr:Warning: STS error (InternalError): internal error (request id mockreq0002); using cached credentials until they expire. With the stand-in back, the next call exchanged the key again before the credentials expired.ReadOnly_key-c8b4449fef56b9ee.json, mode 0600.Error: STS error (InvalidIdentityToken): API key was not accepted (request id mockreq0004), exited 1, and wrote nothing to stdout.credential_process = source-coop creds --api-key-file …in~/.aws/config, ran the CLI and logged "Successfully obtained credentials from credential_process". It then read/vsis3/my-account/my-product/empty.geojsonwith a request signed by the exchanged access key and session token./.stsrequest was anapplication/x-www-form-urlencodedPOST with an empty query string.data.staging.source.cooprunsmain's proxy, which knows no keys, so the token took the same STS route an Ory ID token now takes. The form body reached that route, which answeredSTS error (InvalidIdentityToken): malformed JWT (request id a40d0b771cbed85f).STS error (InternalError): internal error (request id a40d0b783fc2b4a4).login. Its refresh is covered by the updated unit tests. Its decision of when to refresh reads the OS keyring, so it has no unit test, as onmain, but it uses the sameneeds_refreshand fallback as key mode, which do.Docs and ADRs
credential_process", which is how GDAL gets credentials. This PR implements that, so the ADR still holds. ADR-014 is untouched.docs/using-source/data-upload.mddocumentsloginwithcredential_process = source-coop creds. Setup is unchanged, and the page's claim that the AWS CLI and SDKs refresh credentials automatically still holds; they now just stop rerunningcredson every request. User docs for the key workflow belong to Unattended workflow guide docs.source.coop#34, which relies on this PR for GDAL.🤖 Generated with Claude Code
https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd