Am/example/tvc cosign#2
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
2327fe3 to
7ce4dd7
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new tvc-cosign Rust example service demonstrating a TVC enclave “transaction cosigner” that classifies unsigned EVM transactions and returns a Turnkey-stampable SIGN_TRANSACTION_V2 request plus an enclave-signed App Proof.
Changes:
- Introduces an Axum HTTP service with
/health,/pubkeys, and/cosignendpoints, including request stamping + App Proof generation. - Implements unsigned-EVM transaction parsing and a TOML-driven rules engine for
PROGRAMMATIC | ADMIN | REJECTclassification. - Adds reproducible-build packaging (Dockerfile) and extensive documentation (README) describing deployment + verification flow.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tvc-cosign/src/main.rs | Axum server entrypoint, CLI parsing, endpoints, and response shapes. |
| tvc-cosign/src/activity.rs | Deterministic construction of Turnkey SIGN_TRANSACTION_V2 request bodies. |
| tvc-cosign/src/tx.rs | Minimal parser for unsigned EVM transaction signing payloads (typed + legacy). |
| tvc-cosign/src/rules.rs | TOML-backed ruleset parsing + transaction classification logic. |
| tvc-cosign/src/stamp.rs | Turnkey request stamp construction (P-256 ECDSA, base64url envelope). |
| tvc-cosign/src/proof.rs | App Proof envelope + payload construction and signing with ephemeral key. |
| tvc-cosign/src/keys.rs | Quorum-key HKDF derivation of API keys + QOS ephemeral key reconstruction. |
| tvc-cosign/src/config.rs | Startup configuration loading (org id + ruleset selection). |
| tvc-cosign/README.md | End-to-end documentation: setup, reproducible build, deploy, verify proofs. |
| tvc-cosign/rules.example.toml | Example ruleset schema and documented defaults. |
| tvc-cosign/Dockerfile | Reproducible build + minimal runtime image, prints expectedPivotDigest. |
| tvc-cosign/Cargo.toml | Crate manifest and dependencies. |
| tvc-cosign/Cargo.lock | Locked dependency graph for reproducible builds. |
| tvc-cosign/.gitignore | Ignores build artifacts and local per-deployment rules files. |
| tvc-cosign/.dockerignore | Minimizes docker build context for deterministic image digests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// Ruleset compiled into the binary at build time. TVC runs only the pivot binary, | ||
| /// so the ruleset ships inside it rather than as a file in the image. | ||
| const EMBEDDED_RULES_TOML: &str = include_str!("../rules.toml"); | ||
|
|
| pub fn classify(signer: Address, tx: &ParsedTx, rules: &Ruleset) -> Classification { | ||
| if !rules.allowed_signers.contains(&signer) { | ||
| return Classification::Reject; | ||
| } | ||
|
|
||
| let Some(selector) = tx.selector() else { | ||
| // No calldata (e.g. a native transfer) — out of scope for this POC. | ||
| return Classification::Reject; | ||
| }; |
| fn decode_transfer_args(input: &[u8]) -> Option<(Address, U256)> { | ||
| // 4 (selector) + 32 (address) + 32 (amount) | ||
| let args = input.get(4..68)?; | ||
| let (addr_word, amount_word) = args.split_at(32); | ||
| if addr_word[..12].iter().any(|&b| b != 0) { | ||
| return None; // address must be zero-padded in its 32-byte word | ||
| } | ||
| let recipient = Address::from_slice(&addr_word[12..]); | ||
| let amount = U256::from_be_slice(amount_word); | ||
| Some((recipient, amount)) | ||
| } |
There was a problem hiding this comment.
Agree, the copilot suggestion is a little more explicit... but 🤷 , I don't think it matters
| while let Some(flag) = iter.next() { | ||
| match flag.as_str() { | ||
| "--organization-id" => args.organization_id = iter.next(), | ||
| "--rules-path" => args.rules_path = iter.next(), | ||
| "--port" => { | ||
| if let Some(v) = iter.next() { | ||
| match v.parse() { | ||
| Ok(p) => args.port = p, | ||
| Err(_) => { | ||
| eprintln!("args: WARNING invalid --port {v:?}, using {DEFAULT_PORT}") | ||
| } | ||
| } | ||
| } | ||
| } | ||
| other => eprintln!("args: WARNING ignoring unknown argument {other:?}"), | ||
| } | ||
| } |
There was a problem hiding this comment.
I don't agree with copilot here, but you should make sure that the value doesn't start with -- as a minimal check
| data.extend_from_slice(&[0u8; 12]); | ||
| data.extend_from_slice(recipient.as_slice()); // right-padded to 32 bytes | ||
| data.extend_from_slice(&U256::from(amount).to_be_bytes::<32>()); |
| #[serde(rename_all = "camelCase")] | ||
| struct SignTransactionActivity { | ||
| #[serde(rename = "type")] | ||
| activity_type: &'static str, |
There was a problem hiding this comment.
On the off chance you didn't know about this, you can do this:
| activity_type: &'static str, | |
| r#type: &'static str, |
(though I prefer what you did)
| let organization_id = cli_organization_id | ||
| .map(str::to_string) |
There was a problem hiding this comment.
FYI, best practice is to pass in owned types if you need owned types
| } | ||
| } | ||
| } | ||
| other => eprintln!("args: WARNING ignoring unknown argument {other:?}"), |
There was a problem hiding this comment.
Maybe do a small help text too?
| while let Some(flag) = iter.next() { | ||
| match flag.as_str() { | ||
| "--organization-id" => args.organization_id = iter.next(), | ||
| "--rules-path" => args.rules_path = iter.next(), | ||
| "--port" => { | ||
| if let Some(v) = iter.next() { | ||
| match v.parse() { | ||
| Ok(p) => args.port = p, | ||
| Err(_) => { | ||
| eprintln!("args: WARNING invalid --port {v:?}, using {DEFAULT_PORT}") | ||
| } | ||
| } | ||
| } | ||
| } | ||
| other => eprintln!("args: WARNING ignoring unknown argument {other:?}"), | ||
| } | ||
| } |
There was a problem hiding this comment.
I don't agree with copilot here, but you should make sure that the value doesn't start with -- as a minimal check
| println!( | ||
| "keys: programmatic pubkey = {}", | ||
| keys.programmatic.public_key_hex() | ||
| ); | ||
| println!( | ||
| "keys: admin pubkey = {}", | ||
| keys.admin.public_key_hex() | ||
| ); |
There was a problem hiding this comment.
nit:
| println!( | |
| "keys: programmatic pubkey = {}", | |
| keys.programmatic.public_key_hex() | |
| ); | |
| println!( | |
| "keys: admin pubkey = {}", | |
| keys.admin.public_key_hex() | |
| ); | |
| println!( | |
| r#"keys: | |
| programmatic pubkey = {} | |
| admin pubkey = {}"#, | |
| keys.programmatic.public_key_hex(), | |
| keys.admin.public_key_hex(), | |
| ); |
richardpringle
left a comment
There was a problem hiding this comment.
Top notch 🥇
The only comment that I'm leaving in this review that I do feel strongly about is the mermaid diagram comment. The rest are even less minor than nits, take them or leave them... probably leave most of them 😅
| impl fmt::Display for ParseError { | ||
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| match self { | ||
| ParseError::Empty => write!(f, "empty transaction"), | ||
| ParseError::UnsupportedType(b) => { | ||
| write!(f, "unsupported transaction type byte {b:#04x}") | ||
| } | ||
| ParseError::Rlp(e) => write!(f, "malformed RLP: {e}"), | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Can use thiserror for this instead, I don't think it'll hurt compiles times that much and makes the code surface area a little smaller for someone reading
| Turnkey policies evaluate the stamping API user: | ||
| • TVC programmatic → policy ALLOW → COMPLETED | ||
| • TVC admin → require human consensus → CONSENSUS_NEEDED | ||
| ``` |
There was a problem hiding this comment.
Would look nicer as a mermaid diagram
There was a problem hiding this comment.
Just have the one comment about the mermaid diagram, otherwise, 🥇
| fn decode_transfer_args(input: &[u8]) -> Option<(Address, U256)> { | ||
| // 4 (selector) + 32 (address) + 32 (amount) | ||
| let args = input.get(4..68)?; | ||
| let (addr_word, amount_word) = args.split_at(32); | ||
| if addr_word[..12].iter().any(|&b| b != 0) { | ||
| return None; // address must be zero-padded in its 32-byte word | ||
| } | ||
| let recipient = Address::from_slice(&addr_word[12..]); | ||
| let amount = U256::from_be_slice(amount_word); | ||
| Some((recipient, amount)) | ||
| } |
There was a problem hiding this comment.
Agree, the copilot suggestion is a little more explicit... but 🤷 , I don't think it matters
| let raw: RawRuleset = toml::from_str(EMBEDDED_RULES_TOML) | ||
| .map_err(|e| format!("parse embedded rules.toml: {e}"))?; | ||
| raw.into_ruleset() |
There was a problem hiding this comment.
Could use a LazyLock here so that you're not deserializing every time.
| /// sub-derived with the exact [`QOS_SIGN_PATH`]/[`QOS_ENCRYPT_PATH`] construction | ||
| /// QOS uses. Each of a TVC's replicas boots its own ephemeral key. | ||
| pub struct EphemeralKey { | ||
| signing_key: SigningKey, |
There was a problem hiding this comment.
Should we be using Zeroize here just to promote best practices with keys?
Add tvc-cosign example
tvc-cosign is a Rust service, its core endpoint being POST
/cosign: it parses an unsigned EVM transaction, classifies it against a baked-in ruleset (PROGRAMMATIC|ADMIN|REJECT), and returns a stampedSIGN_TRANSACTION_V2activity signed by one of two quorum-key-derived API keys (programmatic/admin). The caller submits the activity to Turnkey, where policies either auto-complete it (programmatic) or hold it for human approval (admin). It also exposes GET/health(liveness) and GET/pubkeys(the two stamping public keys to register as API users).The README covers the reproducible build, CLI deploy, Turnkey org setup (users + policies), and the integration flow.
Closes TVC-176.
Testing
cargo test, 35 tests): transaction parsing (EIP-1559 and legacy), classification across the programmatic / admin / reject cases, stamp construction, activity-body shape, and App Proof generation. Crypto is pinned with known-answer tests, including the enclave key derivation byte-checked against the QOS reference.GET /pubkeysmatched the registered API users. An allowlisted ERC-20transferclassifiedPROGRAMMATIC, was stamped, and submitting the activity to Turnkey returnedACTIVITY_STATUS_COMPLETED(policy auto-approved). An admin-selectorapproveclassifiedADMIN, returnedACTIVITY_STATUS_CONSENSUS_NEEDED, and completed only after a non-root human approver signed off via passkey, confirming the human-consensus policy gated it./cosignresponse carried an App Proof signed by the enclave's ephemeral key.Note
Uses the current static/well-known TVC quorum key, so the derived stamping keys are not yet secret. Documented in the README's limitations.