Skip to content

feat(parser_http_server): validate X-Stamp in the enclave - #451

Draft
pepe-anchor wants to merge 2 commits into
pepefigueira/prs-581-03-pivot-v1from
pepefigueira/prs-581-05-xstamp-auth
Draft

feat(parser_http_server): validate X-Stamp in the enclave#451
pepe-anchor wants to merge 2 commits into
pepefigueira/prs-581-03-pivot-v1from
pepefigueira/prs-581-05-xstamp-auth

Conversation

@pepe-anchor

Copy link
Copy Markdown
Contributor

Why

Today Turnkey's gateway authenticates every caller: it validates X-Stamp against Turnkey's own DB before the request reaches us. When the pivot becomes the front door, we lose that, and the pivot currently has zero authentication (the runbook's smoke test is a plain curl).

This keeps the X-Stamp wire shape and moves validation into the enclave, against a pubkey allowlist pinned at deploy time.

What

  • stamp.rs: Allowlist, StampError, and verify(&HeaderMap, &[u8], &Allowlist). Header value is base64url-no-pad JSON {publicKey, signature, scheme}, publicKey hex compressed SEC1, signature hex DER, over the raw body. Both P256 and secp256k1 schemes.
  • Allowlist arrives via --allowed-stamp-pubkeys-hex / ALLOWED_STAMP_PUBKEYS_HEX, delivered through pivotArgs, the same mechanism that already pins the gateway signing pubkey. Rotation costs a redeploy but no rebuild and no new trust root.
  • Absent allowlist means the routes stay open, which is today's behavior, so this cannot break the existing deployment on merge.
  • Constant-time pubkey comparison via subtle. A timing signal here leaks which keys are allowlisted.
  • 401 responses carry bootProof like every other response, and the error text is deliberately coarse ("invalid or missing X-Stamp") so it cannot be used to probe the allowlist.

Test evidence

cargo test -p parser_http_server -> 7 passed (5 new stamp tests)
  accepts_a_stamp_from_an_allowlisted_key
  rejects_a_stamp_from_an_unlisted_key
  signature_is_checked_against_raw_bytes_not_reserialized_json
  rejects_missing_header_and_malformed_encodings
  rejects_an_unsupported_scheme
cargo test -p integration --test http_server -> 2 passed
  unstamped -> 401 with bootProof; stamped by a listed key -> 200; unlisted key -> 401
make -C src test / fmt / lint -> clean

Stamps in tests are produced by turnkey_api_key_stamper itself, so the real producer is exercised rather than our own re-implementation of it.

One thing worth flagging in review: the raw-bytes test initially passed for the wrong reason. This workspace builds serde_json with preserve_order (it pulls indexmap), so an alphabetically-ordered fixture round-trips byte-identical through Value and the test proved nothing. The fixture now differs by insignificant whitespace, which compact output always drops regardless of key ordering, so the test has real teeth.

Open questions before this merges

  • Blocking: does anything between client and pivot rewrite request bodies? The stamp signs the body, so if Cloudflare normalizes, recompresses, or re-chunks it, body-signed stamps cannot survive the hop and this design has to change. Needs a probe against the live app before merge.
  • The stamp covers the body only: no timestamp, no nonce, no method or path. It is replayable by design, which is acceptable for a stateless read-only parse. With x402, a replayed body plus its original VPM is a free re-parse, but the VPM commits to request_hash, so it cannot be redirected at a different transaction.
  • What we give up versus Turnkey's DB check: per-org and per-user identity, activity-level policy, instant revocation. Acceptable while the caller set is small and known.
  • Recommendation on the gateway's GATEWAY_AUTH_BEARER_TOKEN: keep it. The gateway is internet-facing and sees unauthenticated x402 discovery traffic before any stamp exists on a request. It is a different trust boundary from the enclave hop this PR protects, and it is already an optional flag, so there is no cost to keeping defense in depth.

Rollback

Revert the commit, or simply stop passing --allowed-stamp-pubkeys-hex: with no allowlist configured the routes behave exactly as before this PR.

Linear

PRS-581

Stacked on #450.

🤖 Generated with Claude Code

pepe-anchor and others added 2 commits August 6, 2026 13:01
Turnkey's TVC ingress is HTTP only (Cloudflare in front of
app-<uuid>.turnkey.cloud rejects gRPC with 403), so switching the parse
path onto the pivot needs a binary that speaks HTTP+JSON natively and
calls parser_app::routes::parse in-process.

Open v1 and v2 routes only. No payment enforcement, no auth, no proto
change: those are separate PRs on top. What this PR does own is the three
seams they plug into, so they can be written in parallel without
colliding: handlers take raw Bytes (an X-Stamp signature covers the exact
request bytes, and a Json<T> round-trip would invalidate it), bootProof
comes from a BootProofSource trait, and the manifest fields are already
borsh-encoded the way the Go verifier reads them.

The integration test fails fast if the server dies before binding. It
polls try_wait alongside the port, because wait_until_port_is_bound loops
forever: a pivot built with --features vsock looks for the absolute
in-enclave key path, exits at startup, and would otherwise hang CI
instead of failing it.

Co-Authored-By: Claude <noreply@anthropic.com>
Turnkey's gateway authenticates callers against their DB and we lose that
when the pivot becomes the front door. Keep the X-Stamp wire shape,
move validation into the pivot against a pubkey allowlist pinned via
pivotArgs (same delivery as --gateway-signing-pubkey-hex, so rotation is
a redeploy and nothing new to build).

Verification runs against the raw body bytes. A Json<T> round-trip
re-serializes and changes them, so the seam PR 3 cut (handlers take
Bytes) is what makes this correct; there is a test that pins it.

What we give up versus the DB check: per-org identity, activity policy,
instant revocation. Acceptable while the caller set is small and known;
the signed-allowlist option is the follow-up if rotation gets painful.

Co-Authored-By: Claude <noreply@anthropic.com>
@pepe-anchor pepe-anchor added the CI label Aug 6, 2026
pepe-anchor added a commit that referenced this pull request Aug 26, 2026
…446)

## Why

Two host binaries have to serve the same Turnkey JSON envelope to the
same client.

- `parser_gateway`: REST in front, gRPC to `parser_grpc_server` behind.
Non-TEE local dev and CI, so it emits a mock `bootProof`.
- `parser_http_server` (#450): HTTP+JSON inside the enclave, calling
`parser_app` in process, no gRPC hop. This is what gets deployed to the
TVC, because Cloudflare in front of `app-<uuid>.turnkey.cloud` rejects
gRPC with 403. It emits a real attested `bootProof`.

Different transports, different trust levels, one wire contract. The Go
[`visualsign-turnkeyclient`](https://github.com/anchorageoss/visualsign-turnkeyclient)
and the wallet integrators behind it cannot tell the two apart, and must
not be able to.

Two features also land in that envelope during PRS-581: `bootProof`
(#337) and `intermediateOutput` (#414). If the definition stays inline
in `parser_gateway`, the pivot copies it, and every envelope change from
here on gets made twice and kept in sync by hand. The unmerged x402
branch had already grown its own third copy.

So: one home, two importers, and the `bootProof` difference becomes a
parameter instead of a fork.

Downstream, #450, #451 and #452 build on `parser_http_server`; #449
builds on `parser_gateway`. Both sides import the envelope from here,
which is also what shrinks the PR #304 rebase from "reconcile two
envelope definitions" to "add a module".

## What

- `host_primitives::turnkey` becomes the single home for the Turnkey
request/response envelope, as the union of both existing definitions.
- `bootProof` is now an injected value rather than a hardcoded mock:
`error_response(msg, boot_proof)`. `parser_gateway` keeps its stable
local-dev mock through a local shim; the enclave pivot supplies a real
attested one.
- Construction moved with the types: `success_response` sits next to
`error_response`, so `parse_handler` no longer assembles the success
envelope field by field in the gateway.
- `parser_gateway` imports the shared types and keeps the four
`MOCK_BOOT_PROOF_*` constants and the tests that are about its own mock
and top-level wire shape. Three tests that would have duplicated
coverage now living in `host_primitives::turnkey` moved there instead of
being maintained in both places.
- `intermediate_output` gained `serde(default)` alongside its existing
`skip_serializing_if`. Without it the omitted key serialized fine but
failed to deserialize, so a response could be written and not read back.
Nothing in tree deserializes a response today (both binaries only emit
one), which is why no test caught it. The client-direction derives are
there so the envelope is symmetric for the out-of-tree clients that do
read it, and the new round-trip test is what keeps it that way. Happy to
drop those derives and the `default` until something in tree needs them,
if you'd rather not carry them.

No wire change. The gateway's JSON output is byte-identical.

## Test evidence

```
cargo test -p host_primitives -p parser_gateway
  host_primitives: 5 passed
  parser_gateway: 11 passed
cargo fmt --all -- --check: clean
make -C src lint: clean
```

The gateway test count moves from 13 to 11 because three tests relocated
to `host_primitives::turnkey`, where the types they cover now live: the
six-key `bootProof` assertion
(`boot_proof_wire_shape_is_exactly_six_camel_case_keys`), the empty
`intermediateOutput` omission check, and the Solana chain-metadata
discriminator test. `host_primitives` goes 4 to 5 with the new
round-trip test. No coverage was dropped.

The two regression tests that matter both still pass with unchanged
constant values: `mock_boot_proof_matches_production_wire_shape`,
`error_response_carries_mock_boot_proof`.

The "no wire change" claim was checked rather than assumed: a throwaway
test reconstructed the pre-refactor struct definitions verbatim from
base and byte-compared `serde_json` output against the new
`host_primitives::turnkey` types across success, error,
signature-present, and intermediate-output-present cases. All identical.

One trap worth recording: the x402 branch's `TurnkeyResponseWrapper` had
no `rename_all = "camelCase"`, which the gateway's inline struct did
have. Adding `boot_proof` without it would have serialized as
`boot_proof` instead of `bootProof` and silently broken the wallet
contract. The six-key wire-shape test catches it.

## Rollback

Revert the commits. No deploy, no migration, no wire change, so a revert
restores the previous state exactly.

## Linear

PRS-581

Stack position: base of the PRS-581 stack. #337, #414.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@pepe-anchor
pepe-anchor force-pushed the pepefigueira/prs-581-03-pivot-v1 branch from 7fe0208 to 7bc3e65 Compare August 26, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant