feat(parser_http_server): validate X-Stamp in the enclave - #451
Draft
pepe-anchor wants to merge 2 commits into
Draft
feat(parser_http_server): validate X-Stamp in the enclave#451pepe-anchor wants to merge 2 commits into
pepe-anchor wants to merge 2 commits into
Conversation
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
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
force-pushed
the
pepefigueira/prs-581-03-pivot-v1
branch
from
August 26, 2026 16:20
7fe0208 to
7bc3e65
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Today Turnkey's gateway authenticates every caller: it validates
X-Stampagainst 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 plaincurl).This keeps the
X-Stampwire shape and moves validation into the enclave, against a pubkey allowlist pinned at deploy time.What
stamp.rs:Allowlist,StampError, andverify(&HeaderMap, &[u8], &Allowlist). Header value is base64url-no-pad JSON{publicKey, signature, scheme},publicKeyhex compressed SEC1,signaturehex DER, over the raw body. Both P256 and secp256k1 schemes.--allowed-stamp-pubkeys-hex/ALLOWED_STAMP_PUBKEYS_HEX, delivered throughpivotArgs, the same mechanism that already pins the gateway signing pubkey. Rotation costs a redeploy but no rebuild and no new trust root.subtle. A timing signal here leaks which keys are allowlisted.bootProoflike 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
Stamps in tests are produced by
turnkey_api_key_stamperitself, 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_jsonwithpreserve_order(it pullsindexmap), so an alphabetically-ordered fixture round-trips byte-identical throughValueand 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
request_hash, so it cannot be redirected at a different transaction.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