From 42eb624b3b3e90350581385a84a0b878a2981a3a Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Mon, 6 Jul 2026 00:00:16 -0400 Subject: [PATCH 01/27] chore: scaffold cargo project with /health endpoint --- .gitignore | 9 + Cargo.lock | 581 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 10 + src/main.rs | 34 +++ 4 files changed, 634 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21fd020 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Rust build artifacts +/target + +# Local secrets / ruleset overrides (never commit real ones) +*.local.toml +.env + +# Notes +/notes \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9c7dbbe --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,581 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tvc-cosign" +version = "0.1.0" +dependencies = [ + "axum", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1857e37 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "tvc-cosign" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8.9" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +tokio = { version = "1.52.3", features = ["full"] } diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..170df89 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,34 @@ +//! TVC `/cosign` POC — an enclave pivot binary that stamps Turnkey activity +//! requests with quorum-key-derived P-256 keys. +//! +//! Exposes `GET /health` today; `GET /pubkeys` and `POST /cosign` are wired in +//! as the rules engine and stamping land. + +use axum::{routing::get, Json, Router}; +use serde_json::{json, Value}; + +/// Address the enclave listens on. TVC pivots serve plain HTTP inside the +/// enclave; the host proxies to them. +const LISTEN_ADDR: &str = "0.0.0.0:3000"; + +#[tokio::main] +async fn main() { + let app = router(); + + let listener = tokio::net::TcpListener::bind(LISTEN_ADDR) + .await + .expect("bind listener"); + println!("tvc-cosign listening on {LISTEN_ADDR}"); + + axum::serve(listener, app).await.expect("serve"); +} + +/// Builds the app router. Kept separate from `main` so tests can exercise it. +fn router() -> Router { + Router::new().route("/health", get(health)) +} + +/// TVC liveness probe. Must return `200`. +async fn health() -> Json { + Json(json!({ "status": "ok" })) +} From 1648fb31849d2072a85ee9f26b8959765e2ae77b Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Mon, 6 Jul 2026 00:18:08 -0400 Subject: [PATCH 02/27] feat(keys): derive P256 API keys from quorum seed --- Cargo.lock | 396 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 + src/keys.rs | 165 ++++++++++++++++++++++ src/main.rs | 11 ++ 4 files changed, 576 insertions(+) create mode 100644 src/keys.rs diff --git a/Cargo.lock b/Cargo.lock index 9c7dbbe..d86675f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -60,12 +66,33 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bytes" version = "1.12.0" @@ -78,6 +105,129 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom", + "hybrid-array", + "num-traits", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom", + "hybrid-array", + "rand_core", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common", + "digest", + "ff", + "group", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "errno" version = "0.3.14" @@ -88,6 +238,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -130,6 +290,53 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.2" @@ -175,6 +382,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "1.10.1" @@ -266,12 +484,34 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -295,6 +535,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -307,6 +556,43 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common", + "ff", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -325,6 +611,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -334,6 +632,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac", +] + [[package]] name = "ryu" version = "1.0.23" @@ -346,6 +654,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "serde" version = "1.0.228" @@ -412,6 +734,27 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -422,6 +765,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "slab" version = "0.4.12" @@ -444,6 +797,22 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.118" @@ -542,11 +911,21 @@ name = "tvc-cosign" version = "0.1.0" dependencies = [ "axum", + "hex", + "hkdf", + "p256", "serde", "serde_json", + "sha2", "tokio", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -574,6 +953,23 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wnaf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff", + "group", + "hybrid-array", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 1857e37..f6771b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,10 @@ edition = "2024" [dependencies] axum = "0.8.9" +hex = "0.4.3" +hkdf = "0.13.0" +p256 = { version = "0.14.0", features = ["ecdsa"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" +sha2 = "0.11.0" tokio = { version = "1.52.3", features = ["full"] } diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..2e2ea9a --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,165 @@ +//! Key derivation — the trust crux. +//! +//! Inside the enclave, QOS exposes the quorum master seed (stable across +//! deploys) at [`QUORUM_KEY_PATH`]. We HKDF-derive two independent P-256 API +//! keys from it: `programmatic` and `admin`. Because the seed is stable, the +//! derived public keys are stable too, so they can be registered as Turnkey +//! API users exactly once (via `GET /pubkeys`). +//! +//! Format requirements Turnkey enforces (pinned by the tests below): +//! - public key: compressed SEC1 P-256, 33 bytes, `02`/`03` prefix, hex. +//! - (signing format lives in `stamp.rs` — ECDSA-P256 / SHA-256 / DER.) + +use std::path::Path; + +use hkdf::Hkdf; +use p256::ecdsa::SigningKey; +use p256::SecretKey; +use sha2::Sha512; + +/// Path inside the enclave where QOS writes the hex-encoded 32-byte quorum seed. +pub const QUORUM_KEY_PATH: &str = "/qos.quorum.key"; + +/// HKDF salts. These domain-separate the two keys so they're independent, and +/// are versioned so the derivation can be rotated without colliding with old keys. +const PROG_SALT: &[u8] = b"tvc-cosign-programmatic-v1"; +const ADMIN_SALT: &[u8] = b"tvc-cosign-admin-v1"; + +/// Insecure fixed seed used only when [`QUORUM_KEY_PATH`] is absent (i.e. running +/// outside an enclave, in dev/CI). Keys derived from it are NOT secret. +const DEV_SEED: [u8; 32] = [0x11; 32]; + +/// A single derived Turnkey API key. +pub struct ApiKey { + signing_key: SigningKey, +} + +impl ApiKey { + /// The P-256 signing key, used to produce Turnkey stamps. + #[allow(dead_code)] + pub fn signing_key(&self) -> &SigningKey { + &self.signing_key + } + + /// Compressed SEC1 public key as lowercase hex — the exact form Turnkey + /// registers as an `API_KEY_CURVE_P256` user public key. + pub fn public_key_hex(&self) -> String { + let point = self.signing_key.verifying_key().to_sec1_point(true); + hex::encode(point.as_bytes()) + } +} + +/// The pair of keys derived for this enclave. +pub struct KeySet { + pub programmatic: ApiKey, + pub admin: ApiKey, +} + +impl KeySet { + /// Derive both keys from a 32-byte quorum seed. + pub fn derive(quorum_seed: &[u8; 32]) -> Self { + KeySet { + programmatic: derive_key(quorum_seed, PROG_SALT), + admin: derive_key(quorum_seed, ADMIN_SALT), + } + } + + /// Load the quorum seed from the enclave and derive both keys. Outside an + /// enclave the seed file is absent, so we fall back to [`DEV_SEED`] and warn. + pub fn load() -> Self { + match read_quorum_seed(Path::new(QUORUM_KEY_PATH)) { + Some(seed) => { + println!("keys: derived from quorum seed at {QUORUM_KEY_PATH}"); + Self::derive(&seed) + } + None => { + eprintln!( + "keys: WARNING {QUORUM_KEY_PATH} not found — using INSECURE dev seed \ + (not running in an enclave)" + ); + Self::derive(&DEV_SEED) + } + } + } +} + +/// HKDF-SHA512-expand the quorum seed into a 32-byte P-256 scalar, keyed by `salt`. +fn derive_key(seed: &[u8; 32], salt: &[u8]) -> ApiKey { + let hk = Hkdf::::new(Some(salt), seed); + let mut okm = [0u8; 32]; + hk.expand(&[], &mut okm) + .expect("32 bytes is well under HKDF-SHA512's output limit"); + let secret = + SecretKey::from_slice(&okm).expect("HKDF output is a valid P-256 scalar (overwhelmingly)"); + ApiKey { + signing_key: secret.into(), + } +} + +/// Read and hex-decode the quorum seed. Returns `None` if the file is missing or +/// malformed, so callers can fall back gracefully outside an enclave. +fn read_quorum_seed(path: &Path) -> Option<[u8; 32]> { + let contents = std::fs::read_to_string(path).ok()?; + let bytes = hex::decode(contents.trim()).ok()?; + bytes.try_into().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SEED_A: [u8; 32] = [0xAB; 32]; + const SEED_B: [u8; 32] = [0xCD; 32]; + + #[test] + fn derivation_is_deterministic() { + let k1 = KeySet::derive(&SEED_A); + let k2 = KeySet::derive(&SEED_A); + assert_eq!(k1.programmatic.public_key_hex(), k2.programmatic.public_key_hex()); + assert_eq!(k1.admin.public_key_hex(), k2.admin.public_key_hex()); + } + + #[test] + fn prog_and_admin_are_independent() { + let k = KeySet::derive(&SEED_A); + assert_ne!(k.programmatic.public_key_hex(), k.admin.public_key_hex()); + } + + #[test] + fn different_seeds_give_different_keys() { + let a = KeySet::derive(&SEED_A); + let b = KeySet::derive(&SEED_B); + assert_ne!(a.programmatic.public_key_hex(), b.programmatic.public_key_hex()); + } + + #[test] + fn public_key_is_compressed_sec1() { + let k = KeySet::derive(&SEED_A); + for hexkey in [k.programmatic.public_key_hex(), k.admin.public_key_hex()] { + let bytes = hex::decode(&hexkey).unwrap(); + assert_eq!(bytes.len(), 33, "compressed SEC1 is 33 bytes"); + assert!( + bytes[0] == 0x02 || bytes[0] == 0x03, + "compressed SEC1 prefix must be 02 or 03, got {:#04x}", + bytes[0] + ); + } + } + + /// Known-answer regression guard: if the derivation ever changes, the + /// registered Turnkey pubkeys would silently break. Pin the exact output. + #[test] + fn known_answer_pins_derivation() { + let k = KeySet::derive(&SEED_A); + assert_eq!( + k.programmatic.public_key_hex(), + "0240e6b810d86b5b378d4379680c28b7a6b409ce02bc8b9bd07779f03e4eed163b", + "programmatic pubkey drifted" + ); + assert_eq!( + k.admin.public_key_hex(), + "025090879edf953832e696931b201502980e6bfa73ee70d3738eab1f64f549c85e", + "admin pubkey drifted" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 170df89..7fd1dc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,15 +4,26 @@ //! Exposes `GET /health` today; `GET /pubkeys` and `POST /cosign` are wired in //! as the rules engine and stamping land. +mod keys; + use axum::{routing::get, Json, Router}; use serde_json::{json, Value}; +use keys::KeySet; + /// Address the enclave listens on. TVC pivots serve plain HTTP inside the /// enclave; the host proxies to them. const LISTEN_ADDR: &str = "0.0.0.0:3000"; #[tokio::main] async fn main() { + // Derive the enclave's two API keys once at boot. Enclave stdout is not + // observable in production, so these prints are only a local-dev aid; the + // pubkeys are exposed over HTTP for registration. + let keyset = KeySet::load(); + println!("keys: programmatic pubkey = {}", keyset.programmatic.public_key_hex()); + println!("keys: admin pubkey = {}", keyset.admin.public_key_hex()); + let app = router(); let listener = tokio::net::TcpListener::bind(LISTEN_ADDR) From c515c13b18013fd98f920b70a72d9d503ff68df9 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Mon, 6 Jul 2026 00:49:05 -0400 Subject: [PATCH 03/27] feat(stamp): build Turnkey X-Stamp from signed request body --- Cargo.lock | 7 +++ Cargo.toml | 1 + src/main.rs | 1 + src/stamp.rs | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 src/stamp.rs diff --git a/Cargo.lock b/Cargo.lock index d86675f..8959926 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,6 +72,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -911,6 +917,7 @@ name = "tvc-cosign" version = "0.1.0" dependencies = [ "axum", + "base64", "hex", "hkdf", "p256", diff --git a/Cargo.toml b/Cargo.toml index f6771b5..48d8e4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] axum = "0.8.9" +base64 = "0.22.1" hex = "0.4.3" hkdf = "0.13.0" p256 = { version = "0.14.0", features = ["ecdsa"] } diff --git a/src/main.rs b/src/main.rs index 7fd1dc4..3d63206 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ //! as the rules engine and stamping land. mod keys; +mod stamp; use axum::{routing::get, Json, Router}; use serde_json::{json, Value}; diff --git a/src/stamp.rs b/src/stamp.rs new file mode 100644 index 0000000..7587981 --- /dev/null +++ b/src/stamp.rs @@ -0,0 +1,129 @@ +//! Turnkey request stamping. +//! +//! A "stamp" is how a request authenticates itself to Turnkey as a given API +//! user. We sign the exact request body with a derived P-256 key and wrap the +//! signature in the envelope Turnkey expects: +//! +//! signature = ECDSA-P256 over SHA-256(body), DER-encoded, hex. +//! envelope = base64url(JSON { publicKey, scheme, signature }). +//! +//! The envelope string is sent as the `X-Stamp` HTTP header alongside the +//! unmodified body. Turnkey recomputes SHA-256(body) and verifies the signature +//! against the registered public key. + +use base64::Engine; +use p256::ecdsa::signature::Signer; +use p256::ecdsa::Signature; +use serde::Serialize; + +use crate::keys::ApiKey; + +/// Turnkey's signature-scheme identifier for P-256 API-key stamps. +const STAMP_SCHEME: &str = "SIGNATURE_SCHEME_TK_API_P256"; + +/// The stamp envelope. Field names are camelCase to match Turnkey's JSON. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct StampEnvelope { + public_key: String, + scheme: &'static str, + signature: String, +} + +/// A signed request, ready to POST to Turnkey. +#[allow(dead_code)] // consumed once POST /cosign is wired up +pub struct Stamped { + /// The exact body bytes that were signed — send these unmodified. + pub body: String, + /// Value for the `X-Stamp` header. + pub x_stamp: String, +} + +/// Sign `body` with `key` and produce the `X-Stamp` header value. +#[allow(dead_code)] // consumed once POST /cosign is wired up +pub fn stamp(key: &ApiKey, body: &str) -> Stamped { + // RustCrypto's `Signer` for P-256 hashes the message with SHA-256 and emits + // a low-S–normalized signature — the same construction Turnkey verifies. + let sig: Signature = key.signing_key().sign(body.as_bytes()); + let signature_hex = hex::encode(sig.to_der().as_bytes()); + + let envelope = StampEnvelope { + public_key: key.public_key_hex(), + scheme: STAMP_SCHEME, + signature: signature_hex, + }; + let json = serde_json::to_string(&envelope).expect("stamp envelope serializes"); + let x_stamp = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json); + + Stamped { + body: body.to_string(), + x_stamp, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::keys::KeySet; + use p256::ecdsa::signature::Verifier; + use p256::ecdsa::VerifyingKey; + use serde_json::Value; + + const SEED: [u8; 32] = [0x42; 32]; + const BODY: &str = r#"{"type":"ACTIVITY_TYPE_SIGN_TRANSACTION_V2","hello":"world"}"#; + + /// Decode the base64url X-Stamp back into its JSON envelope. + fn decode_stamp(x_stamp: &str) -> Value { + let json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(x_stamp) + .expect("x_stamp is valid base64url"); + serde_json::from_slice(&json).expect("x_stamp decodes to JSON") + } + + #[test] + fn envelope_has_expected_shape() { + let keys = KeySet::derive(&SEED); + let stamped = stamp(&keys.programmatic, BODY); + let env = decode_stamp(&stamped.x_stamp); + + assert_eq!(env["scheme"], STAMP_SCHEME); + assert_eq!(env["publicKey"], keys.programmatic.public_key_hex()); + assert!(env["signature"].as_str().unwrap().len() > 0); + assert_eq!(stamped.body, BODY, "body is returned unmodified"); + } + + #[test] + fn signature_verifies_against_the_public_key() { + let keys = KeySet::derive(&SEED); + let stamped = stamp(&keys.admin, BODY); + let env = decode_stamp(&stamped.x_stamp); + + // Reconstruct the verifying key from the compressed SEC1 hex in the stamp. + let pk_bytes = hex::decode(env["publicKey"].as_str().unwrap()).unwrap(); + let vk = VerifyingKey::from_sec1_bytes(&pk_bytes).expect("valid SEC1 pubkey"); + + // Parse the DER signature and verify it over the body (SHA-256 internally). + let sig_bytes = hex::decode(env["signature"].as_str().unwrap()).unwrap(); + let sig = Signature::from_der(&sig_bytes).expect("valid DER signature"); + + vk.verify(BODY.as_bytes(), &sig) + .expect("stamp signature verifies against its own public key"); + } + + #[test] + fn wrong_body_fails_verification() { + let keys = KeySet::derive(&SEED); + let stamped = stamp(&keys.programmatic, BODY); + let env = decode_stamp(&stamped.x_stamp); + + let pk_bytes = hex::decode(env["publicKey"].as_str().unwrap()).unwrap(); + let vk = VerifyingKey::from_sec1_bytes(&pk_bytes).unwrap(); + let sig_bytes = hex::decode(env["signature"].as_str().unwrap()).unwrap(); + let sig = Signature::from_der(&sig_bytes).unwrap(); + + assert!( + vk.verify(b"tampered body", &sig).is_err(), + "a signature must not verify over a different body" + ); + } +} From 75d5d835725f4efcee495ec23c08ef46773dfa16 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Mon, 6 Jul 2026 01:07:44 -0400 Subject: [PATCH 04/27] feat(activity): build SIGN_TRANSACTION_V2 request body --- src/activity.rs | 157 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 2 files changed, 158 insertions(+) create mode 100644 src/activity.rs diff --git a/src/activity.rs b/src/activity.rs new file mode 100644 index 0000000..15f998a --- /dev/null +++ b/src/activity.rs @@ -0,0 +1,157 @@ +//! Build a Turnkey `SIGN_TRANSACTION_V2` activity request body. +//! +//! This JSON is what we stamp and the customer submits to Turnkey. The shape and +//! field names are verified against Turnkey's API reference +//! (`POST /public/v1/submit/sign_transaction`): +//! +//! { type, timestampMs, organizationId, +//! parameters: { signWith, unsignedTransaction, type } } +//! +//! `unsignedTransaction` is the raw Ethereum transaction as hex WITHOUT a `0x` +//! prefix (matching Turnkey's SDKs). We accept either form on input and +//! normalize to no-prefix lowercase so the stamped bytes are deterministic. + +use serde::Serialize; + +const ACTIVITY_TYPE_SIGN_TRANSACTION: &str = "ACTIVITY_TYPE_SIGN_TRANSACTION_V2"; +const TRANSACTION_TYPE_ETHEREUM: &str = "TRANSACTION_TYPE_ETHEREUM"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Parameters { + sign_with: String, + unsigned_transaction: String, + #[serde(rename = "type")] + tx_type: &'static str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SignTransactionActivity { + #[serde(rename = "type")] + activity_type: &'static str, + timestamp_ms: String, + organization_id: String, + parameters: Parameters, +} + +/// Inputs needed to build a sign-transaction request. +pub struct SignTransaction<'a> { + /// The customer's (sub-)organization id. + pub organization_id: &'a str, + /// Wallet account address / private key address / id to sign with. + pub sign_with: &'a str, + /// Raw unsigned Ethereum transaction, hex (with or without `0x`). + pub unsigned_transaction: &'a str, + /// Request timestamp in milliseconds (Turnkey uses it for liveness). + pub timestamp_ms: u64, +} + +/// Serialize a `SIGN_TRANSACTION_V2` activity to its exact JSON body. +/// +/// The returned string is both stamped and sent — Turnkey re-hashes these exact +/// bytes, so it must be produced once and used for both. +#[allow(dead_code)] // consumed once POST /cosign is wired up +pub fn build_sign_transaction(req: &SignTransaction) -> String { + let activity = SignTransactionActivity { + activity_type: ACTIVITY_TYPE_SIGN_TRANSACTION, + timestamp_ms: req.timestamp_ms.to_string(), + organization_id: req.organization_id.to_string(), + parameters: Parameters { + sign_with: req.sign_with.to_string(), + unsigned_transaction: strip_0x(req.unsigned_transaction).to_ascii_lowercase(), + tx_type: TRANSACTION_TYPE_ETHEREUM, + }, + }; + serde_json::to_string(&activity).expect("activity serializes") +} + +/// Strip a leading `0x`/`0X` if present. +fn strip_0x(s: &str) -> &str { + s.strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .unwrap_or(s) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn sample() -> SignTransaction<'static> { + SignTransaction { + organization_id: "org-123", + sign_with: "0xabc0000000000000000000000000000000000001", + unsigned_transaction: "0xDEADBEEF", + timestamp_ms: 1_700_000_000_000, + } + } + + #[test] + fn body_matches_turnkey_shape() { + let body = build_sign_transaction(&sample()); + let v: Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(v["type"], "ACTIVITY_TYPE_SIGN_TRANSACTION_V2"); + assert_eq!(v["timestampMs"], "1700000000000"); // string, not number + assert_eq!(v["organizationId"], "org-123"); + assert_eq!(v["parameters"]["signWith"], sample().sign_with); + assert_eq!(v["parameters"]["type"], "TRANSACTION_TYPE_ETHEREUM"); + } + + #[test] + fn unsigned_transaction_is_normalized() { + let body = build_sign_transaction(&sample()); + let v: Value = serde_json::from_str(&body).unwrap(); + // 0x stripped, lowercased. + assert_eq!(v["parameters"]["unsignedTransaction"], "deadbeef"); + } + + #[test] + fn accepts_input_without_0x() { + let mut req = sample(); + req.unsigned_transaction = "deadbeef"; + let body = build_sign_transaction(&req); + let v: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["parameters"]["unsignedTransaction"], "deadbeef"); + } + + #[test] + fn build_is_deterministic() { + assert_eq!( + build_sign_transaction(&sample()), + build_sign_transaction(&sample()) + ); + } + + /// The whole point: an activity body can be stamped and the stamp verifies. + #[test] + fn activity_body_can_be_stamped_and_verified() { + use crate::keys::KeySet; + use crate::stamp::stamp; + use base64::Engine; + use p256::ecdsa::signature::Verifier; + use p256::ecdsa::{Signature, VerifyingKey}; + + let body = build_sign_transaction(&sample()); + let keys = KeySet::derive(&[7u8; 32]); + let stamped = stamp(&keys.programmatic, &body); + + let env: Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&stamped.x_stamp) + .unwrap(), + ) + .unwrap(); + + let vk = VerifyingKey::from_sec1_bytes( + &hex::decode(env["publicKey"].as_str().unwrap()).unwrap(), + ) + .unwrap(); + let sig = + Signature::from_der(&hex::decode(env["signature"].as_str().unwrap()).unwrap()).unwrap(); + + vk.verify(stamped.body.as_bytes(), &sig) + .expect("stamp over the activity body verifies"); + } +} diff --git a/src/main.rs b/src/main.rs index 3d63206..49fa674 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ //! Exposes `GET /health` today; `GET /pubkeys` and `POST /cosign` are wired in //! as the rules engine and stamping land. +mod activity; mod keys; mod stamp; From aaecf4e7784364c5f089d6719e4ee52e71b9db32 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Mon, 6 Jul 2026 22:19:38 -0400 Subject: [PATCH 05/27] feat(server): wire GET /pubkeys and POST /cosign --- src/activity.rs | 1 - src/config.rs | 31 ++++++++++ src/keys.rs | 1 - src/main.rs | 155 +++++++++++++++++++++++++++++++++++++++++++----- src/stamp.rs | 2 - 5 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 src/config.rs diff --git a/src/activity.rs b/src/activity.rs index 15f998a..1665f19 100644 --- a/src/activity.rs +++ b/src/activity.rs @@ -51,7 +51,6 @@ pub struct SignTransaction<'a> { /// /// The returned string is both stamped and sent — Turnkey re-hashes these exact /// bytes, so it must be produced once and used for both. -#[allow(dead_code)] // consumed once POST /cosign is wired up pub fn build_sign_transaction(req: &SignTransaction) -> String { let activity = SignTransactionActivity { activity_type: ACTIVITY_TYPE_SIGN_TRANSACTION, diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1a851c7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,31 @@ +//! Runtime configuration (non-secret), supplied at deploy time. +//! +//! The only secret this app uses is the quorum seed, which is read from +//! `/qos.quorum.key` — never from here. Everything in `Config` is safe to pass +//! as a plain deploy-time argument / environment variable and is not baked into +//! the image. + +/// Environment variable holding the customer's (sub-)organization id. +const ORGANIZATION_ID_ENV: &str = "TVC_ORGANIZATION_ID"; + +/// Non-secret configuration loaded once at startup. +pub struct Config { + /// The `organizationId` placed in every `SIGN_TRANSACTION_V2` body. + pub organization_id: String, +} + +impl Config { + /// Load config from the environment. A missing `organizationId` is tolerated + /// (empty placeholder + warning) so the app still boots for local inspection; + /// it must be set to submit real requests to Turnkey. + pub fn from_env() -> Self { + let organization_id = std::env::var(ORGANIZATION_ID_ENV).unwrap_or_else(|_| { + eprintln!( + "config: WARNING {ORGANIZATION_ID_ENV} not set — using empty placeholder; \ + set it to submit real requests" + ); + String::new() + }); + Config { organization_id } + } +} diff --git a/src/keys.rs b/src/keys.rs index 2e2ea9a..d042bfe 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -36,7 +36,6 @@ pub struct ApiKey { impl ApiKey { /// The P-256 signing key, used to produce Turnkey stamps. - #[allow(dead_code)] pub fn signing_key(&self) -> &SigningKey { &self.signing_key } diff --git a/src/main.rs b/src/main.rs index 49fa674..9be3d23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,47 +1,172 @@ //! TVC `/cosign` POC — an enclave pivot binary that stamps Turnkey activity //! requests with quorum-key-derived P-256 keys. //! -//! Exposes `GET /health` today; `GET /pubkeys` and `POST /cosign` are wired in -//! as the rules engine and stamping land. +//! Endpoints: +//! GET /health — liveness probe. +//! GET /pubkeys — the derived programmatic + admin public keys (register these +//! as Turnkey API users). +//! POST /cosign — classify an unsigned tx and return a stamped +//! SIGN_TRANSACTION_V2 request for the customer to submit. mod activity; +mod config; mod keys; mod stamp; -use axum::{routing::get, Json, Router}; -use serde_json::{json, Value}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; + +use activity::{build_sign_transaction, SignTransaction}; +use config::Config; use keys::KeySet; /// Address the enclave listens on. TVC pivots serve plain HTTP inside the /// enclave; the host proxies to them. const LISTEN_ADDR: &str = "0.0.0.0:3000"; +/// Shared, read-only application state. +struct AppState { + keys: KeySet, + config: Config, +} + #[tokio::main] async fn main() { // Derive the enclave's two API keys once at boot. Enclave stdout is not // observable in production, so these prints are only a local-dev aid; the - // pubkeys are exposed over HTTP for registration. - let keyset = KeySet::load(); - println!("keys: programmatic pubkey = {}", keyset.programmatic.public_key_hex()); - println!("keys: admin pubkey = {}", keyset.admin.public_key_hex()); + // pubkeys are exposed over GET /pubkeys for registration. + let keys = KeySet::load(); + println!("keys: programmatic pubkey = {}", keys.programmatic.public_key_hex()); + println!("keys: admin pubkey = {}", keys.admin.public_key_hex()); - let app = router(); + let config = Config::from_env(); + let state = Arc::new(AppState { keys, config }); let listener = tokio::net::TcpListener::bind(LISTEN_ADDR) .await .expect("bind listener"); println!("tvc-cosign listening on {LISTEN_ADDR}"); - axum::serve(listener, app).await.expect("serve"); + axum::serve(listener, router(state)).await.expect("serve"); } -/// Builds the app router. Kept separate from `main` so tests can exercise it. -fn router() -> Router { - Router::new().route("/health", get(health)) +/// Builds the app router. Takes state so tests can construct it independently. +fn router(state: Arc) -> Router { + Router::new() + .route("/health", get(health)) + .route("/pubkeys", get(pubkeys)) + .route("/cosign", post(cosign)) + .with_state(state) } /// TVC liveness probe. Must return `200`. -async fn health() -> Json { - Json(json!({ "status": "ok" })) +async fn health() -> Json { + Json(serde_json::json!({ "status": "ok" })) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PubkeysResponse { + programmatic: String, + admin: String, +} + +/// Serve the derived public keys so an operator can register them as API users. +async fn pubkeys(State(state): State>) -> Json { + Json(PubkeysResponse { + programmatic: state.keys.programmatic.public_key_hex(), + admin: state.keys.admin.public_key_hex(), + }) +} + +/// How a transaction is routed. Serialized as `PROGRAMMATIC` / `ADMIN` / `REJECT`. +/// `Admin`/`Reject` are handled here but only constructed once the rules engine +/// lands (the placeholder classifier always returns `Programmatic`). +#[derive(Serialize, Clone, Copy)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[allow(dead_code)] +enum Classification { + Programmatic, + Admin, + Reject, +} + +/// Placeholder classifier — always routes to the programmatic key. Replaced by +/// the config-driven rules engine. +fn classify(_signer_address: &str, _unsigned_transaction: &str) -> Classification { + Classification::Programmatic +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CosignRequest { + unsigned_transaction: String, + signer_address: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CosignResponse { + /// Exact JSON bytes to POST to Turnkey — send verbatim; the stamp covers + /// these exact bytes, so re-serializing would break it. + activity_body: String, + x_stamp: String, + classification: Classification, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CosignError { + error: String, + classification: Classification, +} + +/// Classify an unsigned tx, then build + stamp a `SIGN_TRANSACTION_V2` request. +async fn cosign( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let classification = classify(&req.signer_address, &req.unsigned_transaction); + + let key = match classification { + Classification::Programmatic => &state.keys.programmatic, + Classification::Admin => &state.keys.admin, + Classification::Reject => { + return Err(( + StatusCode::BAD_REQUEST, + Json(CosignError { + error: "transaction rejected by ruleset".to_string(), + classification, + }), + )); + } + }; + + let body = build_sign_transaction(&SignTransaction { + organization_id: &state.config.organization_id, + sign_with: &req.signer_address, + unsigned_transaction: &req.unsigned_transaction, + timestamp_ms: now_ms(), + }); + let stamped = stamp::stamp(key, &body); + + Ok(Json(CosignResponse { + activity_body: stamped.body, + x_stamp: stamped.x_stamp, + classification, + })) +} + +/// Current time in milliseconds since the Unix epoch (Turnkey liveness stamp). +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is after the Unix epoch") + .as_millis() as u64 } diff --git a/src/stamp.rs b/src/stamp.rs index 7587981..d04ee90 100644 --- a/src/stamp.rs +++ b/src/stamp.rs @@ -31,7 +31,6 @@ struct StampEnvelope { } /// A signed request, ready to POST to Turnkey. -#[allow(dead_code)] // consumed once POST /cosign is wired up pub struct Stamped { /// The exact body bytes that were signed — send these unmodified. pub body: String, @@ -40,7 +39,6 @@ pub struct Stamped { } /// Sign `body` with `key` and produce the `X-Stamp` header value. -#[allow(dead_code)] // consumed once POST /cosign is wired up pub fn stamp(key: &ApiKey, body: &str) -> Stamped { // RustCrypto's `Signer` for P-256 hashes the message with SHA-256 and emits // a low-S–normalized signature — the same construction Turnkey verifies. From 6858730fd7eb3354f699da700ee46e65a67f1d78 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Tue, 7 Jul 2026 01:05:12 -0400 Subject: [PATCH 06/27] feat(tx): parse unsigned EIP-1559/legacy transactions --- Cargo.lock | 3077 ++++++++++++++++++++++++++++++++++++++++++++------- Cargo.toml | 4 + src/main.rs | 1 + src/tx.rs | 175 +++ 4 files changed, 2849 insertions(+), 408 deletions(-) create mode 100644 src/tx.rs diff --git a/Cargo.lock b/Cargo.lock index 8959926..d076aa9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,675 +3,2475 @@ version = 4 [[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" +name = "alloy-consensus" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +checksum = "a6ff0c4adba2abdcd9fb5829ae5f4394c06f8585ed283a9ba79aa33763c802e1" dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-trie", + "alloy-tx-macros", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "k256", + "once_cell", + "secp256k1 0.30.0", + "serde", "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", + "serde_with", + "thiserror", ] [[package]] -name = "axum-core" -version = "0.5.6" +name = "alloy-eip2124" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", + "alloy-primitives", + "alloy-rlp", + "crc", + "serde", + "thiserror", ] [[package]] -name = "base16ct" -version = "1.0.0" +name = "alloy-eip2930" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] [[package]] -name = "base64" -version = "0.22.1" +name = "alloy-eip7702" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", + "thiserror", +] [[package]] -name = "base64ct" -version = "1.8.3" +name = "alloy-eip7928" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +checksum = "b3b12337f74cbfa451cb04dac173974814a6ff463079e1793aa09600ba8813ab" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "once_cell", + "serde", + "thiserror", +] [[package]] -name = "bitflags" -version = "2.13.0" +name = "alloy-eips" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "ea4c0453065b9206acc0f869a258dc8dcbbd595e144b4446f2c493a24a814d1f" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "serde", + "serde_with", + "sha2 0.10.9", +] [[package]] -name = "block-buffer" -version = "0.12.1" +name = "alloy-primitives" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" dependencies = [ - "hybrid-array", + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "foldhash", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.4", + "rapidhash", + "ruint", + "rustc-hash", + "secp256k1 0.31.1", + "serde", + "sha3", ] [[package]] -name = "bytes" -version = "1.12.0" +name = "alloy-rlp" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] [[package]] -name = "cfg-if" -version = "1.0.4" +name = "alloy-rlp-derive" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] -name = "cmov" -version = "0.5.4" +name = "alloy-serde" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +checksum = "c1e97b3e0b9f816b25083045dcfa69431bd059a078e828e4d82d296d1949b96c" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] [[package]] -name = "const-oid" -version = "0.10.2" +name = "alloy-trie" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "derive_more", + "nybbles", + "serde", + "smallvec", + "thiserror", + "tracing", +] [[package]] -name = "cpubits" -version = "0.1.1" +name = "alloy-tx-macros" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" +checksum = "406bc1183f6843e0aba09f7b3365e828b597213d60793ba5cb41befc863e3a78" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] -name = "cpufeatures" -version = "0.3.0" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] -name = "crypto-bigint" -version = "0.7.5" +name = "ark-ff" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" dependencies = [ - "cpubits", - "ctutils", - "getrandom", - "hybrid-array", + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", "num-traits", - "rand_core", - "subtle", + "paste", + "rustc_version 0.3.3", "zeroize", ] [[package]] -name = "crypto-common" -version = "0.2.2" +name = "ark-ff" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" dependencies = [ - "getrandom", - "hybrid-array", - "rand_core", + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", ] [[package]] -name = "ctutils" -version = "0.4.2" +name = "ark-ff" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" dependencies = [ - "cmov", - "subtle", + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", ] [[package]] -name = "der" -version = "0.8.0" +name = "ark-ff" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" dependencies = [ - "const-oid", - "pem-rfc7468", + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", "zeroize", ] [[package]] -name = "digest" -version = "0.11.3" +name = "ark-ff-asm" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "ctutils", + "quote", + "syn 1.0.109", ] [[package]] -name = "ecdsa" -version = "0.17.0" +name = "ark-ff-asm" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", - "zeroize", + "quote", + "syn 1.0.109", ] [[package]] -name = "elliptic-curve" -version = "0.14.1" +name = "ark-ff-asm" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ - "base16ct", - "crypto-bigint", - "crypto-common", - "digest", - "ff", - "group", - "hybrid-array", - "pem-rfc7468", - "pkcs8", - "rand_core", - "sec1", - "subtle", - "zeroize", + "quote", + "syn 2.0.118", ] [[package]] -name = "errno" -version = "0.3.14" +name = "ark-ff-asm" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" dependencies = [ - "libc", - "windows-sys", + "quote", + "syn 2.0.118", ] [[package]] -name = "ff" -version = "0.14.0" +name = "ark-ff-macros" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" dependencies = [ - "rand_core", - "subtle", + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", ] [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "ark-ff-macros" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" dependencies = [ - "percent-encoding", + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "ark-ff-macros" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" dependencies = [ - "futures-core", + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] -name = "futures-core" -version = "0.3.32" +name = "ark-ff-macros" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] -name = "futures-task" -version = "0.3.32" +name = "ark-serialize" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] [[package]] -name = "futures-util" -version = "0.3.32" +name = "ark-serialize" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", ] [[package]] -name = "getrandom" -version = "0.4.3" +name = "ark-serialize" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "rand_core", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", ] [[package]] -name = "group" -version = "0.14.0" +name = "ark-serialize" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" dependencies = [ - "ff", - "rand_core", - "subtle", + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", ] [[package]] -name = "hex" -version = "0.4.3" +name = "ark-serialize-derive" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] -name = "hkdf" -version = "0.13.0" +name = "ark-std" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" dependencies = [ - "hmac", + "num-traits", + "rand 0.8.6", ] [[package]] -name = "hmac" -version = "0.13.0" +name = "ark-std" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ - "digest", + "num-traits", + "rand 0.8.6", ] [[package]] -name = "http" -version = "1.4.2" +name = "ark-std" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ - "bytes", - "itoa", + "num-traits", + "rand 0.8.6", ] [[package]] -name = "http-body" -version = "1.0.1" +name = "ark-std" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.6", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ + "axum-core", "bytes", + "form_urlencoded", + "futures-util", "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "http-body-util" -version = "0.1.3" +name = "axum-core" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", "http", "http-body", + "http-body-util", + "mime", "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "httparse" -version = "1.10.1" +name = "base16ct" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] -name = "httpdate" -version = "1.0.3" +name = "base16ct" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] -name = "hybrid-array" -version = "0.4.13" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "subtle", - "typenum", + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" +dependencies = [ + "hex-conservative 0.3.2", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blst" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +dependencies = [ + "cc", + "glob", + "threadpool", "zeroize", ] [[package]] -name = "hyper" -version = "1.10.1" +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "c-kzg" +version = "2.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6648ed1e4ea8e8a1a4a2c78e1cda29a3fd500bc622899c340d8525ea9a76b24a" +dependencies = [ + "blst", + "cc", + "glob", + "hex", + "libc", + "once_cell", + "serde", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.118", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.0", + "digest 0.11.3", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array", + "group 0.13.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pem-rfc7468", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", + "subtle", + "zeroize", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.6", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "once_cell", + "sha2 0.10.9", +] + +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "keccak-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "nybbles" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" +dependencies = [ + "alloy-rlp", + "cfg-if", + "proptest", + "ruint", + "serde", + "smallvec", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve 0.14.1", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "serde", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", + "serde", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", + "rand_core 0.9.5", ] [[package]] -name = "hyper-util" -version = "0.1.20" +name = "rapidhash" +version = "4.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" dependencies = [ - "bytes", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", - "tower-service", + "rustversion", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] [[package]] -name = "libc" -version = "0.2.186" +name = "ref-cast" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "ref-cast-impl" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ - "scopeguard", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] -name = "log" -version = "0.4.33" +name = "regex-syntax" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "matchit" -version = "0.8.4" +name = "rfc6979" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] [[package]] -name = "memchr" -version = "2.8.2" +name = "rfc6979" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.5", + "hmac 0.13.0", +] [[package]] -name = "mime" -version = "0.3.17" +name = "rlp" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] [[package]] -name = "mio" -version = "1.2.1" +name = "ruint" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "45caf26f647c19115bf9c453c70ffe4a4a3a6390dceebd942610584f99b8ddce" dependencies = [ - "libc", - "wasi", - "windows-sys", + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "ark-ff 0.6.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.6", + "rand 0.9.4", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "ruint-macro" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] -name = "once_cell" -version = "1.21.4" +name = "rustc-hash" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] -name = "p256" -version = "0.14.0" +name = "rustc-hex" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" dependencies = [ - "ecdsa", - "elliptic-curve", - "primefield", - "primeorder", - "sha2", + "semver 0.11.0", ] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "lock_api", - "parking_lot_core", + "semver 1.0.28", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "cfg-if", + "bitflags", + "errno", "libc", - "redox_syscall", - "smallvec", - "windows-link", + "linux-raw-sys", + "windows-sys", ] [[package]] -name = "pem-rfc7468" -version = "1.0.0" +name = "rustversion" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ - "base64ct", + "fnv", + "quick-error", + "tempfile", + "wait-timeout", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "schemars" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] [[package]] -name = "pkcs8" -version = "0.11.0" +name = "schemars" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ - "der", - "spki", + "dyn-clone", + "ref-cast", + "serde", + "serde_json", ] [[package]] -name = "primefield" -version = "0.14.0" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "crypto-bigint", - "crypto-common", - "ff", - "rand_core", + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", "subtle", "zeroize", ] [[package]] -name = "primeorder" -version = "0.14.0" +name = "sec1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ - "elliptic-curve", - "once_cell", - "primefield", - "serdect", - "wnaf", + "base16ct 1.0.0", + "ctutils", + "der 0.8.0", + "hybrid-array", + "subtle", + "zeroize", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "secp256k1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ - "unicode-ident", + "bitcoin_hashes", + "rand 0.8.6", + "secp256k1-sys 0.10.1", ] [[package]] -name = "quote" -version = "1.0.46" +name = "secp256k1" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ - "proc-macro2", + "bitcoin_hashes", + "rand 0.9.4", + "secp256k1-sys 0.11.0", ] [[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand_core" +name = "secp256k1-sys" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" dependencies = [ - "bitflags", + "cc", ] [[package]] -name = "rfc6979" -version = "0.6.0" +name = "secp256k1-sys" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" dependencies = [ - "crypto-bigint", - "hmac", + "cc", ] [[package]] -name = "ryu" -version = "1.0.23" +name = "semver" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "semver" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] -name = "sec1" -version = "0.8.1" +name = "semver-parser" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" dependencies = [ - "base16ct", - "ctutils", - "der", - "hybrid-array", - "subtle", - "zeroize", + "pest", ] [[package]] @@ -701,7 +2501,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -718,49 +2518,118 @@ dependencies = [ ] [[package]] -name = "serde_path_to_error" -version = "0.1.20" +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "itoa", - "serde", - "serde_core", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "sha2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] -name = "serdect" -version = "0.4.3" +name = "sha3" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ - "base16ct", - "serde", + "digest 0.11.3", + "keccak", ] [[package]] -name = "sha2" -version = "0.11.0" +name = "sha3-asm" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" dependencies = [ + "cc", "cfg-if", - "cpufeatures", - "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -771,14 +2640,24 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest", - "rand_core", + "digest 0.11.3", + "rand_core 0.10.1", ] [[package]] @@ -803,6 +2682,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -810,15 +2699,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.0", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.118" @@ -836,6 +2748,99 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -861,7 +2866,37 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", ] [[package]] @@ -916,6 +2951,10 @@ dependencies = [ name = "tvc-cosign" version = "0.1.0" dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", "axum", "base64", "hex", @@ -923,7 +2962,7 @@ dependencies = [ "p256", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "tokio", ] @@ -933,24 +2972,188 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -960,22 +3163,80 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wnaf" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" dependencies = [ - "ff", - "group", + "ff 0.14.0", + "group 0.14.0", "hybrid-array", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 48d8e4d..8d793c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] +alloy-consensus = "2.1.1" +alloy-eips = "2.1.1" +alloy-primitives = { version = "1.6.0", features = ["serde"] } +alloy-rlp = "0.3.16" axum = "0.8.9" base64 = "0.22.1" hex = "0.4.3" diff --git a/src/main.rs b/src/main.rs index 9be3d23..6bae0ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod activity; mod config; mod keys; mod stamp; +mod tx; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/src/tx.rs b/src/tx.rs new file mode 100644 index 0000000..88d1096 --- /dev/null +++ b/src/tx.rs @@ -0,0 +1,175 @@ +//! Minimal unsigned-EVM-transaction parser. +//! +//! Turnkey's `unsignedTransaction` is the raw serialized transaction that gets +//! hashed for signing — i.e. what alloy calls the "encoded for signing" form: +//! - EIP-1559: `0x02 || rlp([chainId, nonce, maxPrio, maxFee, gas, to, value, data, accessList])` +//! - EIP-2930: `0x01 || rlp([...])` +//! - legacy: `rlp([nonce, gasPrice, gas, to, value, data, chainId, 0, 0])` +//! +//! The rules engine only needs `to`, `value`, and the calldata (for the 4-byte +//! selector + args), so we decode into that and drop the rest. +//! +//! This module is consumed by the rules engine. +#![allow(dead_code)] + +use std::fmt; + +use alloy_consensus::transaction::RlpEcdsaDecodableTx; +use alloy_consensus::{Transaction, TxEip1559, TxEip2930}; +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use alloy_rlp::{Decodable, Header}; + +/// The fields of an unsigned transaction that classification cares about. +#[derive(Debug, Clone, PartialEq)] +pub struct ParsedTx { + /// Recipient. `None` for contract-creation transactions. + pub to: Option
, + /// Native value transferred, in wei. + pub value: U256, + /// Calldata. + pub input: Bytes, +} + +impl ParsedTx { + /// The 4-byte function selector, if the calldata is long enough. + pub fn selector(&self) -> Option<[u8; 4]> { + self.input.get(..4).map(|s| s.try_into().unwrap()) + } +} + +/// Why an unsigned transaction could not be parsed. +#[derive(Debug)] +pub enum ParseError { + /// No bytes to parse. + Empty, + /// First byte is not a supported transaction type / RLP list. + UnsupportedType(u8), + /// The RLP body was malformed. + Rlp(alloy_rlp::Error), +} + +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}"), + } + } +} + +/// Parse a raw unsigned transaction (already hex-decoded) into [`ParsedTx`]. +pub fn parse_unsigned(raw: &[u8]) -> Result { + let first = *raw.first().ok_or(ParseError::Empty)?; + match first { + // Typed transactions: strip the type byte, decode the RLP payload. + // Their signing form is symmetric with `rlp_decode`. + 0x02 => decode_typed::(&raw[1..]), + 0x01 => decode_typed::(&raw[1..]), + // Legacy: the whole thing is an RLP list (first byte is a list header, >= 0xc0). + b if b >= 0xc0 => decode_legacy(raw), + b => Err(ParseError::UnsupportedType(b)), + } +} + +/// Decode a typed tx's RLP body via its [`Transaction`] accessors. +fn decode_typed( + mut buf: &[u8], +) -> Result { + let tx = T::rlp_decode(&mut buf).map_err(ParseError::Rlp)?; + Ok(ParsedTx { + to: tx.to(), + value: tx.value(), + input: tx.input().clone(), + }) +} + +/// Decode a legacy tx's *signing payload*. Unlike a stored legacy tx, this is +/// `rlp([nonce, gasPrice, gas, to, value, data, chainId, 0, 0])` (EIP-155), so +/// we read the six fields we need and ignore any EIP-155 trailer. +fn decode_legacy(mut buf: &[u8]) -> Result { + let header = Header::decode(&mut buf).map_err(ParseError::Rlp)?; + if !header.list { + return Err(ParseError::Rlp(alloy_rlp::Error::UnexpectedString)); + } + let _nonce = u64::decode(&mut buf).map_err(ParseError::Rlp)?; + let _gas_price = U256::decode(&mut buf).map_err(ParseError::Rlp)?; + let _gas_limit = u64::decode(&mut buf).map_err(ParseError::Rlp)?; + let kind = TxKind::decode(&mut buf).map_err(ParseError::Rlp)?; + let value = U256::decode(&mut buf).map_err(ParseError::Rlp)?; + let input = Bytes::decode(&mut buf).map_err(ParseError::Rlp)?; + let to = match kind { + TxKind::Call(addr) => Some(addr), + TxKind::Create => None, + }; + Ok(ParsedTx { to, value, input }) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{SignableTransaction, TxLegacy}; + use alloy_primitives::{address, TxKind, U256}; + + /// Build `transfer(address,uint256)` calldata. + fn transfer_calldata(recipient: Address, amount: u64) -> Vec { + let mut data = vec![0xa9, 0x05, 0x9c, 0xbb]; // transfer selector + 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>()); + data + } + + #[test] + fn round_trip_eip1559_erc20_transfer() { + let token = address!("1111111111111111111111111111111111111111"); + let recipient = address!("00000000000000000000000000000000000000ff"); + let data = transfer_calldata(recipient, 1000); + + let tx = TxEip1559 { + chain_id: 1, + to: TxKind::Call(token), + value: U256::ZERO, + input: data.clone().into(), + ..Default::default() + }; + let raw = tx.encoded_for_signing(); + + let parsed = parse_unsigned(&raw).expect("parses"); + assert_eq!(parsed.to, Some(token)); + assert_eq!(parsed.value, U256::ZERO); + assert_eq!(parsed.selector(), Some([0xa9, 0x05, 0x9c, 0xbb])); + assert_eq!(parsed.input.as_ref(), data.as_slice()); + } + + #[test] + fn round_trip_legacy_native_transfer() { + let recipient = address!("00000000000000000000000000000000000000aa"); + let tx = TxLegacy { + chain_id: Some(1), + to: TxKind::Call(recipient), + value: U256::from(5_000_000u64), + input: Bytes::new(), + ..Default::default() + }; + let raw = tx.encoded_for_signing(); + + let parsed = parse_unsigned(&raw).expect("parses"); + assert_eq!(parsed.to, Some(recipient)); + assert_eq!(parsed.value, U256::from(5_000_000u64)); + assert_eq!(parsed.selector(), None, "no calldata -> no selector"); + } + + #[test] + fn rejects_empty() { + assert!(matches!(parse_unsigned(&[]), Err(ParseError::Empty))); + } + + #[test] + fn rejects_unknown_type_byte() { + assert!(matches!( + parse_unsigned(&[0x7f, 0x00]), + Err(ParseError::UnsupportedType(0x7f)) + )); + } +} From 6a448acf9369f3ae7cf387fc712c93d26c4d97b9 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Tue, 7 Jul 2026 15:52:19 -0400 Subject: [PATCH 07/27] feat(rules): classify transactions PROGRAMMATIC|ADMIN|REJECT --- Cargo.lock | 31 ++++++ Cargo.toml | 1 + rules.example.toml | 32 ++++++ src/config.rs | 32 +++++- src/main.rs | 62 ++++++----- src/rules.rs | 267 +++++++++++++++++++++++++++++++++++++++++++++ src/tx.rs | 3 - 7 files changed, 392 insertions(+), 36 deletions(-) create mode 100644 rules.example.toml create mode 100644 src/rules.rs diff --git a/Cargo.lock b/Cargo.lock index d076aa9..7246813 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2528,6 +2528,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2869,6 +2878,21 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2899,6 +2923,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -2964,6 +2994,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "tokio", + "toml", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8d793c5..0abb041 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,4 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.11.0" tokio = { version = "1.52.3", features = ["full"] } +toml = "1.1.2" diff --git a/rules.example.toml b/rules.example.toml new file mode 100644 index 0000000..d0dfeab --- /dev/null +++ b/rules.example.toml @@ -0,0 +1,32 @@ +# tvc-cosign ruleset — example. +# +# Copy to `rules.toml` (or point TVC_RULES_PATH at it) and fill in real values. +# In production this file is baked into the reproducible enclave image, so the +# rules are covered by the image measurement (i.e. attestable). +# +# Classification: +# PROGRAMMATIC -> stamped with the programmatic key (policy auto-completes) +# ADMIN -> stamped with the admin key (policy requires human consensus) +# REJECT -> not stamped; /cosign returns 400 + +[programmatic] +# An ERC-20 transfer(address,uint256) is PROGRAMMATIC only if ALL hold: +# - the token contract (the tx `to`) is in allowed_tokens +# - the recipient (first arg) is in allowed_recipients +# - the amount (second arg) is <= max_amount +allowed_tokens = [ + "0x1111111111111111111111111111111111111111", # e.g. USDC +] +allowed_recipients = [ + "0x00000000000000000000000000000000000000ff", # e.g. treasury / settlement address +] +# Max transfer amount in the token's base units, as a string (may exceed i64). +# Example below is 1000 * 10^6 for a 6-decimal token like USDC. +max_amount = "1000000000" + +[admin] +# Any transaction whose 4-byte selector is listed here -> ADMIN (human consensus). +# Put privileged operations here (e.g. an "add ticket" / config selector). +selectors = [ + "0x12345678", +] diff --git a/src/config.rs b/src/config.rs index 1a851c7..6d55972 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,22 +2,31 @@ //! //! The only secret this app uses is the quorum seed, which is read from //! `/qos.quorum.key` — never from here. Everything in `Config` is safe to pass -//! as a plain deploy-time argument / environment variable and is not baked into -//! the image. +//! as a plain deploy-time argument / environment variable or as a file baked +//! into the image, and is not secret. + +use crate::rules::Ruleset; /// Environment variable holding the customer's (sub-)organization id. const ORGANIZATION_ID_ENV: &str = "TVC_ORGANIZATION_ID"; +/// Environment variable pointing at the ruleset TOML file. +const RULES_PATH_ENV: &str = "TVC_RULES_PATH"; +/// Default ruleset path if `TVC_RULES_PATH` is unset. +const DEFAULT_RULES_PATH: &str = "rules.toml"; /// Non-secret configuration loaded once at startup. pub struct Config { /// The `organizationId` placed in every `SIGN_TRANSACTION_V2` body. pub organization_id: String, + /// The active classification ruleset. + pub ruleset: Ruleset, } impl Config { - /// Load config from the environment. A missing `organizationId` is tolerated - /// (empty placeholder + warning) so the app still boots for local inspection; - /// it must be set to submit real requests to Turnkey. + /// Load config from the environment. A missing `organizationId` or ruleset + /// file is tolerated so the app still boots for local inspection: the org id + /// falls back to an empty placeholder, and the ruleset falls back to + /// deny-all (every transaction is REJECTed). Both warn loudly. pub fn from_env() -> Self { let organization_id = std::env::var(ORGANIZATION_ID_ENV).unwrap_or_else(|_| { eprintln!( @@ -26,6 +35,17 @@ impl Config { ); String::new() }); - Config { organization_id } + + let rules_path = + std::env::var(RULES_PATH_ENV).unwrap_or_else(|_| DEFAULT_RULES_PATH.to_string()); + let ruleset = Ruleset::load(&rules_path).unwrap_or_else(|e| { + eprintln!("config: WARNING could not load ruleset ({e}) — using deny-all"); + Ruleset::deny_all() + }); + + Config { + organization_id, + ruleset, + } } } diff --git a/src/main.rs b/src/main.rs index 6bae0ad..3f2092a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod activity; mod config; mod keys; +mod rules; mod stamp; mod tx; @@ -26,6 +27,7 @@ use serde::{Deserialize, Serialize}; use activity::{build_sign_transaction, SignTransaction}; use config::Config; use keys::KeySet; +use rules::{classify, Classification}; /// Address the enclave listens on. TVC pivots serve plain HTTP inside the /// enclave; the host proxies to them. @@ -86,24 +88,6 @@ async fn pubkeys(State(state): State>) -> Json { }) } -/// How a transaction is routed. Serialized as `PROGRAMMATIC` / `ADMIN` / `REJECT`. -/// `Admin`/`Reject` are handled here but only constructed once the rules engine -/// lands (the placeholder classifier always returns `Programmatic`). -#[derive(Serialize, Clone, Copy)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[allow(dead_code)] -enum Classification { - Programmatic, - Admin, - Reject, -} - -/// Placeholder classifier — always routes to the programmatic key. Replaced by -/// the config-driven rules engine. -fn classify(_signer_address: &str, _unsigned_transaction: &str) -> Classification { - Classification::Programmatic -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CosignRequest { @@ -125,26 +109,30 @@ struct CosignResponse { #[serde(rename_all = "camelCase")] struct CosignError { error: String, - classification: Classification, + #[serde(skip_serializing_if = "Option::is_none")] + classification: Option, } -/// Classify an unsigned tx, then build + stamp a `SIGN_TRANSACTION_V2` request. +/// Parse an unsigned tx, classify it, then build + stamp a `SIGN_TRANSACTION_V2`. async fn cosign( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let classification = classify(&req.signer_address, &req.unsigned_transaction); + // Decode + parse the unsigned transaction. A malformed tx is a client error. + let raw = decode_hex(&req.unsigned_transaction) + .map_err(|e| bad_request(format!("invalid unsignedTransaction hex: {e}"), None))?; + let parsed = tx::parse_unsigned(&raw) + .map_err(|e| bad_request(format!("could not parse transaction: {e}"), None))?; + + let classification = classify(&parsed, &state.config.ruleset); let key = match classification { Classification::Programmatic => &state.keys.programmatic, Classification::Admin => &state.keys.admin, Classification::Reject => { - return Err(( - StatusCode::BAD_REQUEST, - Json(CosignError { - error: "transaction rejected by ruleset".to_string(), - classification, - }), + return Err(bad_request( + "transaction rejected by ruleset".to_string(), + Some(Classification::Reject), )); } }; @@ -171,3 +159,23 @@ fn now_ms() -> u64 { .expect("system clock is after the Unix epoch") .as_millis() as u64 } + +/// Hex-decode, tolerating an optional `0x` prefix. +fn decode_hex(s: &str) -> Result, hex::FromHexError> { + let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s); + hex::decode(s) +} + +/// Build a `400` response with an error message and optional classification. +fn bad_request( + error: String, + classification: Option, +) -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(CosignError { + error, + classification, + }), + ) +} diff --git a/src/rules.rs b/src/rules.rs new file mode 100644 index 0000000..100c235 --- /dev/null +++ b/src/rules.rs @@ -0,0 +1,267 @@ +//! The classification engine: decide whether an unsigned transaction is +//! `PROGRAMMATIC` (auto-signable), `ADMIN` (needs human consensus), or `REJECT`. +//! +//! Rules are config-driven (see `rules.example.toml`) so the customer can tune +//! allowlists and caps without code changes. In production the ruleset is baked +//! into the reproducible enclave image, so it's covered by the image measurement. + +use std::collections::HashSet; + +use alloy_primitives::{Address, U256}; +use serde::Deserialize; + +use crate::tx::ParsedTx; + +/// ERC-20 `transfer(address,uint256)` selector (`keccak256(...)[..4]`). +const TRANSFER_SELECTOR: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb]; + +/// How a transaction is routed. Serialized as `PROGRAMMATIC` / `ADMIN` / `REJECT`. +#[derive(serde::Serialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Classification { + Programmatic, + Admin, + Reject, +} + +/// The active ruleset, in runtime form (typed, deduplicated). +pub struct Ruleset { + allowed_tokens: HashSet
, + allowed_recipients: HashSet
, + max_amount: U256, + admin_selectors: HashSet<[u8; 4]>, +} + +impl Ruleset { + /// A ruleset that rejects everything — the safe default when no config is + /// present (empty allowlists, zero cap, no admin selectors). + pub fn deny_all() -> Self { + Ruleset { + allowed_tokens: HashSet::new(), + allowed_recipients: HashSet::new(), + max_amount: U256::ZERO, + admin_selectors: HashSet::new(), + } + } + + /// Load and validate a ruleset from a TOML file. + pub fn load(path: &str) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?; + let raw: RawRuleset = toml::from_str(&text).map_err(|e| format!("parse {path}: {e}"))?; + raw.into_ruleset() + } +} + +/// Classify a parsed transaction against the ruleset. +pub fn classify(tx: &ParsedTx, rules: &Ruleset) -> Classification { + let Some(selector) = tx.selector() else { + // No calldata (e.g. a native transfer) — out of scope for this POC. + return Classification::Reject; + }; + + if selector == TRANSFER_SELECTOR { + return classify_transfer(tx, rules); + } + if rules.admin_selectors.contains(&selector) { + return Classification::Admin; + } + Classification::Reject +} + +/// An ERC-20 `transfer` is PROGRAMMATIC only if the token, recipient, and amount +/// all pass the allowlists/cap; otherwise REJECT. +fn classify_transfer(tx: &ParsedTx, rules: &Ruleset) -> Classification { + // A token transfer should not also move native ETH. + if !tx.value.is_zero() { + return Classification::Reject; + } + let Some(token) = tx.to else { + return Classification::Reject; + }; + if !rules.allowed_tokens.contains(&token) { + return Classification::Reject; + } + let Some((recipient, amount)) = decode_transfer_args(&tx.input) else { + return Classification::Reject; + }; + if !rules.allowed_recipients.contains(&recipient) || amount > rules.max_amount { + return Classification::Reject; + } + Classification::Programmatic +} + +/// Decode `transfer(address,uint256)` args: a 32-byte right-aligned address word +/// followed by a 32-byte amount. Rejects non-canonical (non-zero-padded) addresses. +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)) +} + +// --- config deserialization (raw TOML shape -> runtime Ruleset) --- + +#[derive(Deserialize)] +struct RawRuleset { + programmatic: RawProgrammatic, + #[serde(default)] + admin: RawAdmin, +} + +#[derive(Deserialize)] +struct RawProgrammatic { + #[serde(default)] + allowed_tokens: Vec
, + #[serde(default)] + allowed_recipients: Vec
, + /// Max transfer amount as a decimal (or `0x`-hex) string, so it can exceed i64. + max_amount: String, +} + +#[derive(Deserialize, Default)] +struct RawAdmin { + #[serde(default)] + selectors: Vec, +} + +impl RawRuleset { + fn into_ruleset(self) -> Result { + let max_amount = self + .programmatic + .max_amount + .parse::() + .map_err(|e| format!("invalid max_amount: {e}"))?; + + let admin_selectors = self + .admin + .selectors + .iter() + .map(|s| parse_selector(s)) + .collect::, _>>()?; + + Ok(Ruleset { + allowed_tokens: self.programmatic.allowed_tokens.into_iter().collect(), + allowed_recipients: self.programmatic.allowed_recipients.into_iter().collect(), + max_amount, + admin_selectors, + }) + } +} + +/// Parse a 4-byte selector like `"0x12345678"`. +fn parse_selector(s: &str) -> Result<[u8; 4], String> { + let hex = s.strip_prefix("0x").unwrap_or(s); + let bytes = hex::decode(hex).map_err(|e| format!("invalid selector {s}: {e}"))?; + bytes + .try_into() + .map_err(|_| format!("selector {s} must be exactly 4 bytes")) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{address, Bytes, U256}; + + const TOKEN: Address = address!("1111111111111111111111111111111111111111"); + const RECIPIENT: Address = address!("00000000000000000000000000000000000000ff"); + + fn ruleset_toml() -> Ruleset { + let toml = r#" + [programmatic] + allowed_tokens = ["0x1111111111111111111111111111111111111111"] + allowed_recipients = ["0x00000000000000000000000000000000000000ff"] + max_amount = "1000" + + [admin] + selectors = ["0x12345678"] + "#; + let raw: RawRuleset = toml::from_str(toml).unwrap(); + raw.into_ruleset().unwrap() + } + + fn transfer_tx(token: Address, recipient: Address, amount: u64) -> ParsedTx { + let mut input = vec![0xa9, 0x05, 0x9c, 0xbb]; + input.extend_from_slice(&[0u8; 12]); + input.extend_from_slice(recipient.as_slice()); + input.extend_from_slice(&U256::from(amount).to_be_bytes::<32>()); + ParsedTx { + to: Some(token), + value: U256::ZERO, + input: Bytes::from(input), + } + } + + #[test] + fn allowlisted_transfer_under_cap_is_programmatic() { + let tx = transfer_tx(TOKEN, RECIPIENT, 500); + assert_eq!(classify(&tx, &ruleset_toml()), Classification::Programmatic); + } + + #[test] + fn transfer_over_cap_is_rejected() { + let tx = transfer_tx(TOKEN, RECIPIENT, 5000); + assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + } + + #[test] + fn transfer_to_unlisted_recipient_is_rejected() { + let other = address!("00000000000000000000000000000000000000ee"); + let tx = transfer_tx(TOKEN, other, 100); + assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + } + + #[test] + fn transfer_of_unlisted_token_is_rejected() { + let other = address!("2222222222222222222222222222222222222222"); + let tx = transfer_tx(other, RECIPIENT, 100); + assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + } + + #[test] + fn admin_selector_routes_to_admin() { + let tx = ParsedTx { + to: Some(TOKEN), + value: U256::ZERO, + input: Bytes::from(vec![0x12, 0x34, 0x56, 0x78]), + }; + assert_eq!(classify(&tx, &ruleset_toml()), Classification::Admin); + } + + #[test] + fn unknown_selector_is_rejected() { + let tx = ParsedTx { + to: Some(TOKEN), + value: U256::ZERO, + input: Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]), + }; + assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + } + + #[test] + fn no_calldata_is_rejected() { + let tx = ParsedTx { + to: Some(RECIPIENT), + value: U256::from(1u64), + input: Bytes::new(), + }; + assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + } + + #[test] + fn transfer_with_attached_eth_is_rejected() { + let mut tx = transfer_tx(TOKEN, RECIPIENT, 100); + tx.value = U256::from(1u64); + assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + } + + #[test] + fn deny_all_rejects_everything() { + let tx = transfer_tx(TOKEN, RECIPIENT, 1); + assert_eq!(classify(&tx, &Ruleset::deny_all()), Classification::Reject); + } +} diff --git a/src/tx.rs b/src/tx.rs index 88d1096..2a42073 100644 --- a/src/tx.rs +++ b/src/tx.rs @@ -8,9 +8,6 @@ //! //! The rules engine only needs `to`, `value`, and the calldata (for the 4-byte //! selector + args), so we decode into that and drop the rest. -//! -//! This module is consumed by the rules engine. -#![allow(dead_code)] use std::fmt; From f08f3b9c8bddd0832e72c166a65e9f5be076a0b1 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Tue, 7 Jul 2026 17:54:03 -0400 Subject: [PATCH 08/27] style: apply rustfmt and fix clippy lint --- src/keys.rs | 12 +++++++++--- src/stamp.rs | 6 +++--- src/tx.rs | 6 ++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/keys.rs b/src/keys.rs index d042bfe..6638167 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -13,8 +13,8 @@ use std::path::Path; use hkdf::Hkdf; -use p256::ecdsa::SigningKey; use p256::SecretKey; +use p256::ecdsa::SigningKey; use sha2::Sha512; /// Path inside the enclave where QOS writes the hex-encoded 32-byte quorum seed. @@ -114,7 +114,10 @@ mod tests { fn derivation_is_deterministic() { let k1 = KeySet::derive(&SEED_A); let k2 = KeySet::derive(&SEED_A); - assert_eq!(k1.programmatic.public_key_hex(), k2.programmatic.public_key_hex()); + assert_eq!( + k1.programmatic.public_key_hex(), + k2.programmatic.public_key_hex() + ); assert_eq!(k1.admin.public_key_hex(), k2.admin.public_key_hex()); } @@ -128,7 +131,10 @@ mod tests { fn different_seeds_give_different_keys() { let a = KeySet::derive(&SEED_A); let b = KeySet::derive(&SEED_B); - assert_ne!(a.programmatic.public_key_hex(), b.programmatic.public_key_hex()); + assert_ne!( + a.programmatic.public_key_hex(), + b.programmatic.public_key_hex() + ); } #[test] diff --git a/src/stamp.rs b/src/stamp.rs index d04ee90..234c1fd 100644 --- a/src/stamp.rs +++ b/src/stamp.rs @@ -12,8 +12,8 @@ //! against the registered public key. use base64::Engine; -use p256::ecdsa::signature::Signer; use p256::ecdsa::Signature; +use p256::ecdsa::signature::Signer; use serde::Serialize; use crate::keys::ApiKey; @@ -63,8 +63,8 @@ pub fn stamp(key: &ApiKey, body: &str) -> Stamped { mod tests { use super::*; use crate::keys::KeySet; - use p256::ecdsa::signature::Verifier; use p256::ecdsa::VerifyingKey; + use p256::ecdsa::signature::Verifier; use serde_json::Value; const SEED: [u8; 32] = [0x42; 32]; @@ -86,7 +86,7 @@ mod tests { assert_eq!(env["scheme"], STAMP_SCHEME); assert_eq!(env["publicKey"], keys.programmatic.public_key_hex()); - assert!(env["signature"].as_str().unwrap().len() > 0); + assert!(!env["signature"].as_str().unwrap().is_empty()); assert_eq!(stamped.body, BODY, "body is returned unmodified"); } diff --git a/src/tx.rs b/src/tx.rs index 2a42073..1fd957f 100644 --- a/src/tx.rs +++ b/src/tx.rs @@ -49,7 +49,9 @@ 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::UnsupportedType(b) => { + write!(f, "unsupported transaction type byte {b:#04x}") + } ParseError::Rlp(e) => write!(f, "malformed RLP: {e}"), } } @@ -106,7 +108,7 @@ fn decode_legacy(mut buf: &[u8]) -> Result { mod tests { use super::*; use alloy_consensus::{SignableTransaction, TxLegacy}; - use alloy_primitives::{address, TxKind, U256}; + use alloy_primitives::{TxKind, U256, address}; /// Build `transfer(address,uint256)` calldata. fn transfer_calldata(recipient: Address, amount: u64) -> Vec { From 5b47319252bcd561bde689275f1a903bc353be53 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Tue, 7 Jul 2026 17:56:06 -0400 Subject: [PATCH 09/27] feat(rules): gate on allowlisted signer address --- rules.example.toml | 7 +++++ src/main.rs | 28 +++++++++++++---- src/rules.rs | 76 +++++++++++++++++++++++++++++++++++++++------- 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/rules.example.toml b/rules.example.toml index d0dfeab..9fc8886 100644 --- a/rules.example.toml +++ b/rules.example.toml @@ -9,6 +9,13 @@ # ADMIN -> stamped with the admin key (policy requires human consensus) # REJECT -> not stamped; /cosign returns 400 +# Wallets this deployment may sign for (the `signerAddress` / `signWith` target). +# A global gate: any transaction whose signer is not listed here is REJECTed, +# regardless of the rules below. +allowed_signers = [ + "0x00000000000000000000000000000000000000a1", # e.g. the company treasury wallet +] + [programmatic] # An ERC-20 transfer(address,uint256) is PROGRAMMATIC only if ALL hold: # - the token contract (the tx `to`) is in allowed_tokens diff --git a/src/main.rs b/src/main.rs index 3f2092a..bcb8a11 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,16 +18,17 @@ mod tx; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; +use alloy_primitives::Address; use axum::extract::State; use axum::http::StatusCode; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; -use activity::{build_sign_transaction, SignTransaction}; +use activity::{SignTransaction, build_sign_transaction}; use config::Config; use keys::KeySet; -use rules::{classify, Classification}; +use rules::{Classification, classify}; /// Address the enclave listens on. TVC pivots serve plain HTTP inside the /// enclave; the host proxies to them. @@ -45,8 +46,14 @@ async fn main() { // observable in production, so these prints are only a local-dev aid; the // pubkeys are exposed over GET /pubkeys for registration. let keys = KeySet::load(); - println!("keys: programmatic pubkey = {}", keys.programmatic.public_key_hex()); - println!("keys: admin pubkey = {}", keys.admin.public_key_hex()); + println!( + "keys: programmatic pubkey = {}", + keys.programmatic.public_key_hex() + ); + println!( + "keys: admin pubkey = {}", + keys.admin.public_key_hex() + ); let config = Config::from_env(); let state = Arc::new(AppState { keys, config }); @@ -124,7 +131,13 @@ async fn cosign( let parsed = tx::parse_unsigned(&raw) .map_err(|e| bad_request(format!("could not parse transaction: {e}"), None))?; - let classification = classify(&parsed, &state.config.ruleset); + // The wallet to sign with is a global gate — it must be an allowlisted signer. + let signer = req + .signer_address + .parse::
() + .map_err(|e| bad_request(format!("invalid signerAddress: {e}"), None))?; + + let classification = classify(signer, &parsed, &state.config.ruleset); let key = match classification { Classification::Programmatic => &state.keys.programmatic, @@ -162,7 +175,10 @@ fn now_ms() -> u64 { /// Hex-decode, tolerating an optional `0x` prefix. fn decode_hex(s: &str) -> Result, hex::FromHexError> { - let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s); + let s = s + .strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .unwrap_or(s); hex::decode(s) } diff --git a/src/rules.rs b/src/rules.rs index 100c235..e572f9f 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -26,6 +26,7 @@ pub enum Classification { /// The active ruleset, in runtime form (typed, deduplicated). pub struct Ruleset { + allowed_signers: HashSet
, allowed_tokens: HashSet
, allowed_recipients: HashSet
, max_amount: U256, @@ -37,6 +38,7 @@ impl Ruleset { /// present (empty allowlists, zero cap, no admin selectors). pub fn deny_all() -> Self { Ruleset { + allowed_signers: HashSet::new(), allowed_tokens: HashSet::new(), allowed_recipients: HashSet::new(), max_amount: U256::ZERO, @@ -53,7 +55,15 @@ impl Ruleset { } /// Classify a parsed transaction against the ruleset. -pub fn classify(tx: &ParsedTx, rules: &Ruleset) -> Classification { +/// +/// `signer` is the wallet the transaction would be signed by (`signWith`). It is +/// a global gate: the enclave only stamps for allowlisted wallets, whatever the +/// classification. +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; @@ -108,6 +118,9 @@ fn decode_transfer_args(input: &[u8]) -> Option<(Address, U256)> { #[derive(Deserialize)] struct RawRuleset { + /// Wallets (`signWith` targets) this deployment is permitted to sign for. + #[serde(default)] + allowed_signers: Vec
, programmatic: RawProgrammatic, #[serde(default)] admin: RawAdmin, @@ -145,6 +158,7 @@ impl RawRuleset { .collect::, _>>()?; Ok(Ruleset { + allowed_signers: self.allowed_signers.into_iter().collect(), allowed_tokens: self.programmatic.allowed_tokens.into_iter().collect(), allowed_recipients: self.programmatic.allowed_recipients.into_iter().collect(), max_amount, @@ -165,13 +179,16 @@ fn parse_selector(s: &str) -> Result<[u8; 4], String> { #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{address, Bytes, U256}; + use alloy_primitives::{Bytes, U256, address}; + const SIGNER: Address = address!("00000000000000000000000000000000000000a1"); const TOKEN: Address = address!("1111111111111111111111111111111111111111"); const RECIPIENT: Address = address!("00000000000000000000000000000000000000ff"); fn ruleset_toml() -> Ruleset { let toml = r#" + allowed_signers = ["0x00000000000000000000000000000000000000a1"] + [programmatic] allowed_tokens = ["0x1111111111111111111111111111111111111111"] allowed_recipients = ["0x00000000000000000000000000000000000000ff"] @@ -199,27 +216,39 @@ mod tests { #[test] fn allowlisted_transfer_under_cap_is_programmatic() { let tx = transfer_tx(TOKEN, RECIPIENT, 500); - assert_eq!(classify(&tx, &ruleset_toml()), Classification::Programmatic); + assert_eq!( + classify(SIGNER, &tx, &ruleset_toml()), + Classification::Programmatic + ); } #[test] fn transfer_over_cap_is_rejected() { let tx = transfer_tx(TOKEN, RECIPIENT, 5000); - assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + assert_eq!( + classify(SIGNER, &tx, &ruleset_toml()), + Classification::Reject + ); } #[test] fn transfer_to_unlisted_recipient_is_rejected() { let other = address!("00000000000000000000000000000000000000ee"); let tx = transfer_tx(TOKEN, other, 100); - assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + assert_eq!( + classify(SIGNER, &tx, &ruleset_toml()), + Classification::Reject + ); } #[test] fn transfer_of_unlisted_token_is_rejected() { let other = address!("2222222222222222222222222222222222222222"); let tx = transfer_tx(other, RECIPIENT, 100); - assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + assert_eq!( + classify(SIGNER, &tx, &ruleset_toml()), + Classification::Reject + ); } #[test] @@ -229,7 +258,10 @@ mod tests { value: U256::ZERO, input: Bytes::from(vec![0x12, 0x34, 0x56, 0x78]), }; - assert_eq!(classify(&tx, &ruleset_toml()), Classification::Admin); + assert_eq!( + classify(SIGNER, &tx, &ruleset_toml()), + Classification::Admin + ); } #[test] @@ -239,7 +271,10 @@ mod tests { value: U256::ZERO, input: Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]), }; - assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + assert_eq!( + classify(SIGNER, &tx, &ruleset_toml()), + Classification::Reject + ); } #[test] @@ -249,19 +284,38 @@ mod tests { value: U256::from(1u64), input: Bytes::new(), }; - assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + assert_eq!( + classify(SIGNER, &tx, &ruleset_toml()), + Classification::Reject + ); } #[test] fn transfer_with_attached_eth_is_rejected() { let mut tx = transfer_tx(TOKEN, RECIPIENT, 100); tx.value = U256::from(1u64); - assert_eq!(classify(&tx, &ruleset_toml()), Classification::Reject); + assert_eq!( + classify(SIGNER, &tx, &ruleset_toml()), + Classification::Reject + ); + } + + #[test] + fn non_allowlisted_signer_is_rejected() { + let other_signer = address!("00000000000000000000000000000000000000b2"); + let tx = transfer_tx(TOKEN, RECIPIENT, 500); // otherwise valid + assert_eq!( + classify(other_signer, &tx, &ruleset_toml()), + Classification::Reject + ); } #[test] fn deny_all_rejects_everything() { let tx = transfer_tx(TOKEN, RECIPIENT, 1); - assert_eq!(classify(&tx, &Ruleset::deny_all()), Classification::Reject); + assert_eq!( + classify(SIGNER, &tx, &Ruleset::deny_all()), + Classification::Reject + ); } } From 74561241731c4fcc775ca102c3ebb0d8e5ce66b0 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Tue, 7 Jul 2026 21:28:28 -0400 Subject: [PATCH 10/27] feat(proof): generate app proof --- src/keys.rs | 170 +++++++++++++++++++++++++++++++++++++++--- src/main.rs | 53 ++++++++++++- src/proof.rs | 207 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 417 insertions(+), 13 deletions(-) create mode 100644 src/proof.rs diff --git a/src/keys.rs b/src/keys.rs index 6638167..bfc3cec 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -20,15 +20,34 @@ use sha2::Sha512; /// Path inside the enclave where QOS writes the hex-encoded 32-byte quorum seed. pub const QUORUM_KEY_PATH: &str = "/qos.quorum.key"; -/// HKDF salts. These domain-separate the two keys so they're independent, and -/// are versioned so the derivation can be rotated without colliding with old keys. +/// Path inside the enclave where QOS writes the hex-encoded ephemeral key. Like +/// the quorum key this is a 32-byte *master seed* (unique per boot, one per +/// replica), from which the sign/encrypt keys are sub-derived via the QOS KeySet +/// paths below. QOS attests the resulting public keys in the enclave's Boot +/// Proof, so [`EphemeralKey`] must reconstruct them exactly. +pub const EPHEMERAL_KEY_PATH: &str = "/qos.ephemeral.key"; + +/// HKDF salts for our two Turnkey API keys. These domain-separate the keys and +/// are versioned so the derivation can be rotated. Unlike the ephemeral key, we +/// *register* these public keys ourselves (they are not attested), so the salts +/// are ours to choose — validated live against Turnkey. const PROG_SALT: &[u8] = b"tvc-cosign-programmatic-v1"; const ADMIN_SALT: &[u8] = b"tvc-cosign-admin-v1"; +/// QOS KeySet sub-derivation paths — used as the HKDF-SHA512 salt over a master +/// seed. These MUST match `qos_p256/src/lib.rs`, because QOS attests the public +/// keys it derives this way; the ephemeral key reconstruction depends on it. +const QOS_SIGN_PATH: &[u8] = b"qos_p256_sign"; +const QOS_ENCRYPT_PATH: &[u8] = b"qos_p256_encrypt"; + /// Insecure fixed seed used only when [`QUORUM_KEY_PATH`] is absent (i.e. running /// outside an enclave, in dev/CI). Keys derived from it are NOT secret. const DEV_SEED: [u8; 32] = [0x11; 32]; +/// Insecure fixed ephemeral master seed used only when [`EPHEMERAL_KEY_PATH`] is +/// absent. App Proofs signed with it are NOT attestable — dev/CI only. +const DEV_EPHEMERAL_SEED: [u8; 32] = [0x22; 32]; + /// A single derived Turnkey API key. pub struct ApiKey { signing_key: SigningKey, @@ -48,6 +67,81 @@ impl ApiKey { } } +/// The enclave's per-boot ephemeral key, reconstructed from the QOS master seed +/// at [`EPHEMERAL_KEY_PATH`]. It signs App Proofs (see `proof.rs`); its public +/// half is attested in the enclave's Boot Proof, so the sign/encrypt keys are +/// 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, + /// Uncompressed SEC1 (`04‖X‖Y`) hex of the sign public key — the form that + /// appears in the App Proof `publicKey` field. + public_key_hex: String, + /// The QOS KeySet hex used to look up this replica's Boot Proof: uncompressed + /// encrypt pubkey ‖ uncompressed sign pubkey (130 bytes). + boot_ephemeral_key_hex: String, +} + +impl EphemeralKey { + /// Reconstruct the ephemeral key from a 32-byte QOS master seed. + pub fn derive(master_seed: &[u8; 32]) -> Self { + let sign_key = derive_signing_key(master_seed, QOS_SIGN_PATH); + let encrypt_key = derive_signing_key(master_seed, QOS_ENCRYPT_PATH); + + let sign_pub = uncompressed_sec1(&sign_key); + let encrypt_pub = uncompressed_sec1(&encrypt_key); + + // Boot Proof lookup key = encryptPub ‖ signPub (matches qos_p256 KeySet). + let mut keyset = encrypt_pub; + keyset.extend_from_slice(&sign_pub); + + EphemeralKey { + signing_key: sign_key, + public_key_hex: hex::encode(&sign_pub), + boot_ephemeral_key_hex: hex::encode(&keyset), + } + } + + /// Load the master seed from the enclave and reconstruct the key. Outside an + /// enclave the file is absent, so we fall back to [`DEV_EPHEMERAL_SEED`] and + /// warn — proofs then carry no attestation value. + pub fn load() -> Self { + match read_hex_seed(Path::new(EPHEMERAL_KEY_PATH)) { + Some(seed) => { + println!("keys: reconstructed ephemeral key from {EPHEMERAL_KEY_PATH}"); + Self::derive(&seed) + } + None => { + eprintln!( + "keys: WARNING {EPHEMERAL_KEY_PATH} not found — using INSECURE dev ephemeral \ + key (not running in an enclave)" + ); + Self::derive(&DEV_EPHEMERAL_SEED) + } + } + } + + /// The P-256 signing key used to sign App Proofs. + pub fn signing_key(&self) -> &SigningKey { + &self.signing_key + } + + /// Uncompressed SEC1 hex of the sign public key (App Proof `publicKey`). + pub fn public_key_hex(&self) -> &str { + &self.public_key_hex + } + + /// The QOS KeySet hex for Boot Proof lookup (`get_boot_proof`'s `ephemeralKey`). + pub fn boot_ephemeral_key_hex(&self) -> &str { + &self.boot_ephemeral_key_hex + } +} + +/// Uncompressed SEC1 encoding (`04‖X‖Y`, 65 bytes) of a signing key's public key. +fn uncompressed_sec1(key: &SigningKey) -> Vec { + key.verifying_key().to_sec1_point(false).as_bytes().to_vec() +} + /// The pair of keys derived for this enclave. pub struct KeySet { pub programmatic: ApiKey, @@ -66,7 +160,7 @@ impl KeySet { /// Load the quorum seed from the enclave and derive both keys. Outside an /// enclave the seed file is absent, so we fall back to [`DEV_SEED`] and warn. pub fn load() -> Self { - match read_quorum_seed(Path::new(QUORUM_KEY_PATH)) { + match read_hex_seed(Path::new(QUORUM_KEY_PATH)) { Some(seed) => { println!("keys: derived from quorum seed at {QUORUM_KEY_PATH}"); Self::derive(&seed) @@ -82,22 +176,30 @@ impl KeySet { } } -/// HKDF-SHA512-expand the quorum seed into a 32-byte P-256 scalar, keyed by `salt`. -fn derive_key(seed: &[u8; 32], salt: &[u8]) -> ApiKey { +/// HKDF-SHA512-expand a 32-byte seed into a P-256 signing key, keyed by `salt`. +/// The construction (extract with `salt`, expand with empty `info`) matches both +/// our own API-key derivation and QOS's KeySet sub-derivation. +fn derive_signing_key(seed: &[u8; 32], salt: &[u8]) -> SigningKey { let hk = Hkdf::::new(Some(salt), seed); let mut okm = [0u8; 32]; hk.expand(&[], &mut okm) .expect("32 bytes is well under HKDF-SHA512's output limit"); let secret = SecretKey::from_slice(&okm).expect("HKDF output is a valid P-256 scalar (overwhelmingly)"); + secret.into() +} + +/// Derive one of our Turnkey API keys from the quorum seed. +fn derive_key(seed: &[u8; 32], salt: &[u8]) -> ApiKey { ApiKey { - signing_key: secret.into(), + signing_key: derive_signing_key(seed, salt), } } -/// Read and hex-decode the quorum seed. Returns `None` if the file is missing or -/// malformed, so callers can fall back gracefully outside an enclave. -fn read_quorum_seed(path: &Path) -> Option<[u8; 32]> { +/// Read and hex-decode a 32-byte hex seed file (`/qos.quorum.key` or +/// `/qos.ephemeral.key`). Returns `None` if the file is missing or malformed, so +/// callers can fall back gracefully outside an enclave. +fn read_hex_seed(path: &Path) -> Option<[u8; 32]> { let contents = std::fs::read_to_string(path).ok()?; let bytes = hex::decode(contents.trim()).ok()?; bytes.try_into().ok() @@ -151,6 +253,56 @@ mod tests { } } + #[test] + fn ephemeral_sign_pubkey_is_uncompressed_sec1() { + let e = EphemeralKey::derive(&SEED_A); + let bytes = hex::decode(e.public_key_hex()).unwrap(); + assert_eq!(bytes.len(), 65, "uncompressed SEC1 is 65 bytes"); + assert_eq!(bytes[0], 0x04, "uncompressed SEC1 prefix must be 0x04"); + } + + #[test] + fn boot_ephemeral_key_is_encrypt_then_sign_pubkeys() { + let e = EphemeralKey::derive(&SEED_A); + let keyset = hex::decode(e.boot_ephemeral_key_hex()).unwrap(); + // encryptPub (65) ‖ signPub (65) — matches the QOS KeySet layout. + assert_eq!(keyset.len(), 130); + assert_eq!(keyset[0], 0x04, "encrypt pubkey is uncompressed"); + assert_eq!(keyset[65], 0x04, "sign pubkey is uncompressed"); + // The sign half is exactly the App Proof public key. + let sign_pub = hex::encode(&keyset[65..]); + assert_eq!(sign_pub, e.public_key_hex()); + } + + #[test] + fn ephemeral_derivation_is_deterministic() { + assert_eq!( + EphemeralKey::derive(&SEED_A).boot_ephemeral_key_hex(), + EphemeralKey::derive(&SEED_A).boot_ephemeral_key_hex() + ); + } + + /// Known-answer, cross-checked byte-for-byte against the production Go + /// reference (tvc-chainalysis `buildBootEphemeralKey` / `qos_p256_sign`) for + /// the seed `[0x22; 32]`. If this drifts, App Proofs stop linking to Boot + /// Proofs — the whole verifiability chain silently breaks. + #[test] + fn ephemeral_matches_qos_reference() { + let e = EphemeralKey::derive(&[0x22; 32]); + assert_eq!( + e.public_key_hex(), + "049050ec6740957f5eefab0fdaf858bb33e0bd1e6c0f7128ad5c5cfc0f64db2877\ + 891a0bf1d63c82630abc16adefff48cd0703956e2f265b82ea8df8a99ca58e89" + ); + assert_eq!( + e.boot_ephemeral_key_hex(), + "04bd9e94d0358df8fec7d6d654cc66f46952f2b0f710f76e8478bc1620b32bb024\ + 2a9d1a9dd3c7a5a71526c3bb3be749e4ced4f7d7344af33ef35d3824c8f58d09\ + 049050ec6740957f5eefab0fdaf858bb33e0bd1e6c0f7128ad5c5cfc0f64db2877\ + 891a0bf1d63c82630abc16adefff48cd0703956e2f265b82ea8df8a99ca58e89" + ); + } + /// Known-answer regression guard: if the derivation ever changes, the /// registered Turnkey pubkeys would silently break. Pin the exact output. #[test] diff --git a/src/main.rs b/src/main.rs index bcb8a11..968f85a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod activity; mod config; mod keys; +mod proof; mod rules; mod stamp; mod tx; @@ -27,7 +28,8 @@ use serde::{Deserialize, Serialize}; use activity::{SignTransaction, build_sign_transaction}; use config::Config; -use keys::KeySet; +use keys::{EphemeralKey, KeySet}; +use proof::{AppProof, ProofInputs, app_proof}; use rules::{Classification, classify}; /// Address the enclave listens on. TVC pivots serve plain HTTP inside the @@ -37,6 +39,8 @@ const LISTEN_ADDR: &str = "0.0.0.0:3000"; /// Shared, read-only application state. struct AppState { keys: KeySet, + /// Per-boot ephemeral key that signs App Proofs (see `proof.rs`). + ephemeral: EphemeralKey, config: Config, } @@ -55,8 +59,20 @@ async fn main() { keys.admin.public_key_hex() ); + // The ephemeral key is per-boot (one per replica); its public half is pinned + // in this replica's Boot Proof. + let ephemeral = EphemeralKey::load(); + println!( + "keys: boot ephemeral key = {}", + ephemeral.boot_ephemeral_key_hex() + ); + let config = Config::from_env(); - let state = Arc::new(AppState { keys, config }); + let state = Arc::new(AppState { + keys, + ephemeral, + config, + }); let listener = tokio::net::TcpListener::bind(LISTEN_ADDR) .await @@ -87,7 +103,11 @@ struct PubkeysResponse { admin: String, } -/// Serve the derived public keys so an operator can register them as API users. +/// Serve the quorum-derived stamping keys so an operator can register them as the +/// two Turnkey API users. These are stable across the TVC's replicas (the quorum +/// key is shared), so they only need to be fetched and registered once. The +/// per-replica ephemeral/Boot-Proof key is NOT here — it rides on each `/cosign` +/// response instead (see [`CosignResponse::boot_ephemeral_key`]). async fn pubkeys(State(state): State>) -> Json { Json(PubkeysResponse { programmatic: state.keys.programmatic.public_key_hex(), @@ -110,6 +130,12 @@ struct CosignResponse { activity_body: String, x_stamp: String, classification: Classification, + /// Enclave-signed proof committing to this decision (see `proof.rs`). + app_proof: AppProof, + /// The QOS KeySet of the replica that produced `app_proof`. Pass it to + /// `get_boot_proof` to fetch the Boot Proof and verify the proof against this + /// enclave's attested code. Per-replica, so it must come from this response. + boot_ephemeral_key: String, } #[derive(Serialize)] @@ -150,18 +176,37 @@ async fn cosign( } }; + // One timestamp shared by the activity body and the proof for this request. + let timestamp_ms = now_ms(); let body = build_sign_transaction(&SignTransaction { organization_id: &state.config.organization_id, sign_with: &req.signer_address, unsigned_transaction: &req.unsigned_transaction, - timestamp_ms: now_ms(), + timestamp_ms, }); let stamped = stamp::stamp(key, &body); + // Attach an App Proof committing to this decision. `raw` re-encoded is the + // same normalized (no-`0x`, lowercase) form the activity body carries. + let proof = app_proof( + &state.ephemeral, + &ProofInputs { + organization_id: &state.config.organization_id, + signer_address: &req.signer_address, + unsigned_transaction: &hex::encode(&raw), + classification, + stamped_with: &key.public_key_hex(), + activity_body: &stamped.body, + timestamp_ms, + }, + ); + Ok(Json(CosignResponse { activity_body: stamped.body, x_stamp: stamped.x_stamp, classification, + app_proof: proof, + boot_ephemeral_key: state.ephemeral.boot_ephemeral_key_hex().to_string(), })) } diff --git a/src/proof.rs b/src/proof.rs new file mode 100644 index 0000000..3c0f3c4 --- /dev/null +++ b/src/proof.rs @@ -0,0 +1,207 @@ +//! App Proof over the cosign decision — the verifiability story. +//! +//! Stamping (see `stamp.rs`) authenticates the request to Turnkey as an API user. +//! An *App Proof* proves something stronger and orthogonal: that *this enclave*, +//! running *this attested code*, classified *this transaction* *this way*. It is +//! a statement signed by the enclave's Ephemeral Key — a per-boot P-256 key whose +//! public half is pinned in the enclave's Boot Proof. A verifier fetches the Boot +//! Proof from Turnkey by that public key, confirms it against the expected code +//! manifest, then verifies this signature — linking the decision to the code. +//! +//! Envelope matches Turnkey's standardized App Proof: +//! +//! { scheme, publicKey, proofPayload (stringified JSON), signature } +//! +//! signature = ECDSA-P256 over SHA-256(proofPayload bytes), DER-encoded, hex. +//! +//! `proofPayload` is a strictly-typed JSON string. We define one proof type, +//! `APP_PROOF_TYPE_COSIGN_DECISION`, committing to the facts a verifier cares +//! about: which wallet, which transaction, the classification, the stamping key, +//! and a digest binding the proof to the exact request that was stamped. + +use p256::ecdsa::Signature; +use p256::ecdsa::signature::Signer; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::keys::EphemeralKey; +use crate::rules::Classification; + +/// Turnkey's signature-scheme identifier for Ephemeral-Key App Proofs. +const APP_PROOF_SCHEME: &str = "SIGNATURE_SCHEME_EPHEMERAL_KEY_P256"; + +/// This application's proof type. Turnkey's own proof types (e.g. +/// `APP_PROOF_TYPE_POLICY_OUTCOME`) are reserved; TVC apps define their own. +const COSIGN_PROOF_TYPE: &str = "APP_PROOF_TYPE_COSIGN_DECISION"; + +/// The facts this proof commits to. Serialized (as a string) into `proofPayload`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CosignDecision<'a> { + /// The (sub-)organization the request targets. + organization_id: &'a str, + /// The wallet the transaction would be signed by (`signWith`). + signer_address: &'a str, + /// The unsigned transaction, normalized to no-`0x` lowercase hex — the exact + /// form that appears in the stamped activity body. + unsigned_transaction: &'a str, + /// How the enclave routed it: `PROGRAMMATIC` or `ADMIN`. + classification: Classification, + /// Compressed-SEC1 hex of the derived API key that stamped the request. Lets a + /// verifier confirm which Turnkey user (and thus which policy path) applies. + stamped_with: &'a str, + /// SHA-256 (hex) of the exact stamped activity body. Binds this proof to the + /// precise bytes the customer submits to Turnkey — re-serialize and it breaks. + activity_body_sha256: String, +} + +/// The typed proof payload: `{ type, timestampMs, cosignDecision }`, matching the +/// shape of Turnkey's own App Proof payloads. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProofPayload<'a> { + #[serde(rename = "type")] + proof_type: &'static str, + timestamp_ms: String, + cosign_decision: CosignDecision<'a>, +} + +/// A finished App Proof, ready to embed in the `/cosign` response. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppProof { + scheme: &'static str, + public_key: String, + /// The JSON proof payload, serialized to a string (verifiers hash these bytes). + proof_payload: String, + /// ECDSA-P256/SHA-256/DER signature over `proof_payload`, hex. + signature: String, +} + +/// Everything needed to build a cosign App Proof. +pub struct ProofInputs<'a> { + pub organization_id: &'a str, + pub signer_address: &'a str, + pub unsigned_transaction: &'a str, + pub classification: Classification, + /// Compressed-SEC1 hex of the key that stamped the request. + pub stamped_with: &'a str, + /// The exact stamped activity body (bound into the proof via its digest). + pub activity_body: &'a str, + pub timestamp_ms: u64, +} + +/// Build and sign an App Proof over a cosign decision, using the enclave's +/// ephemeral key. +pub fn app_proof(ephemeral: &EphemeralKey, inputs: &ProofInputs) -> AppProof { + let activity_body_sha256 = hex::encode(Sha256::digest(inputs.activity_body.as_bytes())); + + let payload = ProofPayload { + proof_type: COSIGN_PROOF_TYPE, + timestamp_ms: inputs.timestamp_ms.to_string(), + cosign_decision: CosignDecision { + organization_id: inputs.organization_id, + signer_address: inputs.signer_address, + unsigned_transaction: inputs.unsigned_transaction, + classification: inputs.classification, + stamped_with: inputs.stamped_with, + activity_body_sha256, + }, + }; + + // Serialize once — this exact string is both signed and returned. A verifier + // re-hashes these bytes, so they must not be re-serialized downstream. + let proof_payload = serde_json::to_string(&payload).expect("proof payload serializes"); + + // RustCrypto's `Signer` for P-256 hashes with SHA-256 and low-S–normalizes — + // the construction Turnkey verifies App Proofs against. + let sig: Signature = ephemeral.signing_key().sign(proof_payload.as_bytes()); + + AppProof { + scheme: APP_PROOF_SCHEME, + public_key: ephemeral.public_key_hex().to_string(), + proof_payload, + signature: hex::encode(sig.to_der().as_bytes()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::VerifyingKey; + use p256::ecdsa::signature::Verifier; + use serde_json::Value; + + fn ephemeral() -> EphemeralKey { + EphemeralKey::derive(&[0x42; 32]) + } + + fn inputs<'a>() -> ProofInputs<'a> { + ProofInputs { + organization_id: "org-123", + signer_address: "0xabc0000000000000000000000000000000000001", + unsigned_transaction: "deadbeef", + classification: Classification::Programmatic, + stamped_with: "02aaaa", + activity_body: r#"{"type":"ACTIVITY_TYPE_SIGN_TRANSACTION_V2"}"#, + timestamp_ms: 1_700_000_000_000, + } + } + + #[test] + fn envelope_has_expected_shape() { + let key = ephemeral(); + let proof = app_proof(&key, &inputs()); + let v: Value = serde_json::to_value(&proof).unwrap(); + + assert_eq!(v["scheme"], APP_PROOF_SCHEME); + assert_eq!(v["publicKey"], key.public_key_hex()); + assert!(!v["signature"].as_str().unwrap().is_empty()); + + // proofPayload is a JSON *string*; parse it and check the typed schema. + let payload: Value = serde_json::from_str(v["proofPayload"].as_str().unwrap()).unwrap(); + assert_eq!(payload["type"], COSIGN_PROOF_TYPE); + assert_eq!(payload["timestampMs"], "1700000000000"); + assert_eq!(payload["cosignDecision"]["classification"], "PROGRAMMATIC"); + assert_eq!( + payload["cosignDecision"]["signerAddress"], + inputs().signer_address + ); + } + + #[test] + fn payload_commits_to_the_activity_body() { + let proof = app_proof(&ephemeral(), &inputs()); + let payload: Value = serde_json::from_str(&proof.proof_payload).unwrap(); + let expected = hex::encode(Sha256::digest(inputs().activity_body.as_bytes())); + assert_eq!(payload["cosignDecision"]["activityBodySha256"], expected); + } + + #[test] + fn signature_verifies_against_the_ephemeral_key() { + let key = ephemeral(); + let proof = app_proof(&key, &inputs()); + + let vk = VerifyingKey::from_sec1_bytes(&hex::decode(&proof.public_key).unwrap()).unwrap(); + let sig = Signature::from_der(&hex::decode(&proof.signature).unwrap()).unwrap(); + + vk.verify(proof.proof_payload.as_bytes(), &sig) + .expect("app proof signature verifies over its payload"); + } + + #[test] + fn tampered_payload_fails_verification() { + let key = ephemeral(); + let proof = app_proof(&key, &inputs()); + + let vk = VerifyingKey::from_sec1_bytes(&hex::decode(&proof.public_key).unwrap()).unwrap(); + let sig = Signature::from_der(&hex::decode(&proof.signature).unwrap()).unwrap(); + + let tampered = proof.proof_payload.replace("PROGRAMMATIC", "ADMIN"); + assert_ne!(tampered, proof.proof_payload); + assert!( + vk.verify(tampered.as_bytes(), &sig).is_err(), + "a proof must not verify after its decision is altered" + ); + } +} From 53cf2bdd18f11ad1119849953fee324c78fadd4b Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 8 Jul 2026 23:53:09 -0400 Subject: [PATCH 11/27] feat(config): configure via CLI args for pivotArgs deploy --- src/config.rs | 53 +++++++++++++++++++++++++++++++----------------- src/main.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 84 insertions(+), 25 deletions(-) diff --git a/src/config.rs b/src/config.rs index 6d55972..5717e97 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,16 +2,24 @@ //! //! The only secret this app uses is the quorum seed, which is read from //! `/qos.quorum.key` — never from here. Everything in `Config` is safe to pass -//! as a plain deploy-time argument / environment variable or as a file baked -//! into the image, and is not secret. +//! as a plain deploy-time argument or as a file baked into the image, and is not +//! secret. +//! +//! In a TVC deployment there is no way to inject environment variables — runtime +//! config arrives through the pivot binary's CLI arguments (`pivotArgs`), which +//! are recorded in the QOS manifest and therefore attested. So the organization +//! id is passed as `--organization-id`, and the ruleset is baked into the image +//! at `--rules-path` (covered by the image digest). Environment variables are +//! still honored as a convenience for local development. use crate::rules::Ruleset; -/// Environment variable holding the customer's (sub-)organization id. +/// Environment variable holding the customer's (sub-)organization id (dev only; +/// in a deployment this comes from `--organization-id`). const ORGANIZATION_ID_ENV: &str = "TVC_ORGANIZATION_ID"; -/// Environment variable pointing at the ruleset TOML file. +/// Environment variable pointing at the ruleset TOML file (dev-only fallback). const RULES_PATH_ENV: &str = "TVC_RULES_PATH"; -/// Default ruleset path if `TVC_RULES_PATH` is unset. +/// Default ruleset path when neither `--rules-path` nor `TVC_RULES_PATH` is set. const DEFAULT_RULES_PATH: &str = "rules.toml"; /// Non-secret configuration loaded once at startup. @@ -23,21 +31,28 @@ pub struct Config { } impl Config { - /// Load config from the environment. A missing `organizationId` or ruleset - /// file is tolerated so the app still boots for local inspection: the org id - /// falls back to an empty placeholder, and the ruleset falls back to - /// deny-all (every transaction is REJECTed). Both warn loudly. - pub fn from_env() -> Self { - let organization_id = std::env::var(ORGANIZATION_ID_ENV).unwrap_or_else(|_| { - eprintln!( - "config: WARNING {ORGANIZATION_ID_ENV} not set — using empty placeholder; \ - set it to submit real requests" - ); - String::new() - }); + /// Resolve config with precedence CLI argument > environment variable > + /// default. A missing `organizationId` or ruleset file is tolerated so the + /// app still boots for local inspection: the org id falls back to an empty + /// placeholder, and the ruleset falls back to deny-all (every transaction is + /// REJECTed). Both warn loudly. + pub fn load(cli_organization_id: Option<&str>, cli_rules_path: Option<&str>) -> Self { + let organization_id = cli_organization_id + .map(str::to_string) + .or_else(|| std::env::var(ORGANIZATION_ID_ENV).ok()) + .unwrap_or_else(|| { + eprintln!( + "config: WARNING organization id not set (--organization-id / \ + {ORGANIZATION_ID_ENV}) — using empty placeholder; set it to submit real \ + requests" + ); + String::new() + }); - let rules_path = - std::env::var(RULES_PATH_ENV).unwrap_or_else(|_| DEFAULT_RULES_PATH.to_string()); + let rules_path = cli_rules_path + .map(str::to_string) + .or_else(|| std::env::var(RULES_PATH_ENV).ok()) + .unwrap_or_else(|| DEFAULT_RULES_PATH.to_string()); let ruleset = Ruleset::load(&rules_path).unwrap_or_else(|e| { eprintln!("config: WARNING could not load ruleset ({e}) — using deny-all"); Ruleset::deny_all() diff --git a/src/main.rs b/src/main.rs index 968f85a..23649c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,12 @@ //! as Turnkey API users). //! POST /cosign — classify an unsigned tx and return a stamped //! SIGN_TRANSACTION_V2 request for the customer to submit. +//! +//! Runtime config comes from CLI arguments (`pivotArgs` in a TVC deployment): +//! --organization-id the (sub-)org to stamp requests for (attested) +//! --rules-path ruleset TOML (default `rules.toml`; baked image +//! deployments pass `/rules.toml`) +//! --port listen port (default 3000) mod activity; mod config; @@ -32,9 +38,44 @@ use keys::{EphemeralKey, KeySet}; use proof::{AppProof, ProofInputs, app_proof}; use rules::{Classification, classify}; -/// Address the enclave listens on. TVC pivots serve plain HTTP inside the -/// enclave; the host proxies to them. -const LISTEN_ADDR: &str = "0.0.0.0:3000"; +/// Default listen port when `--port` is not supplied. +const DEFAULT_PORT: u16 = 3000; + +/// Parsed command-line arguments (a TVC deployment supplies these as `pivotArgs`). +struct Args { + organization_id: Option, + rules_path: Option, + port: u16, +} + +/// Minimal hand-rolled arg parsing — avoids a CLI dependency for three flags. +/// TVC pivots serve plain HTTP inside the enclave and bind all interfaces. +fn parse_args() -> Args { + let mut args = Args { + organization_id: None, + rules_path: None, + port: DEFAULT_PORT, + }; + let mut iter = std::env::args().skip(1); + 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:?}"), + } + } + args +} /// Shared, read-only application state. struct AppState { @@ -46,6 +87,8 @@ struct AppState { #[tokio::main] async fn main() { + let args = parse_args(); + // Derive the enclave's two API keys once at boot. Enclave stdout is not // observable in production, so these prints are only a local-dev aid; the // pubkeys are exposed over GET /pubkeys for registration. @@ -67,17 +110,18 @@ async fn main() { ephemeral.boot_ephemeral_key_hex() ); - let config = Config::from_env(); + let config = Config::load(args.organization_id.as_deref(), args.rules_path.as_deref()); let state = Arc::new(AppState { keys, ephemeral, config, }); - let listener = tokio::net::TcpListener::bind(LISTEN_ADDR) + let listen_addr = format!("0.0.0.0:{}", args.port); + let listener = tokio::net::TcpListener::bind(&listen_addr) .await .expect("bind listener"); - println!("tvc-cosign listening on {LISTEN_ADDR}"); + println!("tvc-cosign listening on {listen_addr}"); axum::serve(listener, router(state)).await.expect("serve"); } From 115e49a03b8e59fd1290a695fa1ebabea2ca62fd Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 8 Jul 2026 23:55:19 -0400 Subject: [PATCH 12/27] chore: add reproducible Dockerfile --- .dockerignore | 11 +++++++++++ Cargo.toml | 9 +++++++++ Dockerfile | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ed8e9a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Keep the build context minimal and deterministic: only Cargo manifests, src, +# and the baked ruleset are COPYed in the Dockerfile. Excluding everything else +# ensures the image digest is a pure function of the committed source. +/target +/notes +/.git +/.claude +*.local.toml +.env +README.md +rules.example.toml diff --git a/Cargo.toml b/Cargo.toml index 0abb041..e948516 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,12 @@ serde_json = "1.0.150" sha2 = "0.11.0" tokio = { version = "1.52.3", features = ["full"] } toml = "1.1.2" + +# Release profile tuned for a small, deterministic enclave binary. `strip` +# removes symbols/debug info (which carry build-machine paths), so the pivot +# digest is reproducible across build hosts. +[profile.release] +strip = true +lto = true +codegen-units = 1 +panic = "abort" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..112abcc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 +# ── Build stage ────────────────────────────────────────────────────────────── +# Reproducible builds require pinning every input by digest. Before an attestable +# deployment, pin this image: +# docker pull rust:1.94-alpine +# docker inspect rust:1.94-alpine --format='{{index .RepoDigests 0}}' +# then append the resulting @sha256:... to the FROM line below. Keep the Rust +# version aligned with rust-toolchain / your local toolchain for output parity. +FROM rust:1.94-alpine AS builder + +# musl-dev supplies the headers + static C runtime the linker needs. Alpine's +# default target is x86_64-unknown-linux-musl, so cargo produces a fully static +# binary with no libc dependency. +RUN apk add --no-cache musl-dev + +WORKDIR /app +# Copy only what the build needs (see .dockerignore) so the image can't pick up +# stray host files and the digest stays a function of the committed source. +COPY Cargo.toml Cargo.lock ./ +COPY src ./src + +# --locked builds against the committed Cargo.lock exactly (no dependency drift). +RUN cargo build --release --locked + +# ── Runtime stage ──────────────────────────────────────────────────────────── +# StageX busybox is itself reproducibly built and provides the /bin/sh that TVC's +# containerd requires to start the task. There are deliberately NO ca-certificates +# and no libc: the binary is static and makes ZERO network egress — it only stamps +# requests, so it has nothing to reach out to. That is a security property, not an +# omission (see README "No egress"). +FROM stagex/core-busybox:1.36.1@sha256:cac5d773db1c69b832d022c469ccf5f52daf223b91166e6866d42d6983a3b374 + +COPY --from=builder /app/target/release/tvc-cosign /tvc-cosign + +# The ruleset is baked into the image so it is covered by the image digest — i.e. +# attested as part of the deployment. Create ./rules.toml from rules.example.toml +# (with your real allowlists) before building. +COPY rules.toml /rules.toml + +# Print the pivot binary digest at build time. This is the `expectedPivotDigest` +# you record in the TVC deployment manifest. +RUN sha256sum /tvc-cosign | awk '{print "expectedPivotDigest=sha256:" $1}' + +EXPOSE 3000 + +# For local `docker run`. In a TVC deployment these are overridden by the +# manifest's pivotPath (/tvc-cosign) + pivotArgs, where you also add +# --organization-id (see README Step 6). +ENTRYPOINT ["/tvc-cosign"] +CMD ["--rules-path", "/rules.toml", "--port", "3000"] From 5d73f9b849b447d97b026ba39da9cf3688796fe7 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 8 Jul 2026 23:56:50 -0400 Subject: [PATCH 13/27] docs: add README --- README.md | 385 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8f9a7d --- /dev/null +++ b/README.md @@ -0,0 +1,385 @@ +# tvc-cosign + +A [Turnkey Verifiable Cloud (TVC)](https://docs.turnkey.com/features/verifiable-cloud/overview) +pivot binary that runs inside an AWS Nitro Enclave and exposes a single +`POST /cosign` endpoint. It parses an unsigned EVM transaction, classifies it +against a baked-in ruleset, and returns a **stamped** Turnkey `SIGN_TRANSACTION` +activity request signed by one of two keys deterministically derived from the +enclave's quorum key: `programmatic` or `admin`. The customer submits that +request to Turnkey, where policies either auto-complete it (programmatic) or hold +it for human approval (admin). + +The app **never holds Turnkey credentials and makes zero network egress**, it +only stamps. All trust flows from the quorum-key-derived API keys plus Turnkey +policies, and every decision is accompanied by a verifiable **App Proof**. + +``` +unsigned tx bytes ──▶ POST /cosign (this app, in the enclave) + │ 1. parse: to / selector / args / value + │ 2. classify: PROGRAMMATIC | ADMIN | REJECT + │ 3. build SIGN_TRANSACTION_V2 body + │ 4. stamp body with the derived prog OR admin key + │ 5. App-Proof the decision with the ephemeral key + ▼ + { activityBody, xStamp, classification, appProof, bootEphemeralKey } + │ + customer submits activityBody + xStamp to api.turnkey.com + ▼ + Turnkey policies evaluate the stamping API user: + • TVC programmatic → policy ALLOW → COMPLETED + • TVC admin → require human consensus → CONSENSUS_NEEDED +``` + +--- + +## Contents + +- [How it works](#how-it-works) +- [Endpoints](#endpoints) +- [Local development](#local-development) +- [Reproducible build](#reproducible-build) +- [Deploy to TVC](#deploy-to-tvc) +- [Turnkey org setup: users + policies](#turnkey-org-setup-users--policies) +- [Integration example](#integration-example) +- [Verifying the proofs](#verifying-the-proofs) +- [Configuration](#configuration) +- [Limitations & operational considerations](#limitations--operational-considerations) + +--- + +## How it works + +The quorum key is **stable across deployments** and exposed inside the enclave at +`/qos.quorum.key`. Two independent API keys are HKDF-derived from it: + +``` +prog_key = P256(HKDF-SHA512(salt="tvc-cosign-programmatic-v1", ikm=quorum_seed)) +admin_key = P256(HKDF-SHA512(salt="tvc-cosign-admin-v1", ikm=quorum_seed)) +``` + +Because the seed is stable, these public keys are stable and are registered as +Turnkey API users **once** (read them from `GET /pubkeys`). Classification: + +- **PROGRAMMATIC**: an ERC-20 `transfer` whose token, recipient, and amount all + pass the ruleset → stamped with `prog_key`. +- **ADMIN**: a privileged selector on the admin allowlist → stamped with + `admin_key`. +- **REJECT**: anything else, or a signer not on the allowlist → `400`, no stamp. + +Separately, each enclave has a per-boot **ephemeral key** (`/qos.ephemeral.key`, +one per replica) whose public half is attested in the enclave's **Boot Proof**. +Every `/cosign` response carries an **App Proof** signed by that key, committing +to the exact decision. See [Verifying the proofs](#verifying-the-proofs). + +## Endpoints + +| Endpoint | Purpose | +|---|---| +| `GET /health` | Liveness probe → `200 {"status":"ok"}` (required by TVC). | +| `GET /pubkeys` | `{ programmatic, admin }`: the two stamping public keys to register as API users. Stable across replicas; fetch once. | +| `POST /cosign` | In: `{ unsignedTransaction, signerAddress }`. Out: `{ activityBody, xStamp, classification, appProof, bootEphemeralKey }`. | + +--- + +## Local development + +```bash +# Build + run the full test suite (crypto is pinned by known-answer tests). +cargo test + +# Create a ruleset from the example and run locally. +cp rules.example.toml rules.toml +cargo run -- --organization-id "$YOUR_SUB_ORG_ID" --rules-path rules.example.toml + +# In another shell: +curl -s localhost:3000/health +curl -s localhost:3000/pubkeys | jq + +# Cosign an ERC-20 transfer (matches rules.example.toml). +TX=02f862018080808094111111111111111111111111111111111111111180b844a9059cbb00000000000000000000000000000000000000000000000000000000000000ff00000000000000000000000000000000000000000000000000000000000001f4c0 +curl -s -X POST localhost:3000/cosign -H 'content-type: application/json' \ + -d "{\"unsignedTransaction\":\"$TX\",\"signerAddress\":\"0x00000000000000000000000000000000000000a1\"}" | jq +``` + +> Outside an enclave, `/qos.quorum.key` and `/qos.ephemeral.key` are absent, so +> the app falls back to **insecure dev seeds** and warns loudly. The real keys +> (and therefore the registered pubkeys) only appear once deployed, read them +> from `GET /pubkeys` on the live enclave. + +--- + +## Reproducible build + +A TVC app boots from a container image whose digest is committed to the +deployment, and the QOS manifest additionally pins the pivot binary's digest. If +the binary changes between builds, even from identical source, attestation +fails. Every build input is therefore pinned: + +| Input | How it's pinned | +|---|---| +| Rust toolchain | `rust:1.94-alpine` in `Dockerfile` (pin its `@sha256:` before deploying; see the comment in the file) | +| Runtime base | `stagex/core-busybox:1.36.1@sha256:cac5d773…` (StageX is itself reproducible) | +| Rust dependencies | `Cargo.lock`, committed; built with `--locked` | +| Symbols / paths | `[profile.release] strip = true` removes build-machine paths from the binary | + +The runtime image ships **no ca-certificates and no libc**, the binary is static +and makes no egress (see [Limitations](#limitations--operational-considerations)). + +```bash +# Bake your real ruleset into the image (it becomes part of the attested digest). +cp rules.example.toml rules.toml && $EDITOR rules.toml + +# Build + push for linux/amd64 (required by Nitro Enclaves). +docker buildx build --platform linux/amd64 \ + -t ghcr.io/YOUR_ORG/tvc-cosign:latest --push . + +# The container image digest for the deployment (pick the linux/amd64 manifest): +docker buildx imagetools inspect ghcr.io/YOUR_ORG/tvc-cosign:latest +# → use ghcr.io/YOUR_ORG/tvc-cosign@sha256: + +# The pivot binary digest (expectedPivotDigest). The build also prints this as +# an `expectedPivotDigest=sha256:...` line; to recompute from the pushed image: +docker create --name x ghcr.io/YOUR_ORG/tvc-cosign:latest /bin/true \ + && docker cp x:/tvc-cosign ./tvc-cosign.bin && docker rm x +sha256sum ./tvc-cosign.bin +``` + +--- + +## Deploy to TVC + +Requires the [`tvc` CLI](https://docs.turnkey.com/getting-started/verifiable-cloud-quickstart) +(`cargo install tvc`) and TVC access enabled for your org. + +```bash +tvc login # generates an operator keypair +tvc app init --output app.json # set "name": "tvc-cosign", then: +tvc app create --config-file app.json + +tvc deploy init # edit the generated deploy-*.json: +# qosVersion: +# pivotContainerImageUrl: ghcr.io/YOUR_ORG/tvc-cosign@sha256: +# pivotPath: /tvc-cosign +# pivotArgs: ["--organization-id","", +# "--rules-path","/rules.toml","--port","3000"] +# expectedPivotDigest: sha256: +# healthCheckType: TVC_HEALTH_CHECK_TYPE_HTTP +# healthCheckPort: 3000 +# publicIngressPort: 3000 +# debugMode: false +tvc deploy create --config-file deploy-.json +tvc deploy approve --deploy-id --operator-id +``` + +`--organization-id` is passed in `pivotArgs`, so it is recorded in the QOS +manifest and **attested**, so the deployment provably stamps only for that org. The +ruleset is baked into the image at `/rules.toml`, so it is covered by the image +digest. One deployment = one org + one ruleset. + +Once **LIVE**, the app is reachable at `https://app-.turnkey.cloud`: + +```bash +curl https://app-.turnkey.cloud/health # → {"status":"ok"} +curl https://app-.turnkey.cloud/pubkeys # the REAL stamping keys +``` + +--- + +## Turnkey org setup: users + policies + +Do this once, against the sub-org you passed as `--organization-id`. + +**1. A wallet** whose account address is your `signerAddress` / `signWith` +target, and which appears in `allowed_signers` in `rules.toml`. + +**2. Two API-only users**, with API public keys taken verbatim from +`GET /pubkeys` on the live enclave (curve `API_KEY_CURVE_P256`): + +| User | API public key | +|---|---| +| `TVC programmatic` | `pubkeys.programmatic` | +| `TVC admin` | `pubkeys.admin` | + +**3. Two policies.** The engine is default-deny, so each policy only grants a +specific ALLOW. (Field syntax: [policy language](https://docs.turnkey.com/features/policies/language).) + +Programmatic → auto-complete (self-consensus by the programmatic user): + +```json +{ + "policyName": "TVC programmatic: allow signing", + "effect": "EFFECT_ALLOW", + "consensus": "approvers.any(user, user.id == '')", + "condition": "activity.action == 'SIGN'" +} +``` + +Admin → require human consensus (holds at `CONSENSUS_NEEDED` until the named +humans approve; they get implicit approve permission by being in `consensus`): + +```json +{ + "policyName": "TVC admin: require 2 human approvers", + "effect": "EFFECT_ALLOW", + "consensus": "approvers.any(user, user.id == '') && approvers.any(user, user.id == '') && approvers.any(user, user.id == '')", + "condition": "activity.action == 'SIGN'" +} +``` + +The two policies are disjoint by initiator: a programmatic-stamped activity never +matches the admin policy and vice-versa. To tighten further (belt-and-suspenders +with the enclave-side signer allowlist), extend `condition`, e.g. +`activity.action == 'SIGN' && wallet.id == ''`, or scope by +`eth.tx.to`. For a larger human quorum, tag your approvers and use +`approvers.filter(user, user.tags.contains('')).count() >= N`. + +--- + +## Integration example + +The customer calls `/cosign`, then forwards the result to Turnkey. **Send +`activityBody` verbatim**, the stamp covers those exact bytes, so any +re-serialization breaks it. `classification` is informational (client-side only). + +```bash +OUT=$(curl -s -X POST "$TVC_URL/cosign" -H 'content-type: application/json' \ + -d "{\"unsignedTransaction\":\"$TX\",\"signerAddress\":\"$SIGNER\"}") + +curl -s https://api.turnkey.com/public/v1/submit/sign_transaction \ + -H 'Content-Type: application/json' \ + -H "X-Stamp: $(printf '%s' "$OUT" | jq -r .xStamp)" \ + --data "$(printf '%s' "$OUT" | jq -r .activityBody)" # verbatim body +``` + +```javascript +const cosign = await fetch(`${TVC_URL}/cosign`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ unsignedTransaction, signerAddress }), +}).then((r) => r.json()); + +// Forward to Turnkey. activityBody is sent as-is; xStamp goes in the header. +const res = await fetch( + "https://api.turnkey.com/public/v1/submit/sign_transaction", + { + method: "POST", + headers: { "Content-Type": "application/json", "X-Stamp": cosign.xStamp }, + body: cosign.activityBody, // do NOT JSON.parse/stringify; send verbatim + }, +).then((r) => r.json()); + +switch (res.activity.status) { + case "ACTIVITY_STATUS_COMPLETED": { + const signed = res.activity.result.signTransactionResult.signedTransaction; + // broadcast `signed` + break; + } + case "ACTIVITY_STATUS_CONSENSUS_NEEDED": { + // admin path: notify human approvers; poll get_activity or use webhooks. + const activityId = res.activity.id; + break; + } +} +``` + +--- + +## Verifying the proofs + +Every `/cosign` response includes an `appProof` and a `bootEphemeralKey`. Together +with the enclave's Boot Proof they prove that **this attested code classified this +transaction this way**, independent of Turnkey and of this app's operator. + +`appProof` is the standard Turnkey App Proof envelope: + +```json +{ + "scheme": "SIGNATURE_SCHEME_EPHEMERAL_KEY_P256", + "publicKey": "04…", // uncompressed SEC1 sign key (65 bytes) + "proofPayload": "{\"type\":\"APP_PROOF_TYPE_COSIGN_DECISION\", …}", + "signature": "30…" // P-256 / SHA-256 / DER over proofPayload +} +``` + +The `proofPayload` commits to `organizationId`, `signerAddress`, +`unsignedTransaction`, `classification`, `stampedWith` (which API key stamped), +and `activityBodySha256` (SHA-256 of the exact submitted body). + +To verify: + +1. **App Proof signature**: verify `signature` over the raw `proofPayload` + string bytes (ECDSA P-256, SHA-256) against `publicKey`. If it checks out, the + decision is intact and was produced by the holder of that ephemeral key. +2. **Boot Proof**: fetch it for the replica that answered, using the response's + `bootEphemeralKey` (per-replica; **use the value from the same response**): + + ``` + POST https://api.turnkey.com/public/v1/query/get_boot_proof + { "organizationId": "", "ephemeralKey": "" } + ``` +3. **Link them**: `bootProof.ephemeralPublicKeyHex` ends with + `appProof.publicKey` (the boot key is `encryptPub ‖ signPub`; the sign half is + the App Proof key). +4. **Confirm the code**: `bootProof.awsAttestationDocB64` is an AWS-signed COSE + document with the enclave's PCRs, and `bootProof.qosManifestB64` contains the + pivot binary hash, compare it to your `expectedPivotDigest` from the build. + +Turnkey publishes verification tooling so you don't hand-roll steps 1–4: +[`turnkey_proofs`](https://crates.io/crates/turnkey_proofs) (Rust), +[`@turnkey/crypto` `proof.ts`](https://github.com/tkhq/sdk/blob/main/packages/crypto/src/proof.ts) +(JS), and the [Go SDK](https://github.com/tkhq/go-sdk/tree/main/pkg/proofs). + +> **Replicas.** A production TVC runs multiple replicas, each with its own +> ephemeral key, and requests are load-balanced across them. Always verify using +> the `appProof.publicKey` / `bootEphemeralKey` returned **in that same response** +> and never a cached one from a different call. + +--- + +## Configuration + +All runtime config is passed as CLI arguments (`pivotArgs` in a deployment). None +of it is secret; the only secrets are the enclave-provided key files. + +| Argument | Default | Meaning | +|---|---|---| +| `--organization-id ` | none (warns, empty) | The (sub-)org placed in every `SIGN_TRANSACTION_V2` body. Attested via `pivotArgs`. | +| `--rules-path ` | `rules.toml` | Ruleset TOML. Baked deployments pass `/rules.toml`. | +| `--port ` | `3000` | Listen port (binds `0.0.0.0`). | + +For local dev, `TVC_ORGANIZATION_ID` and `TVC_RULES_PATH` env vars are honored as +fallbacks (a TVC deployment cannot inject env vars). See `rules.example.toml` for +the ruleset format: `allowed_signers`, and a `[programmatic]` block +(`allowed_tokens`, `allowed_recipients`, `max_amount`) plus `[admin] selectors`. + +--- + +## Limitations & operational considerations + +This is a POC. Known scope limits, all deliberate: + +- **No caller authentication on `/cosign`.** Access is network-perimeter only. + Anyone who can reach the endpoint can request a stamp; safety comes from the + ruleset + the Turnkey-side policies, not from authenticating the caller. Put it + behind your own authenticated ingress. +- **`max_amount` is a single global cap**, interpreted in the token's base units, + it ignores per-token decimals and USD value. Easy extension: per-token caps in + `rules.toml`. +- **No price/velocity/cumulative limits.** Each `/cosign` is stateless, so a + per-transaction cap is not a spending limit. Rate/velocity limits would need + state (in tension with the stateless, attestable design) or a Turnkey-side + policy. +- **ERC-20 `transfer` only** for the programmatic path; native-ETH transfers and + other selectors are REJECT (admin selectors excepted). Contract-creation is + out of scope. +- **One deployment = one org + one ruleset.** New contracts/rules mean a new + deployment (and a new attested image), not new individual policies. +- **No network egress, by design.** The app only stamps, so it needs nothing + external; the image ships without ca-certificates. Egress would only be + justified to add on-chain / sanctions / price-oracle rules, and TVC external + connectivity is a separate feature. +- **Verification status.** The stamp construction is validated live against + Turnkey, and the ephemeral-key / App-Proof construction is validated + byte-for-byte against Turnkey's production reference. The full live round-trip + (submit → policy outcome → Boot-Proof verification) is exercised once deployed + to a real enclave. From 6062513051bb9523ba769e801fa8bfb7787c3a74 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Fri, 10 Jul 2026 00:01:44 -0400 Subject: [PATCH 14/27] fix(rules): embed ruleset in binary so it reaches the TVC enclave --- .gitignore | 8 ++++++++ Dockerfile | 15 ++++++++------- src/config.rs | 44 ++++++++++++++++++++++++-------------------- src/rules.rs | 38 +++++++++++++++++++------------------- 4 files changed, 59 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index 21fd020..454d10a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,17 @@ # Rust build artifacts /target +# Per-deployment ruleset: compiled into the binary via include_str!, but it holds +# each deployer's own allowlists. Create it from rules.example.toml before building +# (see README Step 1); this demo does not commit its rules, but you might want to do so if it fits your use case. +/rules.toml + # Local secrets / ruleset overrides (never commit real ones) *.local.toml .env +# Extracted pivot binaries / digest-check artifacts +*.bin + # Notes /notes \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 112abcc..8505ae7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ # docker inspect rust:1.94-alpine --format='{{index .RepoDigests 0}}' # then append the resulting @sha256:... to the FROM line below. Keep the Rust # version aligned with rust-toolchain / your local toolchain for output parity. -FROM rust:1.94-alpine AS builder +FROM rust:1.94-alpine@sha256:77237dd363a0b127bb5ef532c2d64c0deb380b738e43a9c4bdac73398d6d0a08 AS builder # musl-dev supplies the headers + static C runtime the linker needs. Alpine's # default target is x86_64-unknown-linux-musl, so cargo produces a fully static @@ -18,6 +18,9 @@ WORKDIR /app # stray host files and the digest stays a function of the committed source. COPY Cargo.toml Cargo.lock ./ COPY src ./src +# rules.toml is compiled into the binary via include_str! (see src/rules.rs), so it +# must be present in the build context. +COPY rules.toml ./rules.toml # --locked builds against the committed Cargo.lock exactly (no dependency drift). RUN cargo build --release --locked @@ -32,10 +35,8 @@ FROM stagex/core-busybox:1.36.1@sha256:cac5d773db1c69b832d022c469ccf5f52daf223b9 COPY --from=builder /app/target/release/tvc-cosign /tvc-cosign -# The ruleset is baked into the image so it is covered by the image digest — i.e. -# attested as part of the deployment. Create ./rules.toml from rules.example.toml -# (with your real allowlists) before building. -COPY rules.toml /rules.toml +# The ruleset is compiled into the binary, so it is covered by expectedPivotDigest. +# There is deliberately no rules.toml file in this image. # Print the pivot binary digest at build time. This is the `expectedPivotDigest` # you record in the TVC deployment manifest. @@ -45,6 +46,6 @@ EXPOSE 3000 # For local `docker run`. In a TVC deployment these are overridden by the # manifest's pivotPath (/tvc-cosign) + pivotArgs, where you also add -# --organization-id (see README Step 6). +# --organization-id (see README). ENTRYPOINT ["/tvc-cosign"] -CMD ["--rules-path", "/rules.toml", "--port", "3000"] +CMD ["--port", "3000"] diff --git a/src/config.rs b/src/config.rs index 5717e97..9108488 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,22 +5,20 @@ //! as a plain deploy-time argument or as a file baked into the image, and is not //! secret. //! -//! In a TVC deployment there is no way to inject environment variables — runtime -//! config arrives through the pivot binary's CLI arguments (`pivotArgs`), which -//! are recorded in the QOS manifest and therefore attested. So the organization -//! id is passed as `--organization-id`, and the ruleset is baked into the image -//! at `--rules-path` (covered by the image digest). Environment variables are -//! still honored as a convenience for local development. +//! In a TVC deployment there is no way to inject environment variables: runtime +//! config arrives through the pivot binary's CLI arguments (`pivotArgs`), which are +//! recorded in the QOS manifest and therefore attested. The organization id is +//! passed as `--organization-id`; the ruleset is compiled into the binary (see +//! [`crate::rules::Ruleset::embedded`]). `--rules-path` / `TVC_RULES_PATH` remain a +//! local-dev override only. use crate::rules::Ruleset; /// Environment variable holding the customer's (sub-)organization id (dev only; /// in a deployment this comes from `--organization-id`). const ORGANIZATION_ID_ENV: &str = "TVC_ORGANIZATION_ID"; -/// Environment variable pointing at the ruleset TOML file (dev-only fallback). +/// Environment variable pointing at a ruleset TOML file (dev-only override). const RULES_PATH_ENV: &str = "TVC_RULES_PATH"; -/// Default ruleset path when neither `--rules-path` nor `TVC_RULES_PATH` is set. -const DEFAULT_RULES_PATH: &str = "rules.toml"; /// Non-secret configuration loaded once at startup. pub struct Config { @@ -31,11 +29,12 @@ pub struct Config { } impl Config { - /// Resolve config with precedence CLI argument > environment variable > - /// default. A missing `organizationId` or ruleset file is tolerated so the - /// app still boots for local inspection: the org id falls back to an empty - /// placeholder, and the ruleset falls back to deny-all (every transaction is - /// REJECTed). Both warn loudly. + /// Resolve config. The `organizationId` comes from `--organization-id` (or env + /// var for dev); if unset it falls back to an empty placeholder and warns. + /// + /// The ruleset is the one compiled into the binary ([`Ruleset::embedded`]). A + /// `--rules-path` / `TVC_RULES_PATH` override is honored for local dev only, and + /// if it fails to load falls back to the embedded ruleset (never deny-all). pub fn load(cli_organization_id: Option<&str>, cli_rules_path: Option<&str>) -> Self { let organization_id = cli_organization_id .map(str::to_string) @@ -49,14 +48,19 @@ impl Config { String::new() }); - let rules_path = cli_rules_path + let ruleset = match cli_rules_path .map(str::to_string) .or_else(|| std::env::var(RULES_PATH_ENV).ok()) - .unwrap_or_else(|| DEFAULT_RULES_PATH.to_string()); - let ruleset = Ruleset::load(&rules_path).unwrap_or_else(|e| { - eprintln!("config: WARNING could not load ruleset ({e}) — using deny-all"); - Ruleset::deny_all() - }); + { + Some(path) => Ruleset::load(&path).unwrap_or_else(|e| { + eprintln!( + "config: WARNING could not load --rules-path {path} ({e}); using the \ + embedded ruleset" + ); + Ruleset::embedded().expect("embedded ruleset (rules.toml) is valid") + }), + None => Ruleset::embedded().expect("embedded ruleset (rules.toml) is valid"), + }; Config { organization_id, diff --git a/src/rules.rs b/src/rules.rs index e572f9f..60e09bf 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -15,6 +15,10 @@ use crate::tx::ParsedTx; /// ERC-20 `transfer(address,uint256)` selector (`keccak256(...)[..4]`). const TRANSFER_SELECTOR: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb]; +/// 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"); + /// How a transaction is routed. Serialized as `PROGRAMMATIC` / `ADMIN` / `REJECT`. #[derive(serde::Serialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] @@ -34,24 +38,21 @@ pub struct Ruleset { } impl Ruleset { - /// A ruleset that rejects everything — the safe default when no config is - /// present (empty allowlists, zero cap, no admin selectors). - pub fn deny_all() -> Self { - Ruleset { - allowed_signers: HashSet::new(), - allowed_tokens: HashSet::new(), - allowed_recipients: HashSet::new(), - max_amount: U256::ZERO, - admin_selectors: HashSet::new(), - } - } - - /// Load and validate a ruleset from a TOML file. + /// Load and validate a ruleset from a TOML file (local-dev override only; see + /// [`Ruleset::embedded`]). pub fn load(path: &str) -> Result { let text = std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?; let raw: RawRuleset = toml::from_str(&text).map_err(|e| format!("parse {path}: {e}"))?; raw.into_ruleset() } + + /// The ruleset compiled into the binary, covered by the attested + /// `expectedPivotDigest`. This is what a real TVC deployment enforces. + pub fn embedded() -> Result { + let raw: RawRuleset = toml::from_str(EMBEDDED_RULES_TOML) + .map_err(|e| format!("parse embedded rules.toml: {e}"))?; + raw.into_ruleset() + } } /// Classify a parsed transaction against the ruleset. @@ -311,11 +312,10 @@ mod tests { } #[test] - fn deny_all_rejects_everything() { - let tx = transfer_tx(TOKEN, RECIPIENT, 1); - assert_eq!( - classify(SIGNER, &tx, &Ruleset::deny_all()), - Classification::Reject - ); + fn embedded_ruleset_parses() { + // The ruleset compiled in via include_str! is what a deployment enforces, + // so it must always be valid; a malformed rules.toml should fail here, not + // silently at enclave startup. + Ruleset::embedded().expect("embedded rules.toml parses"); } } From 7ea2fa180c24d1cb2ae8917a8987ff71577321f8 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Fri, 10 Jul 2026 00:02:33 -0400 Subject: [PATCH 15/27] docs(readme): CLI deploy flow, provenance, and quorum-key limitation --- README.md | 233 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 191 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index a8f9a7d..df0e131 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ cargo test # Create a ruleset from the example and run locally. cp rules.example.toml rules.toml -cargo run -- --organization-id "$YOUR_SUB_ORG_ID" --rules-path rules.example.toml +cargo run -- --organization-id "$YOUR_ORG_ID" --rules-path rules.example.toml # In another shell: curl -s localhost:3000/health @@ -110,84 +110,213 @@ curl -s -X POST localhost:3000/cosign -H 'content-type: application/json' \ ## Reproducible build -A TVC app boots from a container image whose digest is committed to the -deployment, and the QOS manifest additionally pins the pivot binary's digest. If -the binary changes between builds, even from identical source, attestation -fails. Every build input is therefore pinned: +A TVC app boots by extracting and running **only the pivot binary** from the +container image (measured as `expectedPivotDigest`); the rest of the image +filesystem is never mounted in the enclave. The QOS manifest pins that binary's +digest, so if the binary changes between builds, even from identical source, +attestation fails. Because the container filesystem is not available at runtime, +the **ruleset is compiled into the binary** (`include_str!("rules.toml")`, see +`src/rules.rs`) rather than shipped as a file, which also means it is covered by +the attested `expectedPivotDigest`. Every build input is pinned: | Input | How it's pinned | |---|---| | Rust toolchain | `rust:1.94-alpine` in `Dockerfile` (pin its `@sha256:` before deploying; see the comment in the file) | | Runtime base | `stagex/core-busybox:1.36.1@sha256:cac5d773…` (StageX is itself reproducible) | | Rust dependencies | `Cargo.lock`, committed; built with `--locked` | +| Ruleset | `rules.toml` compiled into the binary via `include_str!`, so it is covered by `expectedPivotDigest` | | Symbols / paths | `[profile.release] strip = true` removes build-machine paths from the binary | The runtime image ships **no ca-certificates and no libc**, the binary is static and makes no egress (see [Limitations](#limitations--operational-considerations)). +### Step 1 — Set your ruleset (compiled into the binary) + +`rules.toml` at the crate root is compiled into the binary at build time +(`include_str!`), so it becomes part of the attested `expectedPivotDigest` and is +present in the enclave (a file baked into the image would not be — TVC runs only +the pivot binary). Fill in your real allowlists before building: + ```bash -# Bake your real ruleset into the image (it becomes part of the attested digest). cp rules.example.toml rules.toml && $EDITOR rules.toml +``` + +> `rules.toml` must exist at the crate root for the build to compile. Changing it +> changes the binary, and therefore `expectedPivotDigest`, which is exactly what +> makes the ruleset attestable. + +Optionally pin the builder toolchain for a bit-for-bit reproducible build (see the +comment at the top of the `Dockerfile`), not required for a working deployment, +only for third parties to reproduce the exact binary digest. + +### Step 2 — Build and push to a container registry + +GHCR is used here only as an example. Any OCI-compliant registry works +(Docker Hub, Amazon ECR, GCP Artifact Registry, a self-hosted registry) as long +as TVC can pull the image **by digest** and it's a standard `linux/amd64` OCI +image. Substitute your registry host/namespace for `ghcr.io/YOUR_GITHUB_USERNAME` +throughout (and use that registry's login instead of `docker login ghcr.io`). + +```bash +# Create a GitHub Personal Access Token with the `write:packages` scope: +# https://github.com/settings/tokens/new?scopes=write:packages +# Read it without it landing in shell history (-s silences echo; `read` is a +# builtin so the value is never a command argument). +read -s GITHUB_TOKEN && export GITHUB_TOKEN +echo "$GITHUB_TOKEN" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin + +# Build for linux/amd64 (required by Nitro Enclaves) and push. +# --provenance=false --sbom=false keeps the push a single image manifest instead of +# wrapping it in a multi-arch index, so there is exactly one digest to pin. +docker buildx build --platform linux/amd64 --provenance=false --sbom=false \ + -t ghcr.io/YOUR_GITHUB_USERNAME/tvc-cosign:latest --push . +``` + +Make the package public so the enclave can pull it without a pull secret: +GitHub → Packages → `tvc-cosign` → Package settings → Change visibility → Public. +(If you keep it private, you must set `pivotContainerEncryptedPullSecret` in the +deploy config instead.) -# Build + push for linux/amd64 (required by Nitro Enclaves). -docker buildx build --platform linux/amd64 \ - -t ghcr.io/YOUR_ORG/tvc-cosign:latest --push . +### Step 3 — Capture the two digests -# The container image digest for the deployment (pick the linux/amd64 manifest): -docker buildx imagetools inspect ghcr.io/YOUR_ORG/tvc-cosign:latest -# → use ghcr.io/YOUR_ORG/tvc-cosign@sha256: +TVC pins **both** the container image and the pivot binary inside it. -# The pivot binary digest (expectedPivotDigest). The build also prints this as -# an `expectedPivotDigest=sha256:...` line; to recompute from the pushed image: -docker create --name x ghcr.io/YOUR_ORG/tvc-cosign:latest /bin/true \ - && docker cp x:/tvc-cosign ./tvc-cosign.bin && docker rm x +```bash +# (a) Container image digest -> pivotContainerImageUrl. With the single-manifest +# build above, this is just the top-level `Digest:` (MediaType +# ...manifest.v2+json). If you built without --provenance=false, the output is an +# index instead and you pick the child whose line says `Platform: linux/amd64`. +docker buildx imagetools inspect ghcr.io/YOUR_GITHUB_USERNAME/tvc-cosign:latest +# → ghcr.io/YOUR_GITHUB_USERNAME/tvc-cosign@sha256: + +# (b) Pivot binary digest -> expectedPivotDigest. The build already prints it as +# `expectedPivotDigest=sha256:...`; to recompute from the image: +docker create --platform linux/amd64 --name tvc-extract \ + ghcr.io/YOUR_GITHUB_USERNAME/tvc-cosign:latest /bin/true \ + && docker cp tvc-extract:/tvc-cosign ./tvc-cosign.bin && docker rm tvc-extract sha256sum ./tvc-cosign.bin ``` +> **Source provenance.** Build from a clean, committed tree and record the git +> commit (ideally an annotated tag) the image was built from, and publish it +> alongside the deployment, since nothing in the registry infers it. To verify: +> `git checkout `, rebuild Steps 1–3, and confirm the pivot binary +> `sha256` equals the `expectedPivotDigest` in the enclave's attested QOS manifest +> ([Verifying the proofs](#verifying-the-proofs)). With the builder pinned by +> digest + `Cargo.lock` + `--locked`, that binary digest is deterministic from the +> source. Note `rules.toml` is compiled into the binary (and is **not** committed, +> it is per-deployment), so it also determines the digest: to reproduce a specific +> deployment, a verifier needs both that commit **and** that deployment's exact +> `rules.toml`. Publish your `rules.toml` alongside the deployment if you want +> third parties to reproduce your `expectedPivotDigest`. + --- ## Deploy to TVC Requires the [`tvc` CLI](https://docs.turnkey.com/getting-started/verifiable-cloud-quickstart) -(`cargo install tvc`) and TVC access enabled for your org. +(`cargo install tvc`) and TVC access enabled for your org. The steps below are the +CLI flow; some parts (creating the app, creating the deployment) can also be done +from the Turnkey dashboard. See the +[Verifiable Cloud quickstart](https://docs.turnkey.com/features/verifiable-cloud/quickstart#create-your-first-verifiable-app) +for the dashboard walkthrough. Approval is always done via the `tvc` CLI. + +### Step 4 — Create the TVC app ```bash tvc login # generates an operator keypair -tvc app init --output app.json # set "name": "tvc-cosign", then: +tvc app init --output app.json # set "name": "tvc-cosign" tvc app create --config-file app.json +``` + +### Step 5 — Create the deployment + +```bash +tvc deploy init # writes deploy-.json +``` + +Edit the generated `deploy-.json`: -tvc deploy init # edit the generated deploy-*.json: -# qosVersion: -# pivotContainerImageUrl: ghcr.io/YOUR_ORG/tvc-cosign@sha256: -# pivotPath: /tvc-cosign -# pivotArgs: ["--organization-id","", -# "--rules-path","/rules.toml","--port","3000"] -# expectedPivotDigest: sha256: -# healthCheckType: TVC_HEALTH_CHECK_TYPE_HTTP -# healthCheckPort: 3000 -# publicIngressPort: 3000 -# debugMode: false +```jsonc +{ + "qosVersion": "0.12.0", // LatestQosReleaseVersion + "pivotContainerImageUrl": "ghcr.io/YOUR_GITHUB_USERNAME/tvc-cosign@sha256:", + "pivotPath": "/tvc-cosign", + "pivotArgs": ["--organization-id", "", "--port", "3000"], + "expectedPivotDigest": "", + "healthCheckType": "TVC_HEALTH_CHECK_TYPE_HTTP", + "healthCheckPort": 3000, + "publicIngressPort": 3000, + "dangerousDeployDebugMode": false + // remove pivotContainerEncryptedPullSecret (image is public, see below) +} +``` + +**On `pivotContainerEncryptedPullSecret`:** `tvc deploy init` always generates this +line with the placeholder `""`. It is +only needed to pull the image from a **private** registry. If your image is public, +delete the line entirely, otherwise `tvc deploy create` rejects the config with a +placeholder error. (If you keep the image private, supply the secret with +`--pivot-pull-secret ` instead.) In this demo we made the ghcr image public +in Step 2, so **remove the line**. + +Also keep `dangerousDeployDebugMode: false` for any real deployment: debug mode +disables normal attestation enforcement (PCRs come back zeroed), which invalidates +the boot and app proofs. + +```bash tvc deploy create --config-file deploy-.json -tvc deploy approve --deploy-id --operator-id +# → prints Deployment ID and App ID; copy the Deployment ID. ``` -`--organization-id` is passed in `pivotArgs`, so it is recorded in the QOS -manifest and **attested**, so the deployment provably stamps only for that org. The -ruleset is baked into the image at `/rules.toml`, so it is covered by the image -digest. One deployment = one org + one ruleset. +`--organization-id` rides in `pivotArgs`, so it is recorded in the QOS manifest +and **attested**, so the deployment provably stamps only for that org. The ruleset +is compiled into the binary, covered by `expectedPivotDigest`. One deployment = one +org + one ruleset. -Once **LIVE**, the app is reachable at `https://app-.turnkey.cloud`: +### Step 6 — Approve the manifest + +Passing `--deploy-id` is enough: `tvc deploy approve` fetches the manifest for that +deployment (so it resolves the manifest ID itself) and resolves the operator ID and +operator seed from your logged-in tvc profile (`~/.config/turnkey`, where +`tvc app create` cached them). It then walks you through the interactive approval +and posts it. ```bash +tvc deploy approve --deploy-id +``` + +You only need the extra flags in specific cases: + +- `--operator-id ` if your profile has **more than one** saved operator + (otherwise it auto-selects the single one, or prompts interactively). The operator + ID is printed by `tvc app create` as "Manifest Set Operator IDs" and stored under + `last_operator_ids` in `~/.config/turnkey`; it is **not** shown by `deploy status`. +- `--manifest-id ` only if you approve from a manifest file + (`--manifest `) instead of `--deploy-id`. When needed, the manifest ID *is* + shown by `tvc deploy status --deploy-id `. +- `--dangerous-skip-interactive` if you run without a TTY (CI); otherwise the + interactive approval prompts require a terminal. + +### Step 7 — Go live + +The deployment reaches **LIVE** a few minutes after approval: + +```bash +tvc deploy status --deploy-id # wait for LIVE curl https://app-.turnkey.cloud/health # → {"status":"ok"} curl https://app-.turnkey.cloud/pubkeys # the REAL stamping keys ``` +The `/pubkeys` values are the quorum-derived keys you register as API users in +[Turnkey org setup](#turnkey-org-setup-users--policies). + --- ## Turnkey org setup: users + policies -Do this once, against the sub-org you passed as `--organization-id`. +Do this once, against the org you passed as `--organization-id` (the org that owns +the signing wallets, the two API users, and the policies). **1. A wallet** whose account address is your `signerAddress` / `signWith` target, and which appears in `allowed_signers` in `rules.toml`. @@ -343,14 +472,16 @@ of it is secret; the only secrets are the enclave-provided key files. | Argument | Default | Meaning | |---|---|---| -| `--organization-id ` | none (warns, empty) | The (sub-)org placed in every `SIGN_TRANSACTION_V2` body. Attested via `pivotArgs`. | -| `--rules-path ` | `rules.toml` | Ruleset TOML. Baked deployments pass `/rules.toml`. | +| `--organization-id ` | none (warns, empty) | The org placed in every `SIGN_TRANSACTION_V2` body (owns the wallets + API users + policies). Attested via `pivotArgs`. | +| `--rules-path ` | embedded ruleset | **Local-dev override only.** Loads a ruleset TOML from disk instead of the one compiled into the binary; a deployment does not use this (the file would not exist in the enclave). If the path fails to load, the app falls back to the embedded ruleset, never to deny-all. | | `--port ` | `3000` | Listen port (binds `0.0.0.0`). | -For local dev, `TVC_ORGANIZATION_ID` and `TVC_RULES_PATH` env vars are honored as -fallbacks (a TVC deployment cannot inject env vars). See `rules.example.toml` for -the ruleset format: `allowed_signers`, and a `[programmatic]` block -(`allowed_tokens`, `allowed_recipients`, `max_amount`) plus `[admin] selectors`. +The ruleset a deployment enforces is the `rules.toml` **compiled into the binary** +(`include_str!`), covered by `expectedPivotDigest`. For local dev, `--rules-path` / +`TVC_RULES_PATH` can point at a different file, and `TVC_ORGANIZATION_ID` is honored +(a TVC deployment cannot inject env vars). See `rules.example.toml` for the ruleset +format: `allowed_signers`, and a `[programmatic]` block (`allowed_tokens`, +`allowed_recipients`, `max_amount`) plus `[admin] selectors`. --- @@ -358,6 +489,24 @@ the ruleset format: `allowed_signers`, and a `[programmatic]` block This is a POC. Known scope limits, all deliberate: +- **Quorum-key provisioning: the stamping keys are not yet secret.** TVC currently + provisions every app with a **static, well-known quorum key** (custom + provisioning is "coming soon"). The programmatic and admin stamping keys are + HKDF-derived from that quorum key with public salts, so today anyone who knows + the well-known quorum key can re-derive both private keys. The programmatic + path's safety therefore does not rest on key secrecy: a party who derives the + programmatic key could stamp an arbitrary `SIGN_TRANSACTION` that the + programmatic policy (`activity.action == 'SIGN'`) allows, bypassing the enclave + ruleset (the ruleset only binds when the enclave itself stamps). Separately, a + quorum-key signature is not enclave-exclusive by design (the quorum key can be + provisioned into any conforming enclave), which is why enclave-exclusivity comes + from the **App Proof** (signed by the per-boot Ephemeral Key), not from the + stamp. Treat this deployment as **testnet / demo only** until custom (secret) + quorum-key provisioning is available; only then do the derived keys become + secret and the "only the attested enclave can stamp" property hold. Interim + mitigations: tighten the Turnkey programmatic policy (constrain `eth.tx.to` / + wallet / chain) so a leaked key can sign less, and verify the App Proof + out-of-band before acting on a stamp. - **No caller authentication on `/cosign`.** Access is network-perimeter only. Anyone who can reach the endpoint can request a stamp; safety comes from the ruleset + the Turnkey-side policies, not from authenticating the caller. Put it From 561f92c1dd978108fc7a4449ec6d8e481bb716e6 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 15 Jul 2026 15:57:07 -0400 Subject: [PATCH 16/27] fix(rules): reject contract-creation txs before selector checks --- tvc-cosign/src/rules.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tvc-cosign/src/rules.rs b/tvc-cosign/src/rules.rs index 60e09bf..1f3b863 100644 --- a/tvc-cosign/src/rules.rs +++ b/tvc-cosign/src/rules.rs @@ -65,6 +65,13 @@ pub fn classify(signer: Address, tx: &ParsedTx, rules: &Ruleset) -> Classificati return Classification::Reject; } + if tx.to.is_none() { + // Contract creation (`to == None`) is out of scope for this POC. Reject it + // before the selector checks: otherwise initcode whose first 4 bytes happen + // to match an admin selector would misclassify as ADMIN. + 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; @@ -265,6 +272,22 @@ mod tests { ); } + #[test] + fn contract_creation_with_admin_selector_initcode_is_rejected() { + // `to == None` is contract creation (out of scope). Even if the initcode's + // first 4 bytes collide with an allowlisted admin selector, it must REJECT, + // not route to ADMIN. + let tx = ParsedTx { + to: None, + value: U256::ZERO, + input: Bytes::from(vec![0x12, 0x34, 0x56, 0x78]), + }; + assert_eq!( + classify(SIGNER, &tx, &ruleset_toml()), + Classification::Reject + ); + } + #[test] fn unknown_selector_is_rejected() { let tx = ParsedTx { From 16c69fd76f1549f12fc38967348beafa1dfc40d3 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 15 Jul 2026 16:09:11 -0400 Subject: [PATCH 17/27] fix(rules): require exact canonical length for transfer calldata --- tvc-cosign/src/rules.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tvc-cosign/src/rules.rs b/tvc-cosign/src/rules.rs index 1f3b863..531f20e 100644 --- a/tvc-cosign/src/rules.rs +++ b/tvc-cosign/src/rules.rs @@ -112,8 +112,10 @@ fn classify_transfer(tx: &ParsedTx, rules: &Ruleset) -> Classification { /// followed by a 32-byte amount. Rejects non-canonical (non-zero-padded) addresses. 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 input.len() != 68 { + return None; + } + let (addr_word, amount_word) = input[4..].split_at(32); if addr_word[..12].iter().any(|&b| b != 0) { return None; // address must be zero-padded in its 32-byte word } @@ -314,6 +316,20 @@ mod tests { ); } + #[test] + fn transfer_with_trailing_calldata_is_rejected() { + // Canonical transfer calldata is exactly 68 bytes; extra trailing bytes + // are non-canonical and must not be auto-classified as PROGRAMMATIC. + let mut tx = transfer_tx(TOKEN, RECIPIENT, 500); + let mut input = tx.input.to_vec(); + input.push(0xff); + tx.input = Bytes::from(input); + assert_eq!( + classify(SIGNER, &tx, &ruleset_toml()), + Classification::Reject + ); + } + #[test] fn transfer_with_attached_eth_is_rejected() { let mut tx = transfer_tx(TOKEN, RECIPIENT, 100); From 90b069cbb70d13d00be5912050c7483624fbb669 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 15 Jul 2026 16:34:12 -0400 Subject: [PATCH 18/27] docs(tx): fix misleading address padding comment in test helper --- tvc-cosign/src/tx.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tvc-cosign/src/tx.rs b/tvc-cosign/src/tx.rs index 1fd957f..d4b2090 100644 --- a/tvc-cosign/src/tx.rs +++ b/tvc-cosign/src/tx.rs @@ -114,7 +114,7 @@ mod tests { fn transfer_calldata(recipient: Address, amount: u64) -> Vec { let mut data = vec![0xa9, 0x05, 0x9c, 0xbb]; // transfer selector data.extend_from_slice(&[0u8; 12]); - data.extend_from_slice(recipient.as_slice()); // right-padded to 32 bytes + data.extend_from_slice(recipient.as_slice()); // left-padded with zeros to 32 bytes data.extend_from_slice(&U256::from(amount).to_be_bytes::<32>()); data } From b8e013daed8d928a49a268b1a465f9f51c1f60eb Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 15 Jul 2026 17:35:10 -0400 Subject: [PATCH 19/27] refactor(config): take owned Option args instead of cloning borrows --- tvc-cosign/src/config.rs | 8 ++------ tvc-cosign/src/main.rs | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tvc-cosign/src/config.rs b/tvc-cosign/src/config.rs index 9108488..84c79e7 100644 --- a/tvc-cosign/src/config.rs +++ b/tvc-cosign/src/config.rs @@ -35,9 +35,8 @@ impl Config { /// The ruleset is the one compiled into the binary ([`Ruleset::embedded`]). A /// `--rules-path` / `TVC_RULES_PATH` override is honored for local dev only, and /// if it fails to load falls back to the embedded ruleset (never deny-all). - pub fn load(cli_organization_id: Option<&str>, cli_rules_path: Option<&str>) -> Self { + pub fn load(cli_organization_id: Option, cli_rules_path: Option) -> Self { let organization_id = cli_organization_id - .map(str::to_string) .or_else(|| std::env::var(ORGANIZATION_ID_ENV).ok()) .unwrap_or_else(|| { eprintln!( @@ -48,10 +47,7 @@ impl Config { String::new() }); - let ruleset = match cli_rules_path - .map(str::to_string) - .or_else(|| std::env::var(RULES_PATH_ENV).ok()) - { + let ruleset = match cli_rules_path.or_else(|| std::env::var(RULES_PATH_ENV).ok()) { Some(path) => Ruleset::load(&path).unwrap_or_else(|e| { eprintln!( "config: WARNING could not load --rules-path {path} ({e}); using the \ diff --git a/tvc-cosign/src/main.rs b/tvc-cosign/src/main.rs index 23649c5..3a647b2 100644 --- a/tvc-cosign/src/main.rs +++ b/tvc-cosign/src/main.rs @@ -110,7 +110,7 @@ async fn main() { ephemeral.boot_ephemeral_key_hex() ); - let config = Config::load(args.organization_id.as_deref(), args.rules_path.as_deref()); + let config = Config::load(args.organization_id, args.rules_path); let state = Arc::new(AppState { keys, ephemeral, From 4203411c4223693e1191f9bd28eb5b589432e0f2 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 15 Jul 2026 18:03:39 -0400 Subject: [PATCH 20/27] feat(args): add --help/-h usage text --- tvc-cosign/src/main.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tvc-cosign/src/main.rs b/tvc-cosign/src/main.rs index 3a647b2..295a78e 100644 --- a/tvc-cosign/src/main.rs +++ b/tvc-cosign/src/main.rs @@ -41,6 +41,20 @@ use rules::{Classification, classify}; /// Default listen port when `--port` is not supplied. const DEFAULT_PORT: u16 = 3000; +/// Usage text printed by `--help` / `-h`. +const HELP: &str = "\ +tvc-cosign — TVC /cosign pivot binary + +USAGE: + tvc-cosign [OPTIONS] + +OPTIONS: + --organization-id (sub-)org to stamp requests for (attested) + --rules-path ruleset TOML override (local dev only; default: embedded) + --port listen port (default: 3000) + -h, --help print this help and exit +"; + /// Parsed command-line arguments (a TVC deployment supplies these as `pivotArgs`). struct Args { organization_id: Option, @@ -59,6 +73,10 @@ fn parse_args() -> Args { let mut iter = std::env::args().skip(1); while let Some(flag) = iter.next() { match flag.as_str() { + "--help" | "-h" => { + print!("{HELP}"); + std::process::exit(0); + } "--organization-id" => args.organization_id = iter.next(), "--rules-path" => args.rules_path = iter.next(), "--port" => { From 66d5867b66dca2d5da9acf67ca40eab895cd62dd Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 15 Jul 2026 18:26:44 -0400 Subject: [PATCH 21/27] fix(args): guard flag values against consuming the next flag --- tvc-cosign/src/main.rs | 50 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/tvc-cosign/src/main.rs b/tvc-cosign/src/main.rs index 295a78e..3e29b94 100644 --- a/tvc-cosign/src/main.rs +++ b/tvc-cosign/src/main.rs @@ -62,6 +62,13 @@ struct Args { port: u16, } +/// Take the next arg as a flag's value, but only if it is not itself a flag. +/// Guards against e.g. `--organization-id --port 3000` silently consuming +/// `--port` as the org id. +fn take_value(iter: &mut std::iter::Peekable>) -> Option { + iter.next_if(|v| !v.starts_with("--")) +} + /// Minimal hand-rolled arg parsing — avoids a CLI dependency for three flags. /// TVC pivots serve plain HTTP inside the enclave and bind all interfaces. fn parse_args() -> Args { @@ -70,17 +77,17 @@ fn parse_args() -> Args { rules_path: None, port: DEFAULT_PORT, }; - let mut iter = std::env::args().skip(1); + let mut iter = std::env::args().skip(1).peekable(); while let Some(flag) = iter.next() { match flag.as_str() { "--help" | "-h" => { print!("{HELP}"); std::process::exit(0); } - "--organization-id" => args.organization_id = iter.next(), - "--rules-path" => args.rules_path = iter.next(), + "--organization-id" => args.organization_id = take_value(&mut iter), + "--rules-path" => args.rules_path = take_value(&mut iter), "--port" => { - if let Some(v) = iter.next() { + if let Some(v) = take_value(&mut iter) { match v.parse() { Ok(p) => args.port = p, Err(_) => { @@ -302,3 +309,38 @@ fn bad_request( }), ) } + +#[cfg(test)] +mod tests { + use super::take_value; + + fn peekable(args: &[&str]) -> std::iter::Peekable> { + args.iter() + .map(|s| s.to_string()) + .collect::>() + .into_iter() + .peekable() + } + + #[test] + fn take_value_returns_a_non_flag_value() { + let mut it = peekable(&["value", "--next"]); + assert_eq!(take_value(&mut it), Some("value".to_string())); + // The following flag is left for the main loop to handle. + assert_eq!(it.next(), Some("--next".to_string())); + } + + #[test] + fn take_value_does_not_consume_a_following_flag() { + let mut it = peekable(&["--port", "3000"]); + assert_eq!(take_value(&mut it), None); + // `--port` was not swallowed as a value. + assert_eq!(it.next(), Some("--port".to_string())); + } + + #[test] + fn take_value_none_at_end_of_args() { + let mut it = peekable(&[]); + assert_eq!(take_value(&mut it), None); + } +} From bf7197726840097a9f49d8e393260676a1725e81 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 15 Jul 2026 21:43:15 -0400 Subject: [PATCH 22/27] style(main): consolidate boot key logging into one block --- tvc-cosign/src/main.rs | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/tvc-cosign/src/main.rs b/tvc-cosign/src/main.rs index 3e29b94..2605a5f 100644 --- a/tvc-cosign/src/main.rs +++ b/tvc-cosign/src/main.rs @@ -114,25 +114,21 @@ struct AppState { async fn main() { let args = parse_args(); - // Derive the enclave's two API keys once at boot. Enclave stdout is not - // observable in production, so these prints are only a local-dev aid; the - // pubkeys are exposed over GET /pubkeys for registration. + // Derive the enclave's two API keys and the per-boot ephemeral key at boot. + // Enclave stdout is not observable in production, so these prints are only a + // local-dev aid: the API pubkeys are exposed over GET /pubkeys for + // registration, and the ephemeral key (one per replica) has its public half + // pinned in this replica's Boot Proof. let keys = KeySet::load(); - println!( - "keys: programmatic pubkey = {}", - keys.programmatic.public_key_hex() - ); - println!( - "keys: admin pubkey = {}", - keys.admin.public_key_hex() - ); - - // The ephemeral key is per-boot (one per replica); its public half is pinned - // in this replica's Boot Proof. let ephemeral = EphemeralKey::load(); println!( - "keys: boot ephemeral key = {}", - ephemeral.boot_ephemeral_key_hex() + "keys: + programmatic pubkey = {} + admin pubkey = {} + boot ephemeral key = {}", + keys.programmatic.public_key_hex(), + keys.admin.public_key_hex(), + ephemeral.boot_ephemeral_key_hex(), ); let config = Config::load(args.organization_id, args.rules_path); From 898576d1dfd7fbed5e7ff7b9a6f40598dc9a3f34 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 15 Jul 2026 22:18:31 -0400 Subject: [PATCH 23/27] refactor(tx): derive ParseError messages with thiserror --- tvc-cosign/Cargo.lock | 1 + tvc-cosign/Cargo.toml | 1 + tvc-cosign/src/tx.rs | 19 ++++--------------- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/tvc-cosign/Cargo.lock b/tvc-cosign/Cargo.lock index 7246813..a3527fc 100644 --- a/tvc-cosign/Cargo.lock +++ b/tvc-cosign/Cargo.lock @@ -2993,6 +2993,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "thiserror", "tokio", "toml", ] diff --git a/tvc-cosign/Cargo.toml b/tvc-cosign/Cargo.toml index e948516..b189fa4 100644 --- a/tvc-cosign/Cargo.toml +++ b/tvc-cosign/Cargo.toml @@ -16,6 +16,7 @@ p256 = { version = "0.14.0", features = ["ecdsa"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.11.0" +thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["full"] } toml = "1.1.2" diff --git a/tvc-cosign/src/tx.rs b/tvc-cosign/src/tx.rs index d4b2090..154e5c3 100644 --- a/tvc-cosign/src/tx.rs +++ b/tvc-cosign/src/tx.rs @@ -9,8 +9,6 @@ //! The rules engine only needs `to`, `value`, and the calldata (for the 4-byte //! selector + args), so we decode into that and drop the rest. -use std::fmt; - use alloy_consensus::transaction::RlpEcdsaDecodableTx; use alloy_consensus::{Transaction, TxEip1559, TxEip2930}; use alloy_primitives::{Address, Bytes, TxKind, U256}; @@ -35,28 +33,19 @@ impl ParsedTx { } /// Why an unsigned transaction could not be parsed. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ParseError { /// No bytes to parse. + #[error("empty transaction")] Empty, /// First byte is not a supported transaction type / RLP list. + #[error("unsupported transaction type byte {0:#04x}")] UnsupportedType(u8), /// The RLP body was malformed. + #[error("malformed RLP: {0}")] Rlp(alloy_rlp::Error), } -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}"), - } - } -} - /// Parse a raw unsigned transaction (already hex-decoded) into [`ParsedTx`]. pub fn parse_unsigned(raw: &[u8]) -> Result { let first = *raw.first().ok_or(ParseError::Empty)?; From 285204527994bceed127b4a84c9c4a3fe589cdec Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Wed, 15 Jul 2026 23:43:03 -0400 Subject: [PATCH 24/27] docs(readme): replace ascii cosign flow with a mermaid sequence diagram --- tvc-cosign/README.md | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tvc-cosign/README.md b/tvc-cosign/README.md index df0e131..f5ba6a1 100644 --- a/tvc-cosign/README.md +++ b/tvc-cosign/README.md @@ -13,21 +13,27 @@ The app **never holds Turnkey credentials and makes zero network egress**, it only stamps. All trust flows from the quorum-key-derived API keys plus Turnkey policies, and every decision is accompanied by a verifiable **App Proof**. -``` -unsigned tx bytes ──▶ POST /cosign (this app, in the enclave) - │ 1. parse: to / selector / args / value - │ 2. classify: PROGRAMMATIC | ADMIN | REJECT - │ 3. build SIGN_TRANSACTION_V2 body - │ 4. stamp body with the derived prog OR admin key - │ 5. App-Proof the decision with the ephemeral key - ▼ - { activityBody, xStamp, classification, appProof, bootEphemeralKey } - │ - customer submits activityBody + xStamp to api.turnkey.com - ▼ - Turnkey policies evaluate the stamping API user: - • TVC programmatic → policy ALLOW → COMPLETED - • TVC admin → require human consensus → CONSENSUS_NEEDED +```mermaid +%%{init: {'themeVariables': {'actorBorder':'#4C48FF','actorLineColor':'#4C48FF','noteBorderColor':'#4C48FF', 'labelBoxBorderColor': '#4C48FF'}}}%% +sequenceDiagram + participant C as Customer + participant E as TVC app (Nitro Enclave) + participant T as Turnkey API + + C->>E: POST /cosign (unsignedTransaction, signerAddress) + Note over E: parse → classify → build SIGN_TRANSACTION_V2
→ stamp (prog OR admin key) → App-Proof + + alt PROGRAMMATIC + E-->>C: 200 (activityBody, xStamp, appProof, bootEphemeralKey) + C->>T: submit activityBody + xStamp + T-->>C: policy ALLOW → COMPLETED + else ADMIN + E-->>C: 200 (activityBody, xStamp, appProof, bootEphemeralKey) + C->>T: submit activityBody + xStamp + T-->>C: human consensus → CONSENSUS_NEEDED + else REJECT + E-->>C: 400 (no stamp) + end ``` --- From d981d1ab09f4fb0d2d2add9ff066522dcf18ac8d Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Thu, 16 Jul 2026 00:05:42 -0400 Subject: [PATCH 25/27] feat(keys): zeroize transient seed and scalar buffers on drop --- tvc-cosign/Cargo.lock | 1 + tvc-cosign/Cargo.toml | 1 + tvc-cosign/src/keys.rs | 17 +++++++++-------- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tvc-cosign/Cargo.lock b/tvc-cosign/Cargo.lock index a3527fc..a647184 100644 --- a/tvc-cosign/Cargo.lock +++ b/tvc-cosign/Cargo.lock @@ -2996,6 +2996,7 @@ dependencies = [ "thiserror", "tokio", "toml", + "zeroize", ] [[package]] diff --git a/tvc-cosign/Cargo.toml b/tvc-cosign/Cargo.toml index b189fa4..1859ba1 100644 --- a/tvc-cosign/Cargo.toml +++ b/tvc-cosign/Cargo.toml @@ -19,6 +19,7 @@ sha2 = "0.11.0" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["full"] } toml = "1.1.2" +zeroize = { version = "1.9.0", features = ["alloc"] } # Release profile tuned for a small, deterministic enclave binary. `strip` # removes symbols/debug info (which carry build-machine paths), so the pivot diff --git a/tvc-cosign/src/keys.rs b/tvc-cosign/src/keys.rs index bfc3cec..7b76e03 100644 --- a/tvc-cosign/src/keys.rs +++ b/tvc-cosign/src/keys.rs @@ -16,6 +16,7 @@ use hkdf::Hkdf; use p256::SecretKey; use p256::ecdsa::SigningKey; use sha2::Sha512; +use zeroize::Zeroizing; /// Path inside the enclave where QOS writes the hex-encoded 32-byte quorum seed. pub const QUORUM_KEY_PATH: &str = "/qos.quorum.key"; @@ -181,11 +182,11 @@ impl KeySet { /// our own API-key derivation and QOS's KeySet sub-derivation. fn derive_signing_key(seed: &[u8; 32], salt: &[u8]) -> SigningKey { let hk = Hkdf::::new(Some(salt), seed); - let mut okm = [0u8; 32]; - hk.expand(&[], &mut okm) + let mut okm = Zeroizing::new([0u8; 32]); + hk.expand(&[], &mut okm[..]) .expect("32 bytes is well under HKDF-SHA512's output limit"); - let secret = - SecretKey::from_slice(&okm).expect("HKDF output is a valid P-256 scalar (overwhelmingly)"); + let secret = SecretKey::from_slice(&okm[..]) + .expect("HKDF output is a valid P-256 scalar (overwhelmingly)"); secret.into() } @@ -199,10 +200,10 @@ fn derive_key(seed: &[u8; 32], salt: &[u8]) -> ApiKey { /// Read and hex-decode a 32-byte hex seed file (`/qos.quorum.key` or /// `/qos.ephemeral.key`). Returns `None` if the file is missing or malformed, so /// callers can fall back gracefully outside an enclave. -fn read_hex_seed(path: &Path) -> Option<[u8; 32]> { - let contents = std::fs::read_to_string(path).ok()?; - let bytes = hex::decode(contents.trim()).ok()?; - bytes.try_into().ok() +fn read_hex_seed(path: &Path) -> Option> { + let contents = Zeroizing::new(std::fs::read_to_string(path).ok()?); + let bytes = Zeroizing::new(hex::decode(contents.trim()).ok()?); + Some(Zeroizing::new(bytes.as_slice().try_into().ok()?)) } #[cfg(test)] From 35931c4fefb814f0d95ef45069bdafa935d23d20 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Thu, 16 Jul 2026 14:49:24 -0400 Subject: [PATCH 26/27] docs(readme): reflect that the builder toolchain is already digest-pinned --- tvc-cosign/Dockerfile | 9 +++++---- tvc-cosign/README.md | 16 ++++++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tvc-cosign/Dockerfile b/tvc-cosign/Dockerfile index 8505ae7..42c9f1d 100644 --- a/tvc-cosign/Dockerfile +++ b/tvc-cosign/Dockerfile @@ -1,11 +1,12 @@ # syntax=docker/dockerfile:1 # ── Build stage ────────────────────────────────────────────────────────────── -# Reproducible builds require pinning every input by digest. Before an attestable -# deployment, pin this image: +# Reproducible builds require pinning every input by digest. The builder image +# below is already pinned by @sha256:. To re-pin it (e.g. after bumping the Rust +# version): # docker pull rust:1.94-alpine # docker inspect rust:1.94-alpine --format='{{index .RepoDigests 0}}' -# then append the resulting @sha256:... to the FROM line below. Keep the Rust -# version aligned with rust-toolchain / your local toolchain for output parity. +# then replace the @sha256:... on the FROM line below. Keep the Rust version +# aligned with rust-toolchain / your local toolchain for output parity. FROM rust:1.94-alpine@sha256:77237dd363a0b127bb5ef532c2d64c0deb380b738e43a9c4bdac73398d6d0a08 AS builder # musl-dev supplies the headers + static C runtime the linker needs. Alpine's diff --git a/tvc-cosign/README.md b/tvc-cosign/README.md index f5ba6a1..e664dd4 100644 --- a/tvc-cosign/README.md +++ b/tvc-cosign/README.md @@ -127,7 +127,7 @@ the attested `expectedPivotDigest`. Every build input is pinned: | Input | How it's pinned | |---|---| -| Rust toolchain | `rust:1.94-alpine` in `Dockerfile` (pin its `@sha256:` before deploying; see the comment in the file) | +| Rust toolchain | `rust:1.94-alpine`, pinned by `@sha256:` in `Dockerfile` (re-pin if you bump the version; see the comment in the file) | | Runtime base | `stagex/core-busybox:1.36.1@sha256:cac5d773…` (StageX is itself reproducible) | | Rust dependencies | `Cargo.lock`, committed; built with `--locked` | | Ruleset | `rules.toml` compiled into the binary via `include_str!`, so it is covered by `expectedPivotDigest` | @@ -141,19 +141,23 @@ and makes no egress (see [Limitations](#limitations--operational-considerations) `rules.toml` at the crate root is compiled into the binary at build time (`include_str!`), so it becomes part of the attested `expectedPivotDigest` and is present in the enclave (a file baked into the image would not be — TVC runs only -the pivot binary). Fill in your real allowlists before building: +the pivot binary). Copy the example, then open `rules.toml` in your editor and +fill in your real allowlists before building: ```bash -cp rules.example.toml rules.toml && $EDITOR rules.toml +cp rules.example.toml rules.toml +# then edit rules.toml ``` > `rules.toml` must exist at the crate root for the build to compile. Changing it > changes the binary, and therefore `expectedPivotDigest`, which is exactly what > makes the ruleset attestable. -Optionally pin the builder toolchain for a bit-for-bit reproducible build (see the -comment at the top of the `Dockerfile`), not required for a working deployment, -only for third parties to reproduce the exact binary digest. +The builder toolchain is pinned by digest in the `Dockerfile` +(`FROM rust:1.94-alpine@sha256:…`). That pin is what makes the build bit-for-bit +reproducible, so anyone can rebuild from this source and confirm the exact +`expectedPivotDigest`. If you bump the Rust version, re-pin it (see the comment at +the top of the `Dockerfile`). ### Step 2 — Build and push to a container registry From 30e9c29460cc906d76558be1ec32aec546948e38 Mon Sep 17 00:00:00 2001 From: Angel Hernandez Date: Thu, 16 Jul 2026 15:45:42 -0400 Subject: [PATCH 27/27] docs(readme): clarify assembling pivotContainerImageUrl from the Digest line --- tvc-cosign/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tvc-cosign/README.md b/tvc-cosign/README.md index e664dd4..a21001f 100644 --- a/tvc-cosign/README.md +++ b/tvc-cosign/README.md @@ -197,7 +197,9 @@ TVC pins **both** the container image and the pivot binary inside it. # ...manifest.v2+json). If you built without --provenance=false, the output is an # index instead and you pick the child whose line says `Platform: linux/amd64`. docker buildx imagetools inspect ghcr.io/YOUR_GITHUB_USERNAME/tvc-cosign:latest -# → ghcr.io/YOUR_GITHUB_USERNAME/tvc-cosign@sha256: +# This prints a `Digest: sha256:...` line (not the full URL). Assemble +# pivotContainerImageUrl by joining the image name (`Name: ghcr.io/YOUR_GITHUB_USERNAME/tvc-cosign:latest`) to that digest, placing the `:latest` at the end with `@` + the digest value: +# ghcr.io/YOUR_GITHUB_USERNAME/tvc-cosign@sha256: # (b) Pivot binary digest -> expectedPivotDigest. The build already prints it as # `expectedPivotDigest=sha256:...`; to recompute from the image: