Skip to content

feat(sts): exchange opaque API keys at /.sts by hash lookup - #235

Merged
alukach merged 3 commits into
mainfrom
feat/opaque-api-keys
Sep 30, 2026
Merged

alukach merged 3 commits into
mainfrom
feat/opaque-api-keys

Conversation

@alukach

@alukach alukach commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Supersedes #233. Implements the proxy half of API keys as recorded in the revised ADR-013 (#234, stacked on #232). Pairs with source-cooperative/source.coop#580 (into source-cooperative/source.coop#570's branch), which serves the route this calls. Closes #231. Part of source-cooperative/source.coop#491.

What I'm changing

A service account's API key is an opaque sck_ secret, 30 random base62 characters and a six-character CRC-32 checksum of them (ADR-013, #242), that source.coop stores only as a SHA-256 hash. Nothing signs it. /.sts now accepts one as WebIdentityToken and resolves it by asking source.coop:

  • Body only. A key is read from the POST form body. One in the query string is refused before any lookup with API key must be sent in the request body, not the URL (request id …), because Cloudflare logs request URLs. A JWT in the query string still goes to the STS route as before.
  • Local checks first. Trim surrounding whitespace, since every hand-made token file ends in a newline, then require exactly sck_ + 36 base62 characters whose last six are the CRC-32 of the thirty before them (IEEE, as zlib computes it, written out in a few lines rather than a crate). Anything else with the sck_ prefix is refused without a lookup.
  • One lookup, cached. POST {SOURCE_API_URL}/api/v1/service-account-keys/exchanges with {"key_hash"}, authenticated as the proxy itself (sentinel subject urn:source:data-proxy, the one route ADR-005 now lets the proxy call as itself). The route always answers 200: {account_id, key_id, active: true} or {active: false}. Both are cached for 60 seconds, so revocation takes effect within a minute and an unknown key costs one lookup a minute. A non-200 or network failure fails closed as a 500 InternalError, which SDKs retry, and caches nothing.
  • One refusal for the key, and one for a mangled one. A key that fails its shape or checksum was cut short or mistyped, and reads InvalidIdentityToken: API key is malformed; check that it was copied whole (request id …); the format is public, so this reveals nothing, and it still counts against the rate limit. Unknown, revoked, expired and disabled all read InvalidIdentityToken: API key was not accepted (request id …), with the id also in x-amzn-requestid. The id is in the message because SDKs show the user nothing else. The proxy logs one WARN line per refusal with the reason it knows (malformed, inactive, query_string, rate_limited) plus the key id and an 8-hex hash prefix; source.coop logs which of unknown, revoked, expired or disabled under the same request id.
  • Minting. Credentials for the named account under the _default role, sealed like every other session, with the same 900s floor, 3600s default and STS_MAX_SESSION_DURATION_SECS cap. RoleArn's account segment is ignored, as for an Ory token. ReadOnly/FullAccess arrive with Add the ReadOnly Role alongside FullAccess #221.
  • Rate limit. A new KEY_EXCHANGE_LIMIT ratelimit binding, 100 attempts a minute per client IP, in every wrangler config. A client exchanges about once a session, so a cluster behind one NAT stays far under it. A deployment without the binding logs an error and does not refuse traffic.

Decisions to flag

  • The limit applies to every sck_ attempt, not only cache misses. The cache sits inside the fetch helper, and at 100 a minute per IP the distinction makes no difference to legitimate traffic. ADR-013's wording is updated in docs(adr): revise ADR-013 — API keys are opaque secrets resolved by the platform #234 to match.
  • No <RequestId> element in the STS error XML. That lives in multistore-sts's build_sts_error_response; the header plus the message cover what SDKs surface, so no upstream change is needed now.
  • namespace_ids 1001 (production), 1002 (staging) and 1003 (previews) for the ratelimit binding. They only need to be unique within the Cloudflare account.
  • CodeQL flags key_hash as weak password hashing (src/keys.rs), and it should be dismissed as a false positive. The rule matches on the name: it takes the key for a password. A key is 256 random bits, so a slow hash or salt adds nothing, and the lookup is a key get, so there is no comparison to time. This is how GitHub stores its own tokens, and ADR-013 (docs(adr): revise ADR-013 — API keys are opaque secrets resolved by the platform #234) records it so nobody later "fixes" it to bcrypt. I have not dismissed the alert; that is the repo owner's call.
  • Security Audit. It failed here because main's lockfile carried a rustls advisory (RUSTSEC-2026-0285). fix(deps): bump rustls to 0.23.45 for RUSTSEC-2026-0285 #238 fixed that on main, and this branch is rebased onto the fix.

How I did it

  • src/keys.rs (new, wasm-free): parse_api_key, looks_like_api_key, key_hash, KeyStanding, credentials_for.
  • src/lib.rs: api_key_exchange runs after ApiAuth is built and before the router, and returns None for anything that isn't an sck_ exchange so the STS route handles it unchanged. exchange_api_key does the lookup and minting; key_refusal builds the uniform refusal; finish adds CORS and the request-id headers to a pre-gateway response; within_rate_limit wraps the binding.
  • src/source_api/auth.rs: PROXY_SELF_SUBJECT, ApiCaller (anonymous, an account, or the proxy), authorization_header_as_self; authorization_header refuses the sentinel so no request can claim it.
  • src/source_api/cache.rs: get_or_fetch_key_standing; cached_fetch takes a method, an optional JSON body and an ApiCaller. The cache key is {api_url}?key_hash={hash}, so it is a URL, as the Cache API requires, and is scoped to the environment's API.
  • src/sts.rs: default_role factored out of StsCredentialRegistry::new.
  • wrangler.toml, wrangler.preview.toml: the binding per environment. README.md: a Bindings table and an API keys section.

From #233 this keeps finish, the shape of api_key_exchange/exchange_api_key, credentials_for and the method argument to cached_fetch. It drops /.keys, minting, self-verification against the proxy's own JWKS, the api_key role, chrono/rsa and the [patch.crates-io] pin on multistore; developmentseed/multistore#147 is not needed.

How to test it

  • cargo test: all suites, including the new tests/keys.rs (a key's shape, trimming \n and \r\n, rejection of short, long, wrong-case, mistyped, wrong-checksum, non-base62, checksum-less 47-character and JWT tokens, checksum vectors computed independently with Python's zlib (a CRC above 2^31, one with a leading zero), assembled with concat! so scanners don't flag the file, the SHA-256 test vector, and credentials sealed for the account within floor, default and cap). Run by the pre-commit hook, along with cargo clippy --target wasm32-unknown-unknown -- -D warnings and cargo check --target wasm32-unknown-unknown.

  • pytest tests/test_api_keys.py against wrangler dev and tests/stub_api.py, which gains the exchanges route keyed by the hash of fixed test keys, with a per-hash call counter. Nine tests, all passing locally with wrangler 3.114: a live key exchanges; an unmodified boto3, configured only by AWS_ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE (a file holding the key and a trailing newline) and AWS_ENDPOINT_URL_STS, acquires credentials; the second exchange within 60s never reaches the API; a trailing newline is harmless; unknown and revoked keys get byte-identical refusals apart from the id, and the refusal is cached; a key in the query string is refused without a lookup; malformed keys, including a mistyped one whose checksum fails, are refused without a lookup and with the malformed message; a wrong role is reported as such; an API 500 fails closed and is not cached. test_control_plane.py and test_writes.py still pass; their credentialed tests need CI's GitHub token and were skipped locally. The checksum commits (d6745e0, 30ad22f) were run by CI's Integration Tests job, which exchanges the new-format keys against the worker; it passed on both.

  • End to end, once source.coop#580 is on a deployment this preview points at: issue a key from a service account's page, save it to a file, then

    AWS_WEB_IDENTITY_TOKEN_FILE=./key AWS_ROLE_ARN=arn:aws:iam::000000000000:role/_default \
      AWS_ENDPOINT_URL_STS=https://<preview>/.sts AWS_ENDPOINT_URL_S3=https://<preview> AWS_REGION=us-east-1 \
      aws s3 ls s3://<owner>/<product>/

    Revoke the key and see the next exchange refused within 60s, then quote the printed request id to find the proxy's and source.coop's log lines. Not run here: it needs feat(accounts): opaque API keys resolved by hash, and a working key UI source.coop#580 deployed.

PR Checklist

Related Issues

Closes #231. Supersedes #233. ADR: #234 (revises ADR-013, amends ADR-005). Route: source-cooperative/source.coop#580, into source-cooperative/source.coop#570. Epic: source-cooperative/source.coop#491.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

@claude

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 30s —— View job


✅ No blocking issues — safe to merge.

I read the source diff (src/, Cargo.toml, both wrangler configs). I didn't run the tests. origin/main couldn't be fetched, so I diffed against HEAD~3.

Checked and fine

  • Panics: parse_api_key checks key.len() == 40 before it slices rest[30..], and the alphanumeric check means byte slicing is safe. credentials_for calls clamp(900, max), which panics if max < 900. config.rs:116 already floors that value at 900.
  • Key handling:
    • The key is hashed after trimming.
    • Only the hash is sent to the API and cached.
    • The log line carries an 8-hex prefix, not the key.
    • authorization_header refuses the urn:source:data-proxy subject, so a request can't claim it.
  • Refusals: a query-string key is refused before any lookup. Non-200 responses fail closed and aren't cached.

Non-blocking notes

  • Stale PR description: it says keys are sck_ plus 43 base64url characters. The code now has 30 base62 characters plus a 6-character CRC-32 checksum (40 characters in total). Please update the description, and the README if it repeats the old format.
  • Shared rate-limit bucket: if cf-connecting-ip is missing, every request lands in one "unknown" bucket (src/lib.rs, within_rate_limit). That's fine behind Cloudflare, but it would throttle everyone if the header were ever stripped.
  • Empty audiences: the key path mints credentials even when auth_audiences is empty, which turns the JWT path off with a 501. Check that this is intended.

Simplify (ponytail)

  • src/keys.rs checksum: the hand-rolled bitwise CRC-32 could be crc32fast::hash if it's already in the lockfile, or crc32fast could be added. It's about 10 lines, and the test vector covers it, so leave it if you'd rather not add a dependency.
  • src/keys.rs key_hash: the per-byte format! loop could be hex::encode(Sha256::digest(..)) if hex is already a dependency.
  • src/source_api/cache.rs: #[allow(clippy::too_many_arguments)] on cached_fetch, now at 8 arguments, suggests a small request-options struct. It's advisory, so skip it unless a third caller appears.
  • src/lib.rs sts_error_xml: it hand-builds XML for a single 429 and doesn't escape its arguments, though both are constants today. It could be a constant string.

💰 Estimated review cost: $0.20 · 0m29s · 7 turns

Comment thread src/keys.rs Dismissed
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

🚀 Latest commit deployed to https://source-data-proxy-pr-235.source-coop.workers.dev

  • Date: 2026-09-29T23:44:12Z
  • Commit: 6440670

alukach added a commit that referenced this pull request Sep 25, 2026
## What I'm changing

`cargo audit` fails on `main`, and so the Security Audit check fails on
every open PR, including the machine-identity PRs (#232, #235). The
cause is a new advisory against rustls 0.23.42,
[RUSTSEC-2026-0285](https://rustsec.org/advisories/RUSTSEC-2026-0285):
TLS 1.3 handshake messages were accepted across encryption-level
boundaries. It is patched in 0.23.45. This bumps the lockfile to it.

## How I did it

`cargo update -p rustls --precise 0.23.45`. A plain `cargo update -p
rustls` stops at 0.23.43; the precise bump moves aws-lc-rs to 1.18.1,
aws-lc-sys to 0.45.0 and rustls-webpki to 0.103.15 with it. `Cargo.lock`
only.

rustls never reaches the Worker. `cargo tree -i rustls --target
wasm32-unknown-unknown` prints nothing; natively it comes in through
multistore → reqwest → hyper-rustls, which the native tests use.
Production was not exposed.

## How to test it

- `cargo audit`: no vulnerabilities, with the one allowed warning
(chacha20) CI already allows.
- The pre-commit hook: `cargo fmt --check`, `cargo clippy --target
wasm32-unknown-unknown -- -D warnings`, `cargo check --target
wasm32-unknown-unknown` and `cargo test`, all passing.

## PR Checklist

- [x] This PR has **no** breaking changes.
- [x] I have updated or added new tests to cover the changes in this PR.
(None apply: lockfile only.)
- [x] This PR does not affect the Source Cooperative Frontend & API.

## Related Issues

Unblocks the Security Audit check on #232, #235 and the PRs stacked on
#235 (#236, #237); their pull-request runs check out the merge with
`main`, so a re-run passes once this lands. #220 would catch the next
one on a schedule. Part of source-cooperative/source.coop#491 only in
that it clears CI for it.

🤖 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>
ADR-013 as revised (#234): a service account's API key is an opaque `sck_` secret that source.coop stores as a SHA-256 hash. When `/.sts` receives one as `WebIdentityToken`, the proxy trims and format-checks it locally, hashes it, and asks `POST {SOURCE_API_URL}/api/v1/service-account-keys/exchanges` whether it is active and for which account, authenticated as itself with the sentinel subject `urn:source:data-proxy`. The answer is cached for 60 seconds, inactive answers included; an API failure fails closed with a 500 and caches nothing. It then mints credentials under the `_default` role for the account the API names, through the STS crate's minting and sealing.

A key is accepted only from a POST form body. One in the query string is refused before any lookup, with a message saying why, because Cloudflare logs request URLs. Every refusal of the key itself reads "API key was not accepted (request id …)" with the id also in `x-amzn-requestid`; the reason is logged once under that id. Attempts are rate-limited per client IP by a new `KEY_EXCHANGE_LIMIT` ratelimit binding in every wrangler config; a deployment without it logs an error and does not refuse traffic.

`ApiAuth` gains `authorization_header_as_self` and refuses the sentinel as an on-behalf-of subject; `cached_fetch` takes a method, an optional JSON body and an `ApiCaller`. `sts::default_role` is factored out so the key exchange mints under the same role.

Supersedes #233: nothing signs a key, so `/.keys`, self-verification against the proxy's own JWKS, the `api_key` role and the multistore pin are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
@alukach
alukach force-pushed the feat/opaque-api-keys branch from 670fd6c to 7f00571 Compare September 25, 2026 23:02
@alukach
alukach added this pull request to stack #241 September 29, 2026 20:29
@alukach
alukach marked this pull request as ready for review September 29, 2026 20:29
alukach added a commit that referenced this pull request Sep 29, 2026
…he platform (#234)

One commit on `main`, now that #232 (ADR-014) is merged. It revises
ADR-013 in place, since ADR-013 was never implemented, and it replaces
an earlier draft that added an ADR-015 and then folded it back. Part of
source-cooperative/source.coop#491. Records the decision that replaces
#233, developmentseed/multistore#147 and the proxy half of
source-cooperative/source.coop#570.

## What it records

**ADR-013, revised: API keys are opaque secrets resolved by the
platform.** A key is `sck_` + 32 random bytes (a fixed 47 characters,
`^sck_[A-Za-z0-9_-]{43}$`), stored as a sha256 hash on the key record in
source.coop and shown once. Nothing signs it. At `/.sts` the proxy
accepts it as `WebIdentityToken` from a POST body only, trims and
format-checks it locally, hashes it, and asks `POST
/api/v1/service-account-keys/exchanges` for `{account_id, key_id,
active}`, cached 60s positive and negative, failing closed; then mints
session credentials through the same code as every other exchange. The
cache-miss path is rate-limited by client IP. A stock AWS SDK does the
exchange and the refresh itself from `AWS_WEB_IDENTITY_TOKEN_FILE`; the
emergency stop for a leaked key is disabling the service account.

**Why the first Decision was withdrawn** is recorded under Context, each
point checkable against #233 and source.coop#570: the revocation lookup
was never optional, so the signature verified what the lookup restates;
a Worker cannot fetch its own JWKS, so the "no new path" benefit was
gone; minting was a source.coop→Ory→proxy→source.coop cycle whose
`/.keys` would sign any `jti` for a manager; `exp` in the token overrode
the editable record; and non-expiring keys died on the second rotation
of a signing key shared with outbound federation. The ADR's original
Context and its rejected alternatives stand.

**Amendments**, in ADR-014's house form: ADR-005 gains the one
proxy-as-itself route, with the sentinel subject `urn:source:data-proxy`
that fails both account-id grammars; ADR-014's first two bullets
amending ADR-013 lose their `sub`/`jti` wording. The RFC index gains
ADR-014's row, which #232 omitted.

It also fixes a sentence in ADR-014's "How it authenticates" section,
which the automated review caught: it said a key's subject is the
service account's id, and now says a key names its account on the key
record.

**Alternatives** rejected with reasons: the proxy-signed JWT (the first
Decision), source.coop-signed JWTs verified via a source.coop JWKS, the
two-hop exchange (opaque key → short-lived Ory ID token → `/.sts`), the
proxy's `get_credential` slot, Ory-native tokens, and long-lived Ory
refresh tokens.

## The two-hop spike the ADR cites

Run on 2026-09-25 against the staging Ory project, driving the headless
authorization-code flow source.coop already uses for a person's proxy
credentials (`getOryIdToken` on `main`) with a service-account-shaped
subject and, as a control, a random UUID that matches no identity. Both
returned an RS256 ID token from `https://auth.staging.source.coop` whose
`sub` was exactly the subject given, with the default 3600s lifetime.
The run used a throwaway confidential OAuth2 client created and deleted
for the purpose. Conclusion: Ory Network will mint for a subject with no
identity record, so the two-hop design needs no proxy change and remains
available; the ADR rejects it for the first release on the client-side
cost to HPC and VM users, not on feasibility.

## Review

Five targeted reviews of the plan and the decision text (security, proxy
implementer, source.coop implementer, ops and client experience, ADR and
issue hygiene), each returning "approve with changes"; all changes are
folded in. The ones that moved a decision: keys refused in the query
string (invocation logs are on; GDAL sends STS as a GET and is routed
through the CLI); the sentinel is a URN, not a slug; unknown keys answer
`active: false` rather than 404 so the existing cache code applies; rate
limiting is per IP on misses and ships with the branch; the revocation
numbers distinguish writes (60s) from restricted reads (300s) per the
proxy's caches; ADR-005 is amended, not merely depended on.

## What follows

#235, the proxy PR replacing #233 (its exchange branch survives;
minting, self-verification and the multistore pin do not),
source.coop#570 reworked in place, multistore#147 closed, #231
rewritten, and the epic's Phase 5 items edited.

## PR Checklist

- [x] This PR has **no** breaking changes. (Documentation only.)
- [x] I have updated or added new tests to cover the changes in this PR.
(None apply; no code.)
- [x] This PR affects the [Source Cooperative Frontend &
API](https://github.com/source-cooperative/source.coop), and I have
opened issue/PR source-cooperative/source.coop#570 to track the change.
(Existing PR, to be reworked to this ADR.)

## Related Issues

#230, #231, #232, #233, #235; developmentseed/multistore#146,
developmentseed/multistore#147; source-cooperative/source.coop#491,
source-cooperative/source.coop#548, source-cooperative/source.coop#561,
source-cooperative/source.coop#570, source-cooperative/source.coop#580.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit to source-cooperative/source.coop that referenced this pull request Sep 29, 2026
#580)

Into #570's branch, as the in-place rework that PR's description will
need once this merges; #570 then no longer depends on the proxy and can
land before it. Implements ADR-013 as revised in
source-cooperative/data.source.coop#234. Part of #491 and #548.

## What

**The key is opaque.** `sck_` + 32 random bytes in base64url, a fixed 47
characters matching `^sck_[A-Za-z0-9_-]{43}$`, from `randomBytes` (not
the modulo-biased legacy generator). Its SHA-256 is the record's
partition key; a random public `key_id` is what the UI, revoke and
expiry actions handle. The table's `account_id` index stands. Nothing
signs the key and nothing calls the proxy at issue: `proxy-keys.ts`, the
Ory ID-token round-trip and the compensating delete are gone, and
`getOryIdToken` is private again.

**The proxy asks by hash, as itself.** `POST
/api/v1/service-account-keys/exchanges` takes `{key_hash}` and answers
`{account_id, key_id, active: true}` for a live key, or `{active:
false}` for an unknown, revoked, expired or disabled one, with the
reason only in the log under the forwarded `x-request-id`. Because the
proxy does not yet know which account is calling, the route
authenticates the proxy itself: `verifyProxyAssertion`, extracted from
`authenticateWithOidcToken` and logging under its own operation name,
verifies the assertion, and the route accepts only the sentinel subject
`urn:source:data-proxy`. `authenticateWithOidcToken` refuses that
subject outright, so it can never become a session, and the route never
touches `getApiSession`, so no cookie reaches it. Last use is recorded
best-effort: a throttled write must not refuse a live key.

**The hash stays on the server.** `ServiceAccountKeyRecord` is the
stored row; `publicKey` strips `key_hash` before either page hands
records to the client component. The Storybook mock returns a key of the
real shape.

**The key UI works end to end.** Opening the issue dialog threw on
#570's branch: its "Never" expiry option had an empty value, which Radix
Select refuses. The select is now `ApiKeyExpiryField`, where "never"
stands for the empty `expires_in_days` the actions read as no expiry.
The smoke test renders its stories, and it skips the dialogs that
contain it, which is how the crash went unnoticed. The show-once view
prints the five variables a stock AWS SDK or the AWS CLI needs to use
the key from a file, filled in for this environment's proxy, with the
same `role/FullAccess` ARN form and region as the GitHub workflow
snippet. `setApiKeyExpiry` had no caller: each live key now has a
"Change expiry" dialog on the same field. The key list says to disable
the account to stop every key at once, and the danger zone says what
disabling does to credentials already issued and warns that enabling
lets unrevoked keys sign in again.

**Decisions to flag.**
- `revokeApiKey` and `setApiKeyExpiry` find the key via
`listByAccount(...).find(key_id)` rather than a third index: one query,
and the ownership check comes free.
- The route answers `active: false` rather than 404 for an unknown hash,
so the proxy's existing cache code applies and the client learns nothing
it did not already hold.
- `after()` is not used for the last-use write; it is awaited in a
`try/catch` because `after()` throws outside a request scope and would
need mocking in every route test.
- The printed `AWS_ROLE_ARN` names `FullAccess`, like the GitHub snippet
already on `main`; both work once
source-cooperative/data.source.coop#221 serves the named roles. Until
then, `role/_default` is the name the proxy accepts.

## Stories

- `IssueApiKeyDialog` › **Default**:
https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-issueapikeydialog--default.
Open it, pick "Never", and submit to reach the show-once view with the
variables.
- `ApiKeyExpiryField` › **Default**, **Never**:
https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeyexpiryfield--default
- `ServiceAccountDetail` › **Default**, with "Change expiry" on each
live key:
https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--default
- `ServiceAccountDetail` › **Disabled**, with the re-enable warning:
https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--disabled
- `ServiceAccountList` › **Default**:
https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountlist--default

Captured from a static Storybook build with headless Chromium, with no
page errors:

![The issue dialog with a label and "Never — until revoked"
chosen](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/opaque-api-keys/issue-api-key-form.png)

![The show-once view: the key, and five export lines for AWS_ROLE_ARN,
AWS_WEB_IDENTITY_TOKEN_FILE, AWS_ENDPOINT_URL_STS, AWS_ENDPOINT_URL_S3
and AWS_REGION with a copy
button](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/opaque-api-keys/issue-api-key-show-once.png)

![The change-expiry dialog: "When should HPC cron job expire?" with the
expiry select, Close and
Save](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/opaque-api-keys/change-expiry.png)

![A disabled service account: the API keys section with Change expiry
and Revoke on the live key, and the danger zone warning that enabling
lets unrevoked keys sign in
again](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/opaque-api-keys/detail-disabled.png)

## Testing

- `npx jest` on the touched suites, 5 suites and 204 tests, all passing:
- `service-account-keys.test.ts`: stores only the hash and returns the
key once; a different key each time; no-expiry; disabled account,
non-manager, bad label and bad expiry refused before any write; revoke
and expiry by `key_id`.
- `oidc.test.ts`: the existing 16, plus: the sentinel never becomes a
session even if an account had that id; `verifyProxyAssertion` returns
claims without resolving anyone, and null for wrong issuer, wrong
audience, expired, or no token.
- The exchanges route: active with account and last-use written; still
active when the write fails; revoked, expired, disabled and unknown all
answer inactive without a write; 401 for any other subject or none; 400
for a non-hex body.
- `ApiKeyExpiryField.test.tsx`: 90 days by default; an empty value for
"never".
- The stories smoke test, which now renders both `ApiKeyExpiryField`
stories.
- `npm run type-check`, `npm run lint` (one `require()` warning in the
new route test, the same pattern as the trusts route test), `npm run
build-storybook`: clean.
- The screenshots above drive both dialogs in a real browser, including
choosing "Never", which crashed before.

## Docs and ADRs

data.source.coop: ADR-013 as revised in
source-cooperative/data.source.coop#234 is what this implements. The
route contract here (`{key_hash}` in, always-200 standing out,
proxy-as-itself auth) is the seam that
source-cooperative/data.source.coop#235 calls;
source-cooperative/data.source.coop#235 is the proxy PR replacing
source-cooperative/data.source.coop#233. The ADR-005 amendment in
source-cooperative/data.source.coop#234 records the sentinel subject.

docs.source.coop: the unattended-workflow guide
(source-cooperative/docs.source.coop#34) carries the setup the show-once
view prints, and uses the same variables. Nothing existing describes
keys.

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

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
alukach added a commit to source-cooperative/source.coop that referenced this pull request Sep 29, 2026
#580)

Into #570's branch, as the in-place rework that PR's description will
need once this merges; #570 then no longer depends on the proxy and can
land before it. Implements ADR-013 as revised in
source-cooperative/data.source.coop#234. Part of #491 and #548.

## What

**The key is opaque.** `sck_` + 32 random bytes in base64url, a fixed 47
characters matching `^sck_[A-Za-z0-9_-]{43}$`, from `randomBytes` (not
the modulo-biased legacy generator). Its SHA-256 is the record's
partition key; a random public `key_id` is what the UI, revoke and
expiry actions handle. The table's `account_id` index stands. Nothing
signs the key and nothing calls the proxy at issue: `proxy-keys.ts`, the
Ory ID-token round-trip and the compensating delete are gone, and
`getOryIdToken` is private again.

**The proxy asks by hash, as itself.** `POST
/api/v1/service-account-keys/exchanges` takes `{key_hash}` and answers
`{account_id, key_id, active: true}` for a live key, or `{active:
false}` for an unknown, revoked, expired or disabled one, with the
reason only in the log under the forwarded `x-request-id`. Because the
proxy does not yet know which account is calling, the route
authenticates the proxy itself: `verifyProxyAssertion`, extracted from
`authenticateWithOidcToken` and logging under its own operation name,
verifies the assertion, and the route accepts only the sentinel subject
`urn:source:data-proxy`. `authenticateWithOidcToken` refuses that
subject outright, so it can never become a session, and the route never
touches `getApiSession`, so no cookie reaches it. Last use is recorded
best-effort: a throttled write must not refuse a live key.

**The hash stays on the server.** `ServiceAccountKeyRecord` is the
stored row; `publicKey` strips `key_hash` before either page hands
records to the client component. The Storybook mock returns a key of the
real shape.

**The key UI works end to end.** Opening the issue dialog threw on
#570's branch: its "Never" expiry option had an empty value, which Radix
Select refuses. The select is now `ApiKeyExpiryField`, where "never"
stands for the empty `expires_in_days` the actions read as no expiry.
The smoke test renders its stories, and it skips the dialogs that
contain it, which is how the crash went unnoticed. The show-once view
prints the five variables a stock AWS SDK or the AWS CLI needs to use
the key from a file, filled in for this environment's proxy, with the
same `role/FullAccess` ARN form and region as the GitHub workflow
snippet. `setApiKeyExpiry` had no caller: each live key now has a
"Change expiry" dialog on the same field. The key list says to disable
the account to stop every key at once, and the danger zone says what
disabling does to credentials already issued and warns that enabling
lets unrevoked keys sign in again.

**Decisions to flag.**
- `revokeApiKey` and `setApiKeyExpiry` find the key via
`listByAccount(...).find(key_id)` rather than a third index: one query,
and the ownership check comes free.
- The route answers `active: false` rather than 404 for an unknown hash,
so the proxy's existing cache code applies and the client learns nothing
it did not already hold.
- `after()` is not used for the last-use write; it is awaited in a
`try/catch` because `after()` throws outside a request scope and would
need mocking in every route test.
- The printed `AWS_ROLE_ARN` names `FullAccess`, like the GitHub snippet
already on `main`; both work once
source-cooperative/data.source.coop#221 serves the named roles. Until
then, `role/_default` is the name the proxy accepts.

## Stories

- `IssueApiKeyDialog` › **Default**:
https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-issueapikeydialog--default.
Open it, pick "Never", and submit to reach the show-once view with the
variables.
- `ApiKeyExpiryField` › **Default**, **Never**:
https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeyexpiryfield--default
- `ServiceAccountDetail` › **Default**, with "Change expiry" on each
live key:
https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--default
- `ServiceAccountDetail` › **Disabled**, with the re-enable warning:
https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--disabled
- `ServiceAccountList` › **Default**:
https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountlist--default

Captured from a static Storybook build with headless Chromium, with no
page errors:

![The issue dialog with a label and "Never — until revoked"
chosen](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/opaque-api-keys/issue-api-key-form.png)

![The show-once view: the key, and five export lines for AWS_ROLE_ARN,
AWS_WEB_IDENTITY_TOKEN_FILE, AWS_ENDPOINT_URL_STS, AWS_ENDPOINT_URL_S3
and AWS_REGION with a copy
button](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/opaque-api-keys/issue-api-key-show-once.png)

![The change-expiry dialog: "When should HPC cron job expire?" with the
expiry select, Close and
Save](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/opaque-api-keys/change-expiry.png)

![A disabled service account: the API keys section with Change expiry
and Revoke on the live key, and the danger zone warning that enabling
lets unrevoked keys sign in
again](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/opaque-api-keys/detail-disabled.png)

## Testing

- `npx jest` on the touched suites, 5 suites and 204 tests, all passing:
- `service-account-keys.test.ts`: stores only the hash and returns the
key once; a different key each time; no-expiry; disabled account,
non-manager, bad label and bad expiry refused before any write; revoke
and expiry by `key_id`.
- `oidc.test.ts`: the existing 16, plus: the sentinel never becomes a
session even if an account had that id; `verifyProxyAssertion` returns
claims without resolving anyone, and null for wrong issuer, wrong
audience, expired, or no token.
- The exchanges route: active with account and last-use written; still
active when the write fails; revoked, expired, disabled and unknown all
answer inactive without a write; 401 for any other subject or none; 400
for a non-hex body.
- `ApiKeyExpiryField.test.tsx`: 90 days by default; an empty value for
"never".
- The stories smoke test, which now renders both `ApiKeyExpiryField`
stories.
- `npm run type-check`, `npm run lint` (one `require()` warning in the
new route test, the same pattern as the trusts route test), `npm run
build-storybook`: clean.
- The screenshots above drive both dialogs in a real browser, including
choosing "Never", which crashed before.

## Docs and ADRs

data.source.coop: ADR-013 as revised in
source-cooperative/data.source.coop#234 is what this implements. The
route contract here (`{key_hash}` in, always-200 standing out,
proxy-as-itself auth) is the seam that
source-cooperative/data.source.coop#235 calls;
source-cooperative/data.source.coop#235 is the proxy PR replacing
source-cooperative/data.source.coop#233. The ADR-005 amendment in
source-cooperative/data.source.coop#234 records the sentinel subject.

docs.source.coop: the unattended-workflow guide
(source-cooperative/docs.source.coop#34) carries the setup the show-once
view prints, and uses the same variables. Nothing existing describes
keys.

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

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
alukach added a commit to source-cooperative/source.coop that referenced this pull request Sep 29, 2026
…accounts (#570)

Closes #548. Closes #546. API keys are the second Integration type
beside the GitHub trusts #567 shipped, and #566 replaced proof of
control with per-account trust. The `source.coop` half of #548. Part of
#491.

**Merge order:** this can merge and deploy before the proxy. It no
longer calls the proxy; the proxy calls it.
source-cooperative/data.source.coop#235 is the proxy half and needs the
route here to be live, or every key exchange fails closed.

## What

API keys for environments without OIDC — a server, a scheduler, an
instrument — per ADR-013 as revised in
source-cooperative/data.source.coop#234: **a key is an opaque secret
that source.coop resolves by hash; nothing signs it.** Six commits on
`main`: the original feature, dropping the Ory-id guard once #567
namespaced service-account ids, #580's rework to opaque keys, the key
hint, a calmer key list, and that list as its own component with
stories.

**The key** is `sck_` + 32 random bytes in base64url: a fixed 47
characters, all entropy after the prefix, matching
`sck_[A-Za-z0-9_-]{43}`, which is what gets registered with secret
scanners (#561). It is shown once and never stored.

**The record** (`service-account-keys` table) is keyed by the key's hex
SHA-256, with a public `key_id` (a UUID) for listing, revoking and
changing expiry, a label, who issued it and when, an optional expiry,
`revoked_at` and `last_used_at`. `publicKey()` strips the hash before
any record reaches a client component.

**The hint**: the record also keeps the key's last four characters, and
the key list shows each key as `sck_…Xy9Q`, so someone holding a key can
tell which record, and so which service account, it is. Four characters
are 24 of the key's 256 random bits, leaving far too many to guess. The
hint only confirms a key in hand against its record; it is never used to
look a key up, since keys across the platform will share it. The issue
dialog says how the new key will be listed. Keys issued before the hint
existed are listed without one.

**Issuing** (`issueApiKey`): whoever manages the service account gives a
label and an expiry (30/90/365 days, or never). The action generates the
key, writes the record and returns the key once. A disabled service
account is refused. **Revoking** sets `revoked_at`; **expiry** can be
changed after issuance, including to never.

**Exchanging**: `POST /api/v1/service-account-keys/exchanges` with
`{key_hash}` is what the proxy calls at `/.sts` when a key is presented,
authenticated as the proxy itself (`verifyProxyAssertion`, sentinel
subject `urn:source:data-proxy`, recorded in the ADR-005 amendment in
source-cooperative/data.source.coop#234). It always answers 200 with
`{account_id, key_id, active}`. `active` means known, not revoked, not
expired, and its service account not disabled; an unknown hash is
answered as inactive, indistinguishable from a revoked one. It records
last use. The proxy caches the answer for 60s, so revocation takes
effect for new exchanges within that time; credentials already issued
live to their session cap.

**Resolving the subject**: after an exchange, the proxy's credentials
name the service account itself, and `authenticateWithOidcToken`
resolves it with `fetchByOryId`, then a service account by id. No
service account's id can be someone's Ory identity id: #567 namespaces
it as `{owner}--{id}`, and a UUID never contains `--`.

**UI**: the service account's page (#567's `ServiceAccountDetail`) has
an API keys section, rendered by `ApiKeyList`, a row per key. On the
left, the label with a Revoked or Expired marker, and the key's hint
beneath. On the right, two short lines: how it has been used ("Used 3
days ago", "Never used") and when it ends ("Expires in 5 months", "Never
expires", "Revoked 9 months ago"). The exact dates, and who issued the
key and when, are in their tooltip. Change expiry and Revoke are in a
"⋯" menu; a revoked key has none, and keeps an invisible copy of the
button so its lines align. The section header carries
`IssueApiKeyDialog`, which shows the key once with a copy button, and
the environment variables that point any AWS SDK or the AWS CLI at the
proxy. The list row counts live keys as a way to sign in.

![The API keys section: HPC cron job over sck_…Xy9Q, with Used 6 months
ago and Expires in 5 months on the right and a ⋯ menu; Old laptop,
marked REVOKED, over sck_…a_7k, with Never used and Revoked 9 months
ago](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-key-rows.png)

Every state a key can be in, from `ApiKeyList`'s Default story:

![Five keys: HPC cron job, used 3 days ago, expires in 5 months;
Instrument uploader, used today, never expires; Laptop, for testing,
never used, expires next month; Last year's sync marked EXPIRED, used
last month, expired last month; Old laptop marked REVOKED, never used,
revoked 9 months ago. Each shows its sck_… hint; live keys have a ⋯
menu](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/api-key-list-states.png)

## Stories

- `ApiKeyList` › **Default** (every key state), **Single**,
**WithoutHint**, **Empty** —
https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeylist--default
(dates are set relative to today; hover a row's dates for the exact
ones)
- `ServiceAccountDetail` › **Default** —
https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--default
- `ServiceAccountList` › **Default** —
https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountlist--default
- `IssueApiKeyDialog` › **Default** —
https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-issueapikeydialog--default
(submitting reaches the show-once view with the hint line)
- `ApiKeyExpiryField` —
https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeyexpiryfield--default

## Testing

- `npx jest` — 79 suites, 835 tests, all pass on the branch as rebased
onto `main`. `service-account-keys.test.ts` covers issuing (the record
holds the hash, never the key; the hint is the key's last four
characters; the key is returned once), no-expiry keys, refusals before
any write, revoking only own keys, and expiry changes including to
never. The exchanges route test covers the proxy-only auth, active and
inactive answers, and last-use recording.
- `npm run type-check`, `next lint`, `npm run build-storybook` — clean.

## Docs and ADRs

data.source.coop: this implements ADR-013 as revised in
source-cooperative/data.source.coop#234, with ADR-014 from
source-cooperative/data.source.coop#232; the proxy side is
source-cooperative/data.source.coop#235, which supersedes
source-cooperative/data.source.coop#233. The hint is a display detail
the ADR doesn't need. docs.source.coop: the unattended-workflow guide,
source-cooperative/docs.source.coop#37 for
source-cooperative/docs.source.coop#34, should mention matching a key to
its account by its last four characters.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
A key is now `sck_`, 30 random base62 characters and a six-character base62 CRC-32 of those 30 — 40 characters, GitHub's own token layout, as source.coop now issues them. The proxy checks the shape and the checksum before the rate limit's lookup, so a key that was cut short or mistyped costs nothing and is refused as malformed ("API key is malformed; check that it was copied whole"), a message that reveals nothing since the format is public. Every other refusal still reads "API key was not accepted".

The CRC-32 is the IEEE one zlib computes, written out in a few lines rather than adding a crate. The unit tests pin it to vectors computed independently with Python's zlib, including a CRC above 2^31 and one whose checksum keeps a leading zero; the integration stub builds its keys the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
Once `sck_` keys are registered with GitHub's secret scanning, any whole key in a public repository is reported to source.coop and may trip push protection. The unit tests' keys are now concatenated from pieces, as the integration stub and the Source CLI's tests already build theirs at run time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
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".

![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
alukach merged commit e9d2485 into main Sep 30, 2026
21 checks passed
@alukach
alukach deleted the feat/opaque-api-keys branch September 30, 2026 03:34
alukach added a commit that referenced this pull request Sep 30, 2026
On `main`, now that #235 is merged; two commits. **How to test it**
lists what I ran locally.

## What I'm changing

`/.sts` serves three hardcoded Roles instead of one, for ID tokens and
API keys alike, as ADR-014 (#232) specifies:

| Role | Credentials may |
| --- | --- |
| `FullAccess` | do everything the account's memberships allow |
| `ReadOnly` | do the same, except write |
| `_default` | do what `FullAccess` does; kept because deployed clients
name it |

- **Names.** Each Role is accepted bare or as the `role/<name>` resource
of an ARN of any partition and account
(`arn:aws:iam::000000000000:role/ReadOnly`), because SDKs check the ARN
shape before sending. A pathed resource (`role/team/ReadOnly`), another
case (`readonly`) or any other name is `RoleNotFound`, never a fallback
to a default.
- **The ceiling.** `ReadOnly` seals one scope into the session token's
`allowed_scopes`: every product (`*`), read actions only (`GetObject`,
`HeadObject`, `ListBucket`). The gateway never calls multistore's own
scope check (`auth::authorize`), because this proxy's registry is the
authorizer, so the registry enforces it. `get_bucket` checks the ceiling
first, before any Source API lookup, and refuses with the same
`AccessDenied` as every other refusal (ADR-011, Denial Semantics). An
INFO log line is the only record of why.
- **It only subtracts.** A write the ceiling allows still needs the
account's own write permission, fetched as before.
- **No scopes, no ceiling.** `FullAccess` and `_default` seal no scopes,
as `_default` never has, so every session already issued keeps working.
A `_default` exchange returns the same response as before,
`AssumedRoleId` included.
- **Both entry points.** The STS route
(`StsCredentialRegistry::get_role`) and the API-key exchange
(`exchange_api_key`, which accepted only `_default`) share one lookup,
`sts::role`. The key exchange's log line now names the Role.

source.coop `main`'s GitHub integration snippet
(`src/lib/services/github-workflow.ts`) already hands out
`arn:aws:iam::<service-account-id>:role/FullAccess`. This PR makes that
Role name resolve. The rest of that path is the next PR in this stack,
which trusts a GitHub token for the account it names (#222, #223), plus
`GetCallerIdentity` in developmentseed/multistore#126.

**Decisions to flag**

- **The first comment on #221 is superseded.** It says `ReadOnly` cannot
be enforced until multistore plumbs the assumed Role through to the
registry. That isn't needed: what is sealed is the Role's ceiling, not
its name, and `AuthenticatedIdentity.allowed_scopes` already reaches
`BucketRegistry::get_bucket` (multistore 0.7.2, `auth/identity.rs`). No
multistore change.
- **The ceiling understands only what the two Roles need**: a scope over
every product (`*`) with no prefix. A scope naming one product or a
prefix permits nothing rather than being half-interpreted. ADR-011's
resource matching can arrive with account-owned Roles. The `*` sentinel
means something only to this proxy, because multistore's exact-match
`authorize` never runs here.
- **Empty scopes mean no ceiling in the registry.** This is the reverse
of multistore's `authorize`, where empty means deny-all, as ADR-001
noted. It is safe because only this proxy mints session tokens, sealed
with `SESSION_TOKEN_KEY`, and every `_default` session carries empty
scopes.
- **`ReadOnly` excludes `GetObjectVersion`** (reading a versioned copy
source). The write gate already classes it as a write, and a native test
pins that the ceiling and `is_write_action` agree on every action. That
is the divergence ADR-011 warns about.
- **ARNs are now parsed as six colon-separated fields with a
`role/<name>` resource.** The old `_default` check matched only the
`arn:` prefix and the `:role/_default` suffix, so it also accepted
malformed strings such as `arn:role/_default`. Those are now refused,
and no SDK sends them anyway: they fail the SDKs' own 20-character
minimum.

## How I did it

- `src/sts.rs`: `role(role_arn, issuer, audiences, max_session)` returns
the named Role or `None`, replacing `default_role` and
`is_default_role`. `ALL_PRODUCTS` is the `*` sentinel.
- `src/authz.rs`: `ceiling_permits(scopes, action)`.
- `src/source_api/registry.rs`: the check at the top of `get_bucket`.
- `src/lib.rs`: the key exchange resolves the named Role before its
lookup, as before, and mints under it.
- `README.md`: a Roles section. `adrs/001`, `004` and `011`, in a
separate commit: see below.

## How to test it

- `cargo test`: all suites pass. `tests/sts.rs` covers bare and ARN
names for all three Roles, including a service-account-shaped account
segment, and refusal of unknown, lower-case, pathed, suffixed and
non-role names. `tests/authz.rs` covers that `ReadOnly`'s sealed ceiling
allows exactly the actions `is_write_action` calls reads, that
`FullAccess` and `_default` allow every action, and that narrower scopes
permit nothing. `tests/keys.rs` checks that a `ReadOnly` key's session
unseals with `ReadOnly`'s ceiling.
- `cargo fmt --check`, `cargo clippy --target wasm32-unknown-unknown --
-D warnings` and `cargo check --target wasm32-unknown-unknown`, through
the pre-commit hook.
- `pytest tests/ --ignore=tests/test_contract.py` against `wrangler dev`
(wrangler 3.114) and `tests/stub_api.py`: 35 passed, 15 skipped. The
skipped ones are the credentialed tests that need CI's GitHub token. The
new `test_read_only_refuses_a_write_before_anything_is_looked_up`
exchanges the stub's live key for `ReadOnly` and for `FullAccess`, then
writes to a product the stub has never heard of. `ReadOnly` gets
`AccessDenied`. `FullAccess` gets past the ceiling to the product lookup
and gets `NoSuchBucket`, and so does a `ReadOnly` read. The upstream
write itself fails closed in CI, so the product lookup is where the
ceiling's path can be told apart. I also disabled the check, saw this
test fail (`ReadOnly` got `NoSuchBucket`), and restored it.

## Docs and ADRs

- **ADR-011** (Role-Ceiling Authorization) still describes the model.
This PR implements its step 2 and its denial semantics for the two
hardcoded Roles, so its status line now says it is implemented in part.
- **ADR-004** described "a single built-in Role, `_default`". A note
under that section now points to ADR-014 and this PR, and ADR-004's
"Implemented by" line lists this PR.
- **ADR-001** said `assumed_role_id` is "currently always `_default`"
and that the sealed `allowed_scopes` is empty and "not enforced on this
path". Its credential table now names the three Roles and their
ceilings. Its bullet now says the registry enforces the ceiling and
reads empty as no ceiling.
- **ADR-014** (#232) says "two Roles are hardcoded, `FullAccess` and
`ReadOnly`, with `_default` kept as an alias (#221)". This PR implements
that, so it still holds. ADR-010's scope note is ADR-014's amendment and
also holds.
- **ADR-013 as revised** (#234) says a key's "role must be one the proxy
serves". That still holds, and keys can now name all three Roles.
- **docs.source.coop**: no page on `main` describes `/.sts` or its Roles
yet. The unattended-workflow guide,
source-cooperative/docs.source.coop#34, is where the Role names belong.

## PR Checklist

- [x] This PR has **no** breaking changes. `_default` behaves and
answers as before; only malformed ARNs that no SDK can send are newly
refused.
- [x] I have updated or added new tests to cover the changes in this PR.
- [x] This PR affects the [Source Cooperative Frontend &
API](https://github.com/source-cooperative/source.coop): source.coop
`main`'s GitHub snippet names `role/FullAccess`, which resolves from
this PR on. No source.coop change is needed.

## Related Issues

Closes #221. Builds on #235, merged. ADRs: #232 (ADR-014), #234 (ADR-013
revised). Epic: source-cooperative/source.coop#491.

🤖 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 that referenced this pull request Sep 30, 2026
…]] (#245)

## What I'm changing

Follow-up to #235. The `KEY_EXCHANGE_LIMIT` rate limit binding is
declared under `[[unsafe.bindings]]` because the workflows pin wrangler
3, and the first-class `[[ratelimits]]` key [needs wrangler 4.36.0 or
later](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/).
This moves the proxy to wrangler 4 and declares the binding the
documented way, so wrangler validates it instead of passing an `unsafe`
block through unchecked. `workers/public-log-stream` is already on
wrangler 4 (4.77.0 in its lockfile), so after this the whole repo is on
v4.

**Decision to flag: wrangler 4 will start applying observability
settings that wrangler 3 ignores.** Wrangler 3 warns on the current
`wrangler.toml` with `Unexpected fields found in observability field:
"traces"`, `"destinations"`. So the Axiom `destinations =
["axiom-logs"]` / `["axiom-traces"]` and the `[observability.traces]`
block added in d0b46cb have probably never been applied from config,
since every deploy since then went through `wrangler@3`. The first
wrangler 4 production deploy may start applying them. If the
`axiom-logs` and `axiom-traces` destinations don't exist in the
Cloudflare account, that deploy may fail; the preview and staging
deploys won't catch it, because staging and preview set `destinations =
[]`. Please confirm both destinations exist under Workers Observability
→ Destinations before this reaches production.

## How I did it

- `.github/workflows/ci.yml`, `deploy.yml`, `preview.yml` and
`README.md`: `wrangler@3` → `wrangler@4`.
- `wrangler.toml` (production and `env.staging`) and
`wrangler.preview.toml`: `[[unsafe.bindings]]` → `[[ratelimits]]`,
dropping `type = "ratelimit"`. Name, `namespace_id` (1001, 1002, 1003)
and `simple = { limit = 100, period = 60 }` are unchanged.
- No Rust changes: the worker reads the binding through
`env.rate_limiter("KEY_EXCHANGE_LIMIT")`, which doesn't care how it was
declared.

The pin bump and the config change have to land together. Wrangler 3
reports `Unexpected fields found in top-level field: "ratelimits"` and
would deploy without the binding.

## How to test it

- `wrangler deploy --dry-run` with wrangler 4.86.0 on a copy of each
config (build step stubbed): production, `--env staging` and
`wrangler.preview.toml --name …` each list `env.KEY_EXCHANGE_LIMIT (100
requests/60s) Rate Limit` with no config warnings. The same production
dry run on wrangler 3 shows the `ratelimits` and observability warnings
quoted above.
- The pre-commit hook passed: `cargo fmt --check`, clippy and check for
wasm32, `cargo test`.
- CI's Integration Tests job runs `wrangler dev` on wrangler 4 against
`tests/test_api_keys.py`, and this PR's preview deploy exercises
`wrangler deploy` on v4.

## PR Checklist

- [x] This PR has **no** breaking changes.
- [ ] I have updated or added new tests to cover the changes in this PR.
(Config and tooling only; covered by the dry runs and CI above.)
- [ ] This PR affects the [Source Cooperative Frontend &
API](https://github.com/source-cooperative/source.coop), and I have
opened issue/PR #XXX to track the change. (It doesn't.)

## Related Issues

Follows #235. Checked the ADRs: ADR-013 describes the per-IP limit, not
how the binding is declared, so it still holds. ADR-008 says "In
production, logs and traces ship to Axiom"; given the observability note
above, that may not have been true while production deployed on wrangler
3, and this change makes it true, so ADR-008 needs no edit.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit 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

1 active deployment
preview — 30ad22ff Deployed Sep 29, 2026 by alukach via Deploy & Test / Deploy #386
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.

Accept API-key JWTs at /.sts: jti revocation check, latency floor, and key minting

2 participants