feat(prs-556): choose the ABI trust posture at deploy time - #421
feat(prs-556): choose the ABI trust posture at deploy time#421pepe-anchor wants to merge 14 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR makes caller-supplied Ethereum ABI trust a deploy-time choice instead of an implicit/per-request default by introducing a chain-neutral MetadataTrustPolicy, threading it through parser_app configuration and registry construction, and ensuring deployment tooling/workflows bake the chosen posture into TVC pivotArgs for out-of-band auditability.
Changes:
- Introduces
visualsign::signing::MetadataTrustPolicyand threads it throughparser_app::config::ParserConfiginto Ethereum converter construction. - Updates
tvc-deployand GitHub workflows to require/encode an explicit ABI trust posture at deploy time and validate signer pubkey shape early. - Adds/updates unit + integration tests to pin the behavior difference between permissive vs strict deployments.
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tools/tvc-deploy/src/main.rs | Adds explicit deploy-time ABI trust flags, pivots args assembly, and SEC1 pubkey shape validation. |
| tools/tvc-deploy/README.md | Documents the required ABI trust posture flags and their semantics. |
| src/visualsign/src/signing.rs | Introduces MetadataTrustPolicy and helpers for accept-unsigned vs require-allowlisted-signer. |
| src/parser/grpc-server/src/main.rs | Threads ParserConfig into parse calls and adds minimal CLI parsing for ABI trust posture. |
| src/parser/grpc-server/Cargo.toml | Adds visualsign dependency to reference MetadataTrustPolicy. |
| src/parser/app/src/service.rs | Stores deploy-time ParserConfig in the processor and passes it into parsing. |
| src/parser/app/src/routes/parse.rs | Extends parse API to accept &ParserConfig and uses it for registry construction. |
| src/parser/app/src/registry.rs | Threads config into registry creation and pins Ethereum converter posture via with_policy. |
| src/parser/app/src/lib.rs | Exposes new config module. |
| src/parser/app/src/config.rs | New deploy-time configuration module + posture resolution and validation. |
| src/parser/app/src/cli.rs | Adds parser_app CLI posture flags and enforces XOR + non-empty selection at startup. |
| src/Makefile | Updates local parser_app run target to pass --accept-unsigned-abis explicitly. |
| src/integration/tests/parser.rs | Adds end-to-end tests proving the same request behaves differently based solely on deployment flags. |
| src/integration/src/lib.rs | Updates integration harness to always start parser_app with an explicit ABI trust posture. |
| src/examples/library_integration_test.rs | Updates example to construct registry with an explicit permissive posture. |
| src/chain_parsers/visualsign-ethereum/tests/lib_test.rs | Updates tests to use with_policy and a strict “require-signed” helper converter. |
| src/chain_parsers/visualsign-ethereum/src/lib.rs | Replaces allowlist-only model with MetadataTrustPolicy and updates constructors/call sites. |
| src/chain_parsers/visualsign-ethereum/src/cli_plugin.rs | Makes CLI use strict posture with allowlisted dev signer key (consistent with CLI signing behavior). |
| src/chain_parsers/visualsign-ethereum/src/abi_metadata.rs | Makes unsigned-acceptance posture-driven; signature integrity always verified when present; adds deploy-time allowlist parsing. |
| src/Cargo.lock | Records dependency graph update for grpc-server → visualsign. |
| CLAUDE.md | Documents the deploy-time ABI trust posture model and flags. |
| .github/workflows/tvc-deploy.yml | Forces explicit posture for workflow_dispatch deployments; defaults remain for PR-label TEST path. |
| .github/workflows/stagex.yml | Updates deployment manifest guidance to include required ABI posture args. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Accepting caller-supplied ABI mappings with no signature has been the parser's unconditional default since #406. Any caller could omit the signature and get an unverified ABI decoded and rendered, and the only visibility was a warn! log that Turnkey does not surface, so nobody sees it. The trust decision belongs to whoever deploys the parser, not to whatever each request happens to contain. This adds visualsign::signing::MetadataTrustPolicy, the XOR the extractor now takes instead of a bare SignerAllowlist: AcceptUnsigned accept entries with no signature RequireAllowlistedSigner reject anything not signed by an allowed key Deliberately no Default impl: a deployment has to say which one it runs. Wiring the binaries to the cmdline comes in the next commit. One behaviour change beyond plumbing. Under AcceptUnsigned a present signature is verified for integrity but its signer is no longer checked against the allowlist. The old combination was incoherent: in production (no dev-signing, no env var) the allowlist was empty, so a correctly signed ABI was rejected while the same ABI with the signature stripped was accepted. An attacker facing an identity check just drops the signature, so the check bought nothing while making the strictly worse input the accepted one. Integrity is still enforced, which keeps the "present-but-invalid signature signals tampering" property. EthereumVisualSignConverter::new() keeps today's permissive behaviour for library and example callers; parser_cli now runs require-signed against the dev key it signs its own ABI files with, which is what authorized_abi_signers() and VISUALSIGN_ETH_ABI_SIGNERS now exclusively serve. Solana IDL mappings have the same per-request default and are untouched here; extending the policy to that path is mechanical and gets its own ticket. Co-Authored-By: Claude <noreply@anthropic.com>
The doc comments said a present signature "must still verify (integrity)", which reads stronger than what the code delivers. The signature is checked against the public key carried alongside it in the same untrusted SignatureMetadata, so it only catches a tamperer who mutates abi.value and leaves the signature and key alone. Someone who controls the whole entry re-signs with a key of their own and passes. That is still worth keeping, and the posture argument is unchanged. Just say what it actually covers, so a future reader does not mistake it for provenance. Comment-only, no behaviour change. Co-Authored-By: Claude <noreply@anthropic.com>
The same five-line with_policy(RequireAllowlistedSigner(...)) block was copy-pasted at four test sites. One helper next to test_abi_signer_allowlist instead, so the strict posture has a single definition and the tests read as what they are asserting rather than how the converter is built. Co-Authored-By: Claude <noreply@anthropic.com>
Gives the policy from the previous commit a cmdline, so the posture a deployment runs is fixed at startup and auditable out of band. parser_app requires exactly one of: --accept-unsigned-abis today's behaviour, explicit --accept-signatures-from-pubkey <hex> repeatable, rejects unsigned Neither flag and it refuses to start; both and it refuses to start. That is deliberate. Coming up in a mode nobody chose is the failure this replaces, so there is no default to fall back on. tvc-deploy enforces the same XOR and appends the chosen flag to the deployment's pivotArgs, which is the part that matters for the signer: the posture is in the manifest the operators approve, so it can be checked against what they expect for that deployment instead of trusting a per-request signal or logs the TEE never surfaces. parser_grpc_server takes the same flags but keeps accept-unsigned as its default, with a startup line saying so. It is the non-attested dev server: there is no manifest to audit the posture against, so requiring the flag would only break local dev without buying the property. Two integration tests are the end-to-end evidence. They send an identical request (an unsigned ABI for frobnicate(uint256,address), a selector no compiled-in visualizer knows) to two parser_app instances that differ only in cmdline. One renders the decoded call, the other falls back to raw hex. Existing launch paths (integration harness, src/Makefile, the tvc-deploy workflow) now pass --accept-unsigned-abis explicitly, so behaviour is unchanged; the choice is just no longer implicit. Live deployments need a redeploy to pick up a posture, since the flags live in the manifest. Rollback needs a redeploy, not just a revert. qos_core's parser rejects unknown cmdline args, so an older parser_app started with a manifest that still carries --accept-unsigned-abis panics at startup. To roll back: revert the code, then create a deployment whose pivotArgs are back to just --host-ip / --host-port. No state or wire-format changes otherwise. Co-Authored-By: Claude <noreply@anthropic.com>
Two ways the posture could go wrong between choosing it and the enclave honouring it, both found in review of this branch. The workflow_dispatch input took a free-text app_id but let the posture default to blank, and blank meant --accept-unsigned-abis. So dispatching against a non-test app without filling the field would quietly deploy the permissive posture, which is the exact class of implicit choice this branch exists to remove. Dispatch now refuses to run without a stated posture, and takes the literal "unsigned" to opt into accept-unsigned on purpose. The PR-label path has no inputs context and only ever targets the dev TEST app, so it keeps the permissive default. A malformed pubkey used to reach the manifest unchecked. Nothing looks at it until parser_app decodes it at startup, so a typo cost a consensus round and an enclave that never reports healthy. Validate the SEC1 shape before writing the manifest instead. This crate has no elliptic-curve dependency, so the check is format-only and says so: it catches truncated and typo'd input, not a point that is off the curve. The accepted prefixes are 02/03/05 for 33 bytes and 04 for 65. 05 is SEC1's compact tag, and k256 accepts it via decompact, so rejecting it here would have blocked a deployment whose key parser_app would have honoured. Co-Authored-By: Claude <noreply@anthropic.com>
ce93c29 to
7acaf0e
Compare
The doc comments on `MetadataTrustPolicy` and the converter constructors described the cmdline flags and the TVC `pivotArgs` wiring in present tense. None of that exists yet: it lands in #421. Today only `parser_cli` constructs an explicit posture. Also say out loud, on `new()`, what the accept-unsigned posture changed. It previously claimed to preserve the behaviour every in-process caller had, which is not true for `parser_app`: its allowlist is empty in production, so an entry signed by an unlisted key used to be rejected and is now accepted. That is the deliberate correction described on `MetadataTrustPolicy`, but the constructor doc should not imply nothing moved. Comment-only, no behaviour change. Co-Authored-By: Claude <noreply@anthropic.com>
The three-arm `match` on `abi.signature` had a `None => {}` arm carrying no
meaning; an `if let ... else if` says the same thing in fewer lines. Behaviour
identical across all three reachable states.
The require-signed rejection warning named `--accept-signatures-from-pubkey`,
and the module and `authorized_abi_signers` docs described the deploy-time flags
as though they were already wired. They are not, that is #421. Drop the flag
name from the runtime message and mark the wiring as planned rather than
present.
Co-Authored-By: Claude <noreply@anthropic.com>
A request whose every ABI mapping the posture refused was byte-identical to one that supplied no mappings at all: no registry either way, raw selector rendered either way, no error, no counter, no marker. The only thing separating them was a log::warn!, and that is not a channel anyone can read from the enclave. parser_app declares no log dependency and initialises no logger, so those calls are compiled-in no-ops there rather than log lines nobody happens to look at. Once #421 can flip a deployment to require-signed against a caller that does not sign, total refusal becomes the normal failure and it would surface nowhere. try_extract_from_chain_metadata now returns AbiExtraction, carrying the registry plus the counts, so the caller can tell the two apart. Rendering them needs a payload field and is left to the follow-up; this is the plumbing that gives the follow-up something to read. The unverified counter also changes meaning, from "signature absent" to "identity was not enforced". Under accept-unsigned the signer is never checked against an allowlist, so an entry an attacker self-signed is exactly as unattributed as one they left unsigned. Keying off signature.is_none() reported zero unverified mappings for a request whose entire decode came from an unverified caller ABI. That state is newly reachable in production: on main the compiled-in allowlist is empty, so a signed-by-unlisted entry was rejected and never reached the counter. Malformed entries (bad address, oversized or unparseable JSON) are counted separately from policy refusals, so the count keeps meaning "this deployment refused you" rather than "something went wrong". Co-Authored-By: Claude <noreply@anthropic.com>
…uses it signer_allowlist_from_hex has no non-test caller in the tree, in a PR whose body says "library groundwork only, no flags and no deployment changes here". Nobody can review whether its error strings and its empty-input rule match #421's clap definition without reading #421, which is the wrong way round. Its empty-input rule also contradicted the type it feeds, in three places: MetadataTrustPolicy documents an empty allowlist as rejecting everything (fail-closed), a signing.rs test exercises that state as valid, and authorized_abi_signers documents the same, while this parser treated empty as an error. So a deployment wanting the strictest posture, refuse every caller-supplied ABI, could not express it through the parser shipped here. Settling that contract belongs where the flag is defined and observable, not here. The flag and its parser land together in #421. Co-Authored-By: Claude <noreply@anthropic.com>
* feat(prs-556): make ABI trust posture an explicit policy Accepting caller-supplied ABI mappings with no signature has been the parser's unconditional default since #406. Any caller could omit the signature and get an unverified ABI decoded and rendered, and the only visibility was a warn! log that Turnkey does not surface, so nobody sees it. The trust decision belongs to whoever deploys the parser, not to whatever each request happens to contain. This adds visualsign::signing::MetadataTrustPolicy, the XOR the extractor now takes instead of a bare SignerAllowlist: AcceptUnsigned accept entries with no signature RequireAllowlistedSigner reject anything not signed by an allowed key Deliberately no Default impl: a deployment has to say which one it runs. Wiring the binaries to the cmdline comes in the next commit. One behaviour change beyond plumbing. Under AcceptUnsigned a present signature is verified for integrity but its signer is no longer checked against the allowlist. The old combination was incoherent: in production (no dev-signing, no env var) the allowlist was empty, so a correctly signed ABI was rejected while the same ABI with the signature stripped was accepted. An attacker facing an identity check just drops the signature, so the check bought nothing while making the strictly worse input the accepted one. Integrity is still enforced, which keeps the "present-but-invalid signature signals tampering" property. EthereumVisualSignConverter::new() keeps today's permissive behaviour for library and example callers; parser_cli now runs require-signed against the dev key it signs its own ABI files with, which is what authorized_abi_signers() and VISUALSIGN_ETH_ABI_SIGNERS now exclusively serve. Solana IDL mappings have the same per-request default and are untouched here; extending the policy to that path is mechanical and gets its own ticket. Co-Authored-By: Claude <noreply@anthropic.com> * docs(prs-556): narrow what integrity buys under accept-unsigned The doc comments said a present signature "must still verify (integrity)", which reads stronger than what the code delivers. The signature is checked against the public key carried alongside it in the same untrusted SignatureMetadata, so it only catches a tamperer who mutates abi.value and leaves the signature and key alone. Someone who controls the whole entry re-signs with a key of their own and passes. That is still worth keeping, and the posture argument is unchanged. Just say what it actually covers, so a future reader does not mistake it for provenance. Comment-only, no behaviour change. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(prs-556): extract require_signed_converter test helper The same five-line with_policy(RequireAllowlistedSigner(...)) block was copy-pasted at four test sites. One helper next to test_abi_signer_allowlist instead, so the strict posture has a single definition and the tests read as what they are asserting rather than how the converter is built. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(prs-556): pin the CLI's trust posture in one helper The posture the CLI registers its converter under was built inline in `register`, and nothing asserted what it was. A signed entry survives under either posture (accept-unsigned still integrity-checks a signature that is present), so the existing extraction assertions could not tell the two apart: flipping the CLI to accept-unsigned left the whole suite green. Extract `cli_trust_policy()` so the posture has one definition, use it in `register` and in the test that exercises extraction, and assert directly that it does not accept unsigned entries. Co-Authored-By: Claude <noreply@anthropic.com> * docs(prs-556): stop claiming the deploy-time flags already exist The doc comments on `MetadataTrustPolicy` and the converter constructors described the cmdline flags and the TVC `pivotArgs` wiring in present tense. None of that exists yet: it lands in #421. Today only `parser_cli` constructs an explicit posture. Also say out loud, on `new()`, what the accept-unsigned posture changed. It previously claimed to preserve the behaviour every in-process caller had, which is not true for `parser_app`: its allowlist is empty in production, so an entry signed by an unlisted key used to be rejected and is now accepted. That is the deliberate correction described on `MetadataTrustPolicy`, but the constructor doc should not imply nothing moved. Comment-only, no behaviour change. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(prs-556): flatten the posture branch and narrow its warning The three-arm `match` on `abi.signature` had a `None => {}` arm carrying no meaning; an `if let ... else if` says the same thing in fewer lines. Behaviour identical across all three reachable states. The require-signed rejection warning named `--accept-signatures-from-pubkey`, and the module and `authorized_abi_signers` docs described the deploy-time flags as though they were already wired. They are not, that is #421. Drop the flag name from the runtime message and mark the wiring as planned rather than present. Co-Authored-By: Claude <noreply@anthropic.com> * test(prs-556): stop the posture tests from passing vacuously Three tests asserted the right thing for the wrong reason, so the properties this PR exists to establish were not actually pinned. Each fix below was confirmed by mutation: break the production code the test names, and the test now fails where it previously stayed green. `test_accept_unsigned_still_rejects_tampered_signature` swapped in `[{"type":"function","name":"approve"}]` as the tampered body. That is not a parseable ABI, so `register_embedded_abi` dropped the entry before `validate_abi_signature` ever ran. Deleting the accept-unsigned integrity check outright left the suite green. Swap in `OTHER_VALID_ABI`, a well-formed ABI that differs from the signed one, so only the signature check can reject it. `test_accept_unsigned_does_not_enforce_signer_identity` signed its fixture with `CLI_DEV_SIGNING_KEY_SEED`, which `authorized_abi_signers()` allowlists under `cfg(test)`. The entry therefore passed an identity check too, so restoring the old incoherent "unsigned ok, signed-by-stranger rejected" pairing also left the suite green, which is exactly what that test claims to prevent. Sign it with `FOREIGN_SIGNER_SEED` instead, a key no allowlist here knows. `test_try_extract_invalid_address_skipped` ran under require-signed with an unsigned fixture, so the posture check dropped the entry and the invalid-address guard stopped being load-bearing. Run it under accept-unsigned. Also add `test_converter_honours_stored_require_signed_posture`: nothing pinned that the converter honours the posture it was constructed with, because every other metadata-ABI test supplies a signed mapping, which both postures accept. It feeds one unsigned mapping to both and asserts they diverge, using a function no built-in visualizer knows so the metadata ABI is the only thing that can decode it. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(prs-556): carry the ABI refusal count out of extraction A request whose every ABI mapping the posture refused was byte-identical to one that supplied no mappings at all: no registry either way, raw selector rendered either way, no error, no counter, no marker. The only thing separating them was a log::warn!, and that is not a channel anyone can read from the enclave. parser_app declares no log dependency and initialises no logger, so those calls are compiled-in no-ops there rather than log lines nobody happens to look at. Once #421 can flip a deployment to require-signed against a caller that does not sign, total refusal becomes the normal failure and it would surface nowhere. try_extract_from_chain_metadata now returns AbiExtraction, carrying the registry plus the counts, so the caller can tell the two apart. Rendering them needs a payload field and is left to the follow-up; this is the plumbing that gives the follow-up something to read. The unverified counter also changes meaning, from "signature absent" to "identity was not enforced". Under accept-unsigned the signer is never checked against an allowlist, so an entry an attacker self-signed is exactly as unattributed as one they left unsigned. Keying off signature.is_none() reported zero unverified mappings for a request whose entire decode came from an unverified caller ABI. That state is newly reachable in production: on main the compiled-in allowlist is empty, so a signed-by-unlisted entry was rejected and never reached the counter. Malformed entries (bad address, oversized or unparseable JSON) are counted separately from policy refusals, so the count keeps meaning "this deployment refused you" rather than "something went wrong". Co-Authored-By: Claude <noreply@anthropic.com> * test(prs-556): pin the trust posture at both real registration sites The posture a binary runs is decided entirely by which constructor its registry builder calls, and nothing observed the result. Editing parser_app's create_registry, or the CLI plugin's register, compiled clean and left the whole suite green, so the one production behaviour change in this PR was unpinned at both of its actual call sites. The previous CLI assertion called cli_trust_policy() directly, which routes around register: reverting register to new() left it passing. That is the same gap it was written to close, one layer up. Both tests now go through a TransactionConverterRegistry and convert a real transaction whose only possible decoder is a caller-supplied unsigned ABI, so what they observe is the converter that actually got registered. Each was mutation-checked: revert the production line, confirm the test goes red, restore. parser_app's fixture transaction is a hardcoded hex string. RLP is deterministic, so regenerating it costs nothing and it is not worth pulling the alloy stack into that crate's dev-dependencies to rebuild the same bytes at test time. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(prs-556): move the deploy-time signer parser to the PR that uses it signer_allowlist_from_hex has no non-test caller in the tree, in a PR whose body says "library groundwork only, no flags and no deployment changes here". Nobody can review whether its error strings and its empty-input rule match #421's clap definition without reading #421, which is the wrong way round. Its empty-input rule also contradicted the type it feeds, in three places: MetadataTrustPolicy documents an empty allowlist as rejecting everything (fail-closed), a signing.rs test exercises that state as valid, and authorized_abi_signers documents the same, while this parser treated empty as an error. So a deployment wanting the strictest posture, refuse every caller-supplied ABI, could not express it through the parser shipped here. Settling that contract belongs where the flag is defined and observable, not here. The flag and its parser land together in #421. Co-Authored-By: Claude <noreply@anthropic.com> * docs(prs-556): stop the Solana IDL path claiming Ethereum parity extract_idl_mappings accepts unsigned IDLs while rejecting a correctly signed IDL from an unlisted signer. This PR declares that exact pairing strictly worse than either coherent posture and removes it from the Ethereum ABI path, so the three comments describing the Solana behaviour as "parity with the Ethereum ABI path" stopped being true with this change. Left as-is behaviourally: MetadataTrustPolicy is chain-neutral so extending it here is mechanical, but it is out of scope and wants its own ticket. The comments now say it is a known gap, so a Solana reader does not read the pairing as deliberate. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
Resolved content conflicts against #422 (`make ABI trust posture an explicit policy type`), which landed on main as a per-request implementation of the same feature this branch supersedes with a deploy-time posture. Conflicts resolved (all in the ethereum converter + core signing layer): - src/visualsign/src/signing.rs - src/chain_parsers/visualsign-ethereum/src/abi_metadata.rs - src/chain_parsers/visualsign-ethereum/src/cli_plugin.rs - src/chain_parsers/visualsign-ethereum/src/lib.rs These 4 files were kept at this branch's version: our branch is the coherent deploy-time implementation the PR ships, and nothing outside these files references #422's per-request-only `AbiExtraction`/`rejected_by_policy`/ `unverified` counts (verified by grep across the tree), so dropping that unconsumed instrumentation is safe. main's other additions (visualsign-near skeleton, solana diagnostics, parser_app wiring, tvc-deploy/smoke) auto-merged. Also fixed a semantic auto-merge conflict git could not detect textually: parser/app/src/registry.rs's `create_registry` takes a `&ParserConfig` (our deploy-time signature), but #422's test still called it with no args. Updated that test to build an explicit `AcceptUnsigned` config and pass it through, and refreshed its doc comment now that the deploy-time flag has landed. Verified: cargo check --all, cargo clippy --all-targets -D warnings, cargo fmt --check, all unit tests (visualsign, visualsign-ethereum, parser_app, parser_cli, diagnostics variants), and all 11 gRPC integration tests pass.
The merge that superseded #422 dropped `test_register_installs_require_signed_posture` along with the instrumentation it was written beside. Nothing replaced it, so `EthereumPlugin::register` ended up with no coverage at any layer: editing it back to `EthereumVisualSignConverter::new()` compiles clean, leaves the suite green, and silently drops parser_cli to accept-unsigned. That is the exact regression this PR exists to make visible, so the guard goes back in. Verified by flipping `register` to the permissive converter and watching it fail. Two surviving tests had also stopped testing what they name: `test_try_extract_invalid_address_skipped` was switched to the require-signed policy while its fixture stayed unsigned, so the posture dropped the entry before the address was ever parsed. It passed with address validation removed entirely. It runs under accept-unsigned again, so the address parse is the only possible reason for the drop. `test_accept_unsigned_does_not_enforce_signer_identity` signed with the dev seed, which `authorized_abi_signers()` allowlists under cfg(test), so it would have held even if identity were being enforced. It signs with a restored foreign seed no allowlist knows. Also fills two coverage gaps the review surfaced. `create_registry` only pinned the accept-unsigned direction, so a version that ignored `config.abi_trust` entirely would have passed; the require-signed mirror now fails unless the posture reaches the converter. At the transport layer only two of the four posture and signature combinations were exercised, leaving "gates on signer identity" unproven against a real process; the allowlisted-accept and foreign-signed-drop cases close that. The signed fixtures are checked in rather than produced at test time. Signing in the integration crate means depending on `visualsign-ethereum/dev-signing`, and because `integration` is a workspace member Cargo would then unify that feature ON for parser_app in any workspace-wide invocation, which is precisely what the keep-out-of-prod note on that feature warns against. ECDSA here is RFC6979 deterministic, so the fixtures are stable, and the regeneration recipe is in a comment. The comments in cli_plugin.rs described the CLI's dev-key signature as buying "verified rather than logged as unverified". That outcome no longer exists, and under the CLI's require-signed posture an unsigned entry is dropped outright, so the signature is load-bearing rather than bookkeeping. Left as they were, they invited a future editor to delete the signing step. Co-Authored-By: Claude <noreply@anthropic.com>
…st is signed `validate_signer_pubkey` only checked length, SEC1 tag and hex charset, so a well-formed hex string that is not a point on secp256k1 passed, landed in `pivotArgs`, and got quorum-signed into the deployment manifest. parser_app decodes the key for real at enclave startup, so the mistake only surfaced there: a burned consensus round plus a redeploy to fix a typo. Decoding it locally turns that into an error before any network call, which is what the eager validation was already trying to buy. The stated reason for skipping the check was that this crate has no elliptic-curve dependency, but k256 was already in the lockfile transitively, so adding it costs one line and no new supply chain. The existing fixtures used repeated hex digits, which are not on the curve, so they are derived from a real key now. Worth knowing for anyone extending them: `02` followed by 64 'a' characters IS a valid point, so the off-curve case uses a value above the field prime instead. Also bounds the value echoed back in the error. Both this and `validate_digest` printed operator input unbounded, so a mistaken paste of a whole file landed verbatim in CI logs. Truncation is on char boundaries because the input need not be ASCII. Co-Authored-By: Claude <noreply@anthropic.com>
`visualsign` is the core library crate, so every public enum is public API. Adding a third posture later would break any downstream exhaustive match, and the cost of preventing that is near zero today: every match on this enum lives in `signing.rs` itself, and consumers only construct variants. Verified with a workspace-wide `cargo check --all-targets`, which stays clean, so no caller is forced into a wildcard arm that would quietly swallow a future variant. Co-Authored-By: Claude <noreply@anthropic.com>
Nothing under docs/ was touched when `create_registry` gained a required `&ParserConfig`, so the library getting-started page still showed the zero-arg form in two snippets. Anyone copying the published snippet got a compile error, with no hint that a trust posture has to be chosen. The same drift was in the add-a-chain guide, which also pointed at `routes/parse.rs` for a function that lives in `registry.rs`. The dApp page was worse than stale. It told authors the parser accepts any well-formed signature from any key and advised shipping unsigned ABIs until their signing pipeline was stable. Against a require-signed deployment the parser enforces the allowlist itself and drops unsigned entries, so following that advice silently degrades rendering rather than easing a rollout. It now splits the guidance by posture and says a dropped entry is indistinguishable from one never sent. The gRPC server page documents that binary as a self-hosted deployment, with a Dockerfile CMD and a k8s spec that pass no arguments, and never mentioned that a posture exists. Nothing there breaks, since the dev server defaults to accept-unsigned, but a self-hoster ends up honouring unsigned caller ABIs without having chosen to. The flag is now stated explicitly in both snippets so the choice lands in their deployment config instead of being inherited from a default. Co-Authored-By: Claude <noreply@anthropic.com>
The step's own comment states the convention is to pass inputs via env to avoid shell injection from interpolated expressions, and the line right below it interpolated one into the run block. `github.event_name` is GitHub-controlled, so there is no live injection risk here, but leaving it invites the pattern to be copied for a value that is not. GitHub already exports `$GITHUB_EVENT_NAME`, so this needs no new env entry. Co-Authored-By: Claude <noreply@anthropic.com>
…t messaging - Add explicit k256 round-trip test for SEC1 compact (05) keys, proving no divergence between the format check and the actual curve decode. Improve the on-curve rejection error to name the SEC1 tag that was detected, so an operator seeing a 'does not decode to a point' error for a 05-prefixed key knows k256 saw the compact tag rather than mistaking it for a corrupted key. - Include the allowlist size in the unsigned-ABI rejection log message under require-signed posture, matching MetadataTrustPolicy::Display. An empty allowlist now says 'no authorized signers configured' instead of implying signing would help. Co-Authored-By: Claude <noreply@anthropic.com>
- Update validate_signer_pubkey doc comment to reflect two-phase validation (format + on-curve via k256) - Guard against flag-as-value in gRPC server's hand-rolled arg parser - cargo fmt fix in abi_metadata.rs
…deploy workflow Merge the redundant elif/else branches in the bash script that both produced --accept-unsigned-abis into a single [ -z ] || [ = unsigned ] guard. No behavior change.
|
Superseded by stacked PRs #440-#444 (stack #445). Same changes, split into reviewable layers:
|
There was a problem hiding this comment.
🟡 Changes recommended
There are a couple of correctness/robustness issues to address (notably tvc-deploy pubkey validation/manifest formatting not trimming whitespace consistently, and a brittle parser_app CLI test assertion tied to an internal error-variant string).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (3)
tools/tvc-deploy/src/main.rs:491
validate_signer_pubkeyrejects otherwise-valid keys if they include leading/trailing whitespace, but the runtime allowlist parsing (signer_allowlist_from_hex(entry.trim())) trims. This makestvc-deploy deploystricter thanparser_appfor the same input and can cause avoidable deploy failures from copy/paste artifacts.
fn validate_signer_pubkey(hex_str: &str) -> Result<()> {
let stripped = hex_str
.strip_prefix("0x")
.or_else(|| hex_str.strip_prefix("0X"))
.unwrap_or(hex_str);
tools/tvc-deploy/src/main.rs:333
pivot_argsforwards signer pubkeys verbatim intopivotArgs. If the pubkey string contains leading/trailing whitespace (e.g. quoted env var), it will be recorded in the manifest even though the parser trims during decoding. Trimming here keeps the manifest canonical and consistent with runtime parsing.
for key in &args.accept_signatures_from_pubkey {
pivot.push("--accept-signatures-from-pubkey".to_string());
pivot.push(key.clone());
}
src/parser/app/src/cli.rs:253
- This assertion depends on an internal error-variant name (
MutuallyExclusiveInput) fromqos_core's CLI parser, making the test brittle across dependency updates. It's enough to assert parsing fails, or (if you want a message check) assert the error mentions the conflicting flags instead.
assert!(err.contains("MutuallyExclusiveInput"), "error: {err}");
- Files reviewed: 26/29 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Why
PRS-556, part 2 of 2. Part 1 (#422) has merged, so this now targets
maindirectly and is no longer stacked.#406 made the parser accept caller-supplied ABI mappings with no signature instead of dropping them. That fixed a real bug, but it left acceptance of unsigned ABIs as the parser's unconditional default: any caller can omit the signature and get an unverified ABI decoded and rendered.
@prasanna flagged the posture itself as designed wrong, and the visibility mechanism #406 added does not work. The aggregated
warn!per request is never seen by a signer, because Turnkey doesn't surface parser logs today. So the trust decision is both implicit and unauditable.This moves it to where the deployer chooses it and the signer can check it: the parser's cmdline, which for the enclave binary lands verbatim in the
pivotArgsof the manifest the operators approve.What
Two mutually exclusive postures on
parser_app:Neither flag and it refuses to start. Both and it refuses to start. There is no default: coming up in a mode nobody chose is the failure this replaces.
Threaded
ParserConfig->parse()->create_registry()->EthereumVisualSignConverter::with_policy, using theMetadataTrustPolicytype from #422.tvc-deploy deployenforces the same XOR and appends the chosen flag topivotArgs, so the posture is part of the signed manifest rather than something a caller influences per request.It also fails fast on the two ways the posture could still go wrong between choosing it and the enclave honouring it:
workflow_dispatchtook a free-textapp_idbut let the posture default to blank, and blank meant--accept-unsigned-abis. Dispatching against a non-test app without filling the field would quietly deploy the permissive posture, which is the class of implicit choice this PR exists to remove. Dispatch now refuses to run without a stated posture, and takes the literalunsignedto opt into accept-unsigned on purpose. The PR-label path has noinputscontext and only ever targets the dev TEST app, so it keeps the permissive default.parser_appdecodes it at startup. A typo cost a consensus round and an enclave that never reported healthy.tvc-deploynow validates the key before writing the manifest: SEC1 shape, then a real point decode viak256. Accepted prefixes are 02/03/05 at 33 bytes and 04 at 65. 05 is SEC1's compact tag and k256 accepts it viadecompact, so rejecting it would have blocked a deployment whose keyparser_appwould have honoured.k256was already in that crate's lockfile transitively, so the on-curve check costs one line and no new supply chain.parser_grpc_servertakes the same flags but keeps accept-unsigned as its default with a startup line saying so. It's the non-attested dev server, there's no manifest to audit the posture against, so requiring the flag would only break local dev without buying the property.Existing launch paths (integration harness,
src/Makefile, the tvc-deploy workflow) now pass--accept-unsigned-abisexplicitly, so behaviour is unchanged. The choice is just no longer implicit.From the review pass
The merge that brought
mainin resolved four files to this branch's side, superseding #422's per-requestAbiExtraction/rejected_by_policy/unverifiedcounters. Nothing outside those files consumed them, but the merge also took five tests with it, and that part was not deliberate:test_register_installs_require_signed_postureis back. It was the only test that went throughChainPlugin::register, and without it, editingregisterback toEthereumVisualSignConverter::new()compiles clean, leaves the suite green, and silently dropsparser_clito accept-unsigned. Confirmed by making that exact edit and watching the restored test fail.test_try_extract_invalid_address_skippedhad been switched to the require-signed policy while its fixture stayed unsigned, so the posture dropped the entry before the address was ever parsed: it passed with address validation removed entirely.test_accept_unsigned_does_not_enforce_signer_identitysigned with the dev seed, whichauthorized_abi_signers()allowlists undercfg(test), so it would have held even if identity were being enforced.create_registryonly pinned the accept-unsigned direction, so a version that ignoredconfig.abi_trustoutright would still have passed. The require-signed mirror now fails unless the posture reaches the converter.cli_plugin.rsstill described the CLI's dev-key signature as buying "verified rather than logged as unverified". That outcome no longer exists, and under the CLI's require-signed posture an unsigned entry is dropped outright, so the signature is load-bearing rather than bookkeeping.MetadataTrustPolicyis now#[non_exhaustive], so a third posture can be added later without breaking downstream matches. Every match on it lives insigning.rs; consumers only construct variants, so nothing is forced into a wildcard arm.Docs were the part this PR had invalidated without touching.
create_registrygained a required argument whiledocs/wallet-integration/library/getting-started.mdxstill showed the zero-arg form in two snippets, so the published snippet no longer compiled. The dApp page was worse than stale: it said the parser accepts any signature from any key and advised shipping unsigned ABIs until the signing pipeline stabilised, which against a require-signed deployment silently degrades rendering instead of easing a rollout. The gRPC server page presents that binary as a self-hosted deployment with a Dockerfile CMD and k8s spec that pass no arguments, and never mentioned a posture existed.Deploy coupling
tools/tvc-deployis not a member of thesrc/workspace, so it has no compile-time link to the parser: a tvc-deploy change alone goes green in CI while still being a live hazard. Landing thepivotArgsflag before the binary that accepts it meansqos_coresees an unknown cmdline arg and panics at startup. The flag and the binary that understands it ship together here, which is why the deploy tooling was not split out further.One follow-up lives outside this repo: the weekly deploy driver in the
pepe-parser-deployplugin builds thetvc-deploy deployARGV with no posture flag, so it will abort at argument parsing once this lands. It fails closed, so there is no misdeploy risk, but it needs a matching change before the next weekly deploy.Testing
The end-to-end evidence is four integration tests that send an identical request to
parser_appinstances differing only in cmdline. The ABI describesfrobnicate(uint256,address), a selector no compiled-in visualizer knows, so decoding depends purely on whether the caller ABI was honoured:The last two are what prove the strict posture gates on signer identity rather than merely on a signature being present.
The signed fixtures are checked in rather than produced at test time. Signing in the integration crate means depending on
visualsign-ethereum/dev-signing, and becauseintegrationis a workspace member, Cargo would then unify that feature ON forparser_appin any workspace-wide invocation, which is what the keep-out-of-prod note on that feature warns against. ECDSA here is RFC6979 deterministic, so the fixtures are stable, and the regeneration recipe is in a comment beside them.Unit coverage for deploy-time pubkey parsing (valid, compressed/uncompressed canonicalization, invalid key errors at startup rather than silently shrinking the allowlist), the
parser_appflag XOR,tvc-deploy'spivotArgsassembly, theabi_metadataposture behaviour, and the pubkey check (02/03/05/04 accepted, truncated and non-hex rejected, wrong length for the prefix rejected, well-formed hex that is off the curve rejected).Full suite:
tools/tvc-deployseparately: 64 tests pass, clippy and fmt clean. Also checkedparser_appunder--no-default-featuresand each of the ethereum/solana/sui/tron features in isolation, sinceconfig.rsis feature-gated.The workflow's posture selection was exercised for every input and event combination rather than just read:
Manual smoke on the arg handling:
Rollback
Needs a redeploy, not just a revert.
qos_core's parser rejects unknown cmdline args, so an olderparser_appstarted against a manifest that still carries--accept-unsigned-abispanics at startup.To roll back: revert the code, then create a deployment whose
pivotArgsare back to just--host-ip/--host-port. No state, schema, or wire-format changes otherwise.Same reason live deployments need a redeploy to pick this up: the flags live in the manifest, so there's no way to flip a running deployment (and, by design, no way to flip it per request).
Backwards compatibility
No proto or payload changes. Every existing launch path keeps its current behaviour, explicitly.
Three intentional incompatibilities, all the point of the change rather than side effects:
parser_apprefuses to start without a posture instead of guessing.workflow_dispatchon the tvc-deploy workflow now needsabi_signer_pubkeyfilled in, either a hex key or the literalunsigned. The PR-label path is unaffected.create_registrytakes a&ParserConfig. In-repo callers are updated and the published snippets now match.Not in scope
Solana IDL mappings have the identical problem (
extract_idl_mappingsaccepts unsigned IDLs unconditionally, same per-request default). Kept this ABI-only per the ticket.MetadataTrustPolicyis chain-neutral so extending it is mechanical, worth its own ticket.PRS-555 is still open and unaffected. Worth restating what it means here, since it bounds what the strict posture buys operationally: a mapping dropped by the posture produces a payload byte-identical to a request that supplied no mappings at all, and the only signal is a
log::warn!in a binary that declares nologdependency and initialises no logger. So a wallet that rotates its ABI signer degrades every transaction from decoded fields to raw calldata, silently. The outcome is content-safe rather than wrong, but it is not diagnosable from the response, and closing that needs a response-surface change rather than a parser change.🤖 Generated with Claude Code