Skip to content

Pin the WASM clientKey contract; document /wasm-inline as server-side only (#804) - #810

Draft
tobyhede wants to merge 5 commits into
remove-v2from
fix/804-wasm-clientkey-browser-contract
Draft

Pin the WASM clientKey contract; document /wasm-inline as server-side only (#804)#810
tobyhede wants to merge 5 commits into
remove-v2from
fix/804-wasm-clientkey-browser-contract

Conversation

@tobyhede

Copy link
Copy Markdown
Contributor

Closes #804.

The question

WasmClientConfig requires clientId / clientKey on every auth arm — including the authStrategy (OIDC federation) arm, the one that exists so a caller never handles a workspace secret. #804 asked whether that was a leftover from the access-key path that the type over-declares. If it were, a browser could hold a client authorised by a federated JWT alone, and the whole portable-runtime browser story would be unblocked.

It is not a leftover.

What I verified

Against the real core (protect-ffi 0.29.0 and 0.30.0 — a stable contract, not a version quirk):

  • Structurally required. Omitting either field rejects during deserialisation of NewClientOpts with missing field `clientKey` , regardless of the strategy supplied.
  • Consumed before auth. clientKey decodes in two stages, both ahead of the strategy: hex, then a key provider. getToken reports zero calls on every failure path — it is not an auth input that federation could displace.
  • It is cryptographic key material, not an identifier. A CS_CLIENT_KEY is hex of a CBOR-serialised key struct. Reaching into it, the core names p1 as a Permutation — a keyed permutation for the searchable-index schemes. Nothing that merely validated a credential's format decodes a permutation out of it.
  • It is a secret. packages/cli/src/commands/env/index.ts writes it under the literal line # CS_CLIENT_KEY and CS_CLIENT_ACCESS_KEY are secrets. Do not commit them.

The repo already asserted this, in packages/stack/src/types.ts on ClientConfig.authStrategy: "the clientKey is still required for encryption". Node types the fields optional only because protect-ffi backfills them from env vars; the WASM entry has no environment to read, so the type demands them. Same constraint, different defaulting — which is why it reads as a WASM-specific quirk but isn't.

What this PR changes

No behaviour change. Types and runtime are untouched. What changes is that the constraint is now stated where callers meet it, and enforced instead of assumed.

  • packages/stack/__tests__/wasm-inline-core-credential-contract.test.ts (new) — the point of the PR. Every other wasm suite mocks newClient, and vitest.shared.ts aliases the whole @cipherstash/protect-ffi/wasm-inline specifier to a throwing stub, which is exactly why this was never caught. This file resolves the module through Node's resolver, which the Vite alias does not intercept, and asserts against the actual core. Runs offline — every failure it asserts happens before any ZeroKMS / CTS call.
  • packages/stack/src/wasm-inline.ts — a NOT BROWSER-SAFE (#804) note on WasmClientConfig, per-field docs explaining that clientKey is secret key material consumed independently of the strategy, and a note on the OidcFederationStrategy re-export that "edge" means a server-side runtime. Verified the note ships in the built .d.ts.
  • skills/stash-encryption/SKILL.md — the entry-point table now marks /wasm-inline server-side only and says why. Every other skill already described it as the "edge" entry and never claimed browser, so nothing else needed changing.
  • Changeset: @cipherstash/stack + stash, both patch (skills/ ships in the stash tarball).

Consequences

Still blocked, now for a verified reason rather than a suspicion: the browser export condition (#805), a live browser smoke test, and browser guidance in skills/stash-supabase. A browser-safe auth shape is a Rust-side design job, not a JS one.

If the new test ever fails, that is good news — it means the core relaxed the requirement and browser support should be re-examined. The header says so, and points at #804.

Verification

pnpm --filter @cipherstash/stack exec vitest run __tests__/wasm-inline   # 11 files, 99 tests, all pass
pnpm --filter @cipherstash/stack build                                   # .d.ts compiles, note present
pnpm run code:check                                                      # exit 0

The test was confirmed live, not vacuous, by inverting its assertions and checking it fails reporting the genuine core error.

The wider @cipherstash/stack suite has 18 pre-existing failures across 16 files on this machine, all Client key not configured: Profile not found from an absent local dev credential profile. None is a wasm suite; none is a file this PR touches.

Not in scope

docs/superpowers/plans/2026-07-24-supabase-portable-runtime.md still instructs the impossible — "In a browser, use OidcFederationStrategy — never ship config.clientKey", which no caller can follow since the client will not construct without it. That file does not exist on remove-v2; it is on main. It needs a separate change on whichever branch carries that work.

@tobyhede
tobyhede requested a review from a team as a code owner July 28, 2026 00:46
@changeset-bot

changeset-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 44c82d0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@cipherstash/stack Patch
stash Patch
@cipherstash/bench Patch
@cipherstash/prisma-next Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: eb1cbb0e-4d49-4dda-8ddd-5d77ad85f317

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/804-wasm-clientkey-browser-contract

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@tobyhede
tobyhede force-pushed the fix/804-wasm-clientkey-browser-contract branch from f83bb6d to fc04e28 Compare July 28, 2026 00:50
tobyhede added 2 commits July 28, 2026 11:07
…erver-side only (#804)

`WasmClientConfig` requires `clientId` / `clientKey` on every auth arm,
including the `authStrategy` (OIDC federation) arm — the arm that exists so a
caller never handles a workspace secret. That looked like it might be a
leftover from the access-key path that the type over-declares, which would have
meant a browser could hold a client authorised by a federated JWT alone.

It is not. Verified against the real core (protect-ffi 0.29.0 and 0.30.0):

- Omitting either field rejects during deserialisation of `NewClientOpts`
  (``missing field `clientKey` ``) regardless of the strategy supplied.
- `clientKey` is decoded in two stages, both before the strategy is consulted:
  hex, then a key provider that deserialises it into cryptographic key material
  (the core names `p1` as a `Permutation` — a keyed permutation for the
  searchable-index schemes). Nothing that merely validated a credential's
  format would decode a permutation out of it, and `getToken` is never called
  on any failure path, so this is not an auth input federation could displace.
- It is a workspace secret (`stash env` writes it under "these are secrets, do
  not commit them"), so no configuration of this entry belongs in a browser
  bundle. Hence no `browser` export condition.

No behaviour change. What changes is that the constraint is now stated where
callers meet it and enforced instead of assumed:

- A contract test that runs against the REAL WASM core. Every other wasm suite
  mocks `newClient`, and `vitest.shared.ts` aliases the whole
  `@cipherstash/protect-ffi/wasm-inline` specifier to a throwing stub — which is
  why this was never caught. The test resolves the module through Node's
  resolver, which the Vite alias does not intercept. It runs offline: every
  failure it asserts occurs before any ZeroKMS / CTS call. If it ever fails, the
  core changed and browser support is worth revisiting.
- `WasmClientConfig` gains a NOT BROWSER-SAFE note and per-field docs saying
  `clientKey` is secret key material consumed independently of the strategy.
- The `OidcFederationStrategy` re-export notes that "edge" means a server-side
  runtime, and that federation changes who a request authenticates AS without
  removing the `clientKey` requirement.
- `skills/stash-encryption` marks the entry server-side only in the entry table.

Still blocked on the core, now for a verified reason rather than a suspicion:
the `browser` export condition (#805), a live browser smoke test, and browser
guidance in `skills/stash-supabase`. A browser-safe shape is a Rust-side design
job, not a JS one.
#777 added `skills/stash-edge`, and AGENTS.md now routes any change to
`packages/stack/src/wasm-inline.ts` through it. The skill's federation section
is exactly where the #804 mistake gets made: it teaches that an auth strategy
authenticates the client as the end user, which invites the inference that the
entry is therefore browser-safe.

It is not. `clientKey` is required on every auth path including
`authStrategy`, and it is a workspace secret the core decodes into
cryptographic key material. Add a warning alongside the existing
auth-vs-key-binding callout, which already draws the neighbouring distinction.
@tobyhede
tobyhede force-pushed the fix/804-wasm-clientkey-browser-contract branch from fc04e28 to d9e90a7 Compare July 28, 2026 01:08
tobyhede added 2 commits July 28, 2026 11:26
…contract (#804)

Review of #810 found the contract test proved less than it claimed, and that
two of its stated premises were wrong. Both are fixed by testing them.

`expect(calls.getToken).toBe(0)` had no positive control. Nothing in the file
ever reached a state where `getToken` was called, so a count of zero was
equally consistent with "key loading precedes auth" (the claim) and with "the
core never calls `getToken` during `newClient` at all" — a counter that is
never incremented reads as zero either way.

It does call it. Structurally complete key material — derived from the core's
own error messages, not from any credential: `{ p1, p2_from, p2_to, p3 }`, each
a `Permutation` — clears the key provider, after which the core calls
`getToken` exactly once and fails on the deliberately malformed token the
stand-in returns. So auth IS reached during construction, only after the key is
loaded, and the counter is live. Still offline: the token never parses, so
nothing leaves the process.

The claim that a rename of `opts.strategy` would silently hollow out the file
turned out to be false, and is now pinned rather than assumed: the core reads
the strategy by name BEFORE deserialising the credentials, so omitting it
yields `opts.strategy is required`, which matches none of the other tests'
regexes. They would fail loudly, not pass vacuously.

Also pins the consequence the docs assert but nothing enforced: no `browser`
export condition on any subpath. Adding one to quiet a bundler would ship a
workspace secret to the browser and leave `src/wasm-inline.ts` silently wrong.

Three corrections to comments that overstated the evidence:

- "the strategy was never consulted" → never INVOKED. The core does read
  `opts.strategy` and typecheck its `getToken` ahead of serde; what never
  happens is the call.
- "every other wasm test mocks `newClient`" → every wasm test that constructs a
  client. `wasm-inline-normalize.test.ts` relies on the alias stub, and
  `wasm-inline-bundle-isolation.test.ts` never loads protect-ffi at all.
- "a stub that throws" → a stub whose `newClient` throws. Its `isEncrypted`
  returns `false`.

The `e2e/wasm/roundtrip.test.ts` pointer now notes that suite covers the
`accessKey` arm, so the federation arm reasoned about here has no live coverage
anywhere.

Fixes the stub's own docblock while adjacent: it claimed protect-ffi exports no
`/wasm-inline` subpath, which 0.30.0 does, and which the contract test resolves
directly.
#810 documented `@cipherstash/stack/wasm-inline` as server-side only, but left
three places on this branch saying the opposite — including the example a
reader is most likely to copy.

- `examples/supabase-worker/README.md` and its edge function both listed
  "modern browsers" among the runtimes the entry works in. The example reads
  `CS_CLIENT_KEY` from the environment, so it demonstrated the exact thing it
  claimed was browser-safe.
- `packages/stack/tsup.config.ts` listed browsers among "the only runtimes that
  need wasm-inline", in the same file #810 cites as evidence there is no
  `browser` export condition.
- `skills/stash-supabase` gave `pg` introspection as the reason the factory
  cannot run in a browser. True but incomplete, and misleading next to #805
  ("add the `browser` export condition"): removing `pg` unblocks Workers, not
  browsers, because `clientKey` is required on every auth path regardless.

Also corrects two claims in #810's own prose. `clientKey` is loaded before the
core ever CALLS the strategy — it reads `opts.strategy` earlier than that — and
the contract test displaces mocks in eight suites plus a stub in one, not "the
stub every other wasm suite uses".
@tobyhede

Copy link
Copy Markdown
Contributor Author

Work paused pending v1.0 release

…shes mid-walk

`lint-no-hardcoded-runners.mjs` walks the packages tree with a recursive
`readdir`. The repo's own script self-tests create and delete probe package
directories in that same tree while other suites run, and vitest ran those
suites in parallel — so the walk could enumerate a probe directory and find it
gone before recursing into it.

Unhandled, that rejects with ENOENT and Node exits 1: the same code the linter
uses for "found a hardcoded npx". CI then failed naming nothing, on a tree the
identical script had just passed one step earlier, and a re-run went green. The
script already draws this distinction for a missing target — "Exit 2 means the
linter could not run" — but only at the top level, not one frame down.

Observed on three branches before this one (30316404472, 30317589094), which is
why it reads as a flake rather than a defect.

Two changes:

- `walk` treats ENOENT as "nothing here to lint" and skips. A directory that
  no longer exists cannot contain a violation, and crashing with the violation
  exit code is the actual bug.
- `scripts/vitest.config.mjs` sets `fileParallelism: false`. Several of these
  suites mutate the real repo tree, so cross-file parallelism is unsound here
  regardless of this particular pair. Eleven small files; it costs a second.

Verified by reproducing the race locally — churning a probe directory while
running the linter failed 6/30 before, 0/40 after.

One wrinkle worth recording: the first draft of these comments named the probe
directories with their `packages/` prefix, which the sibling
`lint-no-dead-package-paths` linter correctly flagged as references to
directories that do not exist. The names are now written bare.
@tobyhede
tobyhede marked this pull request as draft July 28, 2026 01:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant