Add Stylus attestation verifier contract for on-chain TEE verification#90
Conversation
This introduces an Arbitrum Stylus smart contract written in Rust that provides an alternative to the RiscZeroAttestationVerifier. Instead of verifying a RISC Zero Groth16 ZK proof, it verifies an ECDSA signature from a trusted attestor who has validated the GCP Confidential Space JWT off-chain. The contract implements the same IAttestationVerifier interface, making it a drop-in replacement via TeeKeyManager.updateAttestationVerifier(). Key features: - ABI-compatible with the existing Solidity verifier interface - Uses ecrecover precompile for signature recovery - Validates all attestation claims (JWK hash, timestamps, secure boot, debug mode, image digest, image signer) - Owner-managed trusted attestor list - Standalone crate excluded from workspace (different toolchain target) https://claude.ai/code/session_01Go59koRavN63NtmTiyuYRv
Integrate the Stylus attestation verifier as an alternative to RISC Zero zkVM proof generation. When PROVER_MODE=stylus, the bootstrap client calls a trusted attestor service that verifies the GCP JWT off-chain and returns ECDSA-signed attestation claims for on-chain verification by the Stylus contract. - Add ProverMode::Stylus variant and ATTESTOR_SERVICE_URL config field - Add generate_stylus_attestation() method calling /attest endpoint - Add config validation requiring attestor URL for Stylus mode - Update proof_client to route based on prover_mode field - Add tests for Stylus config validation https://claude.ai/code/session_01Go59koRavN63NtmTiyuYRv
Replace the trusted attestor model with direct JWT verification using EVM precompiles. The contract now verifies GCP Confidential Space JWTs entirely on-chain without any off-chain intermediary: - RS256 signature verification via SHA-256 (0x02) and modexp (0x05) precompiles - Base64url decoding and minimal JSON claim extraction (no serde dependency) - PKCS#1 v1.5 padding verification for RSA-SHA256 - JWK key material hash verification (caller provides RSA key, contract verifies keccak256(n||e) matches stored hash to prevent key substitution) - Validates all attestation claims: issuer, audience, timestamps, secboot, dbgstat, image digest against the PublicValuesStruct - 15 unit tests covering base64, JSON parsing, PKCS#1 v1.5, ABI roundtrips proof_bytes format changed from ECDSA signature to ABI-encoded StylusProofData containing the raw JWT bytes and JWK RSA key material (modulus + exponent). https://claude.ai/code/session_01Go59koRavN63NtmTiyuYRv
Replace the trusted attestor service with local proof construction for Stylus mode. The bootstrap client now: - Parses the JWT locally to extract claims (no external service call) - Fetches the JWK RSA public key from Google's JWKS endpoint - Constructs PublicValuesStruct and StylusProofData (raw JWT + JWK material) - Sends everything to the relayer for on-chain verification by the Stylus contract Changes: - Remove attestor_service_url config (no longer needed) - Add google_jwks_url config with default GCP Confidential Space endpoint - Add generate_stylus_proof() that constructs proof data locally - Add fetch_jwk_by_kid() for Google JWKS fetching - Add JWT/JWKS parsing types and base64url decoder - Add sol! type definitions for PublicValuesStruct and StylusProofData - Add serde_json and sol-types dependencies - Both Service (RISC Zero) and Stylus paths preserved https://claude.ai/code/session_01Go59koRavN63NtmTiyuYRv
Add 50 new tests covering edge cases across all contract components: - Base64: empty input, single/two byte, invalid chars, URL-safe chars, high bytes - JSON extraction: missing keys, empty values, escaped quotes, u64 overflow/zero/max, bool non-bool values, key substrings, key-in-value disambiguation, deep nesting - JWT parsing: invalid UTF-8, missing parts, empty segments, extra dots, signing input - PKCS#1 v1.5: non-0xFF in padding, wrong DigestInfo, too short, minimum valid padding, 128-byte key, no separator, extra data after hash - Precompile input format: modexp EIP-198 layout, ecrecover 128-byte layout - ABI: roundtrip with real values, invalid data decode - Realistic GCP Confidential Space JWT with all claim types - find_subsequence: boundary cases Also fix clippy warnings: remove useless .into() conversions, use iterator in PKCS#1 padding loop, extract test helper for building padded messages. https://claude.ai/code/session_01Go59koRavN63NtmTiyuYRv
Shell script for the full deployment lifecycle of the Stylus contract: - Check: validates WASM compiles and fits Stylus size limits - Deploy: submits bytecode and activates on-chain via cargo-stylus - Initialize: calls initialize() with expiration tolerance - Saves deployment info to .synddb/stylus-deployment.json Supports multiple modes: --check-only, --export-abi, --init-only, --estimate-gas, --no-verify (skip Docker), --no-activate (split deploy). Prints post-deployment configuration commands for adding trusted JWKs, image digests, and image signers. https://claude.ai/code/session_01Go59koRavN63NtmTiyuYRv
Ran 18 automated tests covering argument parsing, error paths, env var fallbacks, address extraction, JSON output, and private key handling. Bugs fixed: - check_prerequisites ran before mode-specific validation, blocking useful error messages (e.g. "missing endpoint" was masked by "missing cargo-stylus") - Prerequisite checks now mode-aware: --init-only only needs cast, not cargo-stylus; --check-only only needs cargo-stylus, not cast - build_key_arg called via $() ran in a subshell where exit 1 didn't kill the parent script - deploy would proceed with empty key arg. Now returns empty string and caller checks explicitly. - Address extraction regex (0x + 40 hex chars) would match the first 42 chars of a 64-char tx hash. Now filters for lines containing "address" first via extract_address helper. - JSON output: "chainId": unknown produced invalid JSON when cast chain-id failed. Now falls back to JSON null. - JSON output: "deploymentTx": "null" was a string. Now outputs unquoted null for proper JSON when empty. - Flags requiring values (--endpoint, --private-key, etc.) produced cryptic "unbound variable" errors under set -u. Now uses require_arg helper with clear "$FLAG requires a value" messages. - Added set -euo pipefail for stricter error handling. - Added --private-key-path file existence check. https://claude.ai/code/session_01Go59koRavN63NtmTiyuYRv
- Fix json_get_string double-backslash escape handling: count consecutive backslashes before quote to correctly handle \\\" (even count = real quote) - Forward expected_audience to generate_stylus_proof() for early validation instead of silently ignoring it - Add zero-address check to transfer_ownership to prevent accidental lockout - Cap expiration_tolerance at 86400s (1 day) matching the Solidity verifier - Replace mock prover manual byte offsets with proper sol! type construction - Update GeneratingProof state comment to be mode-agnostic - Fix clippy is_multiple_of warning in proof_client base64 decoder - Update CLAUDE.md test modification policy https://claude.ai/code/session_01Go59koRavN63NtmTiyuYRv
There was a problem hiding this comment.
Pull request overview
Adds a new Arbitrum Stylus (Rust) attestation verifier contract and updates the bootstrap client to support a new “Stylus” prover mode that constructs proof inputs locally for on-chain JWT verification (instead of calling the RISC Zero proof service).
Changes:
- Introduces a Stylus verifier contract that verifies GCP Confidential Space JWTs on-chain (RS256 via SHA-256 + modexp) and validates required claims + image signer via
ecrecover. - Extends
synddb-bootstrapwith aProverMode::Styluspath that parses JWTs locally, fetches Google JWKS, and ABI-encodes the new proof payload. - Adds a deployment/initialization helper script for the Stylus contract and excludes the new Stylus crate from the workspace.
Reviewed changes
Copilot reviewed 11 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/deploy-stylus.sh | New script to check/deploy/init/verify the Stylus verifier and write deployment metadata JSON. |
| crates/synddb-bootstrap/src/proof_client.rs | Adds Stylus mode to construct proof inputs locally (JWT + JWK key material) and new JWT/JWKS parsing helpers + tests. |
| crates/synddb-bootstrap/src/lib.rs | Updates crate docs to describe Service vs Stylus vs Mock verification modes. |
| crates/synddb-bootstrap/src/config.rs | Adds google_jwks_url config and new ProverMode::Stylus. |
| crates/synddb-bootstrap/Cargo.toml | Moves serde_json to main deps and enables Alloy sol-types. |
| contracts/stylus/attestation-verifier/src/lib.rs | New Stylus verifier contract implementing on-chain RS256 JWT verification and admin-managed allowlists. |
| contracts/stylus/attestation-verifier/rust-toolchain.toml | Pins toolchain/target for building the standalone Stylus crate. |
| contracts/stylus/attestation-verifier/Cargo.toml | Defines standalone Stylus crate dependencies/features. |
| contracts/stylus/attestation-verifier/Cargo.lock | New lockfile for the standalone Stylus crate. |
| Cargo.toml | Excludes the Stylus contract crate from the workspace. |
| Cargo.lock | Minor dependency version bump (thiserror). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| #[public] | ||
| impl StylusAttestationVerifier { | ||
| /// Initializes the contract. Must be called once after deployment. | ||
| /// Sets the caller as the owner. Expiration tolerance is capped at 86400 seconds (1 day). | ||
| pub fn initialize(&mut self, expiration_tolerance: u64) -> Result<(), VerifierError> { | ||
| if self.initialized.get() { | ||
| return Err(VerifierError::AlreadyInitialized(AlreadyInitialized {})); |
There was a problem hiding this comment.
initialize() sets expiration_tolerance without any upper-bound validation. The existing Solidity verifier enforces a max tolerance (e.g., 86400s) to reduce misconfiguration risk (accepting very old attestations). Consider enforcing a similar cap here (and potentially rejecting 0 if that’s not intended).
|
|
||
| /// Removes a trusted image signer. | ||
| pub fn remove_trusted_image_signer(&mut self, signer: Address) -> Result<(), VerifierError> { | ||
| self.only_owner()?; | ||
| self.trusted_image_signers.setter(signer).set(false); | ||
| self.vm().log(TrustedImageSignerRemoved { signer }); | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Transfers ownership of the contract. Reverts if `new_owner` is the zero address. |
There was a problem hiding this comment.
transfer_ownership() allows transferring ownership to the zero address, which would permanently brick admin functions (and differs from OpenZeppelin Ownable’s non-zero requirement). Consider rejecting new_owner == Address::ZERO.
| let claims: JwtClaims = serde_json::from_slice(&payload_bytes).map_err(|e| { | ||
| BootstrapError::ProofGenerationFailed(format!("Failed to parse JWT claims: {e}")) | ||
| })?; | ||
|
|
There was a problem hiding this comment.
In Stylus prover mode, the code parses the JWT header alg but never validates it. This can produce “proof” data that is guaranteed to fail on-chain (the Stylus contract rejects non-RS256). Consider validating header.alg == "RS256" here and returning an error early.
| // Validate that the JWT uses the expected signing algorithm. | |
| // Stylus contracts only accept RS256, so constructing proof data for any | |
| // other algorithm would be guaranteed to fail on-chain. | |
| if header.alg != "RS256" { | |
| return Err(BootstrapError::ProofGenerationFailed(format!( | |
| "Unsupported JWT algorithm: expected 'RS256', got '{}'", | |
| header.alg | |
| ))); | |
| } |
| require_private_key() { | ||
| if [[ -z "$PRIVATE_KEY" && -z "$PRIVATE_KEY_PATH" ]]; then | ||
| error "No private key provided. Use --private-key or --private-key-path" | ||
| exit 1 | ||
| fi | ||
| if [[ -n "$PRIVATE_KEY_PATH" && ! -f "$PRIVATE_KEY_PATH" ]]; then | ||
| error "Private key file not found: $PRIVATE_KEY_PATH" | ||
| exit 1 | ||
| fi | ||
| } |
There was a problem hiding this comment.
require_private_key() permits providing both --private-key and --private-key-path, and later silently prefers --private-key. This is easy to misconfigure (deploying from the wrong key). Consider making these options mutually exclusive and erroring if both are set.
| # Write deployment info as valid JSON | ||
| write_deployment_json() { | ||
| local address="$1" | ||
| local chain_id="$2" | ||
| local deploy_tx="${3:-}" | ||
|
|
||
| # Ensure chain_id is a number, fallback to null for JSON validity | ||
| if ! [[ "$chain_id" =~ ^[0-9]+$ ]]; then | ||
| chain_id="null" | ||
| fi | ||
|
|
||
| # deploymentTx: use JSON null (unquoted) when empty, quoted string otherwise | ||
| local deploy_tx_json="null" | ||
| if [[ -n "$deploy_tx" ]]; then | ||
| deploy_tx_json="\"$deploy_tx\"" | ||
| fi | ||
|
|
||
| mkdir -p "$OUTPUT_DIR" | ||
| cat > "$OUTPUT_DIR/stylus-deployment.json" <<EOF | ||
| { | ||
| "contract": "StylusAttestationVerifier", | ||
| "address": "$address", | ||
| "chainId": $chain_id, | ||
| "endpoint": "$ENDPOINT", | ||
| "expirationTolerance": $EXPIRATION_TOLERANCE, | ||
| "deploymentTx": $deploy_tx_json, | ||
| "deployedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" | ||
| } | ||
| EOF |
There was a problem hiding this comment.
write_deployment_json() writes expirationTolerance as a raw value without validating it’s numeric. If the env/flag contains a non-number, this produces invalid JSON in stylus-deployment.json. Consider validating EXPIRATION_TOLERANCE is an integer (similar to chain_id) or serializing it as a JSON string.
| //! # Verification Modes | ||
| //! | ||
| //! - **Service** (default): Uses RISC Zero zkVM proof service for ZK proof generation. | ||
| //! The proof service runs the RISC Zero guest program to verify the JWT and generate | ||
| //! a Groth16 proof, which is verified on-chain by `RiscZeroAttestationVerifier`. | ||
| //! - **Stylus**: Constructs proof data locally and sends the raw JWT for direct on-chain | ||
| //! verification by an Arbitrum Stylus contract. The contract verifies the RS256 signature | ||
| //! using SHA-256 and modexp EVM precompiles. No external proof service needed. | ||
| //! - **Mock**: For testing only, generates invalid proofs |
There was a problem hiding this comment.
The PR description says the Stylus verifier “verifies an ECDSA signature from a trusted attestor who has validated the JWT off-chain”, but the code/docs here describe (and implement) direct on-chain RS256 JWT verification via SHA-256 + modexp precompiles. Please update the PR description (or implementation) so they match, especially around the trust model (trusted attestor list vs trusted JWKs/key-material hashes).
- Fix line-length formatting in proof_client.rs (cargo fmt) - Update bytes 1.11.0 -> 1.11.1 (RUSTSEC-2026-0007: integer overflow) - Update time 0.3.44 -> 0.3.47 (RUSTSEC-2026-0009: stack exhaustion DoS) - Update keccak 0.1.5 -> 0.1.6 (RUSTSEC-2026-0012: unsound ARMv8 assembly) https://claude.ai/code/session_01Go59koRavN63NtmTiyuYRv
This introduces an Arbitrum Stylus smart contract written in Rust that
provides an alternative to the RiscZeroAttestationVerifier. Instead of
verifying a RISC Zero Groth16 ZK proof, it verifies an ECDSA signature
from a trusted attestor who has validated the GCP Confidential Space
JWT off-chain.
The contract implements the same IAttestationVerifier interface, making
it a drop-in replacement via TeeKeyManager.updateAttestationVerifier().
Key features:
debug mode, image digest, image signer)
https://claude.ai/code/session_01Go59koRavN63NtmTiyuYRv