diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 29bc17bf5..de89c5962 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -15,7 +15,7 @@ env: jobs: rust-checks: - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }} steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/spdx-check.yml b/.github/workflows/spdx-check.yml index 473b5492d..1c9e6e990 100644 --- a/.github/workflows/spdx-check.yml +++ b/.github/workflows/spdx-check.yml @@ -12,7 +12,7 @@ on: jobs: reuse-lint: - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }} steps: - name: Checkout repository diff --git a/.github/workflows/vmm-ui.yml b/.github/workflows/vmm-ui.yml index fb1167eec..fe2980414 100644 --- a/.github/workflows/vmm-ui.yml +++ b/.github/workflows/vmm-ui.yml @@ -15,7 +15,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }} steps: - uses: actions/checkout@v5 diff --git a/.gitignore b/.gitignore index e1804bdef..d30f69ff4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ node_modules/ __pycache__ .planning/ /vmm/src/console_v1.html +.claude/worktrees/ diff --git a/Cargo.lock b/Cargo.lock index cc71da655..3c8510b4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -72,9 +72,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alloy" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4973038846323e4e69a433916522195dce2947770076c03078fc21c80ea0f1c4" +checksum = "50ab0cd8afe573d1f7dc2353698a51b1f93aec362c8211e28cfd3948c6adba39" dependencies = [ "alloy-core", "alloy-signer", @@ -83,9 +83,9 @@ dependencies = [ [[package]] name = "alloy-consensus" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c0dc44157867da82c469c13186015b86abef209bf0e41625e4b68bac61d728" +checksum = "7f16daaf7e1f95f62c6c3bf8a3fc3d78b08ae9777810c0bb5e94966c7cd57ef0" dependencies = [ "alloy-eips", "alloy-primitives", @@ -100,8 +100,8 @@ dependencies = [ "either", "k256", "once_cell", - "rand 0.8.5", - "secp256k1", + "rand 0.8.6", + "secp256k1 0.30.0", "serde", "serde_json", "serde_with", @@ -110,9 +110,9 @@ dependencies = [ [[package]] name = "alloy-consensus-any" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4cdb42df3871cd6b346d6a938ec2ba69a9a0f49d1f82714bc5c48349268434" +checksum = "118998d9015332ab1b4720ae1f1e3009491966a0349938a1f43ff45a8a4c6299" dependencies = [ "alloy-consensus", "alloy-eips", @@ -124,9 +124,9 @@ dependencies = [ [[package]] name = "alloy-core" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23e8604b0c092fabc80d075ede181c9b9e596249c70b99253082d7e689836529" +checksum = "62ddde5968de6044d67af107ad835bc0069a7ca245870b94c5958a7d8712b184" dependencies = [ "alloy-primitives", ] @@ -171,21 +171,23 @@ dependencies = [ [[package]] name = "alloy-eip7928" -version = "0.3.3" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8222b1d88f9a6d03be84b0f5e76bb60cd83991b43ad8ab6477f0e4a7809b98d" +checksum = "6b827a6d7784fe3eb3489d40699407a4cdcce74271421a01bdffe60cf573bb16" dependencies = [ "alloy-primitives", "alloy-rlp", "borsh", + "once_cell", "serde", + "thiserror 2.0.18", ] [[package]] name = "alloy-eips" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9f7ef09f21bd1e9cb8a686f168cb4a206646804567f0889eadb8dcc4c9288c8" +checksum = "e6ef28c9fdad22d4eec52d894f5f2673a0895f1e5ef196734568e68c0f6caca8" dependencies = [ "alloy-eip2124", "alloy-eip2930", @@ -202,14 +204,13 @@ dependencies = [ "serde", "serde_with", "sha2 0.10.9", - "thiserror 2.0.18", ] [[package]] name = "alloy-json-abi" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9dbe713da0c737d9e5e387b0ba790eb98b14dd207fe53eef50e19a5a8ec3dac" +checksum = "7c36c9d7f9021601b04bfef14a4b64849f6d73116a4e91e071d7fbfe10247901" dependencies = [ "alloy-primitives", "alloy-sol-type-parser", @@ -219,9 +220,9 @@ dependencies = [ [[package]] name = "alloy-json-rpc" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff42cd777eea61f370c0b10f2648a1c81e0b783066cd7269228aa993afd487f7" +checksum = "422d110f1c40f1f8d0e5562b0b649c35f345fccb7093d9f02729943dcd1eef71" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -234,9 +235,9 @@ dependencies = [ [[package]] name = "alloy-network" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cbca04f9b410fdc51aaaf88433cbac761213905a65fe832058bcf6690585762" +checksum = "7197a66d94c4de1591cdc16a9bcea5f8cccd0da81b865b49aef97b1b4016e0fa" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -260,9 +261,9 @@ dependencies = [ [[package]] name = "alloy-network-primitives" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d6d15e069a8b11f56bef2eccbad2a873c6dd4d4c81d04dda29710f5ea52f04" +checksum = "eb82711d59a43fdfd79727c99f270b974c784ec4eb5728a0d0d22f26716c87ef" dependencies = [ "alloy-consensus", "alloy-eips", @@ -273,9 +274,9 @@ dependencies = [ [[package]] name = "alloy-primitives" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3b431b4e72cd8bd0ec7a50b4be18e73dab74de0dba180eef171055e5d5926e" +checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" dependencies = [ "alloy-rlp", "bytes", @@ -283,26 +284,27 @@ dependencies = [ "const-hex", "derive_more 2.1.1", "foldhash 0.2.0", - "hashbrown 0.16.1", - "indexmap 2.13.0", + "hashbrown 0.17.1", + "indexmap 2.14.0", "itoa", "k256", "keccak-asm", "paste", "proptest", - "rand 0.9.2", + "rand 0.9.4", "rapidhash", "ruint", "rustc-hash", + "secp256k1 0.31.1", "serde", - "sha3", + "sha3 0.11.0", ] [[package]] name = "alloy-rlp" -version = "0.3.13" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93e50f64a77ad9c5470bf2ad0ca02f228da70c792a8f06634801e202579f35e" +checksum = "dc90b1e703d3c03f4ff7f48e82dd0bc1c8211ab7d079cd836a06fcfeb06651cb" dependencies = [ "alloy-rlp-derive", "arrayvec 0.7.6", @@ -311,9 +313,9 @@ dependencies = [ [[package]] name = "alloy-rlp-derive" -version = "0.3.13" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce8849c74c9ca0f5a03da1c865e3eb6f768df816e67dd3721a398a8a7e398011" +checksum = "f36834a5c0a2fa56e171bf256c34d70fca07d0c0031583edea1c4946b7889c9e" dependencies = [ "proc-macro2", "quote", @@ -322,9 +324,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-any" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd720b63f82b457610f2eaaf1f32edf44efffe03ae25d537632e7d23e7929e1a" +checksum = "3823026d1ed239a40f12364fac50726c8daf1b6ab8077a97212c5123910429ed" dependencies = [ "alloy-consensus-any", "alloy-rpc-types-eth", @@ -333,9 +335,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-eth" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2dc411f13092f237d2bf6918caf80977fc2f51485f9b90cb2a2f956912c8c9" +checksum = "59c095f92c4e1ff4981d89e9aa02d5f98c762a1980ab66bec49c44be11349da2" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -354,9 +356,9 @@ dependencies = [ [[package]] name = "alloy-serde" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ce1e0dbf7720eee747700e300c99aac01b1a95bb93f493a01e78ee28bb1a37" +checksum = "11ece63b89294b8614ab3f483560c08d016930f842bf36da56bf0b764a15c11e" dependencies = [ "alloy-primitives", "serde", @@ -365,9 +367,9 @@ dependencies = [ [[package]] name = "alloy-signer" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2425c6f314522c78e8198979c8cbf6769362be4da381d4152ea8eefce383535d" +checksum = "43f447aefab0f1c0649f71edc33f590992d4e122bc35fb9cdbbf67d4421ace85" dependencies = [ "alloy-primitives", "async-trait", @@ -380,9 +382,9 @@ dependencies = [ [[package]] name = "alloy-signer-local" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3ecb71ee53d8d9c3fa7bac17542c8116ebc7a9726c91b1bf333ec3d04f5a789" +checksum = "f721f4bf2e4812e5505aaf5de16ef3065a8e26b9139ac885862d00b5a55a659a" dependencies = [ "alloy-consensus", "alloy-network", @@ -390,15 +392,15 @@ dependencies = [ "alloy-signer", "async-trait", "k256", - "rand 0.8.5", + "rand 0.8.6", "thiserror 2.0.18", ] [[package]] name = "alloy-sol-macro" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab81bab693da9bb79f7a95b64b394718259fdd7e41dceeced4cad57cb71c4f6a" +checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", @@ -410,27 +412,27 @@ dependencies = [ [[package]] name = "alloy-sol-macro-expander" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489f1620bb7e2483fb5819ed01ab6edc1d2f93939dce35a5695085a1afd1d699" +checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" dependencies = [ "alloy-sol-macro-input", "const-hex", "heck 0.5.0", - "indexmap 2.13.0", + "indexmap 2.14.0", "proc-macro-error2", "proc-macro2", "quote", - "sha3", + "sha3 0.11.0", "syn 2.0.117", "syn-solidity", ] [[package]] name = "alloy-sol-macro-input" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56cef806ad22d4392c5fc83cf8f2089f988eb99c7067b4e0c6f1971fc1cca318" +checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" dependencies = [ "const-hex", "dunce", @@ -444,19 +446,19 @@ dependencies = [ [[package]] name = "alloy-sol-type-parser" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6df77fea9d6a2a75c0ef8d2acbdfd92286cc599983d3175ccdc170d3433d249" +checksum = "857b470ecdd2ed38beaf82ad1a38c516a8ff75266750f38b9eeed001d575241b" dependencies = [ "serde", - "winnow", + "winnow 1.0.3", ] [[package]] name = "alloy-sol-types" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64612d29379782a5dde6f4b6570d9c756d734d760c0c94c254d361e678a6591f" +checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -466,13 +468,12 @@ dependencies = [ [[package]] name = "alloy-trie" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d7fd448ab0a017de542de1dcca7a58e7019fe0e7a34ed3f9543ebddf6aceffa" +checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" dependencies = [ "alloy-primitives", "alloy-rlp", - "arrayvec 0.7.6", "derive_more 2.1.1", "nybbles", "serde", @@ -483,11 +484,11 @@ dependencies = [ [[package]] name = "alloy-tx-macros" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fa0c53e8c1e1ef4d01066b01c737fb62fc9397ab52c6e7bb5669f97d281b9bc" +checksum = "d69722eddcdf1ce096c3ab66cf8116999363f734eb36fe94a148f4f71c85da84" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", "syn 2.0.117", @@ -504,9 +505,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -519,15 +520,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -538,7 +539,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -549,7 +550,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -560,9 +561,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.8.2" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -733,7 +734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -743,7 +744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -753,7 +754,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -779,9 +780,6 @@ name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -dependencies = [ - "serde", -] [[package]] name = "asn1-rs" @@ -889,15 +887,15 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -906,9 +904,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -916,11 +914,25 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-nitro-enclaves-nsm-api" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92c1f4471b33f6a7af9ea421b249ed18a11c71156564baf6293148fa6ad1b09" +dependencies = [ + "libc", + "log", + "nix 0.26.4", + "serde", + "serde_bytes", + "serde_cbor", +] + [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -1044,15 +1056,15 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitcoin-io" -version = "0.1.4" +version = "0.1.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" +checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" [[package]] name = "bitcoin_hashes" -version = "0.14.1" +version = "0.14.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" dependencies = [ "bitcoin-io", "hex-conservative", @@ -1066,9 +1078,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitvec" @@ -1104,16 +1116,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec 0.7.6", "cc", "cfg-if", "constant_time_eq 0.4.2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", ] [[package]] @@ -1134,6 +1146,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "blst" version = "0.3.16" @@ -1192,9 +1213,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d13a61f2963b88eef9c1be03df65d42f6996dfeac1054870d950fcf66686f83" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" dependencies = [ "bon-macros", "rustversion", @@ -1202,11 +1223,11 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d314cc62af2b6b0c65780555abb4d02a03dd3b799cd42419044f0c38d99738c0" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling 0.23.0", + "darling", "ident_case", "prettyplease", "proc-macro2", @@ -1217,19 +1238,20 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", "proc-macro-crate", @@ -1238,6 +1260,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1251,9 +1282,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byte-slice-cast" @@ -1284,9 +1315,9 @@ dependencies = [ [[package]] name = "c-kzg" -version = "2.1.6" +version = "2.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a0f582957c24870b7bfd12bf562c40b4734b533cafbaf8ded31d6d85f462c01" +checksum = "6648ed1e4ea8e8a1a4a2c78e1cda29a3fd500bc622899c340d8525ea9a76b24a" dependencies = [ "blst", "cc", @@ -1299,9 +1330,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -1336,6 +1367,7 @@ dependencies = [ "ra-rpc", "ra-tls", "serde_json", + "tdx-attest", ] [[package]] @@ -1352,7 +1384,7 @@ dependencies = [ "http-body-util", "instant-acme", "path-absolutize", - "rand 0.8.5", + "rand 0.8.6", "rcgen", "reqwest", "serde", @@ -1401,7 +1433,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -1418,6 +1450,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half 2.7.1", +] + [[package]] name = "cipher" version = "0.3.0" @@ -1433,16 +1492,16 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] [[package]] name = "clap" -version = "4.5.60" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -1450,9 +1509,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -1462,9 +1521,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -1474,15 +1533,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -1515,27 +1574,26 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "console" -version = "0.15.11" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "once_cell", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "const-hex" -version = "1.18.1" +version = "1.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531185e432bb31db1ecda541e9e7ab21468d4d844ad7505e0546a49b4945d49b" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -1551,11 +1609,12 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "const_format" -version = "0.2.35" +version = "0.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" dependencies = [ "const_format_proc_macros", + "konst", ] [[package]] @@ -1674,9 +1733,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" @@ -1771,6 +1830,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "crypto-mac" version = "0.11.0" @@ -1848,39 +1916,14 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "serde", - "strsim", - "syn 2.0.117", + "darling_core", + "darling_macro", ] [[package]] @@ -1892,37 +1935,27 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "serde", "strsim", "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.117", ] [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1934,9 +1967,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "dcap-qvl" @@ -2140,7 +2173,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "proc-macro2", "proc-macro2-diagnostics", "quote", @@ -2164,10 +2197,20 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.7", "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.0", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "6.0.0" @@ -2186,14 +2229,14 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -2258,6 +2301,8 @@ dependencies = [ "hex", "hex_fmt", "insta", + "nsm-attest", + "nsm-qvl", "or-panic", "parity-scale-codec", "rmp-serde", @@ -2265,9 +2310,12 @@ dependencies = [ "serde-human-bytes", "serde_json", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", "tdx-attest", "tokio", + "tpm-attest", + "tpm-qvl", + "tpm-types", "tracing", ] @@ -2308,7 +2356,7 @@ dependencies = [ "proxy-protocol", "ra-rpc", "ra-tls", - "rand 0.8.5", + "rand 0.8.6", "reqwest", "rinja", "rmp-serde", @@ -2375,7 +2423,7 @@ dependencies = [ "or-panic", "ra-rpc", "ra-tls", - "rand 0.8.5", + "rand 0.8.6", "rcgen", "reqwest", "ring", @@ -2386,12 +2434,13 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", "strip-ansi-escapes", "sysinfo", "tdx-attest", "tempfile", "tokio", + "tpm-attest", "tracing", "tracing-subscriber", ] @@ -2448,7 +2497,7 @@ dependencies = [ "parity-scale-codec", "ra-rpc", "ra-tls", - "rand 0.8.5", + "rand 0.8.6", "reqwest", "ring", "rocket", @@ -2458,7 +2507,7 @@ dependencies = [ "serde-human-bytes", "serde_json", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", "tempfile", "tokio", "tracing", @@ -2574,7 +2623,7 @@ dependencies = [ "parity-scale-codec", "serde", "serde-human-bytes", - "sha3", + "sha3 0.10.9", "size-parser", ] @@ -2608,7 +2657,7 @@ dependencies = [ "parity-scale-codec", "ra-rpc", "ra-tls", - "rand 0.8.5", + "rand 0.8.6", "regex", "safe-write", "schnorrkel", @@ -2617,12 +2666,14 @@ dependencies = [ "serde-human-bytes", "serde_json", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", "sodiumbox", "tdx-attest", "tempfile", "tokio", "toml", + "tpm-attest", + "tpm-qvl", "tracing", "tracing-subscriber", "x25519-dalek", @@ -2645,6 +2696,7 @@ dependencies = [ "fs-err", "hex", "hex-literal", + "nsm-attest", "ra-tls", "reqwest", "rocket", @@ -2654,6 +2706,8 @@ dependencies = [ "sha2 0.10.9", "tempfile", "tokio", + "tpm-qvl", + "tpm-types", "tracing", "tracing-subscriber", ] @@ -2792,9 +2846,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -2811,6 +2865,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -2933,7 +2988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2948,7 +3003,7 @@ dependencies = [ "md-5", "sha1", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", ] [[package]] @@ -2964,9 +3019,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fastrlp" @@ -3035,13 +3090,12 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -3057,7 +3111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" dependencies = [ "byteorder", - "rand 0.8.5", + "rand 0.8.6", "rustc-hex", "static_assertions", ] @@ -3321,7 +3375,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -3332,7 +3386,7 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" dependencies = [ - "rand 0.8.5", + "rand 0.8.6", "rand_core 0.6.4", ] @@ -3398,9 +3452,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -3408,7 +3462,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -3429,6 +3483,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash_hasher" version = "2.0.4" @@ -3458,9 +3529,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", @@ -3542,7 +3613,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.8.5", + "rand 0.8.6", "thiserror 1.0.69", "tinyvec", "tokio", @@ -3566,7 +3637,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.2", + "rand 0.9.4", "ring", "thiserror 2.0.18", "tinyvec", @@ -3588,7 +3659,7 @@ dependencies = [ "lru-cache", "once_cell", "parking_lot 0.12.5", - "rand 0.8.5", + "rand 0.8.6", "resolv-conf", "smallvec", "thiserror 1.0.69", @@ -3609,7 +3680,7 @@ dependencies = [ "moka", "once_cell", "parking_lot 0.12.5", - "rand 0.9.2", + "rand 0.9.4", "resolv-conf", "smallvec", "thiserror 2.0.18", @@ -3669,9 +3740,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -3762,11 +3833,20 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -3779,7 +3859,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -3802,16 +3881,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -3835,7 +3913,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2", "tokio", "tower-service", "tracing", @@ -3882,12 +3960,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -3895,9 +3974,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -3908,9 +3987,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3922,15 +4001,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -3942,15 +4021,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -3986,9 +4065,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -4027,12 +4106,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -4045,11 +4124,11 @@ checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" [[package]] name = "inotify" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "inotify-sys", "libc", ] @@ -4074,9 +4153,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.46.3" +version = "1.47.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" dependencies = [ "console", "once_cell", @@ -4117,11 +4196,11 @@ dependencies = [ [[package]] name = "intrusive-collections" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0375b6b871e424e9e052e1107d57dc6952f77ff882bd4bf74333a833779bab" +checksum = "80e165935eba36cb526af8389effd2005a741adcbb6ed32106cc68e3f7b92960" dependencies = [ - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -4134,19 +4213,20 @@ dependencies = [ "fs-err", "hex_fmt", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", ] [[package]] name = "ipconfig" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.5.10", + "socket2", "widestring", - "windows-sys 0.48.0", - "winreg", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", ] [[package]] @@ -4158,16 +4238,6 @@ dependencies = [ "serde", ] -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-terminal" version = "0.4.17" @@ -4176,7 +4246,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4214,9 +4284,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jemalloc-sys" @@ -4250,10 +4320,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -4282,11 +4354,21 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[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.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b646a74e746cd25045aa0fd42f4f7f78aa6d119380182c7e63a5593c4ab8df6f" +checksum = "1766b89733097006f3a1388a02849865d6bc98c89273cb622e29fdd209922183" dependencies = [ "digest 0.10.7", "sha3-asm", @@ -4302,11 +4384,26 @@ dependencies = [ "tokio", ] +[[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 = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -4314,11 +4411,11 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", "libc", ] @@ -4339,9 +4436,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -4351,14 +4448,11 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags 2.11.0", "libc", - "plain", - "redox_syscall 0.7.3", ] [[package]] @@ -4392,9 +4486,9 @@ dependencies = [ [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "load_config" @@ -4417,9 +4511,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "loom" @@ -4483,9 +4577,9 @@ dependencies = [ [[package]] name = "macro-string" -version = "0.1.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", @@ -4525,9 +4619,18 @@ checksum = "df39d232f5c40b0891c10216992c2f250c054105cb1e56f0fc9032db6203ecc1" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] [[package]] name = "memoffset" @@ -4545,7 +4648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" dependencies = [ "byteorder", - "keccak", + "keccak 0.1.6", "rand_core 0.6.4", "zeroize", ] @@ -4597,9 +4700,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -4618,9 +4721,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.14" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "crossbeam-channel", "crossbeam-epoch", @@ -4716,30 +4819,43 @@ dependencies = [ "log", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -4765,13 +4881,13 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "fsevent-sys", "inotify", "kqueue", "libc", "log", - "mio 1.1.1", + "mio 1.2.1", "notify-types", "walkdir", "windows-sys 0.60.2", @@ -4783,7 +4899,40 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", +] + +[[package]] +name = "nsm-attest" +version = "0.5.11" +dependencies = [ + "anyhow", + "aws-nitro-enclaves-nsm-api", + "ciborium", + "hex", + "serde", + "tracing", +] + +[[package]] +name = "nsm-qvl" +version = "0.5.11" +dependencies = [ + "anyhow", + "ciborium", + "dcap-qvl-webpki", + "hex", + "nsm-attest", + "p384", + "pem", + "reqwest", + "rustls-pki-types", + "serde", + "sha2 0.10.9", + "tokio", + "tracing", + "tracing-subscriber", + "x509-parser", ] [[package]] @@ -4810,7 +4959,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4834,16 +4983,16 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -4915,7 +5064,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -4950,9 +5099,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" dependencies = [ "critical-section", "portable-atomic", @@ -5006,7 +5155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -5229,7 +5378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap 2.13.0", + "indexmap 2.14.0", ] [[package]] @@ -5239,7 +5388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset 0.5.7", - "indexmap 2.13.0", + "indexmap 2.14.0", ] [[package]] @@ -5286,18 +5435,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -5337,12 +5486,6 @@ dependencies = [ "spki", ] -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "poly1305" version = "0.8.0" @@ -5374,9 +5517,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -5432,7 +5575,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -5505,15 +5648,15 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.11.0", + "bitflags 2.11.1", "num-traits", - "rand 0.9.2", + "rand 0.9.4", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -5707,7 +5850,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.2", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -5723,7 +5866,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -5744,7 +5887,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -5810,7 +5953,7 @@ dependencies = [ "or-panic", "p256", "parity-scale-codec", - "rand 0.8.5", + "rand 0.8.6", "rcgen", "ring", "rmp-serde", @@ -5819,7 +5962,10 @@ dependencies = [ "serde-human-bytes", "serde_json", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", + "tdx-attest", + "tpm-qvl", + "tpm-types", "tracing", "x509-parser", "yasna", @@ -5846,9 +5992,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -5858,9 +6004,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -5869,13 +6015,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -5938,9 +6084,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_hc" @@ -5998,16 +6144,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" -dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -6242,14 +6379,14 @@ dependencies = [ "http", "hyper", "hyper-util", - "indexmap 2.13.0", + "indexmap 2.14.0", "libc", "memchr", "multer", "num_cpus", "parking_lot 0.12.5", "pin-project-lite", - "rand 0.9.2", + "rand 0.9.4", "ref-cast", "ref-swap", "rocket_codegen", @@ -6305,7 +6442,7 @@ source = "git+https://github.com/rwf2/Rocket?branch=master#3a54d079aef060a8f732b dependencies = [ "devise", "glob", - "indexmap 2.13.0", + "indexmap 2.14.0", "proc-macro2", "quote", "rocket_http", @@ -6321,7 +6458,7 @@ source = "git+https://github.com/rwf2/Rocket?branch=master#3a54d079aef060a8f732b dependencies = [ "cookie", "either", - "indexmap 2.13.0", + "indexmap 2.14.0", "memchr", "pear", "percent-encoding", @@ -6357,9 +6494,9 @@ dependencies = [ [[package]] name = "ruint" -version = "1.17.2" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" +checksum = "0298da754d1395046b0afdc2f20ee76d29a8ae310cd30ffa84ed42acba9cb12a" dependencies = [ "alloy-rlp", "ark-ff 0.3.0", @@ -6374,8 +6511,8 @@ dependencies = [ "parity-scale-codec", "primitive-types", "proptest", - "rand 0.8.5", - "rand 0.9.2", + "rand 0.8.6", + "rand 0.9.4", "rlp", "ruint-macro", "serde_core", @@ -6403,9 +6540,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc-hex" @@ -6428,7 +6565,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 1.0.27", + "semver 1.0.28", ] [[package]] @@ -6446,7 +6583,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6459,18 +6596,18 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", @@ -6505,9 +6642,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -6515,9 +6652,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -6560,9 +6697,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "s2n-codec" -version = "0.76.0" +version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f50a47bba1ff6cd526c0018481a13289307aade3fe4798ecb45306e67e726b5" +checksum = "d197a3c92bbe21fc00ba8366f6ba14edb8685316b6c8c14c622d3aba0a3816d8" dependencies = [ "byteorder", "bytes", @@ -6571,16 +6708,16 @@ dependencies = [ [[package]] name = "s2n-quic" -version = "1.76.0" +version = "1.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cac8d7c1941118cb9922b0ba8720793d5df6fbcaf5f989c523b0d7ca9cd14326" +checksum = "8728244102e791769cebe44a4abace966d8826f3266e9691c4233f47921b94b8" dependencies = [ "bytes", "cfg-if", "cuckoofilter", "futures", "hash_hasher", - "rand 0.10.0", + "rand 0.10.1", "s2n-codec", "s2n-quic-core", "s2n-quic-crypto", @@ -6595,9 +6732,9 @@ dependencies = [ [[package]] name = "s2n-quic-core" -version = "0.76.0" +version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c1acca904f1681a854c0db3969407f3b335dcceeed1c2291c860f198f77283" +checksum = "6cc69861a4909ea508b26309504899f4b0f77bb35348f6a36b7de9a28b1a4b92" dependencies = [ "atomic-waker", "byteorder", @@ -6617,9 +6754,9 @@ dependencies = [ [[package]] name = "s2n-quic-crypto" -version = "0.76.0" +version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc143a289765b48bfe7f2986497ed445b4f98ab45e8d787e24054a84b78109ff" +checksum = "5a3ce7f399a87be4b49d76895cdddb987620d34f334072d011bcac913d20fe69" dependencies = [ "aws-lc-rs", "cfg-if", @@ -6643,24 +6780,24 @@ dependencies = [ [[package]] name = "s2n-quic-platform" -version = "0.76.0" +version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca931fd1c528f4eaaf8af8b75ef66ff8d8540d719d00fd03ebb9cb1e546cf62" +checksum = "fa9004809ae3a778b8e015581a47e9fb389f9ec230456a24b81c6287b000fefe" dependencies = [ "cfg-if", "futures", "lazy_static", "libc", "s2n-quic-core", - "socket2 0.6.2", + "socket2", "tokio", ] [[package]] name = "s2n-quic-rustls" -version = "0.76.0" +version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b791196ac1a1363e920f160a27155c5ea1c9b979366d549619fdb57dba775b2c" +checksum = "cf7c34876c77f7560ee4385cd5ff0510acade2eb66dc237a45f7c63d2e7f1af3" dependencies = [ "bytes", "rustls", @@ -6672,14 +6809,14 @@ dependencies = [ [[package]] name = "s2n-quic-transport" -version = "0.76.0" +version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dcf1512601877051e089976f59e074ec5d2ff38a1ab01cdc219e00afe821c4b" +checksum = "d1ddd739c1776770dd2ab0b33da1cf372a395500252ae5250c08e2d6bf51b38f" dependencies = [ "bytes", "futures-channel", "futures-core", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "intrusive-collections", "once_cell", "s2n-codec", @@ -6742,9 +6879,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -6835,11 +6972,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes", - "rand 0.8.5", - "secp256k1-sys", + "rand 0.8.6", + "secp256k1-sys 0.10.1", "serde", ] +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes", + "rand 0.9.4", + "secp256k1-sys 0.11.0", +] + [[package]] name = "secp256k1-sys" version = "0.10.1" @@ -6849,6 +6997,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + [[package]] name = "secrecy" version = "0.8.0" @@ -6864,7 +7021,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -6892,9 +7049,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "semver-parser" @@ -6933,9 +7090,9 @@ dependencies = [ [[package]] name = "serde-human-bytes" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a091af6294712930d01e375cce513e4ac416f823e033e8991ec4e5d6e6ef4c0" +checksum = "3aff481ca1fe108deba0f217b45d9f1d494e7e7f906bcc7366d8a5648c5a1e65" dependencies = [ "base64 0.13.1", "hex", @@ -6952,6 +7109,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half 1.8.3", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -6985,11 +7152,11 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -7042,15 +7209,16 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.17.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -7061,11 +7229,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.17.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", "syn 2.0.117", @@ -7118,19 +7286,29 @@ dependencies = [ [[package]] name = "sha3" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ "digest 0.10.7", - "keccak", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", ] [[package]] name = "sha3-asm" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b31139435f327c93c6038ed350ae4588e2c70a13d50599509fee6349967ba35a" +checksum = "9f3f15d4e239ebe08413eed880e0f9b5af4b40ee0472543320efa91d488e96a7" dependencies = [ "cc", "cfg-if", @@ -7158,9 +7336,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "sigchld" @@ -7216,9 +7394,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "similar" @@ -7228,9 +7406,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "size-parser" @@ -7280,22 +7458,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7467,9 +7635,9 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53f425ae0b12e2f5ae65542e00898d500d4d318b4baf09f40fd0d410454e9947" +checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" dependencies = [ "paste", "proc-macro2", @@ -7556,9 +7724,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -7588,22 +7756,22 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "template-quote" -version = "0.4.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc002ce9580af57b063e49f50f3f0da0682a42897a1f42b2f523893163319e5f" +checksum = "af274c0f7b7b695b3f4fc31d7acfd43fc4e6d73517f7d105193f8a72f06f4ca5" dependencies = [ "quote", "template-quote-impl", @@ -7611,9 +7779,9 @@ dependencies = [ [[package]] name = "template-quote-impl" -version = "0.4.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "771674c8b6053d12596dbc4edb19babd846eba27cd47e0e432f7dc2beac23b16" +checksum = "11f882581e75a001ca0d61ba497a2d368338212f1fe2f10d33f9b0147fed67b4" dependencies = [ "proc-macro-error", "proc-macro2", @@ -7721,9 +7889,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -7731,9 +7899,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -7746,26 +7914,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio 1.1.1", + "mio 1.2.1", "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -7843,9 +8011,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -7856,33 +8024,33 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap 2.13.0", - "toml_datetime 1.0.0+spec-1.1.0", + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] @@ -7908,20 +8076,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -7936,6 +8104,71 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tpm-attest" +version = "0.5.11" +dependencies = [ + "anyhow", + "dstack-types", + "fs-err", + "hex", + "parity-scale-codec", + "serde", + "serde-human-bytes", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tpm-types", + "tpm2", + "tracing", +] + +[[package]] +name = "tpm-qvl" +version = "0.5.11" +dependencies = [ + "anyhow", + "base64 0.22.1", + "dcap-qvl-webpki", + "dstack-types", + "hex", + "nom", + "p256", + "pem", + "reqwest", + "rsa", + "rustls-pki-types", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tpm-types", + "tracing", + "x509-parser", +] + +[[package]] +name = "tpm-types" +version = "0.5.11" +dependencies = [ + "cc-eventlog", + "dstack-types", + "parity-scale-codec", + "serde", + "serde-human-bytes", +] + +[[package]] +name = "tpm2" +version = "0.5.11" +dependencies = [ + "anyhow", + "hex", + "sha2 0.10.9", + "tempfile", + "tracing", +] + [[package]] name = "tracing" version = "0.1.44" @@ -7981,9 +8214,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -8016,9 +8249,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ubyte" @@ -8077,9 +8310,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -8093,7 +8326,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -8147,9 +8380,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -8182,12 +8415,12 @@ checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" [[package]] name = "vsock" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b82aeb12ad864eb8cd26a6c21175d0bdc66d398584ee6c93c76964c3bcfc78ff" +checksum = "6ba782755fc073877e567c2253c0be48e4aa9a254c232d36d3985dfae0bd5205" dependencies = [ "libc", - "nix 0.31.2", + "nix 0.31.3", ] [[package]] @@ -8241,11 +8474,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -8254,14 +8487,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -8272,23 +8505,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8296,9 +8525,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -8309,9 +8538,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -8333,7 +8562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -8344,10 +8573,10 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver 1.0.27", + "indexmap 2.14.0", + "semver 1.0.28", ] [[package]] @@ -8375,9 +8604,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -8395,9 +8624,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -8454,7 +8683,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -8575,6 +8804,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -8611,15 +8851,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -8861,13 +9092,12 @@ dependencies = [ ] [[package]] -name = "winreg" -version = "0.50.0" +name = "winnow" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ - "cfg-if", - "windows-sys 0.48.0", + "memchr", ] [[package]] @@ -8885,6 +9115,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -8904,7 +9140,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.13.0", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -8934,8 +9170,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.0", + "bitflags 2.11.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -8954,9 +9190,9 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", - "semver 1.0.27", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -8966,9 +9202,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -9084,9 +9320,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -9095,9 +9331,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -9107,18 +9343,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -9127,18 +9363,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -9168,9 +9404,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -9179,9 +9415,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -9190,9 +9426,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 2b144b465..1677b9a71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,12 @@ members = [ "ra-rpc", "ra-tls", "tdx-attest", + "tpm-attest", + "nsm-attest", + "tpm2", + "tpm-types", + "tpm-qvl", + "nsm-qvl", "dstack-attest", "dstack-util", "iohash", @@ -44,16 +50,16 @@ members = [ "dstack-types", "cert-client", "lspci", - "sdk/rust", - "sdk/rust/types", "sodiumbox", "serde-duration", "dstack-mr", "dstack-mr/cli", "verifier", - "no_std_check", "size-parser", "port-forward", + "sdk/rust", + "sdk/rust/types", + "no_std_check", ] resolver = "2" @@ -71,7 +77,13 @@ cc-eventlog = { path = "cc-eventlog" } supervisor = { path = "supervisor" } supervisor-client = { path = "supervisor/client" } tdx-attest = { path = "tdx-attest" } +tpm-attest = { path = "tpm-attest" } +nsm-attest = { path = "nsm-attest" } +tpm2 = { path = "tpm2" } +tpm-types = { path = "tpm-types" } dstack-attest = { path = "dstack-attest" } +tpm-qvl = { path = "tpm-qvl" } +nsm-qvl = { path = "nsm-qvl" } certbot = { path = "certbot" } rocket-vsock-listener = { path = "rocket-vsock-listener" } host-api = { path = "host-api", default-features = false } @@ -179,6 +191,7 @@ url = "2.5" aes-gcm = "0.10.3" curve25519-dalek = "4.1.3" dcap-qvl = "0.3.10" +dcap-qvl-webpki = "0.103.4" elliptic-curve = { version = "0.13.8", features = ["pkcs8"] } getrandom = "0.3.1" hkdf = "0.12.4" diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt index 261eeb9e9..d64569567 100644 --- a/LICENSES/Apache-2.0.txt +++ b/LICENSES/Apache-2.0.txt @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ diff --git a/LICENSES/GPL-2.0-only.txt b/LICENSES/GPL-2.0-only.txt deleted file mode 100644 index 17cb28643..000000000 --- a/LICENSES/GPL-2.0-only.txt +++ /dev/null @@ -1,117 +0,0 @@ -GNU GENERAL PUBLIC LICENSE -Version 2, June 1991 - -Copyright (C) 1989, 1991 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. - -When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. - -To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. - -For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. - -We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. - -Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. - -Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. - -The precise terms and conditions for copying, distribution and modification follow. - -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. - -1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. - - c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. - -4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. - -6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. - -7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. - -10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the program's name and an idea of what it does. Copyright (C) yyyy name of author - - This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. - -signature of Ty Coon, 1 April 1989 Ty Coon, President of Vice diff --git a/LICENSES/Linux-syscall-note.txt b/LICENSES/Linux-syscall-note.txt deleted file mode 100644 index fcd056364..000000000 --- a/LICENSES/Linux-syscall-note.txt +++ /dev/null @@ -1,12 +0,0 @@ - NOTE! This copyright does *not* cover user programs that use kernel - services by normal system calls - this is merely considered normal use - of the kernel, and does *not* fall under the heading of "derived work". - Also note that the GPL below is copyrighted by the Free Software - Foundation, but the instance of code that it refers to (the Linux - kernel) is copyrighted by me and others who actually wrote it. - - Also note that the only valid version of the GPL as far as the kernel - is concerned is _this_ particular version of the license (ie v2, not - v2.2 or v3.x or whatever), unless explicitly otherwise stated. - - Linus Torvalds diff --git a/README.md b/README.md index 0e723fd7f..b3dd2c569 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@
-![dstack](./dstack-logo.svg) +![dstack-cloud](./dstack-logo.svg) -### The open framework for confidential AI. +### Deploy confidential workloads on GCP and AWS. [![GitHub Stars](https://img.shields.io/github/stars/dstack-tee/dstack?style=flat-square&logo=github)](https://github.com/Dstack-TEE/dstack/stargazers) [![License](https://img.shields.io/github/license/dstack-tee/dstack?style=flat-square)](https://github.com/Dstack-TEE/dstack/blob/master/LICENSE) @@ -18,36 +18,33 @@ Original Contributors: Hang Yin, Kevin Wang, Andrew Miller --- -## What is dstack? +## What is dstack-cloud? -dstack is the open framework for confidential AI - deploy AI applications with cryptographic privacy guarantees. +dstack-cloud extends [dstack](https://github.com/Dstack-TEE/dstack) to deploy containers on **GCP Confidential VMs** and **AWS Nitro Enclaves**. It provisions the VM, manages attestation, and handles networking. You get confidential computing on cloud infrastructure without running your own TDX hardware. -AI providers ask users to trust them with sensitive data. But trust doesn't scale, and trust can't be verified. With dstack, your containers run inside confidential VMs (Intel TDX) with native support for NVIDIA Confidential Computing (H100, Blackwell). Users can cryptographically verify exactly what's running: private AI with your existing Docker workflow. +Your containers run with full security infrastructure out of the box: key management, remote attestation, hardened OS, and encrypted storage. Users can cryptographically verify exactly what's running. -### Features +## Supported Platforms -**Zero friction onboarding** -- **Docker Compose native**: Bring your docker-compose.yaml as-is. No SDK, no code changes. -- **Encrypted by default**: Network traffic and disk storage encrypted out of the box. - -**Hardware-rooted security** -- **Private by hardware**: Data encrypted in memory, inaccessible even to the host. -- **Reproducible OS**: Deterministic builds mean anyone can verify the OS image hash. -- **Workload identity**: Every app gets an attested identity users can verify cryptographically. -- **Confidential GPUs**: Native support for NVIDIA Confidential Computing (H100, Blackwell). +| Platform | Status | Attestation | +|----------|--------|-------------| +| **[Phala Cloud](https://cloud.phala.network)** | Available | TDX | +| **GCP Confidential VMs** | Available | TDX + TPM | +| **AWS Nitro Enclaves** | Available | NSM | +| **Bare metal TDX** | Available | TDX | -**Trustless operations** -- **Isolated keys**: Per-app keys derived in TEE. Survives hardware failure. Never exposed to operators. -- **Code governance**: Updates follow predefined rules (e.g., multi-party approval). Operators can't swap code or access secrets. +## Quick Start -## Getting Started +**1. Create a project:** -**Try it now:** Chat with LLMs running in TEE at [chat.redpill.ai](https://chat.redpill.ai). Click the shield icon to verify attestations from Intel TDX and NVIDIA GPUs. +```bash +dstack-cloud new my-app +cd my-app +``` -**Deploy your own:** +**2. Edit your docker-compose.yaml:** ```yaml -# docker-compose.yaml services: vllm: image: vllm/vllm-openai:latest @@ -57,54 +54,105 @@ services: - "8000:8000" ``` -Deploy to any TDX host with the [`dstack-nvidia-0.5.x` base image](https://github.com/Dstack-TEE/meta-dstack/releases), or use [Phala Cloud](https://cloud.phala.network) for managed infrastructure. +**3. Deploy:** + +```bash +dstack-cloud deploy +``` + +**4. Check status:** + +```bash +dstack-cloud status +dstack-cloud logs --follow +``` + +For the full walkthrough, see the [Quickstart Guide](./docs/quickstart.md). + +## Features -Want to deploy a self hosted dstack? Check our [full deployment guide →](./docs/deployment.md) +**Zero friction onboarding** +- **Docker Compose native**: Bring your docker-compose.yaml as-is. No SDK, no code changes. +- **Encrypted by default**: Network traffic and disk storage encrypted out of the box. + +**Hardware-rooted security** +- **Private by hardware**: Data encrypted in memory, inaccessible even to the host. +- **Reproducible OS**: Deterministic builds mean anyone can verify the OS image hash. +- **Workload identity**: Every app gets an attested identity users can verify cryptographically. +- **Confidential GPUs**: Native support for NVIDIA Confidential Computing (H100, Blackwell). + +**Trustless operations** +- **Isolated keys**: Per-app keys derived in TEE. Survives hardware failure. Never exposed to operators. +- **Code governance**: Updates follow predefined rules (e.g., multi-party approval). Operators can't swap code or access secrets. ## Architecture ![Architecture](./docs/assets/arch.png) -Your container runs inside a Confidential VM (Intel TDX) with optional GPU isolation via NVIDIA Confidential Computing. The CPU TEE protects application logic; the GPU TEE protects model weights and inference data. +Your container runs inside a Confidential VM (Intel TDX on GCP, Nitro Enclave on AWS). GPU isolation is optional via NVIDIA Confidential Computing. The CPU TEE protects application logic. The GPU TEE protects model weights and inference data. **Core components:** -- **Guest Agent**: Runs inside each CVM. Generates TDX attestation quotes so users can verify exactly what's running. Provisions per-app cryptographic keys from KMS. Encrypts local storage. Apps interact via `/var/run/dstack.sock`. +- **Guest Agent**: Runs inside each CVM. Generates attestation quotes so users can verify exactly what's running. Provisions per-app cryptographic keys from KMS. Encrypts local storage. Apps interact via `/var/run/dstack.sock`. -- **KMS**: Runs in its own TEE. Verifies TDX quotes before releasing keys. Enforces authorization policies defined in on-chain smart contracts — operators cannot bypass these checks. Derives deterministic keys bound to each app's attested identity. +- **KMS**: Runs in its own TEE. Verifies attestation quotes before releasing keys. Enforces authorization policies that operators cannot bypass. Derives deterministic keys bound to each app's attested identity. -- **Gateway**: Terminates TLS at the edge and provisions ACME certificates automatically. Routes traffic to CVMs. All internal communication uses RA-TLS for mutual attestation. +- **Gateway**: Terminates TLS at the edge. Provisions ACME certificates automatically. Routes traffic to CVMs. Internal communication uses RA-TLS for mutual attestation. -- **VMM**: Runs on bare-metal TDX hosts. Parses docker-compose files directly — no app changes needed. Boots CVMs from a reproducible OS image. Allocates CPU, memory, and confidential GPU resources. +- **VMM**: Parses docker-compose files directly — no app changes needed. Boots CVMs from a reproducible OS image. Allocates CPU, memory, and confidential GPU resources. [Full security model →](./docs/security/security-model.md) +## CLI Reference + +``` +dstack-cloud new # Create a new project +dstack-cloud config-edit # Edit global configuration +dstack-cloud deploy # Deploy to cloud +dstack-cloud status # Check deployment status +dstack-cloud logs [--follow] # View console logs +dstack-cloud stop # Stop the VM +dstack-cloud start # Start a stopped VM +dstack-cloud remove # Remove the VM and cleanup +dstack-cloud list # List all deployments +dstack-cloud fw allow # Allow traffic on a port +dstack-cloud fw deny # Block traffic on a port +dstack-cloud fw list # List firewall rules +``` + ## SDKs -Apps communicate with the guest agent via HTTP over `/var/run/dstack.sock`. Use the [HTTP API](./sdk/curl/api.md) directly with curl, or use a language SDK: +Apps communicate with the guest agent via HTTP over `/var/run/dstack.sock`. Use the [HTTP API](https://github.com/Dstack-TEE/dstack/blob/master/sdk/curl/api.md) directly with curl, or use a language SDK: | Language | Install | Docs | |----------|---------|------| -| Python | `pip install dstack-sdk` | [README](./sdk/python/README.md) | -| TypeScript | `npm install @phala/dstack-sdk` | [README](./sdk/js/README.md) | -| Rust | `cargo add dstack-sdk` | [README](./sdk/rust/README.md) | -| Go | `go get github.com/Dstack-TEE/dstack/sdk/go` | [README](./sdk/go/README.md) | +| Python | `pip install dstack-sdk` | [README](https://github.com/Dstack-TEE/dstack/blob/master/sdk/python/README.md) | +| TypeScript | `npm install @phala/dstack-sdk` | [README](https://github.com/Dstack-TEE/dstack/blob/master/sdk/js/README.md) | +| Rust | `cargo add dstack-sdk` | [README](https://github.com/Dstack-TEE/dstack/blob/master/sdk/rust/README.md) | +| Go | `go get github.com/Dstack-TEE/dstack/sdk/go` | [README](https://github.com/Dstack-TEE/dstack/blob/master/sdk/go/README.md) | ## Documentation -**For Developers** -- [Confidential AI](./docs/confidential-ai.md) - Inference, agents, and training with hardware privacy +**Getting Started** +- [Quickstart](./docs/quickstart.md) - Deploy your first app on GCP or AWS - [Usage Guide](./docs/usage.md) - Deploying and managing apps - [Verification](./docs/verification.md) - How to verify TEE attestation -**For Operators** +**Cloud Platforms** +- [GCP Attestation](./docs/attestation-gcp.md) - TDX + TPM attestation on GCP +- [AWS Nitro Attestation](./docs/attestation-nitro-enclave.md) - NSM attestation on AWS + +**For Developers** +- [Confidential AI](./docs/confidential-ai.md) - Inference, agents, and training with hardware privacy +- [App Compose Format](./docs/normalized-app-compose.md) - Compose file specification + +**Self-Hosted / Bare Metal** - [Deployment](./docs/deployment.md) - Self-hosting on TDX hardware -- [On-Chain Governance](./docs/onchain-governance.md) - Smart contract authorization +- [VMM CLI Guide](./docs/vmm-cli-user-guide.md) - VMM command-line reference - [Gateway](./docs/dstack-gateway.md) - Gateway configuration +- [On-Chain Governance](./docs/onchain-governance.md) - Policy-based authorization **Reference** -- [App Compose Format](./docs/normalized-app-compose.md) - Compose file specification -- [VMM CLI Guide](./docs/vmm-cli-user-guide.md) - Command-line reference - [Design Decisions](./docs/design-and-hardening-decisions.md) - Architecture rationale - [FAQ](./docs/faq.md) - Frequently asked questions @@ -121,51 +169,56 @@ Apps communicate with the guest agent via HTTP over `/var/run/dstack.sock`. Use
Why not use AWS Nitro / Azure Confidential VMs / GCP directly? -You can — but you'll build everything yourself: attestation verification, key management, Docker orchestration, certificate provisioning, and governance. dstack provides all of this out of the box. +You can — but you'll build everything yourself: attestation verification, key management, Docker orchestration, certificate provisioning, and governance. dstack-cloud provides all of this out of the box. | Approach | Docker native | GPU TEE | Key management | Attestation tooling | Open source | |----------|:-------------:|:-------:|:--------------:|:-------------------:|:-----------:| -| **dstack** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **dstack-cloud** | ✓ | ✓ | ✓ | ✓ | ✓ | | AWS Nitro Enclaves | - | - | Manual | Manual | - | | Azure Confidential VMs | - | Preview | Manual | Manual | - | | GCP Confidential Computing | - | - | Manual | Manual | - | -Cloud providers give you the hardware primitive. dstack gives you the full stack: reproducible OS images, automatic attestation, per-app key derivation, TLS certificates, and smart contract governance. No vendor lock-in. +Cloud providers give you the hardware primitive. dstack-cloud gives you the full stack: reproducible OS images, automatic attestation, per-app key derivation, and TLS certificates. No vendor lock-in.
How is this different from SGX/Gramine? -SGX requires porting applications to enclaves. dstack uses full-VM isolation (Intel TDX) — bring your Docker containers as-is. Plus GPU TEE support that SGX doesn't offer. +SGX requires porting applications to enclaves. dstack-cloud uses full-VM isolation (Intel TDX, AWS Nitro) — bring your Docker containers as-is. Plus GPU TEE support that SGX doesn't offer.
What's the performance overhead? -Minimal. Intel TDX adds ~2-5% overhead for CPU workloads. NVIDIA Confidential Computing has negligible impact on GPU inference. The main cost is memory encryption, which is hardware-accelerated on supported CPUs. +Minimal. Intel TDX adds ~2-5% overhead for CPU workloads. NVIDIA Confidential Computing has negligible impact on GPU inference. Memory encryption is the main cost, but it's hardware-accelerated on supported CPUs.
Is this production-ready? -Yes. dstack powers production AI infrastructure at [OpenRouter](https://openrouter.ai/provider/phala) and [NEAR AI](https://x.com/ilblackdragon/status/1962920246148268235). The framework has been [audited by zkSecurity](./docs/security/dstack-audit.pdf) and is a Linux Foundation Confidential Computing Consortium project. +Yes. dstack powers production AI at [OpenRouter](https://openrouter.ai/provider/phala) and [NEAR AI](https://x.com/ilblackdragon/status/1962920246148268235). It's been [audited by zkSecurity](./docs/security/dstack-audit.pdf). It's a Linux Foundation Confidential Computing Consortium project.
Can I run this on my own hardware? -Yes. dstack runs on any Intel TDX-capable server. See the [deployment guide](./docs/deployment.md) for self-hosting instructions. You can also use [Phala Cloud](https://cloud.phala.network) for managed infrastructure. +Yes. dstack-cloud runs on any Intel TDX-capable server. See the [deployment guide](./docs/deployment.md) for self-hosting instructions. You can also use [Phala Cloud](https://cloud.phala.network) for managed infrastructure.
What TEE hardware is supported? -Currently: Intel TDX (4th/5th Gen Xeon) and NVIDIA Confidential Computing (H100, Blackwell). AMD SEV-SNP support is planned. +- **GCP**: Intel TDX (Confidential VMs) +- **AWS**: Nitro Enclaves (NSM attestation) +- **Bare metal**: Intel TDX (4th/5th Gen Xeon) +- **GPUs**: NVIDIA Confidential Computing (H100, Blackwell) + +AMD SEV-SNP support is planned.
@@ -187,6 +240,8 @@ dstack is a Linux Foundation [Confidential Computing Consortium](https://confide [Telegram](https://t.me/+UO4bS4jflr45YmUx) · [GitHub Discussions](https://github.com/Dstack-TEE/dstack/discussions) · [Examples](https://github.com/Dstack-TEE/dstack-examples) +For enterprise support and licensing, [book a call](https://cal.com/team/phala/founders) or email us at support@phala.network. + [![Repobeats](https://repobeats.axiom.co/api/embed/0a001cc3c1f387fae08172a9e116b0ec367b8971.svg)](https://github.com/Dstack-TEE/dstack/pulse) ## Cite diff --git a/REUSE.toml b/REUSE.toml index cc27b6179..0d460e9e9 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -73,6 +73,15 @@ path = [ "sdk/simulator/attestation.bin", "ra-tls/assets/tdx_quote", "cc-eventlog/samples/ccel.bin", + "cc-eventlog/samples/tpm_eventlog.bin", + "tpm-attest/tests/tpm_quote_sample.bin", + "tpm-qvl/certs/gcp-root-ca.pem", + "dstack-attest/tests/nitro_attestation.bin", + "dstack-attest/tests/nitro_attestation_dbg.bin", + "nsm-attest/tests/nitro_attestation.bin", + "nsm-qvl/tests/nitro_attestation.bin", + "nsm-qvl/certs/AWS_NitroEnclaves_Root-G1.pem", + "tpm-qvl/certs/AWS_NitroEnclaves_Root-G1.pem", ] SPDX-FileCopyrightText = "NONE" SPDX-License-Identifier = "CC0-1.0" @@ -117,11 +126,6 @@ SPDX-FileCopyrightText = "NONE" SPDX-License-Identifier = "Apache-2.0" precedence = "override" -[[annotations]] -path = "mod-tdx-guest/Kconfig" -SPDX-FileCopyrightText = "© 2022 Intel Corporation" -SPDX-License-Identifier = "GPL-2.0-only" - # Generated files [[annotations]] diff --git a/attestation.md b/attestation.md index c10232cd8..b6fa617fa 100644 --- a/attestation.md +++ b/attestation.md @@ -35,7 +35,7 @@ RTMR3 differs as it contains runtime information like compose hash and instance MRTD, RTMR0, RTMR1, and RTMR2 correspond to the image. dstack OS builds all related software from source. Build version v0.5.4 using these commands: ```bash -git clone https://github.com/Dstack-TEE/meta-dstack.git +git clone https://github.com/Phala-Network/meta-dstack-cloud.git cd meta-dstack/ git checkout f7c795b76faa693f218e1c255007e3a68c541d79 git submodule update --init --recursive diff --git a/cc-eventlog/samples/tpm_eventlog.bin b/cc-eventlog/samples/tpm_eventlog.bin new file mode 100644 index 000000000..bf8459f05 Binary files /dev/null and b/cc-eventlog/samples/tpm_eventlog.bin differ diff --git a/cc-eventlog/src/lib.rs b/cc-eventlog/src/lib.rs index e850f39c7..93bbc77ff 100644 --- a/cc-eventlog/src/lib.rs +++ b/cc-eventlog/src/lib.rs @@ -9,6 +9,7 @@ mod codecs; mod runtime_events; mod tcg; pub mod tdx; +pub mod tpm; #[cfg(test)] mod tests { diff --git a/cc-eventlog/src/tpm.rs b/cc-eventlog/src/tpm.rs new file mode 100644 index 000000000..2d70bd01a --- /dev/null +++ b/cc-eventlog/src/tpm.rs @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM Event Log parsing (binary_bios_measurements format) + +use crate::codecs::VecOf; +use crate::tcg::{TcgDigest, TcgEfiSpecIdEvent}; +use anyhow::{Context, Result}; +use scale::Decode; +use serde::{Deserialize, Serialize}; + +/// Simplified TPM event for PCR replay +#[derive(Clone, Debug, Serialize, Deserialize, scale::Encode, scale::Decode)] +pub struct TpmEvent { + /// PCR index this event was extended to + pub pcr_index: u32, + /// SHA-256 digest of the event data + #[serde(with = "serde_human_bytes")] + pub digest: Vec, +} + +/// TCG_PCR_EVENT2 format +/// +/// See TCG PC Client Platform Firmware Profile spec section 9.2.2 +#[derive(Clone, Decode)] +struct TpmRawEvent { + pcr_index: u32, + event_type: u32, + digests: VecOf, + event: VecOf, +} + +impl core::fmt::Debug for TpmRawEvent { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("TpmRawEvent") + .field("pcr_index", &self.pcr_index) + .field("event_type", &self.event_type) + .field( + "digests", + &self + .digests + .iter() + .map(|d| hex::encode(&d.hash)) + .collect::>(), + ) + .field("event", &hex::encode(&self.event)) + .finish() + } +} + +impl TpmRawEvent { + fn sha256_digest(&self) -> Option> { + self.digests + .iter() + .find(|d| d.algo_id == crate::tcg::TPM_ALG_SHA256) + .map(|d| d.hash.clone()) + } + + fn is_extended_to_pcr(&self) -> bool { + self.event_type != crate::tcg::EV_NO_ACTION + } + + fn to_simple_event(&self) -> Option { + if !self.is_extended_to_pcr() { + return None; + } + self.sha256_digest().map(|digest| TpmEvent { + pcr_index: self.pcr_index, + digest, + }) + } +} + +#[derive(Clone, Debug)] +pub struct TpmEventLog { + pub spec_id_header_event: TcgEfiSpecIdEvent, + pub events: Vec, +} + +impl TpmEventLog { + /// Decode from binary_bios_measurements format + /// + /// First event is TCG_PCClientPCREvent (legacy format with SHA-1). + /// Subsequent events are TCG_PCR_EVENT2 (crypto-agile format). + pub fn decode(input: &mut &[u8]) -> Result { + let (_spec_id_header, spec_id_header_event) = + parse_spec_id_event(input).context("Failed to parse spec id event")?; + + let mut events = vec![]; + loop { + let head_buffer = &mut &input[..]; + let pcr_index = match u32::decode(head_buffer) { + Ok(idx) => idx, + Err(_) => break, + }; + + if pcr_index == 0xFFFFFFFF { + break; + } + + let raw_event = TpmRawEvent::decode(input).context("Failed to decode TPM event")?; + + if let Some(event) = raw_event.to_simple_event() { + events.push(event); + } + } + + Ok(TpmEventLog { + spec_id_header_event, + events, + }) + } + + /// Read and decode TPM Event Log from kernel sysfs + pub fn from_kernel_file() -> Result { + const TPM_BINARY_BIOS_MEASUREMENTS: &str = + "/sys/kernel/security/tpm0/binary_bios_measurements"; + + let data = fs_err::read(TPM_BINARY_BIOS_MEASUREMENTS) + .context("Failed to read TPM binary_bios_measurements")?; + + Self::decode(&mut data.as_slice()) + } + + /// Filter events by PCR index + pub fn filter_by_pcr(&self, pcr_index: u32) -> Vec { + self.events + .iter() + .filter(|e| e.pcr_index == pcr_index) + .cloned() + .collect() + } + + /// Get all PCR 2 events (boot loader and OS measurements) + pub fn pcr2_events(&self) -> Vec { + self.filter_by_pcr(2) + } +} + +/// Parse Spec ID Event in legacy TCG_PCClientPCREvent format +fn parse_spec_id_event(input: &mut I) -> Result<(TpmRawEvent, TcgEfiSpecIdEvent)> { + #[derive(Decode)] + struct SpecIdHeader { + pcr_index: u32, + event_type: u32, + digest_sha1: [u8; 20], + event: VecOf, + } + + let header = SpecIdHeader::decode(input).context("failed to decode spec id header")?; + + let spec_id_event = TcgEfiSpecIdEvent::decode(&mut header.event.as_slice()) + .context("failed to decode TcgEfiSpecIdEvent")?; + + let digests = vec![TcgDigest { + algo_id: crate::tcg::TPM_ALG_SHA1, + hash: header.digest_sha1.to_vec(), + }]; + + let raw_event = TpmRawEvent { + pcr_index: header.pcr_index, + event_type: header.event_type, + digests: (digests.len() as u32, digests).into(), + event: header.event, + }; + + Ok((raw_event, spec_id_event)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_empty() { + let result = TpmEventLog::decode(&mut &[][..]); + assert!(result.is_err()); + } + + #[test] + fn test_decode_gcp_tpm_eventlog() { + let data = include_bytes!("../samples/tpm_eventlog.bin"); + let event_log = TpmEventLog::decode(&mut data.as_slice()).unwrap(); + + assert!(!event_log.events.is_empty()); + assert_eq!(event_log.spec_id_header_event.platform_class, 0); + + let pcr2_events = event_log.pcr2_events(); + assert_eq!(pcr2_events.len(), 4); + + assert_eq!( + hex::encode(&pcr2_events[0].digest), + "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119" + ); + + assert_eq!( + hex::encode(&pcr2_events[1].digest), + "00b8a357e652623798d1bbd16c375ec90fbed802b4269affa3e78e6eb19386cf" + ); + + // Event 28: UKI Authenticode hash + assert_eq!( + hex::encode(&pcr2_events[2].digest), + "9ab14a46f858662a89adc102d2a57a13f52f75c1769d65a4c34edbbfc8855f0f" + ); + + // Event 41: Linux kernel Authenticode hash + assert_eq!( + hex::encode(&pcr2_events[3].digest), + "ade943a0a7a3189a3201ba17d7df778eb380cbd33ce5e361176e974ccf7cdedb" + ); + } + + #[test] + fn test_filter_by_pcr() { + let data = include_bytes!("../samples/tpm_eventlog.bin"); + let event_log = TpmEventLog::decode(&mut data.as_slice()).unwrap(); + + let pcr0_events = event_log.filter_by_pcr(0); + assert!(!pcr0_events.is_empty()); + + let pcr2_events = event_log.filter_by_pcr(2); + assert_eq!(pcr2_events.len(), 4); + + let pcr99_events = event_log.filter_by_pcr(99); + assert_eq!(pcr99_events.len(), 0); + } + + #[test] + fn test_pcr2_uki_hash_extraction() { + let data = include_bytes!("../samples/tpm_eventlog.bin"); + let event_log = TpmEventLog::decode(&mut data.as_slice()).unwrap(); + + let pcr2_events = event_log.pcr2_events(); + assert!(pcr2_events.len() >= 3); + + let uki_hash = &pcr2_events[2].digest; + let expected_uki_hash = + hex::decode("9ab14a46f858662a89adc102d2a57a13f52f75c1769d65a4c34edbbfc8855f0f") + .unwrap(); + + assert_eq!(uki_hash, &expected_uki_hash); + } +} diff --git a/cert-client/Cargo.toml b/cert-client/Cargo.toml index 996e867d8..089b927bf 100644 --- a/cert-client/Cargo.toml +++ b/cert-client/Cargo.toml @@ -16,3 +16,4 @@ dstack-kms-rpc.workspace = true ra-rpc = { workspace = true, features = ["client"] } ra-tls = { workspace = true, features = ["quote"] } serde_json.workspace = true +tdx-attest.workspace = true diff --git a/docs/attestation-gcp.md b/docs/attestation-gcp.md new file mode 100644 index 000000000..01be5c658 --- /dev/null +++ b/docs/attestation-gcp.md @@ -0,0 +1,50 @@ +# Dstack GCP Attestation Flow (GCP TDX + TPM) + +This document describes how dstack produces and verifies attestation on GCP using +TDX plus a TPM quote. It follows the implementation in `dstack-attest`. + +## Components +- TDX quote generator: `tdx-attest::get_quote` +- TDX event log reader: `cc-eventlog::tdx::read_event_log` +- TPM quote generator: `tpm-attest::TpmContext::create_quote` +- Verifier: `dstack-attest` + `dcap-qvl` + `tpm-qvl` + +## Attestation Creation (guest side) +1. **Collect report_data** (64 bytes), optionally bound to RA TLS pubkey. +2. **Generate TDX quote** via `tdx-attest::get_quote(report_data)`. +3. **Read TDX event log** via `cc-eventlog::tdx::read_event_log()`. +4. **Compute TPM qualifying data** as `sha256(tdx_quote)`. +5. **Create TPM quote** with qualifying data and dstack PCR policy: + `tpm_attest::TpmContext::create_quote(qualifying_data, policy)`. +6. **Bundle** into `DstackGcpTdxQuote { tdx_quote, tpm_quote }`. +7. **Include config** from `/dstack/.host-shared/.sys-config.json`. + +## Attestation Verification (verifier side) +Verification runs in `Attestation::verify_with_time` and splits into TDX + TPM. + +### TDX verification +1. **Fetch TDX collateral** and verify quote: + `dcap_qvl::collateral::get_collateral_and_verify(quote, pccs_url)`. +2. **Validate TCB**: + - Debug mode must be off. + - `mr_signer_seam` must be all-zero. +3. **Replay runtime events** to compute RTMR3 and compare with quote RTMR3. +4. **Check report_data** in TD report equals the attestation `report_data`. + +### TPM verification +1. **Fetch TPM collateral** and verify quote: + `tpm_qvl::get_collateral_and_verify(tpm_quote)`. +2. **Replay runtime events** to compute runtime PCR and compare with quoted PCR. +3. **Check qualifying data** equals `sha256(tdx_quote)`. + +### Optional RA TLS binding +If the verifier provides a RA TLS pubkey, it enforces: +`report_data == QuoteContentType::RaTlsCert.to_report_data(pubkey)`. + +## Output +The verifier returns `DstackVerifiedReport::DstackGcpTdx` containing: +- `tdx_report` (verified TDX report and collateral info) +- `tpm_report` (verified TPM quote and PCRs) + +## Relevant Code +- `dstack-attest/src/attestation.rs` diff --git a/docs/attestation-nitro-enclave.md b/docs/attestation-nitro-enclave.md new file mode 100644 index 000000000..1173cb758 --- /dev/null +++ b/docs/attestation-nitro-enclave.md @@ -0,0 +1,64 @@ +# Dstack Nitro Enclave Attestation Flow (NSM) + +> **AWS Nitro Support:** Attestation verification is fully implemented. For AWS deployment options, [book a call](https://calendly.com/aspect-ux/30min) with our team. + +This document describes how dstack produces and verifies attestation on AWS +Nitro Enclaves using the NSM attestation document. It follows the +implementation in `dstack-attest` and `nsm-qvl`. + +## Components +- NSM attestation generator: `nsm-attest::get_attestation` +- Verifier: `dstack-attest` + `nsm-qvl` + +## Attestation Creation (enclave side) +1. **Collect report_data** (64 bytes), optionally bound to RA TLS pubkey. +2. **Request NSM attestation** with user_data = report_data: + `nsm_attest::get_attestation(report_data)`. +3. **Bundle** into `DstackNitroQuote { nsm_quote }`. +4. **Include config** derived from PCRs: + `os_image_hash = sha256(PCR0 || PCR1 || PCR2)` (all zeros if PCRs are zero). + +The NSM attestation document (COSE_Sign1 payload) includes: +- `module_id`, `digest`, `timestamp` +- `pcrs` map +- signing `certificate` and `cabundle` +- optional `user_data`, `nonce`, `public_key` + +## Attestation Verification (verifier side) +Verification runs in `Attestation::verify_with_time`: + +### COSE and document checks (nsm-qvl) +1. **Parse COSE_Sign1** and require `alg = ES384 (-35)`. +2. **Validate COSE critical headers** (`crit`) if present. +3. **Parse attestation document** from payload and enforce: + - `digest == "SHA384"` + - PCR lengths are 48 bytes + - freshness window against `now` + +### Certificate chain and signature +4. **Verify cert chain** to `AWS_NITRO_ENCLAVES_ROOT_G1`. +5. **Verify COSE signature** using the leaf certificate P-384 key. +6. **Key usage sanity** on leaf cert (if present): + - must allow `digitalSignature` + - must not allow `keyCertSign` or `cRLSign` + +### Optional CRL verification +`nsm-qvl` exposes async CRL verification via: +`verify_attestation_with_crl(..., enable_crl, ...)`. +This is **disabled by default** in `dstack-attest` because CRL fetch from +S3 may return 403. The caller can enable CRL explicitly. + +### Dstack-specific checks +7. **Match user_data** to `report_data`. +8. **Decode PCRs** and return verified report. + +## Output +The verifier returns `DstackVerifiedReport::DstackNitroEnclave` containing: +- `module_id` +- `pcrs` (PCR0/1/2) +- `user_data` (report_data) +- `timestamp` + +## Relevant Code +- `dstack-attest/src/attestation.rs` +- `nsm-qvl/src/verify.rs` diff --git a/docs/auth-simple-operations.md b/docs/auth-simple-operations.md index fad7c8fc9..ffd387a80 100644 --- a/docs/auth-simple-operations.md +++ b/docs/auth-simple-operations.md @@ -1,5 +1,7 @@ # auth-simple Operations Guide +> **This guide is for self-hosted deployments** on your own TDX hardware. For cloud deployments, see [Quickstart](./quickstart.md). + This guide covers day-to-day operations for managing apps and devices with auth-simple. For initial deployment setup, see [Deployment Guide](./deployment.md). diff --git a/docs/deployment.md b/docs/deployment.md index bccfb1bbd..06838a27f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,5 +1,7 @@ # Deploying dstack +> **This guide is for self-hosted deployments** on your own TDX hardware. For cloud deployments, see [Quickstart](./quickstart.md). + This guide covers deploying dstack on bare metal TDX hosts. ## Overview @@ -43,7 +45,7 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ### Build Configuration ```bash -git clone https://github.com/Dstack-TEE/meta-dstack.git --recursive +git clone https://github.com/Phala-Network/meta-dstack-cloud.git --recursive cd meta-dstack/ mkdir build && cd build ../build.sh hostcfg @@ -124,7 +126,7 @@ If you skip the KMS allowlist step, the VM may boot and the onboard UI may still Clone and build dstack-vmm: ```bash -git clone https://github.com/Dstack-TEE/dstack +git clone https://github.com/Phala-Network/dstack-cloud cd dstack cargo build --release -p dstack-vmm -p supervisor mkdir -p vmm-data @@ -160,7 +162,7 @@ address = "vsock:2" port = 10000 ``` -Download guest images from [meta-dstack releases](https://github.com/Dstack-TEE/meta-dstack/releases) and extract to `./images/`. +Download guest images from [meta-dstack releases](https://github.com/Phala-Network/meta-dstack-cloud/releases) and extract to `./images/`. > For reproducible builds and verification, see the [Security Model](./security/security-model.md). @@ -257,7 +259,7 @@ AUTH_WEBHOOK_URL=http://your-auth-server:3001 KMS_RPC_ADDR=0.0.0.0:9201 GUEST_AGENT_ADDR=127.0.0.1:9205 OS_IMAGE=dstack-0.5.5 -IMAGE_DOWNLOAD_URL=https://github.com/Dstack-TEE/meta-dstack/releases/download/v0.5.5/dstack-0.5.5.tar.gz +IMAGE_DOWNLOAD_URL=https://github.com/Phala-Network/meta-dstack-cloud/releases/download/v0.5.5/dstack-0.5.5.tar.gz ``` Then run: diff --git a/docs/dstack-gateway.md b/docs/dstack-gateway.md index be1e2cf4a..78ad911bf 100644 --- a/docs/dstack-gateway.md +++ b/docs/dstack-gateway.md @@ -1,5 +1,7 @@ # Setup dstack-gateway for Production +> **This guide is for self-hosted deployments** on your own TDX hardware. For cloud deployments, see [Quickstart](./quickstart.md). + To set up dstack-gateway for production, you need a wildcard domain and SSL certificate. ## Step 1: Setup wildcard domain @@ -60,4 +62,4 @@ Note: The `s` and `g` suffixes cannot be used together Open `vmm.toml` and adjust dstack-gateway configuration in the `gateway` section: - `base_domain`: Same as `base_domain` from `gateway.toml`'s `core.proxy` section -- `port`: Same as `listen_port` from `gateway.toml`'s `core.proxy` section \ No newline at end of file +- `port`: Same as `listen_port` from `gateway.toml`'s `core.proxy` section diff --git a/docs/onchain-governance.md b/docs/onchain-governance.md index dc20ec9e3..a78598a65 100644 --- a/docs/onchain-governance.md +++ b/docs/onchain-governance.md @@ -1,5 +1,7 @@ # On-Chain Governance +> **This guide is for self-hosted deployments** on your own TDX hardware. For cloud deployments, see [Quickstart](./quickstart.md). + This guide covers setting up on-chain governance for dstack using smart contracts on Ethereum. ## Overview diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 000000000..b2702e1e4 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,209 @@ +# Quickstart + +Deploy your first confidential workload on GCP in under 10 minutes. + +> **Interested in AWS Nitro Enclaves?** We support AWS Nitro attestation verification and are expanding deployment tooling. [Book a call](https://calendly.com/aspect-ux/30min) to learn more about AWS deployment options. + +## Prerequisites + +- GCP account with Confidential VM quota (Intel TDX) +- `gcloud` CLI installed and authenticated + +## Install the CLI + +Download the `dstack-cloud` CLI: + +```bash +# Clone the repository (temporary until packaged release) +git clone https://github.com/Dstack-TEE/dstack.git +export PATH="$PATH:$(pwd)/dstack/scripts/bin" +``` + +Verify the installation: + +```bash +dstack-cloud --help +``` + +## Configure + +Set up your cloud credentials: + +```bash +dstack-cloud config-edit +``` + +This opens an editor with the global configuration file. For GCP, configure: + +```toml +[gcp] +project = "your-gcp-project-id" +zone = "us-central1-a" +machine_type = "n2d-standard-4" +``` + +## Create a Project + +Create a new dstack-cloud project: + +```bash +dstack-cloud new my-app +cd my-app +``` + +This creates a project directory with: + +``` +my-app/ +├── app.json # Application configuration +├── docker-compose.yaml # Your container definition +├── .env # Environment variables +└── prelaunch.sh # Pre-launch script (optional) +``` + +## Define Your Workload + +Edit `docker-compose.yaml` with your application: + +```yaml +services: + web: + image: nginx:latest + ports: + - "8080:80" +``` + +For AI workloads with GPU: + +```yaml +services: + vllm: + image: vllm/vllm-openai:latest + runtime: nvidia + command: --model Qwen/Qwen2.5-7B-Instruct + ports: + - "8000:8000" +``` + +## Add Secrets (Optional) + +Add sensitive environment variables to `.env`: + +```bash +API_KEY=your-secret-key +DATABASE_URL=postgres://... +``` + +These are encrypted before leaving your machine and only decrypted inside the TEE. + +## Deploy + +Deploy to your cloud provider: + +```bash +dstack-cloud deploy +``` + +The CLI will: +1. Build and push your container configuration +2. Create a Confidential VM +3. Boot the dstack guest OS +4. Start your containers + +## Check Status + +Monitor your deployment: + +```bash +# Check deployment status +dstack-cloud status + +# View console logs +dstack-cloud logs + +# Follow logs in real-time +dstack-cloud logs --follow +``` + +## Configure Firewall + +Allow traffic to your application: + +```bash +# Allow HTTPS traffic +dstack-cloud fw allow 443 + +# Allow your app port +dstack-cloud fw allow 8080 + +# List firewall rules +dstack-cloud fw list +``` + +## Access Your App + +Once deployed, access your application via the assigned endpoint. The `dstack-cloud status` command shows the public URL. + +For apps with TLS: +``` +https://. +``` + +For specific ports: +``` +https://-8080. +``` + +## Verify Attestation + +Users can verify your deployment is running in a genuine TEE: + +```bash +# Get attestation quote from your app +curl https:///attestation + +# Verify with dstack-verifier +dstack-verifier verify +``` + +See the [Verification Guide](./verification.md) for details. + +## Manage Deployments + +```bash +# List all deployments +dstack-cloud list + +# Stop a deployment +dstack-cloud stop + +# Start a stopped deployment +dstack-cloud start + +# Remove a deployment completely +dstack-cloud remove +``` + +## Next Steps + +- [Usage Guide](./usage.md) - Detailed deployment and management +- [Confidential AI](./confidential-ai.md) - Run AI workloads with hardware privacy +- [GCP Attestation](./attestation-gcp.md) - How TDX + TPM attestation works +- [AWS Nitro Attestation](./attestation-nitro-enclave.md) - How NSM attestation works +- [Security Model](./security/security-model.md) - Understand the trust boundaries + +## Troubleshooting + +**Deployment stuck at "Creating VM":** +- Check your cloud quota for Confidential VMs +- Verify your credentials with `gcloud auth list` + +**Container not starting:** +- Check logs with `dstack-cloud logs` +- Verify your docker-compose.yaml syntax +- Ensure images are accessible from the cloud region + +**Cannot access application:** +- Check firewall rules with `dstack-cloud fw list` +- Verify the port mapping in docker-compose.yaml +- Check if the container is healthy in the logs diff --git a/docs/usage.md b/docs/usage.md index 64684c6c4..10a101267 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,8 +1,10 @@ # dstack Usage Guide -This guide covers deploying and managing applications on dstack. For infrastructure setup and self-hosting, see the [Deployment Guide](./deployment.md). +> **This guide is for self-hosted deployments** on your own TDX hardware. For cloud deployments, see [Quickstart](./quickstart.md). -You can manage VMs via the dashboard or [CLI](./vmm-cli-user-guide.md). +This guide covers deploying and managing applications on self-hosted dstack infrastructure. For initial setup, see the [Deployment Guide](./deployment.md). + +You can manage VMs via the VMM dashboard or [CLI](./vmm-cli-user-guide.md). ## Deploy an App diff --git a/docs/verification.md b/docs/verification.md index 17c5170c2..a6d83d4ff 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -18,7 +18,7 @@ If any of these fail, the cryptographic proof won't verify. **Programmatic verification**: dstack provides several tools: -- [dstack-verifier](https://github.com/Dstack-TEE/dstack/tree/master/verifier) - HTTP service with `/verify` endpoint, also runs as CLI +- [dstack-verifier](https://github.com/Phala-Network/dstack-cloud/tree/master/verifier) - HTTP service with `/verify` endpoint, also runs as CLI - [dcap-qvl](https://github.com/Phala-Network/dcap-qvl) - Open source quote verification library (Rust, Python, JS/WASM, CLI) - [SDKs](../sdk/) - JavaScript and Python SDKs include `replayRtmrs()` for local RTMR verification diff --git a/docs/vmm-cli-user-guide.md b/docs/vmm-cli-user-guide.md index 5befa43aa..bb93315d9 100644 --- a/docs/vmm-cli-user-guide.md +++ b/docs/vmm-cli-user-guide.md @@ -1,5 +1,7 @@ # VMM CLI User Guide +> **This guide is for self-hosted deployments** on your own TDX hardware. For cloud deployments, see [Quickstart](./quickstart.md). + Welcome to the **VMM CLI**! This tool helps you manage CVMs in the dstack platform. ## Table of Contents diff --git a/dstack-attest/Cargo.toml b/dstack-attest/Cargo.toml index d17bdc9f2..0dfd0e8a9 100644 --- a/dstack-attest/Cargo.toml +++ b/dstack-attest/Cargo.toml @@ -27,6 +27,11 @@ serde_json.workspace = true sha2.workspace = true sha3.workspace = true tdx-attest.workspace = true +tpm-attest.workspace = true +nsm-attest.workspace = true +nsm-qvl.workspace = true +tpm-qvl.workspace = true +tpm-types.workspace = true tracing.workspace = true insta.workspace = true errify.workspace = true diff --git a/dstack-attest/src/attestation.rs b/dstack-attest/src/attestation.rs index 55611b5f0..355477292 100644 --- a/dstack-attest/src/attestation.rs +++ b/dstack-attest/src/attestation.rs @@ -20,18 +20,23 @@ use dcap_qvl::{ #[cfg(feature = "quote")] use dstack_types::SysConfig; use dstack_types::{Platform, VmConfig}; -use ez_hash::{sha256, Hasher, Sha384}; +use ez_hash::{sha256, Hasher, Sha256, Sha384}; use or_panic::ResultOrPanic; use scale::{Decode, Encode, Error as ScaleError, Input, Output}; use serde::{Deserialize, Serialize}; use serde_human_bytes as hex_bytes; use sha2::Digest as _; +use tpm_qvl::verify::VerifiedReport as TpmVerifiedReport; + +// Re-export TpmQuote from tpm-types +pub use tpm_types::TpmQuote; pub use crate::v1::{Attestation as AttestationV1, PlatformEvidence, StackEvidence}; const DSTACK_TDX: &str = "dstack-tdx"; const DSTACK_GCP_TDX: &str = "dstack-gcp-tdx"; const DSTACK_NITRO_ENCLAVE: &str = "dstack-nitro-enclave"; + #[cfg(feature = "quote")] const SYS_CONFIG_PATH: &str = "/dstack/.host-shared/.sys-config.json"; @@ -82,8 +87,17 @@ fn platform_from_legacy_quote(quote: AttestationQuote) -> PlatformEvidence { AttestationQuote::DstackTdx(TdxQuote { quote, event_log }) => { PlatformEvidence::Tdx { quote, event_log } } - AttestationQuote::DstackGcpTdx => PlatformEvidence::GcpTdx, - AttestationQuote::DstackNitroEnclave => PlatformEvidence::NitroEnclave, + AttestationQuote::DstackGcpTdx(DstackGcpTdxQuote { + tdx_quote: TdxQuote { quote, event_log }, + tpm_quote, + }) => PlatformEvidence::GcpTdx { + quote, + event_log, + tpm_quote, + }, + AttestationQuote::DstackNitroEnclave(DstackNitroQuote { nsm_quote }) => { + PlatformEvidence::NitroEnclave { nsm_quote } + } } } @@ -92,16 +106,25 @@ fn platform_into_legacy_quote(platform: PlatformEvidence) -> AttestationQuote { PlatformEvidence::Tdx { quote, event_log } => { AttestationQuote::DstackTdx(TdxQuote { quote, event_log }) } - PlatformEvidence::GcpTdx => AttestationQuote::DstackGcpTdx, - PlatformEvidence::NitroEnclave => AttestationQuote::DstackNitroEnclave, + PlatformEvidence::GcpTdx { + quote, + event_log, + tpm_quote, + } => AttestationQuote::DstackGcpTdx(DstackGcpTdxQuote { + tdx_quote: TdxQuote { quote, event_log }, + tpm_quote, + }), + PlatformEvidence::NitroEnclave { nsm_quote } => { + AttestationQuote::DstackNitroEnclave(DstackNitroQuote { nsm_quote }) + } } } fn platform_attestation_mode(platform: &PlatformEvidence) -> AttestationMode { match platform { PlatformEvidence::Tdx { .. } => AttestationMode::DstackTdx, - PlatformEvidence::GcpTdx => AttestationMode::DstackGcpTdx, - PlatformEvidence::NitroEnclave => AttestationMode::DstackNitroEnclave, + PlatformEvidence::GcpTdx { .. } => AttestationMode::DstackGcpTdx, + PlatformEvidence::NitroEnclave { .. } => AttestationMode::DstackNitroEnclave, } } @@ -287,21 +310,37 @@ impl QuoteContentType<'_> { } } -#[allow(clippy::large_enum_variant)] +/// Verified Nitro Enclave attestation report +#[derive(Clone, Debug, Serialize)] +pub struct NitroVerifiedReport { + /// Module ID + pub module_id: String, + /// PCR0 - Enclave image hash + pub pcrs: NitroPcrs, + /// User data from attestation + #[serde(with = "serde_human_bytes")] + pub user_data: Vec, + /// Timestamp + pub timestamp: u64, +} + /// Represents a verified attestation #[derive(Clone)] pub enum DstackVerifiedReport { DstackTdx(TdxVerifiedReport), - DstackGcpTdx, - DstackNitroEnclave, + DstackGcpTdx { + tdx_report: TdxVerifiedReport, + tpm_report: TpmVerifiedReport, + }, + DstackNitroEnclave(NitroVerifiedReport), } impl DstackVerifiedReport { pub fn tdx_report(&self) -> Option<&TdxVerifiedReport> { match self { DstackVerifiedReport::DstackTdx(report) => Some(report), - DstackVerifiedReport::DstackGcpTdx => None, - DstackVerifiedReport::DstackNitroEnclave => None, + DstackVerifiedReport::DstackGcpTdx { tdx_report, .. } => Some(tdx_report), + DstackVerifiedReport::DstackNitroEnclave(_) => None, } } } @@ -535,8 +574,17 @@ impl AttestationV1 { PlatformEvidence::Tdx { quote, .. } => { decode_mr_tdx_from_quote(boottime_mr, &mr_key_provider, quote, runtime_events)? } - PlatformEvidence::GcpTdx | PlatformEvidence::NitroEnclave => { - bail!("Unsupported attestation quote"); + PlatformEvidence::GcpTdx { tpm_quote, .. } => decode_mr_gcp_tpm_from_v1( + boottime_mr, + &mr_key_provider, + &os_image_hash, + tpm_quote, + runtime_events, + )?, + PlatformEvidence::NitroEnclave { nsm_quote } => { + decode_mr_nitro_nsm_from_v1(&DstackNitroQuote { + nsm_quote: nsm_quote.clone(), + })? } }; let compose_hash = if platform_attestation_mode(&self.platform).is_composable() { @@ -601,11 +649,64 @@ impl AttestationV1 { verify_tdx_quote_with_events(pccs_url, quote, &runtime_events, &report_data) .await?, ), - PlatformEvidence::GcpTdx | PlatformEvidence::NitroEnclave => { - bail!( - "Unsupported attestation mode: {:?}", - platform_attestation_mode(&platform) - ); + PlatformEvidence::GcpTdx { + quote, tpm_quote, .. + } => { + let tdx_report = + verify_tdx_quote_with_events(pccs_url, quote, &runtime_events, &report_data) + .await?; + let tpm_report = tpm_qvl::get_collateral_and_verify(tpm_quote) + .await + .context("failed to verify TPM quote")?; + let qualifying_data = sha256(quote); + if tpm_report.attest.qualified_data != qualifying_data[..] { + bail!("tpm qualified_data mismatch"); + } + let pcr_ind: u32 = 14; // GcpTdx runtime PCR + let replayed_rt_pcr = cc_eventlog::replay_events::(&runtime_events, None); + let quoted_rt_pcr = tpm_report + .get_pcr(pcr_ind) + .context("no runtime PCR in TPM report")?; + if replayed_rt_pcr != quoted_rt_pcr[..] { + bail!( + "PCR{pcr_ind} mismatch, quoted: {}, replayed: {}", + hex::encode(quoted_rt_pcr), + hex::encode(replayed_rt_pcr), + ); + } + DstackVerifiedReport::DstackGcpTdx { + tdx_report, + tpm_report, + } + } + PlatformEvidence::NitroEnclave { nsm_quote } => { + let nsm = DstackNitroQuote { + nsm_quote: nsm_quote.clone(), + }; + let verified_report = nsm_qvl::verify_attestation( + &nsm.nsm_quote, + nsm_qvl::AWS_NITRO_ENCLAVES_ROOT_G1, + None, + _now, + ) + .context("NSM attestation verification failed")?; + let Some(user_data) = verified_report.user_data.clone() else { + bail!("NSM attestation document does not contain user_data"); + }; + if user_data != report_data[..] { + bail!("NSM user_data does not match report_data"); + } + // Use the PCRs from the signature-verified report, not a + // re-parse of the raw document, so the values that feed + // os_image_hash / MR derivation are authenticated. + let pcrs = NitroPcrs::from_verified(&verified_report.pcrs) + .context("verified NSM report missing PCR0/1/2")?; + DstackVerifiedReport::DstackNitroEnclave(NitroVerifiedReport { + module_id: verified_report.module_id, + pcrs, + user_data, + timestamp: verified_report.timestamp, + }) } }; @@ -637,19 +738,97 @@ impl AttestationV1 { } } +#[derive(Clone, Encode, Decode)] +pub struct DstackGcpTdxQuote { + pub tdx_quote: TdxQuote, + pub tpm_quote: TpmQuote, +} + +#[derive(Clone, Encode, Decode)] +pub struct DstackNitroQuote { + pub nsm_quote: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct NitroPcrs { + #[serde(with = "serde_human_bytes")] + pub pcr0: Vec, + #[serde(with = "serde_human_bytes")] + pub pcr1: Vec, + #[serde(with = "serde_human_bytes")] + pub pcr2: Vec, +} + +impl NitroPcrs { + /// Build `NitroPcrs` from the PCR map of a signature-verified NSM report + /// (`nsm_qvl::NsmVerifiedReport::pcrs`). This is the trusted source of PCR + /// values: it has been authenticated by the COSE signature, unlike + /// [`DstackNitroQuote::decode_pcrs`] which re-parses the raw document. + pub fn from_verified(pcrs: &std::collections::BTreeMap>) -> Result { + let pcr0 = pcrs.get(&0).cloned().context("PCR 0 not found")?; + let pcr1 = pcrs.get(&1).cloned().context("PCR 1 not found")?; + let pcr2 = pcrs.get(&2).cloned().context("PCR 2 not found")?; + Ok(NitroPcrs { pcr0, pcr1, pcr2 }) + } + + fn is_zero(&self) -> bool { + self.pcr0.iter().all(|&b| b == 0) + && self.pcr1.iter().all(|&b| b == 0) + && self.pcr2.iter().all(|&b| b == 0) + } + + /// Whether the enclave ran in debug mode. AWS zeroes PCR0/1/2 for debug + /// enclaves, so there is no measurement of the actual code; verifiers must + /// refuse to authorize such enclaves. + pub fn is_debug(&self) -> bool { + self.is_zero() + } + + /// The OS image hash = sha256(pcr0 || pcr1 || pcr2). Callers must reject + /// debug enclaves (see [`is_debug`](Self::is_debug)) before trusting this. + pub fn image_hash(&self) -> Vec { + sha256([&self.pcr0, &self.pcr1, &self.pcr2]).to_vec() + } +} + +impl DstackNitroQuote { + pub fn decode_cose(&self) -> Result { + nsm_attest::AttestationDocument::from_cose(&self.nsm_quote) + .context("Failed to decode NSM attestation document") + } + + pub fn decode_image_hash(&self) -> Result> { + let pcrs = self.decode_pcrs()?; + let hash = if pcrs.is_zero() { + [0u8; 32] + } else { + sha256([&pcrs.pcr0, &pcrs.pcr1, &pcrs.pcr2]) + }; + Ok(hash.to_vec()) + } + + pub fn decode_pcrs(&self) -> Result { + let doc = self.decode_cose()?; + let pcr0 = doc.pcrs.get(&0).cloned().context("PCR 0 not found")?; + let pcr1 = doc.pcrs.get(&1).cloned().context("PCR 1 not found")?; + let pcr2 = doc.pcrs.get(&2).cloned().context("PCR 2 not found")?; + Ok(NitroPcrs { pcr0, pcr1, pcr2 }) + } +} + #[derive(Clone, Encode, Decode)] pub enum AttestationQuote { DstackTdx(TdxQuote), - DstackGcpTdx, - DstackNitroEnclave, + DstackGcpTdx(DstackGcpTdxQuote), + DstackNitroEnclave(DstackNitroQuote), } impl AttestationQuote { pub fn mode(&self) -> AttestationMode { match self { AttestationQuote::DstackTdx { .. } => AttestationMode::DstackTdx, - AttestationQuote::DstackGcpTdx => AttestationMode::DstackGcpTdx, - AttestationQuote::DstackNitroEnclave => AttestationMode::DstackNitroEnclave, + AttestationQuote::DstackGcpTdx { .. } => AttestationMode::DstackGcpTdx, + AttestationQuote::DstackNitroEnclave { .. } => AttestationMode::DstackNitroEnclave, } } } @@ -681,16 +860,24 @@ impl Attestation { pub fn tdx_quote_mut(&mut self) -> Option<&mut TdxQuote> { match &mut self.quote { AttestationQuote::DstackTdx(quote) => Some(quote), - AttestationQuote::DstackGcpTdx => None, - AttestationQuote::DstackNitroEnclave => None, + AttestationQuote::DstackGcpTdx(q) => Some(&mut q.tdx_quote), + AttestationQuote::DstackNitroEnclave(_) => None, } } pub fn tdx_quote(&self) -> Option<&TdxQuote> { match &self.quote { AttestationQuote::DstackTdx(quote) => Some(quote), - AttestationQuote::DstackGcpTdx => None, - AttestationQuote::DstackNitroEnclave => None, + AttestationQuote::DstackGcpTdx(q) => Some(&q.tdx_quote), + AttestationQuote::DstackNitroEnclave(_) => None, + } + } + + pub fn tpm_quote(&self) -> Option<&TpmQuote> { + match &self.quote { + AttestationQuote::DstackTdx(_) => None, + AttestationQuote::DstackGcpTdx(q) => Some(&q.tpm_quote), + AttestationQuote::DstackNitroEnclave(_) => None, } } @@ -723,6 +910,13 @@ impl Attestation { pub trait GetDeviceId { fn get_devide_id(&self) -> Vec; + + /// The signature-verified Nitro PCRs, when this report is a verified Nitro + /// report. Returns `None` for raw/unverified reports (e.g. `()`), in which + /// case callers fall back to parsing the raw document. + fn verified_nitro_pcrs(&self) -> Option<&NitroPcrs> { + None + } } impl GetDeviceId for () { @@ -735,8 +929,22 @@ impl GetDeviceId for DstackVerifiedReport { fn get_devide_id(&self) -> Vec { match self { DstackVerifiedReport::DstackTdx(tdx_report) => tdx_report.ppid.to_vec(), - DstackVerifiedReport::DstackGcpTdx => Vec::new(), - DstackVerifiedReport::DstackNitroEnclave => Vec::new(), + DstackVerifiedReport::DstackGcpTdx { tdx_report, .. } => tdx_report.ppid.to_vec(), + DstackVerifiedReport::DstackNitroEnclave(report) => { + // i-1234567890abcdef0-enc9876543210abcde -> i-1234567890abcdef0 + report + .module_id + .split_once('-') + .map(|(id, _)| id.as_bytes().to_vec()) + .unwrap_or_default() + } + } + } + + fn verified_nitro_pcrs(&self) -> Option<&NitroPcrs> { + match self { + DstackVerifiedReport::DstackNitroEnclave(report) => Some(&report.pcrs), + _ => None, } } } @@ -746,6 +954,43 @@ struct Mrs { mr_aggregated: [u8; 32], } +fn decode_mr_gcp_tpm_from_v1( + boottime_mr: bool, + mr_key_provider: &[u8], + os_image_hash: &[u8], + tpm_quote: &TpmQuote, + runtime_events: &[RuntimeEvent], +) -> Result { + let mr_system = sha256([os_image_hash, mr_key_provider]); + let pcr0 = tpm_quote + .pcr_values + .iter() + .find(|p| p.index == 0) + .context("PCR 0 not found")?; + let pcr2 = tpm_quote + .pcr_values + .iter() + .find(|p| p.index == 2) + .context("PCR 2 not found")?; + let runtime_pcr = + cc_eventlog::replay_events::(runtime_events, boottime_mr.then_some("boot-mr-done")); + let mr_aggregated = sha256([&pcr0.value[..], &pcr2.value, &runtime_pcr]); + Ok(Mrs { + mr_system, + mr_aggregated, + }) +} + +fn decode_mr_nitro_nsm_from_v1(nsm_quote: &DstackNitroQuote) -> Result { + let pcrs = nsm_quote.decode_pcrs()?; + let mr_system = sha256([&pcrs.pcr0, &pcrs.pcr1, &pcrs.pcr2]); + let mr_aggregated = mr_system; + Ok(Mrs { + mr_system, + mr_aggregated, + }) +} + fn decode_mr_tdx_from_quote( boottime_mr: bool, mr_key_provider: &[u8], @@ -826,6 +1071,52 @@ async fn verify_tdx_quote_with_events( } impl Attestation { + fn decode_mr_gcp_tpm( + &self, + boottime_mr: bool, + mr_key_provider: &[u8], + os_image_hash: &[u8], + tpm_quote: &TpmQuote, + ) -> Result { + let mr_system = sha256([os_image_hash, mr_key_provider]); + let pcr0 = tpm_quote + .pcr_values + .iter() + .find(|p| p.index == 0) + .context("PCR 0 not found")?; + let pcr2 = tpm_quote + .pcr_values + .iter() + .find(|p| p.index == 2) + .context("PCR 2 not found")?; + let runtime_pcr = + self.replay_runtime_events::(boottime_mr.then_some("boot-mr-done")); + let mr_aggregated = sha256([&pcr0.value[..], &pcr2.value, &runtime_pcr]); + Ok(Mrs { + mr_system, + mr_aggregated, + }) + } + + fn decode_mr_nitro_nsm(&self, nsm_quote: &DstackNitroQuote) -> Result { + // Prefer the signature-verified PCRs from the report; only fall back to + // re-parsing the raw document for unverified reports (e.g. previews), + // which never feed an authorization decision. + let pcrs = match self.report.verified_nitro_pcrs() { + Some(pcrs) => pcrs.clone(), + None => nsm_quote.decode_pcrs()?, + }; + + // Compute mr_system from PCR values and mr_key_provider + let mr_system = sha256([&pcrs.pcr0, &pcrs.pcr1, &pcrs.pcr2]); + let mr_aggregated = mr_system; + + Ok(Mrs { + mr_system, + mr_aggregated, + }) + } + fn decode_mr_tdx( &self, boottime_mr: bool, @@ -909,9 +1200,10 @@ impl Attestation { AttestationQuote::DstackTdx(q) => { self.decode_mr_tdx(boottime_mr, &mr_key_provider, q)? } - AttestationQuote::DstackGcpTdx | AttestationQuote::DstackNitroEnclave => { - bail!("Unsupported attestation quote"); + AttestationQuote::DstackGcpTdx(q) => { + self.decode_mr_gcp_tpm(boottime_mr, &mr_key_provider, &os_image_hash, &q.tpm_quote)? } + AttestationQuote::DstackNitroEnclave(q) => self.decode_mr_nitro_nsm(q)?, }; let compose_hash = if self.quote.mode().is_composable() { self.find_event_payload("compose-hash").unwrap_or_default() @@ -1048,16 +1340,40 @@ impl Attestation { cc_eventlog::tdx::read_event_log().context("Failed to read event log")?; AttestationQuote::DstackTdx(TdxQuote { quote, event_log }) } - AttestationMode::DstackGcpTdx | AttestationMode::DstackNitroEnclave => { - bail!("Unsupported attestation mode: {mode:?}"); + AttestationMode::DstackGcpTdx => { + let quote = tdx_attest::get_quote(report_data).context("Failed to get quote")?; + let event_log = + cc_eventlog::tdx::read_event_log().context("Failed to read event log")?; + let tpm_qualifying_data = sha256("e); + let tdx_quote = TdxQuote { quote, event_log }; + let tpm_ctx = + tpm_attest::TpmContext::detect().context("Failed to open TPM context")?; + let tpm_quote = tpm_ctx + .create_quote(&tpm_qualifying_data, &tpm_attest::dstack_pcr_policy()) + .context("Failed to create TPM quote")?; + AttestationQuote::DstackGcpTdx(DstackGcpTdxQuote { + tdx_quote, + tpm_quote, + }) + } + AttestationMode::DstackNitroEnclave => { + let nsm_quote = nsm_attest::get_attestation(report_data) + .context("Failed to get NSM attestation")?; + AttestationQuote::DstackNitroEnclave(DstackNitroQuote { nsm_quote }) } }; let config = match "e { - AttestationQuote::DstackTdx(_) => { - read_vm_config().context("Failed to read VM config")? + AttestationQuote::DstackTdx(_) | AttestationQuote::DstackGcpTdx(_) => { + read_vm_config().context("Failed to read vm config")? } - AttestationQuote::DstackGcpTdx | AttestationQuote::DstackNitroEnclave => { - bail!("Unsupported attestation mode: {mode:?}"); + AttestationQuote::DstackNitroEnclave(quote) => { + let os_image_hash = quote + .decode_image_hash() + .context("Failed to decode image hash")?; + serde_json::to_string(&serde_json::json!({ + "os_image_hash": hex::encode(os_image_hash), + })) + .context("Failed to serialize config")? } }; @@ -1080,15 +1396,30 @@ impl Attestation { pub async fn verify_with_time( self, pccs_url: Option<&str>, - _now: Option, + now: Option, ) -> Result { let report = match &self.quote { AttestationQuote::DstackTdx(q) => { let report = self.verify_tdx(pccs_url, &q.quote).await?; DstackVerifiedReport::DstackTdx(report) } - AttestationQuote::DstackGcpTdx | AttestationQuote::DstackNitroEnclave => { - bail!("Unsupported attestation mode: {:?}", self.quote.mode()); + AttestationQuote::DstackGcpTdx(q) => { + let tdx_report = self.verify_tdx(pccs_url, &q.tdx_quote.quote).await?; + let tpm_report = self + .verify_tpm(&q.tpm_quote, &sha256(&q.tdx_quote.quote)) + .await + .context("Failed to verify TPM quote")?; + DstackVerifiedReport::DstackGcpTdx { + tdx_report, + tpm_report, + } + } + AttestationQuote::DstackNitroEnclave(quote) => { + let report = self + .verify_nitro_enclave_with_time(quote, now) + .await + .context("Failed to verify Nitro Enclave")?; + DstackVerifiedReport::DstackNitroEnclave(report) } }; @@ -1124,6 +1455,76 @@ impl Attestation { self.verify_with_time(pccs_url, None).await } + /// Verify Nitro Enclave attestation with optional custom time (testing hook) + /// + /// This performs full cryptographic verification: + /// 1. Verifies COSE Sign1 signature using ECDSA P-384 with SHA-384 + /// 2. Verifies certificate chain from attestation document to AWS Nitro root CA + /// 3. Validates user_data matches expected report_data + async fn verify_nitro_enclave_with_time( + &self, + nsm_quote: &DstackNitroQuote, + now: Option, + ) -> Result { + // Verify COSE signature and certificate chain using nsm-qvl + // CRL fetch is unreliable (e.g. 403 from S3), so keep it disabled here by default. + let verified_report = nsm_qvl::verify_attestation( + &nsm_quote.nsm_quote, + nsm_qvl::AWS_NITRO_ENCLAVES_ROOT_G1, + None, + now, + ) + .context("NSM attestation verification failed")?; + + // Verify user_data matches report_data + let Some(user_data) = verified_report.user_data.clone() else { + bail!("NSM attestation document does not contain user_data"); + }; + if user_data != self.report_data { + bail!("NSM user_data does not match report_data"); + } + + // Decode PCRs from quote + let pcrs = nsm_quote + .decode_pcrs() + .context("Failed to decode nitro pcrs")?; + + Ok(NitroVerifiedReport { + module_id: verified_report.module_id, + pcrs, + user_data, + timestamp: verified_report.timestamp, + }) + } + + async fn verify_tpm( + &self, + quote: &TpmQuote, + qualifying_data: &[u8], + ) -> Result { + let report = tpm_qvl::get_collateral_and_verify(quote).await?; + let pcr_ind = self + .quote + .mode() + .tpm_runtime_pcr() + .context("Failed to get runtime PCR no")?; + let replayed_rt_pcr = self.replay_runtime_events::(None); + let quoted_rt_pcr = report + .get_pcr(pcr_ind) + .context("No runtime PCR in TPM report")?; + if replayed_rt_pcr != quoted_rt_pcr[..] { + bail!( + "PCR{pcr_ind} mismatch, quoted: {}, replayed: {}", + hex::encode(quoted_rt_pcr), + hex::encode(replayed_rt_pcr), + ); + } + if report.attest.qualified_data != qualifying_data { + bail!("tpm qualified_data mismatch"); + } + Ok(report) + } + async fn verify_tdx(&self, pccs_url: Option<&str>, quote: &[u8]) -> Result { let mut pccs_url = Cow::Borrowed(pccs_url.unwrap_or_default()); if pccs_url.is_empty() { @@ -1244,10 +1645,7 @@ mod tests { let content = b"test content"; let report_data = content_type.to_report_data(content); - assert_eq!( - hex::encode(report_data), - "7ea0b744ed5e9c0c83ff9f575668e1697652cd349f2027cdf26f918d4c53e8cd50b5ea9b449b4c3d50e20ae00ec29688d5a214e8daff8a10041f5d624dae8a01" - ); + assert_eq!(hex::encode(report_data), "7ea0b744ed5e9c0c83ff9f575668e1697652cd349f2027cdf26f918d4c53e8cd50b5ea9b449b4c3d50e20ae00ec29688d5a214e8daff8a10041f5d624dae8a01"); // Test SHA-256 let result = content_type @@ -1344,4 +1742,43 @@ mod tests { _ => panic!("expected dstack stack"), } } + + #[test] + fn nitro_pcrs_from_verified_extracts_0_1_2() { + let mut map = std::collections::BTreeMap::new(); + map.insert(0u16, vec![0xaa; 48]); + map.insert(1u16, vec![0xbb; 48]); + map.insert(2u16, vec![0xcc; 48]); + map.insert(3u16, vec![0xdd; 48]); // ignored + let pcrs = NitroPcrs::from_verified(&map).unwrap(); + assert_eq!(pcrs.pcr0, vec![0xaa; 48]); + assert_eq!(pcrs.pcr1, vec![0xbb; 48]); + assert_eq!(pcrs.pcr2, vec![0xcc; 48]); + + // missing a required PCR is an error + map.remove(&1u16); + assert!(NitroPcrs::from_verified(&map).is_err()); + } + + #[test] + fn nitro_pcrs_debug_detection_and_image_hash() { + let debug = NitroPcrs { + pcr0: vec![0u8; 48], + pcr1: vec![0u8; 48], + pcr2: vec![0u8; 48], + }; + assert!(debug.is_debug()); + + let prod = NitroPcrs { + pcr0: vec![1u8; 48], + pcr1: vec![0u8; 48], + pcr2: vec![0u8; 48], + }; + assert!(!prod.is_debug()); + // image_hash = sha256(pcr0 || pcr1 || pcr2), never the all-zero sentinel + assert_eq!( + prod.image_hash(), + sha256([&prod.pcr0, &prod.pcr1, &prod.pcr2]).to_vec() + ); + } } diff --git a/dstack-attest/src/lib.rs b/dstack-attest/src/lib.rs index 63ca978f2..c0395112a 100644 --- a/dstack-attest/src/lib.rs +++ b/dstack-attest/src/lib.rs @@ -9,6 +9,7 @@ use cc_eventlog::RuntimeEvent; pub use cc_eventlog as ccel; pub use tdx_attest as tdx; +pub use tpm_attest as tpm; use crate::attestation::AttestationMode; @@ -42,5 +43,11 @@ pub fn emit_runtime_event(event: &str, payload: &[u8]) -> anyhow::Result<()> { let event_type = event.cc_event_type(); tdx_attest::extend_rtmr(3, event_type, digest).context("Failed to extend TDX RTMR")?; } + if let Some(pcr) = mode.tpm_runtime_pcr() { + let digest = event.sha256_digest(); + let tpm = tpm_attest::TpmContext::detect().context("Failed to detect TPM device")?; + tpm.pcr_extend_sha256(pcr, &digest) + .context("Failed to extend TPM RTMR")?; + } Ok(()) } diff --git a/dstack-attest/src/v1.rs b/dstack-attest/src/v1.rs index 900e7aedb..7c9aa3932 100644 --- a/dstack-attest/src/v1.rs +++ b/dstack-attest/src/v1.rs @@ -5,6 +5,7 @@ use anyhow::{anyhow, bail, Context, Result}; use cc_eventlog::{RuntimeEvent, TdxEvent}; use serde::{Deserialize, Serialize}; +use tpm_types::TpmQuote; pub const ATTESTATION_VERSION: u64 = 1; @@ -17,22 +18,42 @@ pub enum PlatformEvidence { event_log: Vec, }, #[serde(rename = "gcp-tdx")] - GcpTdx, + GcpTdx { + quote: Vec, + event_log: Vec, + tpm_quote: TpmQuote, + }, #[serde(rename = "nitro-enclave")] - NitroEnclave, + NitroEnclave { nsm_quote: Vec }, } impl PlatformEvidence { pub fn tdx_quote(&self) -> Option<&[u8]> { match self { - Self::Tdx { quote, .. } => Some(quote.as_slice()), + Self::Tdx { quote, .. } | Self::GcpTdx { quote, .. } => Some(quote.as_slice()), _ => None, } } pub fn tdx_event_log(&self) -> Option<&[TdxEvent]> { match self { - Self::Tdx { event_log, .. } => Some(event_log.as_slice()), + Self::Tdx { event_log, .. } | Self::GcpTdx { event_log, .. } => { + Some(event_log.as_slice()) + } + _ => None, + } + } + + pub fn tpm_quote(&self) -> Option<&TpmQuote> { + match self { + Self::GcpTdx { tpm_quote, .. } => Some(tpm_quote), + _ => None, + } + } + + pub fn nsm_quote(&self) -> Option<&[u8]> { + match self { + Self::NitroEnclave { nsm_quote } => Some(nsm_quote.as_slice()), _ => None, } } @@ -47,6 +68,19 @@ impl PlatformEvidence { .map(|event| event.stripped()) .collect(), }, + Self::GcpTdx { + quote, + event_log, + tpm_quote, + } => Self::GcpTdx { + quote, + event_log: event_log + .into_iter() + .filter(|event| event.imr == 3) + .map(|event| event.stripped()) + .collect(), + tpm_quote, + }, other => other, } } diff --git a/dstack-attest/tests/nitro_attestation.bin b/dstack-attest/tests/nitro_attestation.bin new file mode 100644 index 000000000..e0bbb2aa1 Binary files /dev/null and b/dstack-attest/tests/nitro_attestation.bin differ diff --git a/dstack-attest/tests/nitro_attestation_dbg.bin b/dstack-attest/tests/nitro_attestation_dbg.bin new file mode 100644 index 000000000..3f909e5cb Binary files /dev/null and b/dstack-attest/tests/nitro_attestation_dbg.bin differ diff --git a/dstack-attest/tests/nitro_verify.rs b/dstack-attest/tests/nitro_verify.rs new file mode 100644 index 000000000..77be34938 --- /dev/null +++ b/dstack-attest/tests/nitro_verify.rs @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Integration test: verify Nitro Enclave attestation end-to-end + +use dstack_attest::attestation::{AttestationQuote, DstackVerifiedReport, VersionedAttestation}; +use nsm_qvl::{AttestationDocument, CoseSign1}; +use std::time::{Duration, SystemTime}; + +// Real Nitro Enclave attestation captured from an enclave +const NITRO_ATTESTATION_BIN: &[u8] = include_bytes!("nitro_attestation.bin"); + +#[tokio::test] +async fn verify_nitro_attestation_bin() { + // Decode VersionedAttestation from SCALE + let versioned = VersionedAttestation::from_scale(NITRO_ATTESTATION_BIN) + .expect("decode VersionedAttestation"); + let VersionedAttestation::V0 { attestation } = versioned else { + panic!("expected V0 attestation"); + }; + + let app_info = attestation.decode_app_info(false).unwrap(); + let app_info_str = serde_json::to_string_pretty(&app_info).unwrap(); + + println!("App Info: {app_info_str}"); + insta::assert_snapshot!("app_info", app_info_str); + + // Perform full verification (COSE signature + cert chain + user_data). + // Use the attestation's own timestamp to keep freshness checks stable for this sample. + let fixed_now = match &attestation.quote { + AttestationQuote::DstackNitroEnclave(quote) => { + let cose = + CoseSign1::from_bytes("e.nsm_quote).expect("parse COSE Sign1 from quote"); + let doc = + AttestationDocument::from_cbor(&cose.payload).expect("parse attestation document"); + SystemTime::UNIX_EPOCH + .checked_add(Duration::from_millis(doc.timestamp)) + .expect("attestation timestamp overflow") + } + _ => panic!("unexpected quote type"), + }; + let verified = attestation + .verify_with_time(None, Some(fixed_now)) + .await + .unwrap(); + let DstackVerifiedReport::DstackNitroEnclave(report) = verified.report else { + panic!("Nitro attestation verification failed"); + }; + println!("✓ Nitro attestation verified successfully"); + insta::assert_snapshot!( + "nitro_report", + serde_json::to_string_pretty(&report).unwrap() + ); +} diff --git a/dstack-attest/tests/snapshots/nitro_verify__app_info.snap b/dstack-attest/tests/snapshots/nitro_verify__app_info.snap new file mode 100644 index 000000000..cc08badca --- /dev/null +++ b/dstack-attest/tests/snapshots/nitro_verify__app_info.snap @@ -0,0 +1,15 @@ +--- +source: dstack-attest/tests/nitro_verify.rs +assertion_line: 25 +expression: app_info_str +--- +{ + "app_id": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4", + "compose_hash": "1894b0b29e94a9db16e88a2914f0923e52bb16c08bf3bb484df786a147e2eb79", + "instance_id": "", + "device_id": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "mr_system": "1894b0b29e94a9db16e88a2914f0923e52bb16c08bf3bb484df786a147e2eb79", + "mr_aggregated": "1894b0b29e94a9db16e88a2914f0923e52bb16c08bf3bb484df786a147e2eb79", + "os_image_hash": "1894b0b29e94a9db16e88a2914f0923e52bb16c08bf3bb484df786a147e2eb79", + "key_provider_info": "" +} diff --git a/dstack-attest/tests/snapshots/nitro_verify__nitro_report.snap b/dstack-attest/tests/snapshots/nitro_verify__nitro_report.snap new file mode 100644 index 000000000..dafa10583 --- /dev/null +++ b/dstack-attest/tests/snapshots/nitro_verify__nitro_report.snap @@ -0,0 +1,15 @@ +--- +source: dstack-attest/tests/nitro_verify.rs +assertion_line: 36 +expression: "serde_json::to_string_pretty(&report).unwrap()" +--- +{ + "module_id": "i-0827e799ec9232d44-enc019b5640fdf630d6", + "pcrs": { + "pcr0": "eb7d0dab08ff41546d7e5659aee883af7b32bb2e58e46815b719f9f7bfae7b880188a10d86317e6509923740526cf74a", + "pcr1": "0343b056cd8485ca7890ddd833476d78460aed2aa161548e4e26bedf321726696257d623e8805f3f605946b3d8b0c6aa", + "pcr2": "d7b0a76788c1be24898bd148117ea1239578ba0e72f5d87a33a6c6476026675b6f996940f062e0e3f0b5edd2ddf78811" + }, + "user_data": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b8550000000000000000000000000000000000000000000000000000000000000000", + "timestamp": 1766678659346 +} diff --git a/dstack-util/Cargo.toml b/dstack-util/Cargo.toml index 6bb1d2377..9567ec3d8 100644 --- a/dstack-util/Cargo.toml +++ b/dstack-util/Cargo.toml @@ -35,6 +35,8 @@ ra-rpc = { workspace = true, features = ["client"] } ra-tls = { workspace = true, features = ["quote"] } dstack-gateway-rpc.workspace = true tdx-attest.workspace = true +tpm-attest.workspace = true +tpm-qvl = { workspace = true, features = ["crl-download"] } host-api = { workspace = true, features = ["client"] } cmd_lib.workspace = true toml.workspace = true diff --git a/dstack-util/src/main.rs b/dstack-util/src/main.rs index 05ad9ad61..d2e792b4f 100644 --- a/dstack-util/src/main.rs +++ b/dstack-util/src/main.rs @@ -12,11 +12,13 @@ use host_api::HostApi; use k256::schnorr::SigningKey; use ra_rpc::Attestation; use ra_tls::{ - attestation::QuoteContentType, - cert::generate_ra_cert, + attestation::{QuoteContentType, VersionedAttestation}, + cert::{generate_ra_cert, generate_ra_cert_with_app_id}, kdf::{derive_key, derive_p256_key_pair_from_bytes}, rcgen::KeyPair, }; +use scale::Encode; +use std::path::Path; use std::{ io::{self, Read, Write}, path::PathBuf, @@ -70,6 +72,23 @@ enum Commands { NotifyHost(HostNotifyArgs), /// Remove orphaned containers RemoveOrphans(RemoveOrphansArgs), + /// Perform vTPM attestation (for GCP TEE instances) + VtpmAttest(VtpmAttestArgs), + /// Generate a TPM quote + TpmQuote(TpmQuoteArgs), + /// Verify a TPM quote + TpmVerify(TpmVerifyArgs), + QuoteReport(QuoteReportArgs), + /// Generate a versioned attestation for simulator use + Attest(AttestArgs), + /// Show size breakdown for a versioned attestation file + AttestInfo(AttestInfoArgs), + /// Dump a versioned attestation as JSON + AttestJson(AttestJsonArgs), + /// Strip attestation for certificate embedding + AttestStrip(AttestStripArgs), + /// Get app keys from a KMS server + GetKeys(GetKeysArgs), } #[derive(Parser)] @@ -199,6 +218,437 @@ struct RemoveOrphansArgs { docker_root: String, } +#[derive(Parser)] +/// Perform vTPM attestation +struct VtpmAttestArgs { + /// path to Root CA certificate (PEM format) + #[arg(long)] + root_ca: PathBuf, + + /// nonce for replay protection + #[arg(long)] + nonce: String, + + /// expected OS image SHA256 hash (optional) + #[arg(long)] + expected_os_hash: Option, + + /// key algorithm (rsa or ecc, default: rsa) + #[arg(long, default_value = "rsa")] + key_algo: String, + + /// output format (json or text, default: text) + #[arg(long, default_value = "text")] + format: String, +} + +#[derive(Parser)] +/// Generate a TPM quote +struct TpmQuoteArgs { + /// qualifying data (hex encoded, default: 32 zeros) + #[arg(short, long)] + data: Option, + + /// output file (default: stdout) + #[arg(short, long)] + output: Option, + + /// key algorithm (auto, ecc, or rsa; default: auto) + #[arg(short = 'k', long, default_value = "auto")] + key_algo: String, + + /// The hash algorithm to use (default: none) + #[arg(short = 'H', long, default_value = "none")] + hash_algo: String, +} + +#[derive(Parser)] +/// Verify a TPM quote +struct TpmVerifyArgs { + /// path to Root CA certificate (PEM format) + #[arg(long)] + root_ca: PathBuf, + + /// path to TPM quote JSON file + #[arg(short, long)] + quote: PathBuf, +} + +#[derive(Parser)] +struct QuoteReportArgs { + #[arg(long)] + report_data: Option, + + #[arg(long, default_value = "/dstack/.host-shared/.sys-config.json")] + sys_config: PathBuf, + + #[arg(short, long)] + output: Option, + + #[arg(long, default_value_t = false)] + debug: bool, +} + +#[derive(Parser)] +struct AttestArgs { + /// report data in hex (max 64 bytes) + #[arg(long)] + report_data: Option, + + /// app id (20 bytes in hex) - optional + #[arg(long)] + app_id: Option, + + /// output file (default: attestation.bin) + #[arg(short, long)] + output: Option, + + /// hex encode output + #[arg(long, default_value_t = false)] + hex: bool, +} + +#[derive(Parser)] +struct AttestInfoArgs { + /// input file (default: attestation.bin) + #[arg(short, long)] + input: Option, +} + +#[derive(Parser)] +struct AttestJsonArgs { + /// input file (default: attestation.bin) + #[arg(short, long)] + input: Option, + + /// output file (default: stdout) + #[arg(short, long)] + output: Option, +} + +#[derive(Parser)] +struct AttestStripArgs { + /// input file (default: attestation.bin) + #[arg(short, long)] + input: Option, + + /// output file (default: attestation.strip.bin) + #[arg(short, long)] + output: Option, +} + +#[derive(Parser)] +/// Get app keys from a KMS server +struct GetKeysArgs { + /// KMS server URL (e.g., https://kms.example.com) + #[arg(short, long)] + kms_url: String, + + /// Application ID (20 bytes in hex) - optional + #[arg(long)] + app_id: Option, + + /// Output file path (default: stdout as JSON) + #[arg(short, long)] + output: Option, + + /// Root CA certificate (PEM format) to pin for TLS verification. + /// If not provided, TLS certificate verification is skipped for the initial connection. + #[arg(long)] + root_ca: Option, +} + +fn pad64(data: &[u8]) -> Result<[u8; 64]> { + if data.len() > 64 { + anyhow::bail!("report_data must be at most 64 bytes"); + } + let mut out = [0u8; 64]; + out[..data.len()].copy_from_slice(data); + Ok(out) +} + +fn cmd_quote_report(args: QuoteReportArgs) -> Result<()> { + #[derive(serde::Serialize)] + struct VerificationRequestJson { + pub attestation: String, + } + + let report_data = match args.report_data { + Some(hex_data) => { + pad64(&hex_decode(&hex_data).context("Failed to decode report_data hex")?)? + } + None => [0u8; 64], + }; + let attestation = Attestation::quote(&report_data).context("Failed to get attestation")?; + let request = VerificationRequestJson { + attestation: hex::encode(attestation.into_versioned().to_scale()?), + }; + + let json = + serde_json::to_string_pretty(&request).context("Failed to serialize request JSON")?; + if let Some(output_path) = args.output { + fs::write(&output_path, json).context("Failed to write quote report")?; + } else { + println!("{json}"); + } + Ok(()) +} + +fn decode_app_id(hex_str: Option<&str>) -> Result> { + let Some(hex_str) = hex_str else { + return Ok(None); + }; + let bytes = hex_decode(hex_str).context("Invalid app_id hex string")?; + if bytes.len() != 20 { + anyhow::bail!("app_id must be exactly 20 bytes (40 hex characters)"); + } + let mut arr = [0u8; 20]; + arr.copy_from_slice(&bytes); + Ok(Some(arr)) +} + +fn cmd_attest(args: AttestArgs) -> Result<()> { + let report_data = match args.report_data { + Some(hex_data) => { + pad64(&hex_decode(&hex_data).context("Failed to decode report_data hex")?)? + } + None => [0u8; 64], + }; + let app_id = decode_app_id(args.app_id.as_deref())?; + let attestation = Attestation::quote_with_app_id(&report_data, app_id) + .context("Failed to get attestation")?; + let attestation = attestation.into_versioned().to_scale()?; + + if args.hex { + let encoded = hex::encode(&attestation); + if let Some(output) = args.output { + fs::write(&output, encoded).context("Failed to write attestation hex")?; + } else { + println!("{encoded}"); + } + return Ok(()); + } + + let output = args + .output + .unwrap_or_else(|| PathBuf::from("attestation.bin")); + fs::write(&output, &attestation).context("Failed to write attestation sample")?; + Ok(()) +} + +fn cmd_attest_info(args: AttestInfoArgs) -> Result<()> { + let input = args + .input + .unwrap_or_else(|| PathBuf::from("attestation.bin")); + let data = fs::read(&input).context("Failed to read attestation file")?; + let attestation = + VersionedAttestation::from_scale(&data).context("Failed to decode attestation")?; + + println!("file: {}", input.display()); + println!("total_bytes: {}", data.len()); + + match attestation { + VersionedAttestation::V0 { attestation } => { + println!("version: V0"); + println!("mode: {:?}", attestation.quote.mode()); + println!("config_bytes: {}", attestation.config.len()); + match attestation.tdx_quote() { + Some(tdx) => { + let event_log_json = serde_json::to_vec(&tdx.event_log) + .context("Failed to serialize event log")?; + println!("tdx_quote_bytes: {}", tdx.quote.len()); + println!("event_log_entries: {}", tdx.event_log.len()); + println!("event_log_json_bytes: {}", event_log_json.len()); + } + + None => { + println!("tdx_quote_bytes: 0"); + println!("event_log_entries: 0"); + println!("event_log_json_bytes: 0"); + } + } + match attestation.tpm_quote() { + Some(tpm) => { + let tpm_bytes = tpm.encode(); + println!("tpm_quote_bytes: {}", tpm_bytes.len()); + } + None => println!("tpm_quote_bytes: 0"), + } + } + VersionedAttestation::V1 { attestation } => { + println!("version: V1"); + println!("platform: {:?}", attestation.platform); + println!("stack: {:?}", attestation.stack); + } + } + + Ok(()) +} + +fn cmd_attest_json(args: AttestJsonArgs) -> Result<()> { + let input = args + .input + .unwrap_or_else(|| PathBuf::from("attestation.bin")); + let data = fs::read(&input).context("Failed to read attestation file")?; + let attestation = + VersionedAttestation::from_scale(&data).context("Failed to decode attestation")?; + + let json = match attestation { + VersionedAttestation::V0 { attestation } => { + let mode = attestation.quote.mode().as_str(); + let tdx_quote = match attestation.tdx_quote() { + Some(tdx) => serde_json::json!({ + "quote": hex::encode(&tdx.quote), + "event_log": tdx.event_log, + }), + None => serde_json::Value::Null, + }; + let tpm_quote = match attestation.tpm_quote() { + Some(tpm) => serde_json::to_value(tpm).context("Failed to serialize TPM quote")?, + None => serde_json::Value::Null, + }; + + serde_json::json!({ + "version": "V0", + "mode": mode, + "config": attestation.config, + "tdx_quote": tdx_quote, + "tpm_quote": tpm_quote, + }) + } + VersionedAttestation::V1 { attestation } => { + serde_json::to_value(&attestation).context("Failed to serialize V1 attestation")? + } + }; + + let output = serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?; + if let Some(path) = args.output { + fs::write(&path, output).context("Failed to write JSON output")?; + } else { + println!("{output}"); + } + Ok(()) +} + +fn cmd_attest_strip(args: AttestStripArgs) -> Result<()> { + let input = args + .input + .unwrap_or_else(|| PathBuf::from("attestation.bin")); + let data = fs::read(&input).context("Failed to read attestation file")?; + let attestation = + VersionedAttestation::from_scale(&data).context("Failed to decode attestation")?; + let stripped = attestation.into_stripped(); + let output = args + .output + .unwrap_or_else(|| PathBuf::from("attestation.strip.bin")); + fs::write(&output, stripped.to_scale()?).context("Failed to write stripped attestation")?; + Ok(()) +} + +async fn cmd_get_keys(args: GetKeysArgs) -> Result<()> { + use dstack_kms_rpc::kms_client::KmsClient; + use ra_rpc::client::RaClientConfig; + + let kms_url = if args.kms_url.ends_with("/prpc") { + args.kms_url.clone() + } else { + format!("{}/prpc", args.kms_url.trim_end_matches('/')) + }; + + // Load root CA if provided for TLS pinning + let root_ca_pem = if let Some(root_ca_path) = &args.root_ca { + let pem = fs::read_to_string(root_ca_path) + .with_context(|| format!("failed to read root CA from {}", root_ca_path.display()))?; + Some(pem) + } else { + None + }; + + // Step 1: Get temporary CA certificate + eprintln!("Connecting to KMS: {kms_url}"); + let tls_no_check = root_ca_pem.is_none(); + if tls_no_check { + eprintln!("Warning: no --root-ca provided, TLS certificate verification is disabled for initial connection"); + } + let tmp_ca = { + let client = RaClientConfig::builder() + .remote_uri(kms_url.clone()) + .tls_no_check(tls_no_check) + .tls_built_in_root_certs(false) + .maybe_tls_ca_cert(root_ca_pem.clone()) + .build() + .into_client() + .context("failed to create client")?; + let kms_client = KmsClient::new(client); + kms_client + .get_temp_ca_cert() + .await + .context("Failed to get temp CA cert")? + }; + + // Step 2: Generate RA-TLS client certificate + let app_id = decode_app_id(args.app_id.as_deref())?; + let cert_pair = generate_ra_cert_with_app_id( + tmp_ca.temp_ca_cert.clone(), + tmp_ca.temp_ca_key.clone(), + app_id, + ) + .context("Failed to generate RA cert")?; + + // Step 3: Create authenticated client and request app keys + let ra_client = RaClientConfig::builder() + .tls_no_check(false) + .tls_built_in_root_certs(false) + .remote_uri(kms_url.clone()) + .tls_client_cert(cert_pair.cert_pem) + .tls_client_key(cert_pair.key_pem) + .tls_ca_cert(tmp_ca.ca_cert.clone()) + .build() + .into_client() + .context("Failed to create RA client")?; + + let kms_client = KmsClient::new(ra_client); + let response = kms_client + .get_app_key(dstack_kms_rpc::GetAppKeyRequest { + api_version: 1, + vm_config: "".to_string(), + }) + .await + .context("Failed to get app key")?; + + // Step 4: Build AppKeys structure + let (_, ca_pem) = x509_parser::pem::parse_x509_pem(tmp_ca.ca_cert.as_bytes()) + .context("Failed to parse CA cert")?; + let x509 = ca_pem.parse_x509().context("Failed to parse CA cert")?; + let root_pubkey = x509.public_key().raw.to_vec(); + + let keys = utils::AppKeys { + ca_cert: tmp_ca.ca_cert, + disk_crypt_key: response.disk_crypt_key, + env_crypt_key: response.env_crypt_key, + k256_key: response.k256_key, + k256_signature: response.k256_signature, + gateway_app_id: response.gateway_app_id, + key_provider: KeyProvider::Kms { + url: kms_url, + pubkey: root_pubkey, + tmp_ca_key: tmp_ca.temp_ca_key, + tmp_ca_cert: tmp_ca.temp_ca_cert, + }, + }; + + // Step 5: Output result + let json = serde_json::to_string_pretty(&keys).context("Failed to serialize app keys")?; + if let Some(output_path) = args.output { + fs::write(&output_path, &json).context("Failed to write app keys")?; + eprintln!("App keys written to: {}", output_path.display()); + } else { + println!("{json}"); + } + + Ok(()) +} + fn cmd_quote() -> Result<()> { let mut report_data = [0; 64]; io::stdin() @@ -449,6 +899,351 @@ fn sha256(data: &[u8]) -> [u8; 32] { sha256.finalize().into() } +fn cmd_vtpm_attest(args: VtpmAttestArgs) -> Result<()> { + use cmd_lib::run_cmd; + use serde::Serialize; + + #[derive(Serialize)] + struct AttestationResult { + success: bool, + ek_cert_verified: bool, + quote_verified: bool, + os_image_verified: Option, + nonce: String, + key_algorithm: String, + error: Option, + } + + // verify root CA file exists + if !args.root_ca.exists() { + anyhow::bail!("root CA file not found: {:?}", args.root_ca); + } + + // verify key algorithm + let (ek_algo, ak_algo, ak_scheme, algo_name) = match args.key_algo.to_lowercase().as_str() { + "rsa" => ("rsa", "rsa", "rsassa", "RSA-2048"), + "ecc" | "ecdsa" => ("ecc", "ecc", "ecdsa", "ECC P-256"), + _ => anyhow::bail!( + "invalid key algorithm: {}. Use 'rsa' or 'ecc'", + args.key_algo + ), + }; + + let mut result = AttestationResult { + success: false, + ek_cert_verified: false, + quote_verified: false, + os_image_verified: None, + nonce: args.nonce.clone(), + key_algorithm: algo_name.to_string(), + error: None, + }; + + let attestation_result = (|| -> Result<()> { + if args.format == "text" { + println!("=== vTPM Attestation ==="); + println!("Root CA: {:?}", args.root_ca); + println!("Nonce: {}", args.nonce); + println!("Key Algorithm: {}", algo_name); + println!(); + } + + // step 1: extract EK certificate + if args.format == "text" { + println!("[1/7] extracting EK certificate..."); + } + run_cmd! { + tpm2_nvread -o /tmp/ek_cert.der 0x1c00002 2>/dev/null; + openssl x509 -inform DER -in /tmp/ek_cert.der -out /tmp/ek_cert.pem 2>/dev/null; + } + .context("failed to extract EK certificate")?; + + // step 2: extract intermediate CA URL + if args.format == "text" { + println!("[2/7] downloading intermediate CA..."); + } + let ica_url_output = std::process::Command::new("openssl") + .args(["x509", "-in", "/tmp/ek_cert.pem", "-noout", "-text"]) + .output() + .context("failed to read EK cert")?; + let ica_text = String::from_utf8_lossy(&ica_url_output.stdout); + let ica_url = ica_text + .lines() + .find(|l| l.contains("CA Issuers") && l.contains("URI:")) + .and_then(|l| l.split("URI:").nth(1)) + .map(|s| s.trim()) + .context("failed to find Intermediate CA URL")?; + + run_cmd! { + curl -s -o /tmp/intermediate_ca.crt $ica_url; + } + .context("failed to download intermediate CA")?; + + // try DER first, then PEM + let convert_result = run_cmd! { + openssl x509 -inform DER -in /tmp/intermediate_ca.crt -outform PEM -out /tmp/intermediate_ca.pem 2>/dev/null; + }; + if convert_result.is_err() { + run_cmd! { + openssl x509 -inform PEM -in /tmp/intermediate_ca.crt -outform PEM -out /tmp/intermediate_ca.pem 2>/dev/null; + } + .context("failed to convert intermediate CA")?; + } + + // step 3: verify intermediate CA + if args.format == "text" { + println!("[3/7] verifying certificate chain..."); + } + let root_ca_path = args.root_ca.to_str().context("invalid root CA path")?; + run_cmd! { + openssl verify -CAfile $root_ca_path /tmp/intermediate_ca.pem >/dev/null 2>&1; + } + .context("intermediate CA verification failed")?; + + // step 4: verify EK certificate + run_cmd! { + cat /tmp/intermediate_ca.pem $root_ca_path > /tmp/ca_chain.pem; + openssl verify -CAfile /tmp/ca_chain.pem /tmp/ek_cert.pem >/dev/null 2>&1; + } + .context("EK certificate verification failed")?; + result.ek_cert_verified = true; + + // step 5: create AK + if args.format == "text" { + println!("[4/7] creating attestation key ({})...", algo_name); + } + run_cmd! { + tpm2_createek -c /tmp/ek.ctx -G $ek_algo -u /tmp/ek.pub >/dev/null 2>&1; + tpm2_createak -C /tmp/ek.ctx -c /tmp/ak.ctx -G $ak_algo -g sha256 -s $ak_scheme -u /tmp/ak.pub -n /tmp/ak.name >/dev/null 2>&1; + } + .context("failed to create attestation key")?; + + // step 6: generate quote + if args.format == "text" { + println!("[5/7] generating TPM quote..."); + } + let nonce = &args.nonce; + run_cmd! { + echo -n $nonce > /tmp/nonce.bin; + tpm2_quote -c /tmp/ak.ctx -l sha256:0,1,2,3,4,5,6,7,8,9,10,14 -q /tmp/nonce.bin -m /tmp/quote.msg -s /tmp/quote.sig -o /tmp/quote.pcr -g sha256 >/dev/null 2>&1; + } + .context("failed to generate quote")?; + + // step 7: verify quote + if args.format == "text" { + println!("[6/7] verifying quote signature..."); + } + run_cmd! { + tpm2_checkquote -u /tmp/ak.pub -m /tmp/quote.msg -s /tmp/quote.sig -f /tmp/quote.pcr -g sha256 -q /tmp/nonce.bin >/dev/null 2>&1; + } + .context("quote verification failed")?; + result.quote_verified = true; + + // step 8: verify OS image (optional) + if let Some(expected_hash) = &args.expected_os_hash { + if args.format == "text" { + println!("[7/7] verifying OS image..."); + } + let tpm_eventlog_path = "/sys/kernel/security/tpm0/binary_bios_measurements"; + if Path::new(tpm_eventlog_path).exists() { + let _ = run_cmd! { + tpm2_eventlog $tpm_eventlog_path > /tmp/eventlog.yaml 2>/dev/null; + }; + + let eventlog = fs::read_to_string("/tmp/eventlog.yaml").unwrap_or_default(); + if eventlog.contains(expected_hash) { + result.os_image_verified = Some(true); + } else { + result.os_image_verified = Some(false); + anyhow::bail!("OS image hash mismatch"); + } + } + } + + result.success = true; + Ok(()) + })(); + + if let Err(e) = attestation_result { + result.error = Some(format!("{:#}", e)); + } + + if args.format == "json" { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!(); + println!("=== Attestation Result ==="); + println!( + " EK Certificate Chain: {}", + if result.ek_cert_verified { + "✓ VERIFIED" + } else { + "✗ FAILED" + } + ); + println!( + " TPM Quote: {}", + if result.quote_verified { + "✓ VERIFIED" + } else { + "✗ FAILED" + } + ); + if let Some(os_verified) = result.os_image_verified { + println!( + " OS Image: {}", + if os_verified { + "✓ VERIFIED" + } else { + "✗ MISMATCH" + } + ); + } + println!(); + if result.success { + println!("🎉 ATTESTATION PASSED"); + } else { + println!("❌ ATTESTATION FAILED"); + if let Some(error) = &result.error { + println!("Error: {}", error); + } + anyhow::bail!("attestation failed"); + } + } + + Ok(()) +} + +fn cmd_tpm_quote(args: TpmQuoteArgs) -> Result<()> { + let data = if let Some(hex_data) = args.data { + let decoded = hex_decode(&hex_data).context("Failed to decode hex data")?; + if decoded.len() > 64 { + anyhow::bail!("Qualifying data must be at most 64 bytes"); + } + decoded + } else { + vec![0u8; 32] // TPM 2.0 max qualifying data is 32 bytes + }; + + // Parse key algorithm + let key_algo = args + .key_algo + .parse::() + .context("Failed to parse key algorithm")?; + + let qualifying_data: [u8; 32] = match args.hash_algo.as_str() { + "none" => data + .try_into() + .ok() + .context("qualifying data must be 32 bytes")?, + "sha256" => ez_hash::sha256(&data), + _ => { + anyhow::bail!("Unsupported hash algorithm"); + } + }; + + let tpm = tpm_attest::TpmContext::open(None).context("Failed to open TPM context")?; + let pcr_selection = tpm_attest::dstack_pcr_policy(); + let tpm_quote = tpm + .create_quote_with_algo(&qualifying_data, &pcr_selection, key_algo) + .context("Failed to create TPM quote")?; + + let quote_json = + serde_json::to_string_pretty(&tpm_quote).context("Failed to serialize TPM quote")?; + + if let Some(output_path) = args.output { + fs::write(&output_path, quote_json).context("Failed to write quote to file")?; + eprintln!("TPM quote written to: {:?}", output_path); + } else { + println!("{}", quote_json); + } + + Ok(()) +} + +async fn cmd_tpm_verify(args: TpmVerifyArgs) -> Result<()> { + let root_ca_pem = fs::read_to_string(&args.root_ca).context("Failed to read root CA")?; + let quote_json = fs::read_to_string(&args.quote).context("Failed to read quote file")?; + let tpm_quote: tpm_attest::TpmQuote = + serde_json::from_str("e_json).context("Failed to parse quote JSON")?; + + println!("=== TPM Quote Verification (dcap-qvl architecture) ==="); + println!("Root CA: {:?}", args.root_ca); + println!("Quote file: {:?}", args.quote); + println!(); + + // Step 1: Get collateral (certificates + CRLs) + println!("[Step 1] Fetching quote collateral (certificates + CRLs)..."); + let collateral = tpm_qvl::get_collateral(&tpm_quote, &root_ca_pem) + .await + .context("Failed to get collateral")?; + let crl_count = collateral.crls.len() + + if collateral.root_ca_crl.is_some() { + 1 + } else { + 0 + }; + println!(" ✓ Collateral fetched: {} CRLs downloaded", crl_count); + println!(); + + // Step 2: Verify quote with conditional CRL checking + println!("[Step 2] Verifying quote (CRL verification if CRL DP present)..."); + + match tpm_qvl::verify::verify_quote_with_ca(&tpm_quote, &collateral, &root_ca_pem) { + Ok(_) => { + // Success - print simple success message + println!(); + let crl_count = collateral.crls.len() + + if collateral.root_ca_crl.is_some() { + 1 + } else { + 0 + }; + if crl_count == 0 { + println!("🎉 VERIFICATION PASSED (no CRLs available)"); + } else { + println!( + "🎉 VERIFICATION PASSED (with {} CRL(s) verified)", + crl_count + ); + } + Ok(()) + } + Err(verification_result) => { + // Failure - print detailed status + println!(); + println!("=== Verification Result ==="); + println!( + " AK Certificate Chain (webpki + CRL): {}", + if verification_result.status.ak_verified { + "✓ VERIFIED" + } else { + "✗ FAILED" + } + ); + println!( + " Quote Signature: {}", + if verification_result.status.signature_verified { + "✓ VERIFIED" + } else { + "✗ FAILED" + } + ); + println!( + " PCR Values: {}", + if verification_result.status.pcr_verified { + "✓ VERIFIED" + } else { + "✗ FAILED" + } + ); + println!(" Error: {}", verification_result.error); + println!(); + anyhow::bail!("Verification failed") + } + } +} + #[tokio::main] async fn main() -> Result<()> { { @@ -502,6 +1297,33 @@ async fn main() -> Result<()> { docker_compose::remove_orphans(args.compose, args.dry_run).await?; } } + Commands::VtpmAttest(args) => { + cmd_vtpm_attest(args)?; + } + Commands::TpmQuote(args) => { + cmd_tpm_quote(args)?; + } + Commands::TpmVerify(args) => { + cmd_tpm_verify(args).await?; + } + Commands::QuoteReport(args) => { + cmd_quote_report(args)?; + } + Commands::Attest(args) => { + cmd_attest(args)?; + } + Commands::AttestInfo(args) => { + cmd_attest_info(args)?; + } + Commands::AttestJson(args) => { + cmd_attest_json(args)?; + } + Commands::AttestStrip(args) => { + cmd_attest_strip(args)?; + } + Commands::GetKeys(args) => { + cmd_get_keys(args).await?; + } } Ok(()) diff --git a/dstack-util/src/system_setup.rs b/dstack-util/src/system_setup.rs index 2276ff939..5220ba1d7 100644 --- a/dstack-util/src/system_setup.rs +++ b/dstack-util/src/system_setup.rs @@ -59,6 +59,7 @@ use dstack_gateway_rpc::{ use ra_tls::rcgen::{KeyPair, PKCS_ECDSA_P256_SHA256}; use serde_human_bytes as hex_bytes; use serde_json::Value; +use tpm_attest::{self as tpm, TpmContext}; async fn sign_cert_request( cert_client: &CertRequestClient, @@ -955,6 +956,41 @@ impl<'a> Stage0<'a> { Ok(app_keys) } + fn generate_tpm_app_keys(&self) -> Result { + let tpm = TpmContext::detect().context("failed to detect TPM context")?; + + // Get PCR policy for sealing (boot chain + app PCR) + let pcr_policy = tpm::dstack_pcr_policy(); + + // Try to read sealed seed (bound to PCR values including app PCR) + if let Some(seed) = tpm + .unseal::<32>(tpm::SEALED_NV_INDEX, tpm::PRIMARY_KEY_HANDLE, &pcr_policy) + .context("failed to unseal from TPM")? + { + info!( + "unsealed root key seed from TPM (PCR policy: {})", + pcr_policy.to_arg() + ); + return gen_app_keys_from_seed(&seed, KeyProviderKind::Tpm, None) + .context("failed to generate TPM app keys"); + } + + // No sealed seed exists, generate new one + info!("no sealed seed found, generating new seed..."); + let seed: [u8; 32] = tpm.get_random().context("TPM RNG unavailable")?; + // Seal the new seed to TPM with PCR policy (including app PCR) + tpm.seal( + &seed, + tpm::SEALED_NV_INDEX, + tpm::PRIMARY_KEY_HANDLE, + &pcr_policy, + ) + .context("failed to seal seed to TPM")?; + + gen_app_keys_from_seed(&seed, KeyProviderKind::Tpm, None) + .context("failed to generate TPM app keys") + } + async fn request_app_keys(&self) -> Result { let key_provider = self.shared.app_compose.key_provider(); match key_provider { @@ -967,7 +1003,8 @@ impl<'a> Stage0<'a> { .context("Failed to generate app keys") } KeyProviderKind::Tpm => { - bail!("Tpm key provider is not supported"); + info!("Generating app keys from TPM"); + self.generate_tpm_app_keys() } } } @@ -1364,8 +1401,8 @@ impl<'a> Stage0<'a> { KeyProvider::Local { mr, .. } => { KeyProviderInfo::new("local-sgx".into(), hex::encode(mr)) } - KeyProvider::Tpm { .. } => { - bail!("Tpm key provider is not supported"); + KeyProvider::Tpm { pubkey, .. } => { + KeyProviderInfo::new("tpm".into(), hex::encode(pubkey)) } KeyProvider::Kms { pubkey, .. } => { KeyProviderInfo::new("kms".into(), hex::encode(pubkey)) diff --git a/gateway/dstack-app/builder/Dockerfile b/gateway/dstack-app/builder/Dockerfile index 7889c1f3b..638017be5 100644 --- a/gateway/dstack-app/builder/Dockerfile +++ b/gateway/dstack-app/builder/Dockerfile @@ -19,7 +19,7 @@ RUN apt-get update && \ libprotobuf-dev \ clang \ libclang-dev -RUN git clone ${DSTACK_SRC_URL} && \ +RUN git clone ${DSTACK_SRC_URL} dstack && \ cd dstack && \ git checkout ${DSTACK_REV} RUN rustup target add x86_64-unknown-linux-musl diff --git a/guest-agent/Cargo.toml b/guest-agent/Cargo.toml index 302068682..8f2235f2e 100644 --- a/guest-agent/Cargo.toml +++ b/guest-agent/Cargo.toml @@ -31,6 +31,7 @@ ra-rpc = { workspace = true, features = ["client", "rocket"] } dstack-guest-agent-rpc.workspace = true ra-tls = { workspace = true, features = ["quote"] } tdx-attest.workspace = true +tpm-attest.workspace = true guest-api = { workspace = true, features = ["client"] } host-api = { workspace = true, features = ["client"] } sysinfo.workspace = true diff --git a/kms/dstack-app/builder/Dockerfile b/kms/dstack-app/builder/Dockerfile index 585801b45..f924d0e02 100644 --- a/kms/dstack-app/builder/Dockerfile +++ b/kms/dstack-app/builder/Dockerfile @@ -19,7 +19,7 @@ RUN apt-get update && \ libprotobuf-dev \ clang \ libclang-dev -RUN git clone ${DSTACK_SRC_URL} && \ +RUN git clone ${DSTACK_SRC_URL} dstack && \ cd dstack && \ git checkout ${DSTACK_REV} RUN rustup target add x86_64-unknown-linux-musl diff --git a/kms/dstack-app/deploy-to-vmm.sh b/kms/dstack-app/deploy-to-vmm.sh index b8f6aeeeb..23f8c2917 100755 --- a/kms/dstack-app/deploy-to-vmm.sh +++ b/kms/dstack-app/deploy-to-vmm.sh @@ -10,6 +10,7 @@ if [ -f ".env" ]; then # Load variables from .env echo "Loading environment variables from .env file..." set -a + # shellcheck source=/dev/null source .env set +a else @@ -56,7 +57,7 @@ OS_IMAGE=dstack-0.5.5 KMS_IMAGE=dstacktee/dstack-kms@sha256:11ac59f524a22462ccd2152219b0bec48a28ceb734e32500152d4abefab7a62a # The admin token for the KMS app -ADMIN_TOKEN=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) +ADMIN_TOKEN=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w 32 | head -n 1) EOF echo "Please edit the .env file and set the required variables, then run this script again." exit 1 @@ -86,9 +87,10 @@ CLI="../../vmm/src/vmm-cli.py --url $VMM_RPC" COMPOSE_TMP=$(mktemp) -GIT_REV=$(git rev-parse $GIT_REV) +GIT_REV=$(git rev-parse "$GIT_REV") -ADMIN_TOKEN_HASH=$(echo -n $ADMIN_TOKEN | sha256sum | cut -d' ' -f1) +# shellcheck disable=SC2034 # consumed via `subvar` into compose-*.yaml +ADMIN_TOKEN_HASH=$(echo -n "$ADMIN_TOKEN" | sha256sum | cut -d' ' -f1) cp compose-dev.yaml "$COMPOSE_TMP" @@ -137,10 +139,10 @@ echo "Deploying KMS to dstack-vmm..." $CLI deploy \ --name kms \ --compose .app-compose.json \ - --image $OS_IMAGE \ - --port tcp:$KMS_RPC_ADDR:8000 \ - --port tcp:$AUTH_API_RPC_ADDR:8001 \ - --port tcp:$GUEST_AGENT_ADDR:8090 \ + --image "$OS_IMAGE" \ + --port tcp:"$KMS_RPC_ADDR":8000 \ + --port tcp:"$AUTH_API_RPC_ADDR":8001 \ + --port tcp:"$GUEST_AGENT_ADDR":8090 \ --vcpu 8 \ --memory 8G \ --disk 50G diff --git a/kms/src/onboard_service.rs b/kms/src/onboard_service.rs index bef924b38..bb4086894 100644 --- a/kms/src/onboard_service.rs +++ b/kms/src/onboard_service.rs @@ -116,8 +116,8 @@ impl OnboardRpc for OnboardHandler { .context("Failed to decode attestation")?; let attestation_mode = match &attestation.clone().into_v1().platform { PlatformEvidence::Tdx { .. } => "dstack-tdx", - PlatformEvidence::GcpTdx => "dstack-gcp-tdx", - PlatformEvidence::NitroEnclave => "dstack-nitro-enclave", + PlatformEvidence::GcpTdx { .. } => "dstack-gcp-tdx", + PlatformEvidence::NitroEnclave { .. } => "dstack-nitro-enclave", } .to_string(); let verified = attestation diff --git a/kms/src/www/onboard.html b/kms/src/www/onboard.html index 4ee8993fd..9f28784ca 100644 --- a/kms/src/www/onboard.html +++ b/kms/src/www/onboard.html @@ -315,7 +315,7 @@

Onboard from an Existing KMS Instance

methods: { async handleBootstrap() { try { - const { ca_pubkey, k256_pubkey, quote, eventlog, error } = await rpcCall('Bootstrap', { + const { ca_pubkey, k256_pubkey, attestation, error } = await rpcCall('Bootstrap', { domain: this.bootstrapDomain }); @@ -325,8 +325,7 @@

Onboard from an Existing KMS Instance

this.result = JSON.stringify({ caPubkey: '0x' + ca_pubkey, k256Pubkey: '0x' + k256_pubkey, - quote: '0x' + quote, - eventlog: '0x' + eventlog + attestation: '0x' + attestation }, null, 2); this.error = ''; } catch (err) { diff --git a/mod-tdx-guest/Kconfig b/mod-tdx-guest/Kconfig deleted file mode 100644 index 22dd59e19..000000000 --- a/mod-tdx-guest/Kconfig +++ /dev/null @@ -1,11 +0,0 @@ -config TDX_GUEST_DRIVER - tristate "TDX Guest driver" - depends on INTEL_TDX_GUEST - select TSM_REPORTS - help - The driver provides userspace interface to communicate with - the TDX module to request the TDX guest details like attestation - report. - - To compile this driver as module, choose M here. The module will - be called tdx-guest. diff --git a/mod-tdx-guest/Makefile b/mod-tdx-guest/Makefile deleted file mode 100644 index a9a17e211..000000000 --- a/mod-tdx-guest/Makefile +++ /dev/null @@ -1,22 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0-only -# SPDX-FileCopyrightText: © 2022 Intel Corporation -# SPDX-FileCopyrightText: © 2024 Phala Network - -SRC := $(shell pwd) -KERNEL_SRC ?= /lib/modules/$(shell uname -r)/build -INSTALL_MOD_PATH := $(shell pwd)/dist/ - -obj-m += tdx-guest.o -tdx-guest-objs := tdcall.o mod.o - -all: - $(MAKE) -C $(KERNEL_SRC) M=$(SRC) - -modules_install: - $(MAKE) -C $(KERNEL_SRC) M=$(SRC) modules_install - -clean: - $(MAKE) -C $(KERNEL_SRC) M=$(SRC) clean - -install: - make -C $(KDIR) M=$(PWD) modules_install INSTALL_MOD_PATH=$(INSTALL_MOD_PATH) diff --git a/mod-tdx-guest/mod.c b/mod-tdx-guest/mod.c deleted file mode 100644 index 81259f92d..000000000 --- a/mod-tdx-guest/mod.c +++ /dev/null @@ -1,184 +0,0 @@ -// SPDX-FileCopyrightText: © 2022 Intel Corporation -// SPDX-FileCopyrightText: © 2024 Phala Network -// SPDX-License-Identifier: GPL-2.0-only -/* - * TDX guest user interface driver - * - * Copyright (C) 2022 Intel Corporation - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "tdx-guest.h" -#include "tdx.h" - -#define TDCALL_RETURN_CODE(a) ((a) >> 32) -#define TDCALL_INVALID_OPERAND 0xc0000100 - -#define TDREPORT_SUBTYPE_0 0 - -static int tdx_mcall_get_report0(u8 *reportdata, u8 *tdreport) -{ - struct tdx_module_args args = { - .rcx = virt_to_phys(tdreport), - .rdx = virt_to_phys(reportdata), - .r8 = TDREPORT_SUBTYPE_0, - }; - u64 ret; - - ret = __tdcall(TDG_MR_REPORT, &args); - if (ret) { - if (TDCALL_RETURN_CODE(ret) == TDCALL_INVALID_OPERAND) - return -EINVAL; - return -EIO; - } - - return 0; -} - -static long tdx_get_report0(struct tdx_report_req __user *req) -{ - u8 *reportdata, *tdreport; - long ret; - - reportdata = kmalloc(TDX_REPORTDATA_LEN, GFP_KERNEL); - if (!reportdata) - return -ENOMEM; - - tdreport = kzalloc(TDX_REPORT_LEN, GFP_KERNEL); - if (!tdreport) - { - ret = -ENOMEM; - goto out; - } - - if (copy_from_user(reportdata, req->reportdata, TDX_REPORTDATA_LEN)) - { - ret = -EFAULT; - goto out; - } - - /* Generate TDREPORT0 using "TDG.MR.REPORT" TDCALL */ - ret = tdx_mcall_get_report0(reportdata, tdreport); - if (ret) - goto out; - - if (copy_to_user(req->tdreport, tdreport, TDX_REPORT_LEN)) - ret = -EFAULT; - -out: - kfree(reportdata); - kfree(tdreport); - - return ret; -} - -static long tdx_extend_rtmr(struct tdx_extend_rtmr_req __user *req) -{ - u8 *data; - u8 index; - long ret; - - data = kmalloc(TDX_EXTEND_RTMR_DATA_LEN, GFP_KERNEL); - if (!data) - return -ENOMEM; - - if (copy_from_user(data, req->data, TDX_EXTEND_RTMR_DATA_LEN)) - { - ret = -EFAULT; - goto out; - } - - if (copy_from_user(&index, (u8 __user *)&req->index, 1)) - { - ret = -EFAULT; - goto out; - } - - if (index > 3) { - ret = -EINVAL; - goto out; - } - - { - struct tdx_module_args args = { - .rcx = virt_to_phys(data), - .rdx = index, - }; - - ret = __tdcall(TDG_MR_RTMR_EXTEND, &args); - } -out: - kfree(data); - return ret; -} - -static long tdx_guest_ioctl(struct file *file, unsigned int cmd, - unsigned long arg) -{ - switch (cmd) - { - case TDX_CMD_GET_REPORT0: - return tdx_get_report0((struct tdx_report_req __user *)arg); - case TDX_CMD_EXTEND_RTMR: - case TDX_CMD_EXTEND_RTMR2: - return tdx_extend_rtmr((struct tdx_extend_rtmr_req __user *)arg); - default: - return -ENOTTY; - } -} - -static const struct file_operations tdx_guest_fops = { - .owner = THIS_MODULE, - .unlocked_ioctl = tdx_guest_ioctl, - .llseek = no_llseek, -}; - -static struct miscdevice tdx_misc_dev = { - .name = KBUILD_MODNAME, - .minor = MISC_DYNAMIC_MINOR, - .fops = &tdx_guest_fops, -}; - -static const struct x86_cpu_id tdx_guest_ids[] = { - X86_MATCH_FEATURE(X86_FEATURE_TDX_GUEST, NULL), - {} -}; -MODULE_DEVICE_TABLE(x86cpu, tdx_guest_ids); - -static int __init tdx_guest_init(void) -{ - int ret; - - if (!x86_match_cpu(tdx_guest_ids)) - return -ENODEV; - - ret = misc_register(&tdx_misc_dev); - if (ret) - return ret; - - return 0; -} -module_init(tdx_guest_init); - -static void __exit tdx_guest_exit(void) -{ - misc_deregister(&tdx_misc_dev); -} -module_exit(tdx_guest_exit); - -MODULE_AUTHOR("Kuppuswamy Sathyanarayanan , kvinwang"); -MODULE_DESCRIPTION("TDX Guest Driver"); -MODULE_LICENSE("GPL"); diff --git a/mod-tdx-guest/tdcall.S b/mod-tdx-guest/tdcall.S deleted file mode 100644 index a98528695..000000000 --- a/mod-tdx-guest/tdcall.S +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright (c) 2023, Intel Inc - * SPDX-License-Identifier: GPL-2.0-only WITH Linux-syscall-note - */ -#include -#include - -#include -#include - -#include -#include - -/* - * TDCALL and SEAMCALL are supported in Binutils >= 2.36. - */ -#define tdcall .byte 0x66,0x0f,0x01,0xcc -#define seamcall .byte 0x66,0x0f,0x01,0xcf - -/* - * TDX_MODULE_CALL - common helper macro for both - * TDCALL and SEAMCALL instructions. - * - * TDCALL - used by TDX guests to make requests to the - * TDX module and hypercalls to the VMM. - * SEAMCALL - used by TDX hosts to make requests to the - * TDX module. - * - *------------------------------------------------------------------------- - * TDCALL/SEAMCALL ABI: - *------------------------------------------------------------------------- - * Input Registers: - * - * RAX - TDCALL/SEAMCALL Leaf number. - * RCX,RDX,RDI,RSI,RBX,R8-R15 - TDCALL/SEAMCALL Leaf specific input registers. - * - * Output Registers: - * - * RAX - TDCALL/SEAMCALL instruction error code. - * RCX,RDX,RDI,RSI,RBX,R8-R15 - TDCALL/SEAMCALL Leaf specific output registers. - * - *------------------------------------------------------------------------- - * - * So while the common core (RAX,RCX,RDX,R8-R11) fits nicely in the - * callee-clobbered registers and even leaves RDI,RSI free to act as a - * base pointer, some leafs (e.g., VP.ENTER) make a giant mess of things. - * - * For simplicity, assume that anything that needs the callee-saved regs - * also tramples on RDI,RSI. This isn't strictly true, see for example - * TDH.EXPORT.MEM. - */ -.macro TDX_MODULE_CALL host:req ret=0 - FRAME_BEGIN - - /* Move Leaf ID to RAX */ - mov %rdi, %rax - - /* Move other input regs from 'struct tdx_module_args' */ - movq TDX_MODULE_rcx(%rsi), %rcx - movq TDX_MODULE_rdx(%rsi), %rdx - movq TDX_MODULE_r8(%rsi), %r8 - movq TDX_MODULE_r9(%rsi), %r9 - movq TDX_MODULE_r10(%rsi), %r10 - movq TDX_MODULE_r11(%rsi), %r11 - -.if \host -.Lseamcall\@: - seamcall - /* - * SEAMCALL instruction is essentially a VMExit from VMX root - * mode to SEAM VMX root mode. VMfailInvalid (CF=1) indicates - * that the targeted SEAM firmware is not loaded or disabled, - * or P-SEAMLDR is busy with another SEAMCALL. %rax is not - * changed in this case. - * - * Set %rax to TDX_SEAMCALL_VMFAILINVALID for VMfailInvalid. - * This value will never be used as actual SEAMCALL error code as - * it is from the Reserved status code class. - */ - jc .Lseamcall_vmfailinvalid\@ -.else - tdcall -.endif - -.if \ret - /* Copy output registers to the structure */ - movq %rcx, TDX_MODULE_rcx(%rsi) - movq %rdx, TDX_MODULE_rdx(%rsi) - movq %r8, TDX_MODULE_r8(%rsi) - movq %r9, TDX_MODULE_r9(%rsi) - movq %r10, TDX_MODULE_r10(%rsi) - movq %r11, TDX_MODULE_r11(%rsi) -.endif /* \ret */ - -.if \host -.Lout\@: -.endif - - FRAME_END - RET - -.if \host -.Lseamcall_vmfailinvalid\@: - mov $TDX_SEAMCALL_VMFAILINVALID, %rax - jmp .Lseamcall_fail\@ - -.Lseamcall_trap\@: - /* - * SEAMCALL caused #GP or #UD. By reaching here RAX contains - * the trap number. Convert the trap number to the TDX error - * code by setting TDX_SW_ERROR to the high 32-bits of RAX. - * - * Note cannot OR TDX_SW_ERROR directly to RAX as OR instruction - * only accepts 32-bit immediate at most. - */ - movq $TDX_SW_ERROR, %rdi - orq %rdi, %rax - -.Lseamcall_fail\@: - jmp .Lout\@ - - _ASM_EXTABLE_FAULT(.Lseamcall\@, .Lseamcall_trap\@) -.endif /* \host */ - -.endm - -.section .noinstr.text, "ax" - -/* - * __tdcall() - Used by TDX guests to request services from the TDX - * module (does not include VMM services) using TDCALL instruction. - * - * __tdcall() function ABI: - * - * @fn (RDI) - TDCALL Leaf ID, moved to RAX - * @args (RSI) - struct tdx_module_args for input - * - * Only RCX/RDX/R8-R11 are used as input registers. - * - * Return status of TDCALL via RAX. - */ -SYM_FUNC_START(__tdcall) - TDX_MODULE_CALL host=0 -SYM_FUNC_END(__tdcall) - -/* - * __tdcall_ret() - Used by TDX guests to request services from the TDX - * module (does not include VMM services) using TDCALL instruction, with - * saving output registers to the 'struct tdx_module_args' used as input. - * - * __tdcall_ret() function ABI: - * - * @fn (RDI) - TDCALL Leaf ID, moved to RAX - * @args (RSI) - struct tdx_module_args for input and output - * - * Only RCX/RDX/R8-R11 are used as input/output registers. - * - * Return status of TDCALL via RAX. - */ -SYM_FUNC_START(__tdcall_ret) - TDX_MODULE_CALL host=0 ret=1 -SYM_FUNC_END(__tdcall_ret) diff --git a/mod-tdx-guest/tdx-guest.h b/mod-tdx-guest/tdx-guest.h deleted file mode 100644 index 05b2cb07a..000000000 --- a/mod-tdx-guest/tdx-guest.h +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-FileCopyrightText: © 2022 Intel Corporation -// SPDX-FileCopyrightText: © 2024 Phala Network -// SPDX-License-Identifier: GPL-2.0-only WITH Linux-syscall-note -/* - * Userspace interface for TDX guest driver - * - * Copyright (C) 2022 Intel Corporation - */ - -#ifndef _UAPI_LINUX_TDX_GUEST_H_ -#define _UAPI_LINUX_TDX_GUEST_H_ - -#include -#include - -/* Length of the REPORTDATA used in TDG.MR.REPORT TDCALL */ -#define TDX_REPORTDATA_LEN 64 - -/* Length of TDREPORT used in TDG.MR.REPORT TDCALL */ -#define TDX_REPORT_LEN 1024 - -/** - * struct tdx_report_req - Request struct for TDX_CMD_GET_REPORT0 IOCTL. - * - * @reportdata: User buffer with REPORTDATA to be included into TDREPORT. - * Typically it can be some nonce provided by attestation - * service, so the generated TDREPORT can be uniquely verified. - * @tdreport: User buffer to store TDREPORT output from TDCALL[TDG.MR.REPORT]. - */ -struct tdx_report_req { - __u8 reportdata[TDX_REPORTDATA_LEN]; - __u8 tdreport[TDX_REPORT_LEN]; -}; - -/* - * TDX_CMD_GET_REPORT0 - Get TDREPORT0 (a.k.a. TDREPORT subtype 0) using - * TDCALL[TDG.MR.REPORT] - * - * Return 0 on success, -EIO on TDCALL execution failure, and - * standard errno on other general error cases. - */ -#define TDX_CMD_GET_REPORT0 _IOWR('T', 1, struct tdx_report_req) - -#define TDX_CMD_EXTEND_RTMR _IOR('T', 3, struct tdx_extend_rtmr_req) -#define TDX_CMD_EXTEND_RTMR2 _IOW('T', 3, struct tdx_extend_rtmr_req) -#define TDX_EXTEND_RTMR_DATA_LEN 48 -struct tdx_extend_rtmr_req -{ - u8 data[TDX_EXTEND_RTMR_DATA_LEN]; - u8 index; -}; - -#endif /* _UAPI_LINUX_TDX_GUEST_H_ */ diff --git a/mod-tdx-guest/tdx.h b/mod-tdx-guest/tdx.h deleted file mode 100644 index 05e8b97ea..000000000 --- a/mod-tdx-guest/tdx.h +++ /dev/null @@ -1,101 +0,0 @@ -// SPDX-FileCopyrightText: © 2022 Intel Corporation -// SPDX-FileCopyrightText: © 2024 Phala Network -// SPDX-License-Identifier: GPL-2.0-only WITH Linux-syscall-note -#ifndef _ASM_X86_SHARED_TDX_H -#define _ASM_X86_SHARED_TDX_H - -#include -#include - -#define TDX_HYPERCALL_STANDARD 0 - -#define TDX_CPUID_LEAF_ID 0x21 -#define TDX_IDENT "IntelTDX " - -/* TDX module Call Leaf IDs */ -#define TDG_VP_VMCALL 0 -#define TDG_VP_INFO 1 -#define TDG_MR_RTMR_EXTEND 2 -#define TDG_VP_VEINFO_GET 3 -#define TDG_MR_REPORT 4 -#define TDG_MEM_PAGE_ACCEPT 6 -#define TDG_VM_WR 8 - -/* TDCS fields. To be used by TDG.VM.WR and TDG.VM.RD module calls */ -#define TDCS_NOTIFY_ENABLES 0x9100000000000010 - -/* TDX hypercall Leaf IDs */ -#define TDVMCALL_MAP_GPA 0x10001 -#define TDVMCALL_GET_QUOTE 0x10002 -#define TDVMCALL_REPORT_FATAL_ERROR 0x10003 - -#define TDVMCALL_STATUS_RETRY 1 - -/* - * Bitmasks of exposed registers (with VMM). - */ -#define TDX_RDX BIT(2) -#define TDX_RBX BIT(3) -#define TDX_RSI BIT(6) -#define TDX_RDI BIT(7) -#define TDX_R8 BIT(8) -#define TDX_R9 BIT(9) -#define TDX_R10 BIT(10) -#define TDX_R11 BIT(11) -#define TDX_R12 BIT(12) -#define TDX_R13 BIT(13) -#define TDX_R14 BIT(14) -#define TDX_R15 BIT(15) - -/* - * These registers are clobbered to hold arguments for each - * TDVMCALL. They are safe to expose to the VMM. - * Each bit in this mask represents a register ID. Bit field - * details can be found in TDX GHCI specification, section - * titled "TDCALL [TDG.VP.VMCALL] leaf". - */ -#define TDVMCALL_EXPOSE_REGS_MASK \ - (TDX_RDX | TDX_RBX | TDX_RSI | TDX_RDI | TDX_R8 | TDX_R9 | \ - TDX_R10 | TDX_R11 | TDX_R12 | TDX_R13 | TDX_R14 | TDX_R15) - -/* TDX supported page sizes from the TDX module ABI. */ -#define TDX_PS_4K 0 -#define TDX_PS_2M 1 -#define TDX_PS_1G 2 -#define TDX_PS_NR (TDX_PS_1G + 1) - -#ifndef __ASSEMBLY__ - -#include - -/* - * Used in __tdcall*() to gather the input/output registers' values of the - * TDCALL instruction when requesting services from the TDX module. This is a - * software only structure and not part of the TDX module/VMM ABI - */ -struct tdx_module_args { - /* callee-clobbered */ - u64 rcx; - u64 rdx; - u64 r8; - u64 r9; - /* extra callee-clobbered */ - u64 r10; - u64 r11; - /* callee-saved + rdi/rsi */ - u64 r12; - u64 r13; - u64 r14; - u64 r15; - u64 rbx; - u64 rdi; - u64 rsi; -}; - -/* Used to communicate with the TDX module */ -u64 __tdcall(u64 fn, struct tdx_module_args *args); -u64 __tdcall_ret(u64 fn, struct tdx_module_args *args); -u64 __tdcall_saved_ret(u64 fn, struct tdx_module_args *args); - -#endif /* !__ASSEMBLY__ */ -#endif /* _ASM_X86_SHARED_TDX_H */ diff --git a/nsm-attest/Cargo.toml b/nsm-attest/Cargo.toml new file mode 100644 index 000000000..09d3b3e4e --- /dev/null +++ b/nsm-attest/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: © 2025 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "nsm-attest" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "AWS Nitro Enclave NSM attestation library" + +[dependencies] +anyhow.workspace = true +serde.workspace = true +tracing.workspace = true +aws-nitro-enclaves-nsm-api = "0.4" +ciborium.workspace = true + +[dev-dependencies] +hex.workspace = true diff --git a/nsm-attest/src/lib.rs b/nsm-attest/src/lib.rs new file mode 100644 index 000000000..faf176459 --- /dev/null +++ b/nsm-attest/src/lib.rs @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! AWS Nitro Enclave NSM (Nitro Security Module) Attestation Library +//! +//! This crate wraps the official `aws-nitro-enclaves-nsm-api` crate and provides +//! additional utilities for attestation document parsing. +//! +//! The NSM device is available at `/dev/nsm` inside a Nitro Enclave and +//! provides attestation, PCR operations, and entropy generation. + +use anyhow::{bail, Result}; +use aws_nitro_enclaves_nsm_api::api::{Request, Response}; +use aws_nitro_enclaves_nsm_api::driver; +use std::path::Path; + +mod types; + +pub use types::*; + +/// NSM device path +pub const NSM_DEVICE_PATH: &str = "/dev/nsm"; + +/// Check if running inside a Nitro Enclave +pub fn is_nitro_enclave() -> bool { + Path::new(NSM_DEVICE_PATH).exists() +} + +/// NSM Context for interacting with the Nitro Security Module +#[derive(Debug)] +pub struct NsmContext { + fd: i32, +} + +impl NsmContext { + /// Open the NSM device + pub fn new() -> Result { + let fd = driver::nsm_init(); + if fd < 0 { + bail!("Failed to open NSM device"); + } + Ok(Self { fd }) + } + + /// Get attestation document from NSM + /// + /// # Arguments + /// * `user_data` - Optional user data to include in attestation (max 512 bytes) + /// * `nonce` - Optional nonce for freshness (max 512 bytes) + /// * `public_key` - Optional public key to include (max 1024 bytes) + pub fn get_attestation_doc( + &self, + user_data: Option<&[u8]>, + nonce: Option<&[u8]>, + public_key: Option<&[u8]>, + ) -> Result> { + let request = Request::Attestation { + user_data: user_data.map(|d| d.to_vec().into()), + nonce: nonce.map(|d| d.to_vec().into()), + public_key: public_key.map(|d| d.to_vec().into()), + }; + + let response = driver::nsm_process_request(self.fd, request); + + match response { + Response::Attestation { document } => Ok(document), + Response::Error(err) => bail!("NSM attestation failed: {:?}", err), + _ => bail!("Unexpected NSM response"), + } + } + + /// Describe the NSM module + pub fn describe(&self) -> Result { + let request = Request::DescribeNSM; + let response = driver::nsm_process_request(self.fd, request); + + match response { + Response::DescribeNSM { + version_major, + version_minor, + version_patch, + module_id, + max_pcrs, + locked_pcrs, + digest, + } => Ok(NsmDescription { + version_major, + version_minor, + version_patch, + module_id, + max_pcrs, + locked_pcrs: locked_pcrs.into_iter().collect(), + digest: format!("{:?}", digest), + }), + Response::Error(err) => bail!("NSM describe failed: {:?}", err), + _ => bail!("Unexpected NSM response"), + } + } + + /// Get random bytes from NSM + pub fn get_random(&self) -> Result> { + let request = Request::GetRandom; + let response = driver::nsm_process_request(self.fd, request); + + match response { + Response::GetRandom { random } => Ok(random), + Response::Error(err) => bail!("NSM get_random failed: {:?}", err), + _ => bail!("Unexpected NSM response"), + } + } + + /// Extend a PCR with data + /// + /// # Arguments + /// * `index` - PCR index (0-15 for user PCRs) + /// * `data` - Data to extend into PCR (will be hashed) + pub fn pcr_extend(&self, index: u16, data: &[u8]) -> Result> { + let request = Request::ExtendPCR { + index, + data: data.to_vec(), + }; + let response = driver::nsm_process_request(self.fd, request); + + match response { + Response::ExtendPCR { data } => Ok(data), + Response::Error(err) => bail!("NSM PCR extend failed: {:?}", err), + _ => bail!("Unexpected NSM response"), + } + } + + /// Lock a PCR (prevent further extensions) + pub fn pcr_lock(&self, index: u16) -> Result<()> { + let request = Request::LockPCR { index }; + let response = driver::nsm_process_request(self.fd, request); + + match response { + Response::LockPCR => Ok(()), + Response::Error(err) => bail!("NSM PCR lock failed: {:?}", err), + _ => bail!("Unexpected NSM response"), + } + } + + /// Lock multiple PCRs + pub fn pcr_lock_range(&self, range: u16) -> Result<()> { + let request = Request::LockPCRs { range }; + let response = driver::nsm_process_request(self.fd, request); + + match response { + Response::LockPCRs => Ok(()), + Response::Error(err) => bail!("NSM PCR lock range failed: {:?}", err), + _ => bail!("Unexpected NSM response"), + } + } + + /// Describe a specific PCR + pub fn describe_pcr(&self, index: u16) -> Result { + let request = Request::DescribePCR { index }; + let response = driver::nsm_process_request(self.fd, request); + + match response { + Response::DescribePCR { lock, data } => Ok(PcrInfo { lock, data }), + Response::Error(err) => bail!("NSM describe PCR failed: {:?}", err), + _ => bail!("Unexpected NSM response"), + } + } +} + +impl Drop for NsmContext { + fn drop(&mut self) { + driver::nsm_exit(self.fd); + } +} + +/// PCR information +#[derive(Debug, Clone)] +pub struct PcrInfo { + /// Whether the PCR is locked + pub lock: bool, + /// Current PCR value + pub data: Vec, +} + +/// Create an attestation document with report data +/// +/// This is a convenience function that creates an attestation document +/// with the given report data as user_data. +pub fn get_attestation(report_data: &[u8]) -> Result> { + let ctx = NsmContext::new()?; + ctx.get_attestation_doc(Some(report_data), None, None) +} + +/// Get random bytes from NSM +pub fn get_random() -> Result> { + let ctx = NsmContext::new()?; + ctx.get_random() +} diff --git a/nsm-attest/src/types.rs b/nsm-attest/src/types.rs new file mode 100644 index 000000000..e33ab9be2 --- /dev/null +++ b/nsm-attest/src/types.rs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! NSM types for attestation document parsing + +use anyhow::Context; +use serde::Deserialize; + +/// NSM Description +#[derive(Debug, Clone)] +pub struct NsmDescription { + /// Version major + pub version_major: u16, + /// Version minor + pub version_minor: u16, + /// Version patch + pub version_patch: u16, + /// Module ID + pub module_id: String, + /// Maximum number of PCRs + pub max_pcrs: u16, + /// Locked PCRs bitmap + pub locked_pcrs: Vec, + /// Digest algorithm + pub digest: String, +} + +/// Attestation document structure (COSE Sign1) +/// +/// The attestation document is a COSE Sign1 structure containing: +/// - Protected header with algorithm +/// - Unprotected header (empty) +/// - Payload (CBOR-encoded attestation claims) +/// - Signature +#[derive(Debug, Clone, Deserialize)] +pub struct AttestationDocument { + /// Module ID + pub module_id: String, + /// Digest algorithm used + pub digest: String, + /// Timestamp (milliseconds since epoch) + pub timestamp: u64, + /// PCR values + pub pcrs: std::collections::BTreeMap>, + /// Certificate (DER-encoded) + pub certificate: Vec, + /// CA bundle (list of DER-encoded certificates) + pub cabundle: Vec>, + /// Optional public key + #[serde(default)] + pub public_key: Option>, + /// Optional user data + #[serde(default)] + pub user_data: Option>, + /// Optional nonce + #[serde(default)] + pub nonce: Option>, +} + +impl AttestationDocument { + /// Parse attestation document from COSE Sign1 bytes + pub fn from_cose(data: &[u8]) -> anyhow::Result { + // COSE Sign1 structure is a CBOR array: [protected, unprotected, payload, signature] + let (_protected, _unprotected, payload, _signature): ( + Vec, + std::collections::BTreeMap, + Vec, + Vec, + ) = ciborium::from_reader(data).context("Failed to parse COSE Sign1")?; + + // Parse the payload + let doc: AttestationDocument = ciborium::from_reader(&payload[..]) + .map_err(|e| anyhow::anyhow!("Failed to parse attestation payload: {}", e))?; + + Ok(doc) + } +} diff --git a/nsm-attest/tests/attestation_test.rs b/nsm-attest/tests/attestation_test.rs new file mode 100644 index 000000000..ee0fe7d26 --- /dev/null +++ b/nsm-attest/tests/attestation_test.rs @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2024-2025 The Project Contributors +// SPDX-License-Identifier: Apache-2.0 + +// Test for NSM attestation document parsing +use nsm_attest::AttestationDocument; + +// Real attestation captured from Nitro Enclave +const ATTESTATION_BIN: &[u8] = include_bytes!("nitro_attestation.bin"); + +#[test] +fn test_parse_versioned_attestation_and_extract_nsm_quote() { + // The attestation.bin is a VersionedAttestation (SCALE encoded) + // Format: version (1 byte) + SCALE-encoded Attestation + // Attestation contains: quote (AttestationQuote), runtime_events, report_data, config + + // For DstackNitroEnclave, the quote contains nsm_quote which is the COSE Sign1 document + // Let's find the COSE Sign1 marker (0x8444 = CBOR array tag for COSE_Sign1) + + let data = ATTESTATION_BIN; + println!("Total attestation length: {} bytes", data.len()); + + // Find COSE Sign1 structure (starts with 0x84 0x44 for protected header) + let mut cose_start = None; + for i in 0..data.len().saturating_sub(2) { + if data[i] == 0x84 && data[i + 1] == 0x44 { + cose_start = Some(i); + break; + } + } + + let cose_start = cose_start.expect("Should find COSE Sign1 marker"); + println!("COSE Sign1 starts at offset: {}", cose_start); + + // The COSE Sign1 structure length is encoded before it in SCALE + // For now, let's try parsing from the marker to the end + let cose_data = &data[cose_start..]; + + // Try to parse the attestation document + let result = AttestationDocument::from_cose(cose_data); + match result { + Ok(doc) => { + println!("Successfully parsed attestation document!"); + println!("Module ID: {}", doc.module_id); + println!("Digest: {}", doc.digest); + println!("Timestamp: {}", doc.timestamp); + println!("PCR count: {}", doc.pcrs.len()); + + // Verify expected values + assert!(!doc.module_id.is_empty(), "Module ID should not be empty"); + assert_eq!(doc.digest, "SHA384", "Digest should be SHA384"); + assert!(doc.pcrs.contains_key(&0), "Should have PCR0"); + assert!(doc.pcrs.contains_key(&1), "Should have PCR1"); + assert!(doc.pcrs.contains_key(&2), "Should have PCR2"); + + // Print all PCR values + for idx in 0..16u16 { + if let Some(value) = doc.pcrs.get(&idx) { + let is_zero = value.iter().all(|&b| b == 0); + if is_zero { + println!("PCR{}: ALL ZEROS (len={})", idx, value.len()); + } else { + println!("PCR{}: {:02x?} (len={})", idx, value, value.len()); + } + } + } + } + Err(e) => { + panic!("Failed to parse attestation document: {}", e); + } + } +} + +#[test] +fn test_attestation_document_structure() { + // Verify the COSE Sign1 structure is present + let data = ATTESTATION_BIN; + + // COSE Sign1 is a CBOR array with 4 elements + // The marker 0x84 indicates a 4-element array + let has_cose_marker = data.windows(2).any(|w| w[0] == 0x84 && w[1] == 0x44); + assert!( + has_cose_marker, + "Should contain COSE Sign1 marker (0x84 0x44)" + ); + + // Verify module_id string is present + let module_id_marker = b"module_id"; + let has_module_id = data + .windows(module_id_marker.len()) + .any(|w| w == module_id_marker); + assert!(has_module_id, "Should contain module_id field"); +} diff --git a/nsm-attest/tests/nitro_attestation.bin b/nsm-attest/tests/nitro_attestation.bin new file mode 100644 index 000000000..676305f1b Binary files /dev/null and b/nsm-attest/tests/nitro_attestation.bin differ diff --git a/nsm-qvl/Cargo.toml b/nsm-qvl/Cargo.toml new file mode 100644 index 000000000..27dd8cec0 --- /dev/null +++ b/nsm-qvl/Cargo.toml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: © 2025 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "nsm-qvl" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "AWS Nitro Enclave NSM Quote Verification Library" + +[dependencies] +anyhow.workspace = true +hex.workspace = true +serde = { workspace = true, features = ["derive"] } +tracing.workspace = true + +# CBOR/COSE parsing +ciborium.workspace = true + +# Cryptographic verification +p384 = { workspace = true, features = ["ecdsa"] } +sha2 = { workspace = true, features = ["oid"] } +pem.workspace = true + +# Certificate chain verification +x509-parser.workspace = true +rustls-pki-types.workspace = true +dcap-qvl-webpki = { workspace = true, features = ["alloc", "rustcrypto"] } + +# CRL download +reqwest = { workspace = true, features = ["rustls-tls"] } + +[dev-dependencies] +nsm-attest.workspace = true +tokio = { workspace = true, features = ["full"] } +tracing-subscriber.workspace = true diff --git a/nsm-qvl/certs/AWS_NitroEnclaves_Root-G1.pem b/nsm-qvl/certs/AWS_NitroEnclaves_Root-G1.pem new file mode 100644 index 000000000..221cc0b1d --- /dev/null +++ b/nsm-qvl/certs/AWS_NitroEnclaves_Root-G1.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICETCCAZagAwIBAgIRAPkxdWgbkK/hHUbMtOTn+FYwCgYIKoZIzj0EAwMwSTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMRswGQYD +VQQDDBJhd3Mubml0cm8tZW5jbGF2ZXMwHhcNMTkxMDI4MTMyODA1WhcNNDkxMDI4 +MTQyODA1WjBJMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQL +DANBV1MxGzAZBgNVBAMMEmF3cy5uaXRyby1lbmNsYXZlczB2MBAGByqGSM49AgEG +BSuBBAAiA2IABPwCVOumCMHzaHDimtqQvkY4MpJzbolL//Zy2YlES1BR5TSksfbb +48C8WBoyt7F2Bw7eEtaaP+ohG2bnUs990d0JX28TcPQXCEPZ3BABIeTPYwEoCWZE +h8l5YoQwTcU/9KNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUkCW1DdkF +R+eWw5b6cp3PmanfS5YwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYC +MQCjfy+Rocm9Xue4YnwWmNJVA44fA0P5W2OpYow9OYCVRaEevL8uO1XYru5xtMPW +rfMCMQCi85sWBbJwKKXdS6BptQFuZbT73o/gBh1qUxl/nNr12UO8Yfwr6wPLb+6N +IwLz3/Y= +-----END CERTIFICATE----- diff --git a/nsm-qvl/src/collateral.rs b/nsm-qvl/src/collateral.rs new file mode 100644 index 000000000..a12d73563 --- /dev/null +++ b/nsm-qvl/src/collateral.rs @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Collateral retrieval module +//! +//! Extracts CRL distribution points from the device-provided cert chain and +//! downloads CRLs for revocation checking, similar to dcap-qvl/tpm-qvl. + +use anyhow::{bail, Context, Result}; +use tracing::{debug, warn}; +use x509_parser::{extensions::DistributionPointName, prelude::*}; + +use crate::{ + verify::verify_attestation_with_collateral, AttestationDocument, CoseSign1, NsmCollateral, +}; + +pub async fn get_collateral_and_verify( + cose_sign1_bytes: &[u8], + root_ca_pem: &str, + now: Option, +) -> Result { + let collateral = get_collateral(cose_sign1_bytes, root_ca_pem).await?; + verify_attestation_with_collateral(cose_sign1_bytes, root_ca_pem, &collateral, now) +} + +pub async fn get_collateral(cose_sign1_bytes: &[u8], root_ca_pem: &str) -> Result { + debug!("fetching NSM collateral (intermediate CRLs + root CA CRL)"); + + let cose = CoseSign1::from_bytes(cose_sign1_bytes).context("failed to parse COSE Sign1")?; + let doc = + AttestationDocument::from_cbor(&cose.payload).context("failed to parse attestation doc")?; + + let certs = build_chain_from_doc(&doc); + let crls = download_crls_for_certs(&certs).await?; + + let root_ca_crl = { + let root_ca_der = + extract_certs_webpki(root_ca_pem.as_bytes()).context("failed to parse root CA PEM")?; + if root_ca_der.len() != 1 { + bail!("expected 1 root CA, found {}", root_ca_der.len()); + } + download_crl_for_cert(&root_ca_der[0]).await? + }; + + debug!( + "✓ collateral fetched: {} CRL(s), root CA CRL: {}", + crls.len(), + if root_ca_crl.is_some() { "yes" } else { "no" } + ); + + Ok(NsmCollateral { crls, root_ca_crl }) +} + +fn build_chain_from_doc(doc: &AttestationDocument) -> Vec> { + let mut chain = Vec::new(); + chain.push(doc.certificate.clone()); + chain.extend(doc.cabundle.iter().skip(1).cloned()); + chain +} + +async fn download_crls_for_certs(certs: &[Vec]) -> Result>> { + debug!("downloading CRLs from device-provided cert chain..."); + + let mut crls = Vec::new(); + + for cert_der in certs { + let Some(crl) = download_crl_for_cert(cert_der) + .await + .context("failed to download CRL")? + else { + continue; + }; + crls.push(crl); + } + Ok(crls) +} + +async fn download_crl_for_cert(cert: &[u8]) -> Result>> { + let crl_urls = extract_crl_urls(cert)?; + if crl_urls.is_empty() { + debug!("no CRL Distribution Points found in certificate"); + return Ok(None); + } + + download_first_available_crl(&crl_urls).await.map(Some) +} + +async fn download_first_available_crl(urls: &[String]) -> Result> { + for url in urls { + debug!("downloading CRL from {url}"); + match download_crl(url).await { + Ok(crl) => return Ok(crl), + Err(e) => { + warn!("✗ failed to download CRL from {url}: {e:?}"); + continue; + } + } + } + bail!("failed to download CRL") +} + +fn extract_certs_webpki(cert_pem: &[u8]) -> Result>> { + let pem_items = ::pem::parse_many(cert_pem).context("failed to parse PEM")?; + let certs = pem_items + .into_iter() + .map(|pem| rustls_pki_types::CertificateDer::from(pem.into_contents())) + .collect(); + Ok(certs) +} + +async fn download_crl(url: &str) -> Result> { + debug!("downloading CRL from {url}"); + + let response = reqwest::get(url) + .await + .context(format!("failed to download CRL from {url}"))?; + + if !response.status().is_success() { + bail!("CRL download failed with status: {}", response.status()); + } + + let crl_bytes = response + .bytes() + .await + .context("failed to read CRL response body")? + .to_vec(); + + debug!("downloaded {} bytes CRL from {}", crl_bytes.len(), url); + + Ok(crl_bytes) +} + +fn extract_crl_urls(cert_der: &[u8]) -> Result> { + let (_, cert) = X509Certificate::from_der(cert_der).context("failed to parse certificate")?; + let mut crl_urls = Vec::new(); + + for ext in cert.extensions() { + let ParsedExtension::CRLDistributionPoints(crl_dist_points) = ext.parsed_extension() else { + continue; + }; + for dist_point in crl_dist_points.points.iter() { + let Some(dist_point_name) = &dist_point.distribution_point else { + continue; + }; + + let DistributionPointName::FullName(names) = dist_point_name else { + continue; + }; + for name in names.iter() { + let x509_parser::extensions::GeneralName::URI(uri) = name else { + continue; + }; + crl_urls.push(uri.to_string()); + debug!("found CRL URL: {uri}"); + } + } + } + + Ok(crl_urls) +} diff --git a/nsm-qvl/src/lib.rs b/nsm-qvl/src/lib.rs new file mode 100644 index 000000000..fd6290460 --- /dev/null +++ b/nsm-qvl/src/lib.rs @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! AWS Nitro Enclave NSM Quote Verification Library (QVL) +//! +//! This module provides quote verification for AWS Nitro Enclave attestation documents. +//! It verifies: +//! - COSE Sign1 signature using ECDSA P-384 with SHA-384 +//! - Certificate chain from the attestation document to the AWS Nitro root CA +//! +//! # Architecture +//! The verification follows AWS Nitro Enclave attestation document specification: +//! 1. Decode CBOR/COSE Sign1 structure +//! 2. Extract attestation document payload +//! 3. Verify certificate chain (cabundle + certificate against root CA) +//! 4. Verify COSE signature using the certificate's public key +//! +//! # References +//! - https://docs.aws.amazon.com/enclaves/latest/user/verify-root.html +//! - https://github.com/aws/aws-nitro-enclaves-nsm-api/blob/main/docs/attestation_process.md + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::{collections::BTreeMap, io::Cursor}; + +pub use collateral::{get_collateral, get_collateral_and_verify}; + +mod verify; + +pub use verify::{ + verify_attestation, verify_attestation_with_ca, verify_attestation_with_collateral, + verify_attestation_with_crl, NsmVerifiedReport, +}; + +#[derive(Debug, Clone)] +pub struct NsmCollateral { + /// All CRLs extracted from device-provided cert chain + pub crls: Vec>, + /// Root CA CRL extracted from verifier-provided root CA + pub root_ca_crl: Option>, +} + +/// AWS Nitro Enclaves Root CA certificate (G1) +/// +/// Subject: CN=aws.nitro-enclaves, C=US, O=Amazon, OU=AWS +/// Valid: 2019-10-28 to 2049-10-28 (30 years) +/// Fingerprint: 64:1A:03:21:A3:E2:44:EF:E4:56:46:31:95:D6:06:31:7E:D7:CD:CC:3C:17:56:E0:98:93:F3:C6:8F:79:BB:5B +pub const AWS_NITRO_ENCLAVES_ROOT_G1: &str = include_str!("../certs/AWS_NitroEnclaves_Root-G1.pem"); + +/// Parsed COSE Sign1 structure for NSM attestation +#[derive(Debug)] +pub struct CoseSign1 { + /// Protected header (contains algorithm) + pub protected: Vec, + /// Unprotected header (usually empty for NSM) + pub unprotected: BTreeMap, + /// Payload (CBOR-encoded attestation document) + pub payload: Vec, + /// Signature (ECDSA P-384) + pub signature: Vec, +} + +impl CoseSign1 { + /// Parse COSE Sign1 from raw bytes + pub fn from_bytes(data: &[u8]) -> Result { + // COSE Sign1 structure is a CBOR array: [protected, unprotected, payload, signature] + let mut reader = Cursor::new(data); + let value: ciborium::Value = + ciborium::from_reader(&mut reader).context("Failed to parse COSE Sign1 CBOR")?; + if reader.position() != data.len() as u64 { + bail!("Trailing bytes after COSE Sign1"); + } + + let array = match value { + ciborium::Value::Array(arr) => arr, + ciborium::Value::Tag(18, inner) => { + // COSE_Sign1 tag is 18 + match *inner { + ciborium::Value::Array(arr) => arr, + _ => bail!("COSE Sign1 tag content is not an array"), + } + } + _ => bail!("COSE Sign1 is not an array"), + }; + + if array.len() != 4 { + bail!("COSE Sign1 array must have 4 elements, got {}", array.len()); + } + + let protected = match &array[0] { + ciborium::Value::Bytes(b) => b.clone(), + _ => bail!("COSE Sign1 protected header is not bytes"), + }; + + let unprotected = match &array[1] { + ciborium::Value::Map(m) => { + let mut map = BTreeMap::new(); + for (k, v) in m { + if let ciborium::Value::Integer(i) = k { + let key: i128 = (*i).into(); + map.insert(key as i64, v.clone()); + } + } + map + } + _ => BTreeMap::new(), + }; + + let payload = match &array[2] { + ciborium::Value::Bytes(b) => b.clone(), + _ => bail!("COSE Sign1 payload is not bytes"), + }; + + let signature = match &array[3] { + ciborium::Value::Bytes(b) => b.clone(), + _ => bail!("COSE Sign1 signature is not bytes"), + }; + + Ok(Self { + protected, + unprotected, + payload, + signature, + }) + } + + /// Get the algorithm from protected header + pub fn algorithm(&self) -> Result { + let protected_map = self.protected_map()?; + + // Algorithm is key 1 in COSE + let alg = protected_map + .get(&1) + .context("No algorithm in protected header")?; + + match alg { + ciborium::Value::Integer(i) => { + let val: i128 = (*i).into(); + Ok(val as i64) + } + _ => bail!("Algorithm is not an integer"), + } + } + + /// Validate critical headers (crit) per COSE rules. + pub fn validate_critical_headers(&self) -> Result<()> { + let protected_map = self.protected_map()?; + let Some(crit) = protected_map.get(&2) else { + return Ok(()); + }; + + let crit_list = match crit { + ciborium::Value::Array(arr) => arr, + _ => bail!("COSE crit header is not an array"), + }; + + for item in crit_list { + match item { + ciborium::Value::Integer(i) => { + let val: i128 = (*i).into(); + if val as i64 != 1 { + bail!("Unsupported critical header parameter: {val}"); + } + } + ciborium::Value::Text(name) => { + if name != "alg" { + bail!("Unsupported critical header parameter: {name}"); + } + } + _ => bail!("Invalid critical header parameter type"), + } + } + + Ok(()) + } + + /// Build the Sig_structure for verification + /// Sig_structure = ["Signature1", protected, external_aad, payload] + pub fn sig_structure(&self) -> Result> { + let sig_structure = ciborium::Value::Array(vec![ + ciborium::Value::Text("Signature1".to_string()), + ciborium::Value::Bytes(self.protected.clone()), + ciborium::Value::Bytes(vec![]), // external_aad is empty + ciborium::Value::Bytes(self.payload.clone()), + ]); + + let mut buf = Vec::new(); + ciborium::into_writer(&sig_structure, &mut buf) + .context("Failed to encode Sig_structure")?; + Ok(buf) + } + + fn protected_map(&self) -> Result> { + let mut reader = Cursor::new(&self.protected); + let map = ciborium::from_reader(&mut reader).context("Failed to parse protected header")?; + if reader.position() != self.protected.len() as u64 { + bail!("Trailing bytes after protected header"); + } + Ok(map) + } +} + +/// Attestation document structure (parsed from COSE payload) +#[derive(Debug, Clone, Deserialize)] +pub struct AttestationDocument { + /// Module ID + pub module_id: String, + /// Digest algorithm used + pub digest: String, + /// Timestamp (milliseconds since epoch) + pub timestamp: u64, + /// PCR values + pub pcrs: BTreeMap>, + /// Certificate (DER-encoded) - the signing certificate + pub certificate: Vec, + /// CA bundle (list of DER-encoded certificates) + /// Order: [ROOT_CERT, INTERM_1, INTERM_2, ..., INTERM_N] + pub cabundle: Vec>, + /// Optional public key + #[serde(default)] + pub public_key: Option>, + /// Optional user data + #[serde(default)] + pub user_data: Option>, + /// Optional nonce + #[serde(default)] + pub nonce: Option>, +} + +impl AttestationDocument { + /// Parse attestation document from CBOR payload + pub fn from_cbor(data: &[u8]) -> Result { + let mut reader = Cursor::new(data); + let doc = ciborium::from_reader(&mut reader) + .context("Failed to parse attestation document CBOR")?; + if reader.position() != data.len() as u64 { + bail!("Trailing bytes after attestation document CBOR"); + } + Ok(doc) + } +} + +pub mod collateral; diff --git a/nsm-qvl/src/verify.rs b/nsm-qvl/src/verify.rs new file mode 100644 index 000000000..03c2198bd --- /dev/null +++ b/nsm-qvl/src/verify.rs @@ -0,0 +1,358 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! NSM Attestation Verification Module + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Maximum age of an attestation document relative to `now`. NSM certificates +/// are short-lived, but this additionally bounds replay of an old-but-still-in- +/// cert-validity document. +const MAX_ATTESTATION_AGE: Duration = Duration::from_secs(3600); +/// Tolerated clock skew when rejecting future-dated documents. +const CLOCK_SKEW: Duration = Duration::from_secs(300); + +use anyhow::{bail, Context, Result}; +use p384::ecdsa::{signature::hazmat::PrehashVerifier, Signature, VerifyingKey}; +use rustls_pki_types::{CertificateDer, UnixTime}; +use sha2::{Digest, Sha384}; +use tracing::debug; +use webpki::{BorrowedCertRevocationList, CertRevocationList, EndEntityCert}; +use x509_parser::prelude::*; + +use crate::{AttestationDocument, CoseSign1, NsmCollateral}; + +const DIGEST_SHA384: &str = "SHA384"; +const PCR_SHA384_LEN: usize = 48; + +/// Verified NSM attestation report +#[derive(Debug, Clone)] +pub struct NsmVerifiedReport { + /// Module ID + pub module_id: String, + /// Digest algorithm + pub digest: String, + /// Timestamp (milliseconds since epoch) + pub timestamp: u64, + /// PCR values + pub pcrs: std::collections::BTreeMap>, + /// User data from attestation + pub user_data: Option>, + /// Nonce from attestation + pub nonce: Option>, + /// Public key from attestation + pub public_key: Option>, +} + +/// Verify Nitro attestation with custom root CA (for testing) +pub fn verify_attestation_with_ca( + cose_sign1_bytes: &[u8], + root_ca_pem: &str, + collateral: Option<&NsmCollateral>, +) -> Result { + verify_attestation(cose_sign1_bytes, root_ca_pem, collateral, None) +} + +/// Verify Nitro attestation with custom root CA and custom time (for testing) +/// +/// This enforces digest/PCR consistency, certificate-chain validity at `now`, +/// and a freshness window (`MAX_ATTESTATION_AGE`) on the document timestamp. +/// Callers must still bind the attestation to a challenge via `nonce`/`user_data` +/// for full replay protection. +pub fn verify_attestation( + cose_sign1_bytes: &[u8], + root_ca_pem: &str, + collateral: Option<&NsmCollateral>, + now: Option, +) -> Result { + let now = now.unwrap_or_else(SystemTime::now); + let cose = CoseSign1::from_bytes(cose_sign1_bytes).context("Failed to parse COSE Sign1")?; + cose.validate_critical_headers() + .context("Unsupported COSE critical headers")?; + let alg = cose.algorithm().context("Failed to get algorithm")?; + if alg != -35 { + bail!("Unsupported COSE algorithm: {alg}. Expected -35 (ES384)"); + } + let doc = AttestationDocument::from_cbor(&cose.payload) + .context("Failed to parse attestation document")?; + validate_attestation_document(&doc).context("Attestation document validation failed")?; + + // Freshness: NSM stamps the document (ms since epoch) at generation time. + let doc_time = UNIX_EPOCH + Duration::from_millis(doc.timestamp); + match now.duration_since(doc_time) { + Ok(age) if age > MAX_ATTESTATION_AGE => { + bail!("attestation document is stale: {age:?} old (max {MAX_ATTESTATION_AGE:?})"); + } + Err(future) if future.duration() > CLOCK_SKEW => { + bail!( + "attestation document timestamp is in the future by {:?}", + future.duration() + ); + } + _ => {} + } + + verify_certificate_chain(&doc, root_ca_pem, collateral, Some(now)) + .context("Certificate chain verification failed")?; + verify_cose_signature(&cose, &doc.certificate).context("COSE signature verification failed")?; + + Ok(NsmVerifiedReport { + module_id: doc.module_id, + digest: doc.digest, + timestamp: doc.timestamp, + pcrs: doc.pcrs, + user_data: doc.user_data, + nonce: doc.nonce, + public_key: doc.public_key, + }) +} + +pub fn verify_attestation_with_collateral( + cose_sign1_bytes: &[u8], + root_ca_pem: &str, + collateral: &NsmCollateral, + now: Option, +) -> Result { + verify_attestation(cose_sign1_bytes, root_ca_pem, Some(collateral), now) +} + +pub async fn verify_attestation_with_crl( + cose_sign1_bytes: &[u8], + root_ca_pem: &str, + enable_crl: bool, + now: Option, +) -> Result { + if enable_crl { + let collateral = crate::get_collateral(cose_sign1_bytes, root_ca_pem).await?; + verify_attestation(cose_sign1_bytes, root_ca_pem, Some(&collateral), now) + } else { + verify_attestation(cose_sign1_bytes, root_ca_pem, None, now) + } +} + +/// Verify the certificate chain from attestation document +fn verify_certificate_chain( + doc: &AttestationDocument, + root_ca_pem: &str, + collateral: Option<&NsmCollateral>, + now_override: Option, +) -> Result<()> { + // Parse root CA from PEM + let root_ca_der = parse_pem_cert(root_ca_pem).context("Failed to parse root CA PEM")?; + + // The cabundle order is: [ROOT_CERT, INTERM_1, INTERM_2, ..., INTERM_N] + // We need to verify: TARGET_CERT <- INTERM_N <- ... <- INTERM_1 <- ROOT_CERT + // But we use the verifier-provided root CA, not the one from cabundle + + // Build intermediate chain from cabundle (excluding root at index 0) + let intermediates: Vec> = doc + .cabundle + .iter() + .skip(1) // Skip the root cert from cabundle, use verifier-provided root + .map(|der| CertificateDer::from(der.clone())) + .collect(); + + debug!( + "Certificate chain: 1 leaf + {} intermediates + 1 root", + intermediates.len() + ); + + // Parse the leaf certificate (signing certificate) + let leaf_cert_der = CertificateDer::from(doc.certificate.clone()); + let leaf_cert = + EndEntityCert::try_from(&leaf_cert_der).context("Failed to parse leaf certificate")?; + + // Create trust anchor from root CA + let root_cert_der = CertificateDer::from(root_ca_der); + let trust_anchor = webpki::anchor_from_trusted_cert(&root_cert_der) + .context("Failed to create trust anchor from root CA")?; + + // Get current time + let now = now_override.unwrap_or(SystemTime::now()); + let now = now + .duration_since(std::time::UNIX_EPOCH) + .context("Failed to get current time")?; + let time = UnixTime::since_unix_epoch(now); + + let chain_has_crl_dp = has_crl_distribution_points(&doc.certificate)? + || doc + .cabundle + .iter() + .skip(1) // Skip the root cert from cabundle + .any(|cert| has_crl_distribution_points(cert).unwrap_or(false)); + + let root_has_crl_dp = has_crl_distribution_points(root_cert_der.as_ref()).unwrap_or(false); + + let trust_anchors = [trust_anchor]; + + let Some(collateral) = collateral else { + leaf_cert + .verify_for_usage( + webpki::ALL_VERIFICATION_ALGS, + &trust_anchors, + &intermediates, + time, + webpki::KeyUsage::client_auth(), + None, + None, + ) + .context("Certificate chain verification failed")?; + return Ok(()); + }; + if root_has_crl_dp && collateral.root_ca_crl.is_none() { + bail!("Root CA has CRL distribution points but no root CA CRL provided"); + } + if chain_has_crl_dp && collateral.crls.is_empty() { + bail!("CRL distribution points present but no CRLs downloaded"); + } + + if let Some(root_ca_crl) = &collateral.root_ca_crl { + let crl_refs = vec![root_ca_crl.as_slice()]; + webpki::check_single_cert_crl(root_cert_der.as_ref(), &crl_refs, time) + .context("root CA revoked or invalid CRL")?; + } + + let crls: Vec = collateral + .crls + .iter() + .enumerate() + .map(|(i, der)| { + BorrowedCertRevocationList::from_der(der) + .map(|crl| crl.into()) + .with_context(|| format!("failed to parse intermediate CRL #{i}")) + }) + .collect::>>()?; + let crl_refs: Vec<&CertRevocationList> = crls.iter().collect(); + + let revocation_builder = webpki::RevocationOptionsBuilder::new(&crl_refs) + .map_err(|_| anyhow::anyhow!("failed to create RevocationOptionsBuilder"))?; + + let revocation = revocation_builder + .with_depth(webpki::RevocationCheckDepth::Chain) + .with_status_policy(webpki::UnknownStatusPolicy::Allow) + .with_expiration_policy(webpki::ExpirationPolicy::Enforce) + .build(); + + leaf_cert + .verify_for_usage( + webpki::ALL_VERIFICATION_ALGS, + &trust_anchors, + &intermediates, + time, + webpki::KeyUsage::client_auth(), + Some(revocation), + None, + ) + .context("Certificate chain verification failed")?; + + Ok(()) +} + +fn validate_attestation_document(doc: &AttestationDocument) -> Result<()> { + if doc.digest != DIGEST_SHA384 { + bail!("Unsupported digest algorithm: {}", doc.digest); + } + + if doc.pcrs.is_empty() { + bail!("No PCRs in attestation document"); + } + + for (idx, value) in &doc.pcrs { + if value.len() != PCR_SHA384_LEN { + bail!( + "PCR{idx} length mismatch: {} (expected {PCR_SHA384_LEN})", + value.len() + ); + } + } + + Ok(()) +} + +/// Verify COSE signature using the certificate's public key +fn verify_cose_signature(cose: &CoseSign1, cert_der: &[u8]) -> Result<()> { + // Extract public key from certificate + let (_, cert) = + X509Certificate::from_der(cert_der).context("Failed to parse signing certificate")?; + + let spki = cert.public_key(); + let public_key_bytes = spki.subject_public_key.data.as_ref(); + + // Parse as P-384 public key + let verifying_key = VerifyingKey::from_sec1_bytes(public_key_bytes) + .context("Failed to parse P-384 public key from certificate")?; + + // Build Sig_structure for verification + let sig_structure = cose + .sig_structure() + .context("Failed to build Sig_structure")?; + + // Hash the Sig_structure with SHA-384 + let mut hasher = Sha384::new(); + hasher.update(&sig_structure); + let message_hash = hasher.finalize(); + + // Parse signature (P-384 signature is 96 bytes: 48 bytes r + 48 bytes s) + if cose.signature.len() != 96 { + bail!( + "Invalid P-384 signature length: {} (expected 96)", + cose.signature.len() + ); + } + + let signature = + Signature::from_slice(&cose.signature).context("Failed to parse ECDSA signature")?; + + // Verify signature + verifying_key + .verify_prehash(&message_hash, &signature) + .context("ECDSA signature verification failed")?; + + Ok(()) +} + +/// Parse a PEM certificate to DER +fn parse_pem_cert(pem_str: &str) -> Result> { + let pem_block = ::pem::parse(pem_str).context("Failed to parse PEM")?; + if pem_block.tag() != "CERTIFICATE" { + bail!("PEM is not a certificate: {}", pem_block.tag()); + } + Ok(pem_block.into_contents()) +} + +fn has_crl_distribution_points(cert_der: &[u8]) -> Result { + let (_, cert) = X509Certificate::from_der(cert_der).context("failed to parse certificate")?; + for ext in cert.extensions() { + if let ParsedExtension::CRLDistributionPoints(_) = ext.parsed_extension() { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AWS_NITRO_ENCLAVES_ROOT_G1; + + #[test] + fn test_root_ca_parsing() { + let der = parse_pem_cert(AWS_NITRO_ENCLAVES_ROOT_G1).expect("Failed to parse root CA"); + let (_, cert) = X509Certificate::from_der(&der).expect("Failed to parse X509"); + + // Verify it's the AWS Nitro Enclaves root CA + let subject = cert.subject().to_string(); + assert!( + subject.contains("aws.nitro-enclaves"), + "Subject should contain aws.nitro-enclaves: {}", + subject + ); + assert!( + subject.contains("Amazon"), + "Subject should contain Amazon: {}", + subject + ); + assert!(cert.is_ca()); + } +} diff --git a/nsm-qvl/tests/nitro_attestation.bin b/nsm-qvl/tests/nitro_attestation.bin new file mode 100644 index 000000000..676305f1b Binary files /dev/null and b/nsm-qvl/tests/nitro_attestation.bin differ diff --git a/nsm-qvl/tests/verify_test.rs b/nsm-qvl/tests/verify_test.rs new file mode 100644 index 000000000..cd9e766f1 --- /dev/null +++ b/nsm-qvl/tests/verify_test.rs @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2024-2025 The Project Contributors +// SPDX-License-Identifier: Apache-2.0 +// Test for NSM attestation verification +use nsm_qvl::{AttestationDocument, CoseSign1}; +use std::io::Cursor; + +// Real attestation captured from Nitro Enclave +const ATTESTATION_BIN: &[u8] = include_bytes!("nitro_attestation.bin"); + +fn extract_cose_sign1(data: &[u8]) -> Vec { + // Find COSE Sign1 structure (starts with 0x84 for 4-element array) + for i in 0..data.len().saturating_sub(2) { + if data[i] == 0x84 && data[i + 1] == 0x44 { + let mut reader = Cursor::new(&data[i..]); + let _: ciborium::Value = + ciborium::from_reader(&mut reader).expect("Failed to parse COSE Sign1 CBOR"); + let len = reader.position() as usize; + return data[i..i + len].to_vec(); + } + } + panic!("Could not find COSE Sign1 marker in attestation data"); +} + +#[test] +fn test_parse_cose_sign1() { + let cose_data = extract_cose_sign1(ATTESTATION_BIN); + + let cose = CoseSign1::from_bytes(&cose_data).expect("Failed to parse COSE Sign1"); + + // Verify algorithm is ES384 (-35) + let alg = cose.algorithm().expect("Failed to get algorithm"); + assert_eq!(alg, -35, "Algorithm should be ES384 (-35)"); + + // Verify signature is 96 bytes (P-384) + assert_eq!( + cose.signature.len(), + 96, + "P-384 signature should be 96 bytes" + ); + + println!("COSE Sign1 parsed successfully"); + println!(" Protected header: {} bytes", cose.protected.len()); + println!(" Payload: {} bytes", cose.payload.len()); + println!(" Signature: {} bytes", cose.signature.len()); +} + +#[test] +fn test_parse_attestation_document() { + let cose_data = extract_cose_sign1(ATTESTATION_BIN); + let cose = CoseSign1::from_bytes(&cose_data).expect("Failed to parse COSE Sign1"); + + let doc = AttestationDocument::from_cbor(&cose.payload) + .expect("Failed to parse attestation document"); + + println!("Attestation document parsed:"); + println!(" Module ID: {}", doc.module_id); + println!(" Digest: {}", doc.digest); + println!(" Timestamp: {}", doc.timestamp); + println!(" Certificate: {} bytes", doc.certificate.len()); + println!(" CA bundle: {} certificates", doc.cabundle.len()); + println!(" PCRs: {} entries", doc.pcrs.len()); + + assert!(!doc.module_id.is_empty()); + assert_eq!(doc.digest, "SHA384"); + assert!(!doc.certificate.is_empty()); + assert!(!doc.cabundle.is_empty()); +} + +#[tokio::test] +async fn test_verify_attestation_full() { + tracing_subscriber::fmt::try_init().ok(); + + let cose_data = extract_cose_sign1(ATTESTATION_BIN); + let cose = CoseSign1::from_bytes(&cose_data).expect("Failed to parse COSE Sign1"); + let doc = AttestationDocument::from_cbor(&cose.payload) + .expect("Failed to parse attestation document"); + let attestation_time = std::time::UNIX_EPOCH + std::time::Duration::from_millis(doc.timestamp); + + let report = nsm_qvl::verify_attestation_with_crl( + &cose_data, + nsm_qvl::AWS_NITRO_ENCLAVES_ROOT_G1, + std::env::var("TEST_FETCH_CRL").is_ok(), + Some(attestation_time), + ) + .await + .unwrap(); + println!("✓ Attestation verified successfully!"); + println!(" Module ID: {}", report.module_id); + println!(" Digest: {}", report.digest); + println!(" Timestamp: {}", report.timestamp); + println!(" PCRs: {} entries", report.pcrs.len()); + + // Print non-zero PCR values + for (idx, value) in &report.pcrs { + if !value.iter().all(|&b| b == 0) { + println!(" PCR{idx}: {value:02x?}"); + } + } +} diff --git a/prek.toml b/prek.toml index 1e24d457a..5015768ba 100644 --- a/prek.toml +++ b/prek.toml @@ -35,12 +35,14 @@ types = ["rust"] pass_filenames = false # --- Python: ruff (lint + format) --- +# scripts/bin/dstack-cloud is vendored from the dstack-cloud tree and follows its +# own style; exclude it so we don't reformat/relint a large third-party script. [[repos]] repo = "https://github.com/astral-sh/ruff-pre-commit" rev = "v0.11.4" hooks = [ - { id = "ruff", args = ["--fix", "--select", "E,F,I,D", "--ignore", "D203,D213,E501"] }, - { id = "ruff-format" }, + { id = "ruff", args = ["--fix", "--select", "E,F,I,D", "--ignore", "D203,D213,E501"], exclude = "^scripts/bin/dstack-cloud$" }, + { id = "ruff-format", exclude = "^scripts/bin/dstack-cloud$" }, ] # --- Go: go vet --- diff --git a/ra-tls/Cargo.toml b/ra-tls/Cargo.toml index ab1ca9b20..bc613acb7 100644 --- a/ra-tls/Cargo.toml +++ b/ra-tls/Cargo.toml @@ -28,6 +28,7 @@ x509-parser.workspace = true yasna.workspace = true tracing.workspace = true sha3.workspace = true +tdx-attest.workspace = true scale.workspace = true cc-eventlog.workspace = true @@ -35,7 +36,9 @@ serde-human-bytes.workspace = true flate2.workspace = true or-panic.workspace = true rand.workspace = true +tpm-types.workspace = true dstack-types.workspace = true +tpm-qvl.workspace = true hex_fmt.workspace = true ez-hash.workspace = true dstack-attest.workspace = true diff --git a/rocket-vsock-listener/src/lib.rs b/rocket-vsock-listener/src/lib.rs index fb7014985..4a0a8164c 100644 --- a/rocket-vsock-listener/src/lib.rs +++ b/rocket-vsock-listener/src/lib.rs @@ -258,12 +258,11 @@ mod tests { } #[tokio::test] + #[ignore = "requires vsock support (not available in CI)"] async fn test_vsock_listener_bind() { let endpoint = VsockEndpoint { cid: 1, port: 5000 }; let result = VsockListener::bind(&endpoint); - // Note: This test might fail if you don't have vsock permissions - // or if the port is already in use assert!(result.is_ok()); } diff --git a/scripts/bin/dstack-cloud b/scripts/bin/dstack-cloud new file mode 100755 index 000000000..dd1763465 --- /dev/null +++ b/scripts/bin/dstack-cloud @@ -0,0 +1,2863 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: © 2025 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +""" +dstack-cloud: Multi-cloud VM lifecycle management tool + +A production-grade CLI for managing dstack VMs on various cloud platforms. +Supports local configuration files similar to git's working model. + +Usage: + dstack-cloud new # Create a new project + dstack-cloud config-edit # Edit global configuration + dstack-cloud prepare # Generate shared files + dstack-cloud deploy # Deploy VM to cloud + dstack-cloud status # Check deployment status + dstack-cloud logs [--follow] # View serial console logs + dstack-cloud stop # Stop the VM + dstack-cloud start # Start a stopped VM + dstack-cloud remove # Remove the VM and cleanup + dstack-cloud list # List all deployments + dstack-cloud fw allow # Allow traffic on a port + dstack-cloud fw deny # Block traffic on a port + dstack-cloud fw remove # Remove a firewall rule + dstack-cloud fw list # List firewall rules +""" + +import argparse +import hashlib +import json +import logging +import os +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass, field, asdict +from datetime import datetime +from pathlib import Path +from typing import Optional, List, Dict, Any + +# Try to import cryptography libraries for env encryption +CRYPTO_AVAILABLE = False +ETH_CRYPTO_AVAILABLE = False +try: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.asymmetric import x25519 + from cryptography.hazmat.primitives import serialization + CRYPTO_AVAILABLE = True +except Exception: + pass + +try: + from eth_keys import keys + from eth_utils import keccak + ETH_CRYPTO_AVAILABLE = True +except Exception: + pass + +# Default whitelist file location +DEFAULT_KMS_WHITELIST_PATH = os.path.expanduser("~/.config/dstack-cloud/kms-whitelist.json") + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Configuration file names +APP_CONFIG_FILE = "app.json" +STATE_FILE = "state.json" +GLOBAL_CONFIG_PATH = os.path.expanduser("~/.config/dstack-cloud/config.json") +DEFAULT_OS_IMAGE = "dstack-cloud-0.6.0" + + +@dataclass +class App: + """Application configuration.""" + # App name + name: str = "myapp" + + # OS image + os_image: str = DEFAULT_OS_IMAGE + + # GCP cloud configuration + gcp_config: 'GcpConfig' = field(default_factory=lambda: GcpConfig()) + + # Docker compose file name (relative to project root) + docker_compose_file: str = "docker-compose.yaml" + + # Prelaunch script name (relative to project root) + prelaunch_script: str = "prelaunch.sh" + + # Environment file name (relative to project root) + env_file: str = ".env" + + # Instance identity + instance_id_seed: str = "" + app_id: str = "" + + # Gateway settings + gateway_enabled: bool = True + public_logs: bool = True + public_sysinfo: bool = True + public_tcbinfo: bool = True + + # KMS settings + key_provider: str = "kms" + + # Storage + storage_fs: str = "ext4" + + # Instance settings + no_instance_id: bool = False + secure_time: bool = False + + # Allowed environments + allowed_envs: List[str] = field(default_factory=list) + key_provider_id: str = "" + + def to_dict(self) -> Dict[str, Any]: + data = asdict(self) + # Convert GcpConfig to dict + if isinstance(data.get("gcp_config"), GcpConfig): + data["gcp_config"] = data["gcp_config"].to_dict() + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'App': + known_fields = {f.name for f in cls.__dataclass_fields__.values()} + filtered = {k: v for k, v in data.items() if k in known_fields} + + # Convert gcp_config dict to GcpConfig object + if "gcp_config" in filtered and isinstance(filtered["gcp_config"], dict): + filtered["gcp_config"] = GcpConfig.from_dict(filtered["gcp_config"]) + + return cls(**filtered) + + @classmethod + def get_template(cls) -> Dict[str, Any]: + """Get default template for new projects.""" + import secrets + + # Generate random instance_id_seed (40 hex chars) + instance_id_seed = secrets.token_hex(20) + + # Generate random app_id (40 hex chars) + app_id = secrets.token_hex(20) + + return { + "name": "myapp", + "os_image": DEFAULT_OS_IMAGE, + "gcp_config": GcpConfig.get_template(), + "instance_id_seed": instance_id_seed, + "app_id": app_id, + "docker_compose_file": "docker-compose.yaml", + "prelaunch_script": "prelaunch.sh", + "env_file": ".env", + "gateway_enabled": True, + "public_logs": True, + "public_sysinfo": True, + "public_tcbinfo": True, + "key_provider": "kms", + "storage_fs": "ext4", + "no_instance_id": False, + "secure_time": False, + "allowed_envs": [], + "key_provider_id": "" + } + + +@dataclass +class GcpConfig: + """GCP deployment configuration.""" + # Required settings + project: str = "" + zone: str = "us-central1-a" + + # Instance settings + instance_name: str = "" # Required, no default + machine_type: str = "c3-standard-4" + + # Boot image settings + boot_image: str = "" # GCP image name (auto-derived from app.os_image if empty) + boot_image_tar: str = "" # Explicit tar file path (overrides search) + + # Data disk settings + data_image: str = "dstack-data-disk" + data_size: int = 20 + + # Storage settings + bucket: str = "" + + # Network settings + network: str = "default" + subnet: str = "" + + # Identity settings + service_account: str = "" + scopes: List[str] = field(default_factory=list) + + # Tags and labels + tags: List[str] = field(default_factory=list) + labels: Dict[str, str] = field(default_factory=dict) + + # Scheduling: provisioning model. "STANDARD" (default) or "SPOT". + # SPOT instances are required on projects without on-demand + # NVIDIA_H100_GPUS quota (most projects, as of 2026); GCP + # preempts them with ~30s notice and a max ~24h lifetime, but + # they're billed at a steep discount. + provisioning_model: str = "STANDARD" + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'GcpConfig': + known_fields = {f.name for f in cls.__dataclass_fields__.values()} + filtered = {k: v for k, v in data.items() if k in known_fields} + return cls(**filtered) + + @classmethod + def get_template(cls) -> Dict[str, Any]: + """Get default template for new projects.""" + return { + "project": "", + "zone": "us-central1-a", + "instance_name": "dstack-vm", + "machine_type": "c3-standard-4", + "boot_image": "", + "boot_image_tar": "", + "data_image": "dstack-data-disk", + "data_size": 20, + "bucket": "", + "network": "default", + "subnet": "", + "service_account": "", + "scopes": [], + "tags": [], + "labels": {}, + "provisioning_model": "STANDARD" + } + + +@dataclass +class DeploymentState: + """Deployment state tracking.""" + instance_name: str = "" + project: str = "" + zone: str = "" + external_ip: str = "" + internal_ip: str = "" + status: str = "" # RUNNING, STOPPED, TERMINATED, etc. + created_at: str = "" + updated_at: str = "" + boot_image: str = "" + data_image: str = "" + shared_image: str = "" + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'DeploymentState': + known_fields = {f.name for f in cls.__dataclass_fields__.values()} + filtered = {k: v for k, v in data.items() if k in known_fields} + return cls(**filtered) + + +class CloudDeploymentManager: + """Manages multi-cloud VM deployments.""" + + def __init__(self, work_dir: Optional[str] = None): + self.work_dir = Path(work_dir) if work_dir else Path.cwd() + + def _load_global_config(self) -> Dict[str, Any]: + """Load global configuration.""" + if os.path.exists(GLOBAL_CONFIG_PATH): + with open(GLOBAL_CONFIG_PATH, 'r') as f: + return json.load(f) + return {} + + def _save_global_config(self, config: Dict[str, Any]) -> None: + """Save global configuration.""" + os.makedirs(os.path.dirname(GLOBAL_CONFIG_PATH), exist_ok=True) + with open(GLOBAL_CONFIG_PATH, 'w') as f: + json.dump(config, f, indent=2) + + def _get_shared_dir(self) -> Path: + """Get the shared directory path (at project root).""" + return self.work_dir / "shared" + + def load_gcp_config(self) -> 'GcpConfig': + """Load GCP configuration from app.gcp_config.""" + # Load app config which contains gcp_config + app = self.load_app_config() + + # Get local gcp_config from app + local_gcp = app.gcp_config.to_dict() + + # Merge global config with local config + global_config = self._load_global_config() + global_gcp = global_config.get("gcp", {}) + + # Global config is used as fallback for empty values in local config + merged = {**global_gcp} # Start with global config + for key, value in local_gcp.items(): + # Only override with local value if it's non-empty + if value or value is False or value == 0: + merged[key] = value + return GcpConfig.from_dict(merged) + + def save_gcp_config(self, config: GcpConfig) -> None: + """Save GCP configuration to app.gcp_config.""" + # Load the full app config + app = self.load_app_config() + + # Update gcp_config + app.gcp_config = config + + # Save the updated app config + self.save_app_config(app) + + def load_app_config(self, required: bool = False) -> App: + """Load application configuration. + + Args: + required: If True, raise error when app.json doesn't exist + """ + app_config_path = self.work_dir / APP_CONFIG_FILE + + if not app_config_path.exists(): + if required: + raise FileNotFoundError( + f"No {APP_CONFIG_FILE} found in {self.work_dir}. " + f"Run 'dstack-cloud new ' to create a project." + ) + # Return default config if file doesn't exist + return App() + + with open(app_config_path, 'r') as f: + return App.from_dict(json.load(f)) + + def save_app_config(self, app: App) -> None: + """Save application configuration.""" + app_config_path = self.work_dir / APP_CONFIG_FILE + with open(app_config_path, 'w') as f: + json.dump(app.to_dict(), f, indent=2) + + def _generate_app_compose(self, app: App, env_names: Optional[List[str]] = None) -> Dict[str, Any]: + """Generate app-compose.json content from App configuration.""" + # Read docker-compose.yaml content + docker_compose_path = self.work_dir / app.docker_compose_file + if not docker_compose_path.exists(): + docker_compose_content = "" + else: + with open(docker_compose_path, 'r') as f: + docker_compose_content = f.read() + + # Read prelaunch script content + prelaunch_path = self.work_dir / app.prelaunch_script + if not prelaunch_path.exists(): + prelaunch_content = "" + else: + with open(prelaunch_path, 'r') as f: + prelaunch_content = f.read() + + # Merge app.allowed_envs with env_names from .env file + allowed_envs = list(app.allowed_envs) if app.allowed_envs else [] + if env_names: + allowed_envs.extend(env_names) + # Remove duplicates + allowed_envs = list(set(allowed_envs)) + + return { + "manifest_version": 2, + "name": app.name, + "runner": "docker-compose", + "docker_compose_file": docker_compose_content, + "gateway_enabled": app.gateway_enabled, + "public_logs": app.public_logs, + "public_sysinfo": app.public_sysinfo, + "public_tcbinfo": app.public_tcbinfo, + "key_provider_id": app.key_provider_id, + "allowed_envs": allowed_envs, + "no_instance_id": app.no_instance_id, + "secure_time": app.secure_time, + "key_provider": app.key_provider, + "storage_fs": app.storage_fs, + "pre_launch_script": prelaunch_content + } + + def _generate_sys_config(self, global_config: Dict[str, Any], + gcp_config: GcpConfig, app: App) -> Dict[str, Any]: + """Generate .sys-config.json content.""" + # Get services section from global config + services = global_config.get("services", {}) + + # Get KMS URLs from global config + kms_urls = services.get("kms_urls", []) + if not kms_urls: + kms_urls = ["https://kms.tdxlab.dstack.org:12001"] + + # Get gateway URLs from global config + gateway_urls = services.get("gateway_urls", []) + if not gateway_urls: + gateway_urls = ["https://gateway.tdxlab.dstack.org:12002"] + + # Get other settings + pccs_url = services.get("pccs_url", "") + + # Read OS image hash from the local image directory + os_image_hash = "" + try: + # Find the image directory and read hash file + search_paths = global_config.get("image_search_paths", []) + local_image = gcp_config.boot_image if gcp_config.boot_image else "" + if not local_image: + # Use app.os_image if boot_image is not set + local_image = app.os_image + + for search_path in search_paths: + search_path = os.path.expanduser(search_path) + if not os.path.isabs(search_path): + search_path = os.path.join(self.work_dir, search_path) + + hash_file = Path(search_path) / local_image / "auth_hash.txt" + if hash_file.exists(): + with open(hash_file, 'r') as f: + os_image_hash = f.read().strip() + logger.info(f"Read OS image hash from {hash_file}") + break + except Exception as e: + logger.warning(f"Could not read OS image hash: {e}") + + # Build vm_config + vm_config = { + "spec_version": 2, + "os_image_hash": os_image_hash + } + + return { + "kms_urls": kms_urls, + "gateway_urls": gateway_urls, + "pccs_url": pccs_url, + "vm_config": json.dumps(vm_config) + } + + def load_state(self) -> Optional[DeploymentState]: + """Load deployment state.""" + state_path = self.work_dir / STATE_FILE + + if not state_path.exists(): + return None + + with open(state_path, 'r') as f: + return DeploymentState.from_dict(json.load(f)) + + def save_state(self, state: DeploymentState) -> None: + """Save deployment state.""" + state_path = self.work_dir / STATE_FILE + state.updated_at = datetime.now().isoformat() + with open(state_path, 'w') as f: + json.dump(state.to_dict(), f, indent=2) + + def _run_gcloud(self, args: List[str], capture: bool = True, + check: bool = True) -> subprocess.CompletedProcess: + """Run a gcloud command.""" + cmd = ["gcloud"] + args + logger.debug(f"Running: {' '.join(cmd)}") + + if capture: + result = subprocess.run(cmd, capture_output=True, text=True) + else: + result = subprocess.run(cmd) + + if check and result.returncode != 0: + error_msg = result.stderr if capture else "Command failed" + raise RuntimeError(f"gcloud command failed: {error_msg}") + + return result + + def _run_gsutil(self, args: List[str], check: bool = True) -> subprocess.CompletedProcess: + """Run a gsutil command.""" + cmd = ["gsutil"] + args + logger.debug(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True) + + if check and result.returncode != 0: + raise RuntimeError(f"gsutil command failed: {result.stderr}") + + return result + + def _ensure_data_disk_image(self, config: GcpConfig) -> str: + """Ensure the data disk image exists, creating it if necessary. + + Creates a minimal disk image with GPT partition table and a partition + labeled 'dstack-data' so the guest can discover it. + + Returns the image name to use. + """ + image_name = config.data_image + + # Check if image already exists + result = self._run_gcloud([ + "compute", "images", "describe", image_name, + f"--project={config.project}" + ], check=False) + + if result.returncode == 0: + logger.debug(f"Data disk image '{image_name}' already exists") + return image_name + + logger.info(f"Data disk image '{image_name}' not found, creating...") + + # Create a minimal raw disk image with GPT partition table + with tempfile.TemporaryDirectory() as tmpdir: + raw_file = os.path.join(tmpdir, "disk.raw") + + # Create a 10MB sparse file (enough for GPT) + disk_size_bytes = 10 * 1024 * 1024 + with open(raw_file, 'wb') as f: + f.truncate(disk_size_bytes) + + # Create GPT partition table with dstack-data partition using sgdisk + # -o: clear and create new GPT + # -n 1:0:0: create partition 1, start at first available, end at last available + # -c 1:dstack-data: set partition 1 name (PARTLABEL) to dstack-data + result = subprocess.run( + ["sgdisk", "-o", "-n", "1:0:0", "-c", "1:dstack-data", raw_file], + capture_output=True, text=True + ) + if result.returncode != 0: + raise RuntimeError(f"Failed to create GPT partition table: {result.stderr}") + + logger.debug("Created GPT partition table with dstack-data label") + + # Compress to tar.gz for upload + tar_file = os.path.join(tmpdir, "disk.tar.gz") + result = subprocess.run( + ["tar", "-czf", tar_file, "-C", tmpdir, "disk.raw"], + capture_output=True, text=True + ) + if result.returncode != 0: + raise RuntimeError(f"Failed to create tar.gz: {result.stderr}") + + # Upload to GCS + gcs_path = f"{config.bucket}/{image_name}.tar.gz" + logger.info(f"Uploading data disk image to {gcs_path}...") + self._run_gsutil(["cp", tar_file, gcs_path]) + + # Create GCP image from the uploaded file + logger.info(f"Creating GCP image '{image_name}'...") + self._run_gcloud([ + "compute", "images", "create", image_name, + f"--project={config.project}", + f"--source-uri={gcs_path}", + "--guest-os-features=GVNIC" + ]) + + # Clean up GCS file + self._run_gsutil(["rm", gcs_path], check=False) + + logger.info(f"Created data disk image '{image_name}'") + + return image_name + + def new( + self, + name: str, + os_image: Optional[str] = None, + app_id: Optional[str] = None, + gateway_enabled: Optional[bool] = None, + key_provider: Optional[str] = None, + storage_fs: Optional[str] = None, + secure_time: Optional[bool] = None, + no_instance_id: Optional[bool] = None, + project: Optional[str] = None, + zone: Optional[str] = None, + instance_name: Optional[str] = None, + machine_type: Optional[str] = None, + data_size: Optional[int] = None + ) -> None: + """Create a new project directory with template configuration.""" + project_dir = Path.cwd() / name + if project_dir.exists(): + raise FileExistsError(f"Directory '{name}' already exists.") + + # Create project directory + project_dir.mkdir() + + # Update work_dir to the new project directory + self.work_dir = project_dir + + if not instance_name: + instance_name = f"dstack-{name}" + + # Initialize the project (non-interactive by default for new command) + self._init_project( + force=False, + interactive=False, + app_name=name, + os_image=os_image, + app_id=app_id, + gateway_enabled=gateway_enabled, + key_provider=key_provider, + storage_fs=storage_fs, + secure_time=secure_time, + no_instance_id=no_instance_id, + project=project, + zone=zone, + instance_name=instance_name, + machine_type=machine_type, + data_size=data_size + ) + + logger.info(f"Created new project: {name}") + logger.info(f"Project directory: {project_dir}") + logger.info("") + + def _init_project( + self, + force: bool = False, + interactive: bool = True, + app_name: Optional[str] = None, + os_image: Optional[str] = None, + app_id: Optional[str] = None, + gateway_enabled: Optional[bool] = None, + key_provider: Optional[str] = None, + storage_fs: Optional[str] = None, + secure_time: Optional[bool] = None, + no_instance_id: Optional[bool] = None, + project: Optional[str] = None, + zone: Optional[str] = None, + instance_name: Optional[str] = None, + machine_type: Optional[str] = None, + data_size: Optional[int] = None + ) -> None: + """Initialize project configuration.""" + # Interactive prompts for required fields (only if not provided via CLI) + instance_name_cli = instance_name + + if interactive: + print(f"\n=== dstack-cloud Project Initialization ===\n") + + # Prompt for app name if not provided + if app_name is None: + while True: + app_name = input("App name [myapp]: ").strip() + if not app_name: + app_name = "myapp" + if app_name: + break + print("App name cannot be empty.") + + # Prompt for instance name (required) + if not instance_name_cli: + while True: + instance_name = input("GCP instance name: ").strip() + if instance_name: + break + print("Instance name is required.") + else: + instance_name = instance_name_cli + + print("") # Empty line for readability + else: + # Non-interactive mode: use CLI provided values or fail + if app_name is None: + app_name = "myapp" + if not instance_name_cli: + raise ValueError("instance_name is required. Use --instance-name to specify it.") + instance_name = instance_name_cli + + # Create shared directory at project root (for system-generated files) + shared_dir = self.work_dir / "shared" + shared_dir.mkdir(parents=True, exist_ok=True) + + # Generate app config template with embedded gcp_config + app_template = App.get_template() + + # Apply CLI-provided values (only if specified) + if app_name: + app_template["name"] = app_name + if os_image: + app_template["os_image"] = os_image + if app_id: + # Validate app_id format (40 hex chars) + if len(app_id) != 40 or not all(c in '0123456789abcdef' for c in app_id.lower()): + raise ValueError("app_id must be exactly 40 hexadecimal characters") + app_template["app_id"] = app_id + if gateway_enabled is not None: + app_template["gateway_enabled"] = gateway_enabled + if key_provider is not None: + app_template["key_provider"] = key_provider + + # Auto-disable gateway when key_provider is not "kms" + if app_template["key_provider"] != "kms": + app_template["gateway_enabled"] = False + # Also set no_instance_id=True when KMS is not available + app_template["no_instance_id"] = True + # Remove env_file since .env is only supported in KMS mode + app_template.pop("env_file", None) + if storage_fs is not None: + app_template["storage_fs"] = storage_fs + if secure_time is not None: + app_template["secure_time"] = secure_time + if no_instance_id is not None: + app_template["no_instance_id"] = no_instance_id + + # Apply GCP config values + if instance_name: + app_template["gcp_config"]["instance_name"] = instance_name + if project: + app_template["gcp_config"]["project"] = project + if zone: + app_template["gcp_config"]["zone"] = zone + if machine_type: + app_template["gcp_config"]["machine_type"] = machine_type + if data_size is not None: + app_template["gcp_config"]["data_size"] = data_size + + # Create app.json at project root (not in .dstack/) + app_config_path = self.work_dir / APP_CONFIG_FILE + if not app_config_path.exists() or force: + with open(app_config_path, 'w') as f: + json.dump(app_template, f, indent=2) + + # Note: app-compose.json and .sys-config.json will be generated during deploy + + # Create docker-compose.yaml template at project root + docker_compose = self.work_dir / "docker-compose.yaml" + if not docker_compose.exists() or force: + with open(docker_compose, 'w') as f: + f.write("services:\n") + f.write(" nginx:\n") + f.write(" image: nginx:alpine\n") + f.write(" ports:\n") + f.write(" - \"80:80\"\n") + f.write(" restart: unless-stopped\n") + + # Create prelaunch.sh template at project root + prelaunch = self.work_dir / "prelaunch.sh" + if not prelaunch.exists() or force: + with open(prelaunch, 'w') as f: + f.write("#!/bin/sh\n") + f.write("# Prelaunch script - runs before starting containers\n") + os.chmod(prelaunch, 0o755) + + # Create .env template at project root (only for KMS mode) + if app_template["key_provider"] == "kms": + env_file = self.work_dir / ".env" + if not env_file.exists() or force: + with open(env_file, 'w') as f: + f.write("# Environment variables\n") + + # Create user-config template at project root + user_config = self.work_dir / ".user-config" + if not user_config.exists() or force: + with open(user_config, 'w') as f: + json.dump({}, f, indent=2) + + logger.info(f"Initialized project in {self.work_dir}") + logger.info("") + if interactive: + logger.info("Configuration:") + logger.info(f" App name: {app_name}") + logger.info(f" Instance name: {instance_name}") + logger.info("") + logger.info("Created files:") + logger.info(f" {app_config_path.name} - Application configuration (with embedded GCP config)") + logger.info(f" shared/ - System-generated files") + logger.info(f" {docker_compose.name} - Docker compose file") + logger.info(f" {prelaunch.name} - Prelaunch script") + if app_template["key_provider"] == "kms": + logger.info(f" .env - Environment variables") + logger.info(f" .user-config - User configuration") + logger.info("") + logger.info("Edit the configuration files to customize your deployment.") + + def config_edit(self) -> None: + """Edit global configuration with $EDITOR.""" + # Create global config if it doesn't exist + global_config_dir = os.path.dirname(GLOBAL_CONFIG_PATH) + os.makedirs(global_config_dir, exist_ok=True) + + if not os.path.exists(GLOBAL_CONFIG_PATH): + # Create template with organized sections and comments + template = { + "_comment_services": "Service endpoints configuration", + "services": { + "kms_urls": ["https://kms.tdxlab.dstack.org:12001"], + "gateway_urls": ["https://gateway.tdxlab.dstack.org:12002"], + "pccs_url": "", + }, + "_comment_images": "System image search paths (for boot_image_tar auto-discovery)", + "image_search_paths": [ + "~/.dstack/images" + ], + "_comment_gcp": "GCP cloud platform defaults", + "gcp": { + "project": "", + "zone": "us-central1-a", + "bucket": "" + } + } + with open(GLOBAL_CONFIG_PATH, 'w') as f: + json.dump(template, f, indent=2, ensure_ascii=False) + f.write("\n") # Add trailing newline + + # Get editor + editor = os.environ.get('EDITOR', 'vi') + + # Open editor + logger.info(f"Opening {GLOBAL_CONFIG_PATH} with {editor}...") + subprocess.run([editor, GLOBAL_CONFIG_PATH]) + + def prepare(self) -> None: + """Generate all files in shared directory.""" + import secrets + + # Load app config (required) + app = self.load_app_config(required=True) + + # Ensure instance_id_seed and app_id exist + if not app.instance_id_seed: + app.instance_id_seed = secrets.token_hex(20) + logger.info(f"Generated instance_id_seed: {app.instance_id_seed}") + + if not app.app_id: + app.app_id = secrets.token_hex(20) + logger.info(f"Generated app_id: {app.app_id}") + + # Save updated app config + self.save_app_config(app) + + # Get shared directory + shared_dir = self._get_shared_dir() + shared_dir.mkdir(parents=True, exist_ok=True) + + # Generate .instance_info (instance_id will be generated in CVM) + instance_info = { + "instance_id_seed": app.instance_id_seed, + "app_id": app.app_id + } + instance_info_path = shared_dir / ".instance_info" + with open(instance_info_path, 'w') as f: + json.dump(instance_info, f, indent=2) + logger.info(f"Generated {instance_info_path}") + + # Load GCP config for sys-config generation + try: + gcp_config = self.load_gcp_config() + global_config = self._load_global_config() + + # Generate .sys-config.json + sys_config_content = self._generate_sys_config(global_config, gcp_config, app) + sys_config_path = shared_dir / ".sys-config.json" + with open(sys_config_path, 'w') as f: + json.dump(sys_config_content, f, indent=2) + logger.info(f"Generated {sys_config_path}") + except FileNotFoundError as e: + logger.warning(f"Could not generate .sys-config.json: {e}") + + # Process .env file to collect env_names + env_path = self.work_dir / app.env_file + env_names = [] + if env_path.exists(): + envs = self._parse_env_file(env_path) + if envs: + env_names = list(envs.keys()) + logger.info(f"Found {len(env_names)} environment variable(s) in {app.env_file}") + else: + logger.info(f"{app.env_file} is empty") + + # Generate app-compose.json + app_compose_content = self._generate_app_compose(app, env_names=env_names if env_names else None) + app_compose_path = shared_dir / "app-compose.json" + with open(app_compose_path, 'w') as f: + json.dump(app_compose_content, f, indent=2) + logger.info(f"Generated {app_compose_path}") + + logger.info("") + logger.info(f"Shared files generated in: {shared_dir}") + logger.info("These files will be included in the shared disk image during deploy.") + + def _find_boot_image_tar(self, local_image: str) -> Optional[Path]: + """Search for boot image disk.raw file in configured search paths.""" + global_config = self._load_global_config() + search_paths = global_config.get("image_search_paths", []) + + logger.debug(f"Image search paths: {search_paths}") + logger.debug(f"Looking for image: {local_image}") + + # Expand ~ and convert to Path objects + expanded_paths = [] + for path in search_paths: + expanded = os.path.expanduser(path) + if not os.path.isabs(expanded): + expanded = os.path.join(self.work_dir, expanded) + expanded_paths.append(Path(expanded)) + + # Look for disk.raw in the image directory + patterns = [ + f"{local_image}/disk.raw", + ] + + for search_path in expanded_paths: + logger.debug(f"Checking search path: {search_path}, exists: {search_path.exists()}") + if not search_path.exists(): + continue + for pattern in patterns: + file_path = search_path / pattern + logger.debug(f"Checking: {file_path}, exists: {file_path.exists()}") + if file_path.exists(): + logger.info(f"Found boot image: {file_path}") + return file_path + + return None + + def pull(self, os_image: str) -> None: + """Download UKI image from remote repository or an absolute URL.""" + global_config = self._load_global_config() + search_paths = global_config.get("image_search_paths", []) + + if not search_paths: + logger.error("No image_search_paths configured in global config") + logger.error("Please set image_search_paths in: ~/.config/dstack-cloud/config.json") + return + + # Use the first search path + target_dir = Path(os.path.expanduser(search_paths[0])) + target_dir.mkdir(parents=True, exist_ok=True) + + # Check if os_image is an absolute URL + if os_image.startswith("http://") or os_image.startswith("https://"): + download_url = os_image + # Derive image name from URL filename + url_filename = download_url.rsplit("/", 1)[-1] + if url_filename.endswith("-uki.tar.gz"): + os_image = url_filename[:-len("-uki.tar.gz")] + elif url_filename.endswith(".tar.gz"): + os_image = url_filename[:-len(".tar.gz")] + else: + os_image = url_filename + download_tar = target_dir / url_filename + else: + # Extract version from os_image (e.g., dstack-cloud-nvidia-0.6.0 -> 0.6.0) + # Version is the last component after the last hyphen followed by digits + import re + version_match = re.search(r'-(\d+\.\d+\.\d+)$', os_image) + if not version_match: + logger.error(f"Could not extract version from image name: {os_image}") + logger.error("Expected format: dstack-cloud-- (e.g., dstack-cloud-nvidia-0.6.0)") + return + version = version_match.group(1) + download_url = f"https://github.com/Phala-Network/meta-dstack-cloud/releases/download/v{version}/{os_image}-uki.tar.gz" + download_tar = target_dir / f"{os_image}-uki.tar.gz" + + if download_tar.exists(): + logger.info(f"Download file already exists: {download_tar}") + response = input("Download again to overwrite? [y/N]: ").strip().lower() + if response != 'y': + logger.info("Download cancelled") + return + + logger.info(f"Downloading {os_image} UKI image from {download_url}...") + logger.info(f"Target: {download_tar}") + + try: + # Use curl to download with progress bar + subprocess.run( + ["curl", "-L", "-o", str(download_tar), download_url], + check=True + ) + logger.info(f"Successfully downloaded to {download_tar}") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to download image: {e}") + # Clean up partial download + if download_tar.exists(): + download_tar.unlink() + raise + + # Extract the tar file + logger.info(f"Extracting {download_tar}...") + try: + subprocess.run( + ["tar", "-xzf", str(download_tar), "-C", str(target_dir)], + check=True + ) + logger.info(f"Successfully extracted to {target_dir / os_image}") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to extract image: {e}") + raise + + # Verify the expected structure + expected_dir = target_dir / os_image + expected_disk = expected_dir / "disk.raw" + if expected_disk.exists(): + logger.info(f"Image ready: {expected_disk}") + else: + logger.warning(f"Expected file not found: {expected_disk}") + logger.warning(f"Downloaded structure may be incorrect") + + def _check_and_upload_boot_image(self, config: GcpConfig, app: App, force: bool = False) -> str: + """Check and upload boot image if needed. Returns the image name.""" + image_path = None + + # Derive GCP image name from app.os_image + gcp_image = config.boot_image + if not gcp_image: + # Convert OS image name (dstack-cloud-nvidia-0.6.0 -> dstack-cloud-nvidia-0-6-0) + gcp_image = app.os_image.replace(".", "-") + + # If boot_image_tar is specified, use it (legacy config name, can be disk.raw or tar.gz) + if config.boot_image_tar: + image_path = Path(os.path.expanduser(config.boot_image_tar)) + if not image_path.exists(): + logger.error("") + logger.error(f"Boot image not found: {image_path}") + logger.error("") + logger.error(f"Please download the image using:") + logger.error(f" dstack-cloud pull {app.os_image}") + logger.error("") + raise FileNotFoundError(f"Boot image not found: {image_path}") + else: + # Auto-discover from search paths using OS image name + logger.info(f"Searching for boot image '{app.os_image}'...") + image_path = self._find_boot_image_tar(app.os_image) + if not image_path: + logger.error("") + logger.error(f"Boot image '{app.os_image}' not found locally.") + logger.error("") + logger.error(f"Please download the image using:") + logger.error(f" dstack-cloud pull {app.os_image}") + logger.error("") + raise FileNotFoundError( + f"Boot image '{app.os_image}' not found. " + f"Run 'dstack-cloud pull {app.os_image}' to download it." + ) + + # Use gcp_image as the GCP image name + image_name = gcp_image + local_mtime = image_path.stat().st_mtime + + # Check if GCP image exists and is up-to-date + result = self._run_gcloud([ + "compute", "images", "describe", image_name, + f"--project={config.project}", + "--format=value(creationTimestamp)" + ], check=False) + + need_upload = False + gcp_creation_time = result.stdout.strip() if result.returncode == 0 else "" + + if force: + logger.info("Force enabled: will re-upload boot image") + need_upload = True + elif not gcp_creation_time: + logger.info(f"GCP image '{image_name}' does not exist, will upload") + need_upload = True + else: + # Parse GCP timestamp and compare + try: + from datetime import datetime + gcp_dt = datetime.fromisoformat(gcp_creation_time.replace('Z', '+00:00')) + gcp_epoch = gcp_dt.timestamp() + if local_mtime > gcp_epoch: + logger.info("Local image is newer than GCP image, will re-upload") + logger.info(f" Local: {datetime.fromtimestamp(local_mtime).isoformat()}") + logger.info(f" GCP: {gcp_creation_time}") + need_upload = True + else: + logger.info(f"GCP image '{image_name}' is up-to-date") + except Exception as e: + logger.warning(f"Could not parse GCP timestamp: {e}") + need_upload = True + + if need_upload: + # Compress disk.raw to tar.gz for upload + logger.info("Compressing disk.raw to tar.gz for upload...") + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + tar_file = os.path.join(tmpdir, "disk.tar.gz") + result = subprocess.run( + ["tar", "-czvf", tar_file, "-C", str(image_path.parent), "disk.raw"], + capture_output=True, + text=True + ) + if result.returncode != 0: + raise RuntimeError(f"Failed to create tar.gz: {result.stderr}") + + logger.info("Uploading boot image to GCS...") + self._run_gsutil([ + "cp", tar_file, f"{config.bucket}/{image_name}.tar.gz" + ]) + + # Delete existing image if present + if gcp_creation_time: + logger.info("Deleting existing GCP image...") + self._run_gcloud([ + "compute", "images", "delete", image_name, + f"--project={config.project}", + "--quiet" + ]) + + logger.info("Creating GCP image with TDX support...") + self._run_gcloud([ + "compute", "images", "create", image_name, + f"--project={config.project}", + f"--source-uri={config.bucket}/{image_name}.tar.gz", + "--guest-os-features=UEFI_COMPATIBLE,TDX_CAPABLE,GVNIC" + ]) + + return image_name + + def _create_shared_disk_image(self, config: GcpConfig, app: App) -> str: + """Create and upload shared disk image. Returns the image name.""" + import secrets + import shutil + + # Check if mcopy (mtools) is installed + if not shutil.which("mcopy"): + logger.error("") + logger.error("Error: 'mcopy' command not found.") + logger.error("") + logger.error("Please install mtools:") + logger.error(" Ubuntu/Debian: sudo apt-get install mtools") + logger.error(" Fedora/RHEL: sudo dnf install mtools") + logger.error(" Arch Linux: sudo pacman -S mtools") + logger.error("") + raise FileNotFoundError("mcopy not found. Please install mtools package.") + + # Ensure instance_id_seed and app_id exist + if not app.instance_id_seed: + app.instance_id_seed = secrets.token_hex(20) + logger.info(f"Generated instance_id_seed: {app.instance_id_seed}") + self.save_app_config(app) + + if not app.app_id: + app.app_id = secrets.token_hex(20) + logger.info(f"Generated app_id: {app.app_id}") + self.save_app_config(app) + + shared_dir = self._get_shared_dir() + + # Ensure shared directory exists and generate all required files + shared_dir.mkdir(parents=True, exist_ok=True) + + shared_image_name = f"{config.instance_name}-shared" + + # Process .env file: encrypt and save to shared/.encrypted-env + env_path = self.work_dir / app.env_file + env_names = [] # Collect environment variable names for allowed_envs + + if env_path.exists(): + if app.key_provider != "kms": + raise ValueError(f"{app.env_file} found but KMS is not enabled. " + f"Enable KMS with --key-provider kms or remove {app.env_file}") + + if not app.app_id: + raise ValueError(f"{app.env_file} found but app_id is not set. " + f"Run 'dstack-cloud prepare' to generate app_id") + + # Parse .env file + envs = self._parse_env_file(env_path) + if envs: + env_names = list(envs.keys()) + + # Get KMS URL from global config + global_config = self._load_global_config() + kms_urls = global_config.get("services", {}).get("kms_urls", []) + if not kms_urls: + raise ValueError("KMS enabled but no kms_urls configured in global config") + + # Get encryption public key from KMS + kms_url = kms_urls[0] + pubkey = self._get_app_encrypt_pub_key(app.app_id, kms_url) + + # Encrypt environment variables + encrypted_env = self._encrypt_env(envs, pubkey) + + # Save to shared/.encrypted-env + encrypted_file = shared_dir / ".encrypted-env" + with open(encrypted_file, 'wb') as f: + f.write(encrypted_env) + logger.info(f"Encrypted {app.env_file} -> {encrypted_file}") + else: + logger.info(f"{app.env_file} is empty, skipping") + + # Regenerate app-compose.json with env_names (if any) + # This must be done after .env processing and before creating the disk image + global_config = self._load_global_config() + sys_config_content = self._generate_sys_config(global_config, config, app) + sys_config_path = shared_dir / ".sys-config.json" + with open(sys_config_path, 'w') as f: + json.dump(sys_config_content, f, indent=2) + logger.info(f"Generated {sys_config_path}") + + # Generate .instance_info + instance_info = { + "instance_id_seed": app.instance_id_seed, + "app_id": app.app_id + } + instance_info_path = shared_dir / ".instance_info" + with open(instance_info_path, 'w') as f: + json.dump(instance_info, f, indent=2) + logger.info(f"Generated {instance_info_path}") + + # Generate app-compose.json with env_names (from .env file) + app_compose_content = self._generate_app_compose(app, env_names=env_names if env_names else None) + app_compose_path = shared_dir / "app-compose.json" + with open(app_compose_path, 'w') as f: + json.dump(app_compose_content, f, indent=2) + logger.info(f"Generated {app_compose_path}") + + with tempfile.TemporaryDirectory() as work_dir: + work_path = Path(work_dir) + raw_file = work_path / "disk.raw" + + # Create FAT32 disk image (no root required) + logger.info("Creating shared disk image...") + disk_size = "8M" # 8MB FAT32 disk + subprocess.run( + ["truncate", "-s", disk_size, str(raw_file)], + check=True + ) + subprocess.run( + ["mkfs.fat", "-F", "32", "-n", "DSTACKSHR", str(raw_file)], + check=True, capture_output=True + ) + + # Use mtools to copy files without mounting (no root required) + # Copy generated system files from shared directory + required_files = ["app-compose.json", ".sys-config.json", ".instance_info"] + for f in required_files: + src = shared_dir / f + if src.exists(): + subprocess.run( + ["mcopy", "-i", str(raw_file), str(src), "::"], + check=True + ) + else: + raise FileNotFoundError(f"Required file {f} not found in {shared_dir}") + + # Copy optional system files from shared directory + optional_files = [".encrypted-env"] + for f in optional_files: + src = shared_dir / f + if src.exists(): + subprocess.run( + ["mcopy", "-i", str(raw_file), str(src), "::"], + check=True + ) + + # Copy other user-editable files from project root + user_files = { + ".user-config": ".user-config", + } + + for src_name, dst_name in user_files.items(): + src_path = self.work_dir / src_name + if src_path.exists(): + subprocess.run( + ["mcopy", "-i", str(raw_file), str(src_path), f"::{dst_name}"], + check=True + ) + logger.info(f"Included {src_name}") + else: + logger.warning(f"{src_name} not found, skipping") + + # Create tar + tar_file = work_path / "shared-disk.tar.gz" + subprocess.run( + ["tar", "-C", str(work_path), "-czvf", str(tar_file), "disk.raw"], + check=True, capture_output=True + ) + + # Upload to GCS + logger.info("Uploading shared disk image to GCS...") + self._run_gsutil([ + "cp", str(tar_file), f"{config.bucket}/{shared_image_name}.tar.gz" + ]) + + # Delete existing image if present + result = self._run_gcloud([ + "compute", "images", "describe", shared_image_name, + f"--project={config.project}" + ], check=False) + + if result.returncode == 0: + logger.info("Deleting existing shared disk image...") + self._run_gcloud([ + "compute", "images", "delete", shared_image_name, + f"--project={config.project}", + "--quiet" + ]) + + # Create GCP image + logger.info("Creating GCP image from shared disk...") + self._run_gcloud([ + "compute", "images", "create", shared_image_name, + f"--project={config.project}", + f"--source-uri={config.bucket}/{shared_image_name}.tar.gz", + "--guest-os-features=GVNIC" + ]) + + return shared_image_name + + def deploy(self, delete_existing: bool = False, + force_boot_image: bool = False) -> None: + """Deploy VM to GCP.""" + # Load app config first (required) - validates project exists + app = self.load_app_config(required=True) + config = self.load_gcp_config() + + # Validate required configuration + missing = [] + if not config.instance_name: + missing.append("instance_name") + if not config.project: + missing.append("project") + if not config.zone: + missing.append("zone") + if missing: + raise ValueError( + f"Missing required GCP configuration: {', '.join(missing)}. " + f"Run 'dstack-cloud new ' to create a project or edit dstack-app.json." + ) + + # Auto-detect bucket if not specified + if not config.bucket and config.project: + config.bucket = f"gs://{config.project}-dstack" + + shared_dir = self._get_shared_dir() + + logger.info("=== GCP TDX VM Deployment ===") + logger.info(f"Project: {config.project}") + logger.info(f"Zone: {config.zone}") + logger.info(f"Instance: {config.instance_name}") + logger.info(f"Shared Directory: {shared_dir}") + logger.info(f"GCS Bucket: {config.bucket}") + + # Check if instance already exists + result = self._run_gcloud([ + "compute", "instances", "describe", config.instance_name, + f"--zone={config.zone}", + f"--project={config.project}" + ], check=False) + + if result.returncode == 0: + if delete_existing: + logger.info(f"Deleting existing instance: {config.instance_name}") + self._run_gcloud([ + "compute", "instances", "delete", config.instance_name, + f"--zone={config.zone}", + f"--project={config.project}", + "--quiet" + ]) + else: + raise RuntimeError( + f"Instance '{config.instance_name}' already exists. " + f"Use --delete to replace it." + ) + + # Check and upload boot image + boot_image = self._check_and_upload_boot_image(config, app, force=force_boot_image) + + # Create shared disk image + shared_image = self._create_shared_disk_image(config, app) + + # Ensure data disk image exists (with GPT partition labeled 'dstack-data') + data_image = self._ensure_data_disk_image(config) + + # Create TDX instance + logger.info("Creating TDX instance...") + + create_args = [ + "compute", "instances", "create", config.instance_name, + f"--zone={config.zone}", + f"--project={config.project}", + f"--machine-type={config.machine_type}", + "--confidential-compute-type=TDX", + f"--image={boot_image}", + "--boot-disk-size=10GB", + f"--create-disk=name={config.instance_name}-data,size={config.data_size}GB,type=pd-balanced,image={data_image},auto-delete=yes", + f"--create-disk=name={config.instance_name}-shared,size=1GB,type=pd-balanced,image={shared_image},auto-delete=yes", + "--maintenance-policy=TERMINATE", + ] + + provisioning = (config.provisioning_model or "STANDARD").upper() + if provisioning == "SPOT": + create_args.append("--provisioning-model=SPOT") + # STOP (vs. the gcloud default DELETE) preserves the + # boot/data disks across preemption so the instance can + # be restarted with `dstack-cloud start` and keep its + # LUKS-encrypted data disk intact. + create_args.append("--instance-termination-action=STOP") + elif provisioning != "STANDARD": + raise RuntimeError( + f"Unsupported provisioning_model: {config.provisioning_model!r} " + f"(expected 'STANDARD' or 'SPOT')" + ) + + if config.network != "default": + create_args.append(f"--network={config.network}") + if config.subnet: + create_args.append(f"--subnet={config.subnet}") + if config.service_account: + create_args.append(f"--service-account={config.service_account}") + if config.scopes: + create_args.append(f"--scopes={','.join(config.scopes)}") + # Always attach firewall tag so existing firewall rules continue to work + # after instance recreation (e.g. deploy --delete). + instance_tags = list(config.tags) + firewall_tag = f"fw-{config.instance_name}" + if firewall_tag not in instance_tags: + instance_tags.append(firewall_tag) + if instance_tags: + create_args.append(f"--tags={','.join(instance_tags)}") + if config.labels: + labels_str = ",".join(f"{k}={v}" for k, v in config.labels.items()) + create_args.append(f"--labels={labels_str}") + + self._run_gcloud(create_args) + + # Get instance details + result = self._run_gcloud([ + "compute", "instances", "describe", config.instance_name, + f"--zone={config.zone}", + f"--project={config.project}", + "--format=json" + ]) + + instance_info = json.loads(result.stdout) + external_ip = "" + internal_ip = "" + + for iface in instance_info.get("networkInterfaces", []): + internal_ip = iface.get("networkIP", "") + for access in iface.get("accessConfigs", []): + external_ip = access.get("natIP", "") + break + + # Save state + state = DeploymentState( + instance_name=config.instance_name, + project=config.project, + zone=config.zone, + external_ip=external_ip, + internal_ip=internal_ip, + status="RUNNING", + created_at=datetime.now().isoformat(), + boot_image=boot_image, + data_image=data_image, + shared_image=shared_image, + ) + self.save_state(state) + + logger.info("") + logger.info("=== Deployment Complete ===") + logger.info(f"Instance: {config.instance_name}") + logger.info(f"External IP: {external_ip}") + logger.info(f"Internal IP: {internal_ip}") + logger.info("") + logger.info("To check serial output:") + logger.info(f" dstack-cloud logs") + + def _parse_env_file(self, file_path: Path) -> Dict[str, str]: + """Parse an environment file where each line is formatted as KEY=Value.""" + if not file_path or not file_path.exists(): + return {} + + envs = {} + with open(file_path, 'r') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + if '=' not in line: + continue + key, value = line.split('=', 1) + envs[key.strip()] = value.strip() + return envs + + def _encrypt_env(self, envs: Dict[str, str], hex_public_key: str) -> bytes: + """ + Encrypt environment variables using X25519 key exchange and AES-GCM. + + Args: + envs: Environment variables dictionary + hex_public_key: Remote encryption public key in hexadecimal format + + Returns: + Raw bytes of (ephemeral public key || IV || ciphertext) + """ + if not CRYPTO_AVAILABLE: + raise ImportError( + "Cryptography libraries not available. Please install:\n" + "pip install cryptography eth-keys 'eth-hash[pycryptodome]'" + ) + + # Serialize environment variables to JSON (format: {"env": [{"key": k, "value": v}, ...]}) + env_pairs = [{"key": k, "value": v} for k, v in envs.items()] + envs_json = json.dumps({"env": env_pairs}).encode("utf-8") + + # Remove "0x" prefix if present + if hex_public_key.startswith("0x"): + hex_public_key = hex_public_key[2:] + + # Convert hex public key to bytes + remote_pubkey_bytes = bytes.fromhex(hex_public_key) + + # Generate ephemeral X25519 key pair + ephemeral_private_key = x25519.X25519PrivateKey.generate() + ephemeral_public_key = ephemeral_private_key.public_key() + + # Compute shared secret using X25519 + peer_public_key = x25519.X25519PublicKey.from_public_bytes(remote_pubkey_bytes) + shared = ephemeral_private_key.exchange(peer_public_key) + + # Use shared secret as key for AES-GCM (32 bytes for AES-256) + aesgcm = AESGCM(shared) + iv = os.urandom(12) # 12-byte nonce for AES-GCM + ciphertext = aesgcm.encrypt(iv, envs_json, None) + + # Serialize ephemeral public key to raw bytes + ephemeral_public_bytes = ephemeral_public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw + ) + + # Combine ephemeral public key, IV, and ciphertext + result = ephemeral_public_bytes + iv + ciphertext + return result + + def _get_app_encrypt_pub_key(self, app_id: str, kms_url: str) -> str: + """Get encryption public key for the specified app_id from KMS.""" + try: + import urllib.request + import urllib.error + import ssl + + path = f"{kms_url}/prpc/GetAppEnvEncryptPubKey?json" + data = json.dumps({"app_id": app_id}).encode("utf-8") + + req = urllib.request.Request( + path, + data=data, + headers={"Content-Type": "application/json"} + ) + + # Allow self-signed certificates for KMS server + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + logger.info(f"Getting encryption public key for {app_id} from {kms_url}") + with urllib.request.urlopen(req, timeout=10, context=ssl_context) as response: + response_data = json.loads(response.read().decode("utf-8")) + + if "public_key" not in response_data: + raise ValueError(f"No public_key in response: {response_data}") + + # Verify signature if available + if "signature" not in response_data: + if not self._confirm_untrusted_signer("none"): + raise ValueError("Aborted due to missing signature") + return response_data["public_key"] + + public_key = bytes.fromhex(response_data["public_key"]) + signature = bytes.fromhex(response_data["signature"]) + + signer_pubkey = self._verify_signature(public_key, signature, app_id) + if signer_pubkey: + whitelist = self._load_whitelist() + if whitelist and signer_pubkey not in whitelist: + logger.warning(f"Signer {signer_pubkey} is not in the trusted whitelist!") + if not self._confirm_untrusted_signer(signer_pubkey): + raise ValueError("Aborted due to untrusted signer") + else: + logger.info(f"Verified signature from: {signer_pubkey}") + else: + logger.warning("Could not verify signature!") + if not self._confirm_untrusted_signer("unknown"): + raise ValueError("Aborted due to invalid signature") + + return response_data["public_key"] + + except Exception as e: + logger.warning(f"Failed to get encryption public key: {e}") + raise + + def _verify_signature(self, public_key: bytes, signature: bytes, app_id: str) -> Optional[str]: + """Verify the signature of a public key. + + Args: + public_key: The public key bytes to verify + signature: The signature bytes + app_id: The application ID + + Returns: + The compressed public key if valid, None otherwise + """ + if not ETH_CRYPTO_AVAILABLE: + logger.warning("eth-keys not available, skipping signature verification. " + "Install with: pip install eth-keys 'eth-hash[pycryptodome]'") + return None + + if len(signature) != 65: + return None + + # Create the message to verify + prefix = b"dstack-env-encrypt-pubkey" + if app_id.startswith("0x"): + app_id = app_id[2:] + message = prefix + b":" + bytes.fromhex(app_id) + public_key + + # Hash the message with Keccak-256 and recover the public key + try: + message_hash = keccak(message) + sig = keys.Signature(signature_bytes=signature) + recovered_key = sig.recover_public_key_from_msg_hash(message_hash) + return '0x' + recovered_key.to_compressed_bytes().hex() + except Exception as e: + error_msg = str(e) + if "hashing backends" in error_msg or "pycryptodome" in error_msg: + raise ImportError( + "Ethereum hashing backend not available. Please install:\n" + "pip install cryptography eth-keys 'eth-hash[pycryptodome]'" + ) + logger.debug(f"Signature verification failed: {e}") + return None + + def _confirm_untrusted_signer(self, signer: str) -> bool: + """Ask user to confirm using an untrusted signer.""" + try: + response = input(f"Continue with untrusted signer '{signer}'? (y/N): ") + return response.lower() in ('y', 'yes') + except EOFError: + # Non-interactive mode, reject untrusted signers + return False + + def _load_whitelist(self) -> List[str]: + """Load the whitelist of trusted signers from a file.""" + if not os.path.exists(DEFAULT_KMS_WHITELIST_PATH): + return [] + + try: + with open(DEFAULT_KMS_WHITELIST_PATH, 'r') as f: + data = json.load(f) + return data.get('trusted_signers', []) + except (json.JSONDecodeError, FileNotFoundError): + return [] + + def _save_whitelist(self, whitelist: List[str]) -> None: + """Save the whitelist of trusted signers to a file.""" + os.makedirs(os.path.dirname(DEFAULT_KMS_WHITELIST_PATH), exist_ok=True) + with open(DEFAULT_KMS_WHITELIST_PATH, 'w') as f: + json.dump({'trusted_signers': whitelist}, f, indent=2) + + def _update_kms_whitelist(self, whitelist_path: str, bundle: Dict[str, Any]) -> None: + """Update the KMS whitelist with the k256 pubkey from an AuthBundle.""" + kms_identity = bundle.get("kms_identity", {}) + k256_pubkey = kms_identity.get("k256_pubkey", "") + if not k256_pubkey: + logger.warning("auth bundle contains no kms_identity.k256_pubkey, skipping whitelist update") + return + + existing: List[str] = [] + if os.path.exists(whitelist_path): + try: + with open(whitelist_path, 'r') as f: + existing = json.load(f).get("trusted_signers", []) + except (json.JSONDecodeError, OSError): + pass + + if k256_pubkey not in existing: + existing.append(k256_pubkey) + os.makedirs(os.path.dirname(whitelist_path), exist_ok=True) + with open(whitelist_path, 'w') as f: + json.dump({"trusted_signers": existing}, f, indent=2) + logger.info(f"added {k256_pubkey} to kms whitelist") + + def _load_platform_config(self, platform_url: Optional[str] = None, + api_key: Optional[str] = None) -> Dict[str, Any]: + """Load platform connection settings from config file and environment, with CLI overrides.""" + config_path = os.path.expanduser("~/.config/dstack-cloud/platform.json") + file_cfg: Dict[str, Any] = {} + if os.path.exists(config_path): + try: + with open(config_path, 'r') as f: + file_cfg = json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"could not read platform config: {e}") + + result_url = ( + platform_url + or os.environ.get("DSTACK_PLATFORM_URL") + or file_cfg.get("platform_url", "") + ) + result_key = ( + api_key + or os.environ.get("DSTACK_API_KEY") + or file_cfg.get("api_key", "") + ) + return {"platform_url": result_url, "api_key": result_key} + + def _platform_post(self, platform_url: str, api_key: str, + path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + """POST JSON to the vendor platform API, return parsed response.""" + import urllib.request + import urllib.error + + url = platform_url.rstrip("/") + path + data = json.dumps(payload).encode("utf-8") + headers: Dict[str, str] = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"platform API error {e.code}: {body}") + + def _sidecar_post(self, sidecar_url: str, path: str, + payload: Dict[str, Any]) -> Dict[str, Any]: + """POST JSON to the KMS sidecar courier API, return parsed response.""" + import urllib.request + import urllib.error + + url = sidecar_url.rstrip("/") + path + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, data=data, + headers={"Content-Type": "application/json"}, + method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"sidecar API error {e.code}: {body}") + + def kms_attest(self, customer_id: str, sidecar_url: str, + platform_url: Optional[str] = None, + api_key: Optional[str] = None) -> None: + """Provision a KMS instance via the offline courier protocol. + + Runs the full courier handshake: + 1. request a nonce from the platform + 2. forward nonce to the in-VPC sidecar, get TDX quote + transport key + 3. send quote to platform for verification, receive sealed root key + AuthBundle + 4. deliver the ProvisionPackage into the VPC via sidecar + 5. update the local KMS whitelist (TOFU) + """ + cfg = self._load_platform_config(platform_url, api_key) + p_url = cfg["platform_url"] + p_key = cfg["api_key"] + if not p_url: + raise ValueError("platform URL is required (--platform-url, DSTACK_PLATFORM_URL, or platform.json)") + + # Step 1: request challenge from platform + logger.info("requesting challenge from platform...") + challenge_resp = self._platform_post(p_url, p_key, "/api/v1/challenge", { + "customer_id": customer_id, + "client_ts": int(time.time()), + }) + nonce = challenge_resp["nonce"] + platform_ts = challenge_resp["platform_ts"] + logger.info(f"got nonce {nonce[:16]}...") + + # Step 2: forward nonce to sidecar, get TDX quote + transport key + logger.info("sending nonce to sidecar, fetching TDX quote...") + init_data = self._sidecar_post(sidecar_url, "/courier/init", {"nonce": nonce}) + # init_data: {transport_pub, kms_ts, quote} + + # Step 3: sanity-check clock skew + skew = abs(init_data["kms_ts"] - platform_ts) + if skew > 300: + raise RuntimeError(f"kms clock skew too large: {skew}s (limit 300s)") + + # Step 4: send quote to platform, get ProvisionPackage + logger.info("sending quote to platform for verification...") + provision = self._platform_post(p_url, p_key, "/api/v1/provision", { + "customer_id": customer_id, + "nonce": nonce, + "quote": init_data["quote"], + "transport_pub": init_data["transport_pub"], + "kms_ts": init_data["kms_ts"], + }) + # provision: {sealed_root, auth_bundle} + + # Step 5: deliver ProvisionPackage into VPC via sidecar + logger.info("installing provision package into KMS...") + install_resp = self._sidecar_post(sidecar_url, "/courier/install", { + "sealed_root": provision["sealed_root"], + "auth_bundle": provision["auth_bundle"], + }) + if not install_resp.get("ok"): + raise RuntimeError(f"sidecar install failed: {install_resp}") + + # Step 6: TOFU — update local KMS whitelist with the KMS identity pubkey + whitelist_path = os.path.expanduser("~/.config/dstack-cloud/kms-whitelist.json") + self._update_kms_whitelist(whitelist_path, provision["auth_bundle"]) + + bundle_seq = provision["auth_bundle"].get("bundle_seq", "?") + logger.info(f"KMS provisioned successfully. bundle_seq={bundle_seq}") + + def kms_sync_auth(self, customer_id: str, sidecar_url: str, + platform_url: Optional[str] = None, + api_key: Optional[str] = None) -> None: + """Renew the AuthBundle for a provisioned KMS instance. + + Fetches a fresh AuthBundle from the platform (no TDX quote needed) + and pushes it into the in-VPC KMS via the sidecar. + + TODO P3: collect and upload UsageReceipt before requesting renewal. + """ + cfg = self._load_platform_config(platform_url, api_key) + p_url = cfg["platform_url"] + p_key = cfg["api_key"] + if not p_url: + raise ValueError("platform URL is required (--platform-url, DSTACK_PLATFORM_URL, or platform.json)") + + # TODO P3: fetch UsageReceipt from sidecar before sync + # receipt = self._sidecar_post(sidecar_url, "/courier/usage-receipt", {}) + + # Step 1: fetch fresh AuthBundle from platform + logger.info("fetching fresh auth bundle from platform...") + sync_resp = self._platform_post(p_url, p_key, "/api/v1/sync-auth", { + "customer_id": customer_id, + # "usage_receipt": receipt, # TODO P3 + }) + bundle = sync_resp["auth_bundle"] + + # Step 2: push AuthBundle into VPC via sidecar (no new root key) + logger.info("pushing auth bundle into KMS...") + install_resp = self._sidecar_post(sidecar_url, "/courier/install", { + "sealed_root": None, + "auth_bundle": bundle, + }) + if not install_resp.get("ok"): + raise RuntimeError(f"sidecar install failed: {install_resp}") + + bundle_seq = bundle.get("bundle_seq", "?") + logger.info(f"AuthBundle synced. bundle_seq={bundle_seq}") + + def _derive_instance_id(self, instance_id_seed: str, app_id: str) -> str: + """Derive instance_id from instance_id_seed and app_id. + + Both instance_id_seed and app_id are hex strings that need to be + decoded to bytes before concatenation, matching the Rust implementation. + """ + # Decode hex strings to bytes + seed_bytes = bytes.fromhex(instance_id_seed) + app_bytes = bytes.fromhex(app_id) + + # Concatenate bytes (not hex strings) + id_path = seed_bytes + app_bytes + + # Compute SHA256 and take first 20 bytes, then convert to hex string + instance_id = hashlib.sha256(id_path).digest()[:20] + return instance_id.hex() + + def _get_gateway_urls(self, app: App, instance_id: str) -> Dict[str, str]: + """Construct gateway URLs for app access. + + Returns: + Dict with 'app_url' and 'instance_url' keys + """ + if not app.gateway_enabled: + return {} + + global_config = self._load_global_config() + gateway_urls = global_config.get("services", {}).get("gateway_urls", []) + if not gateway_urls: + return {} + + # Try to get gateway info from RPC + gateway_info = None + for gateway_url in gateway_urls: + try: + info_url = f"{gateway_url}/prpc/Info" + result = subprocess.run( + ["curl", "-sk", info_url], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + gateway_info = json.loads(result.stdout) + break + except Exception as e: + logger.debug(f"Failed to get gateway info from {gateway_url}: {e}") + continue + + if not gateway_info: + return {} + + base_domain = gateway_info.get("base_domain") + external_port = gateway_info.get("external_port") + + if not base_domain or not external_port: + return {} + + # Construct URLs: one with instance_id, one with app_id + app_id = app.app_id + app_url = f"https://{app_id}-8090.{base_domain}:{external_port}/" + instance_url = f"https://{instance_id}-8090.{base_domain}:{external_port}/" + + return { + "app_url": app_url, + "instance_url": instance_url + } + + def status(self) -> None: + """Check deployment status.""" + state = self.load_state() + if not state or not state.instance_name: + logger.info("No deployment found. Run 'dstack-cloud deploy' first.") + return + + # Get current status from GCP + result = self._run_gcloud([ + "compute", "instances", "describe", state.instance_name, + f"--zone={state.zone}", + f"--project={state.project}", + "--format=json" + ], check=False) + + if result.returncode != 0: + logger.info(f"Instance '{state.instance_name}' not found in GCP") + state.status = "NOT_FOUND" + self.save_state(state) + return + + instance_info = json.loads(result.stdout) + status = instance_info.get("status", "UNKNOWN") + + # Update IPs + external_ip = "" + internal_ip = "" + for iface in instance_info.get("networkInterfaces", []): + internal_ip = iface.get("networkIP", "") + for access in iface.get("accessConfigs", []): + external_ip = access.get("natIP", "") + break + + state.status = status + state.external_ip = external_ip + state.internal_ip = internal_ip + self.save_state(state) + + print(f"Instance: {state.instance_name}") + print(f"Project: {state.project}") + print(f"Zone: {state.zone}") + print(f"Status: {status}") + print(f"External IP: {external_ip or 'N/A'}") + print(f"Internal IP: {internal_ip or 'N/A'}") + print(f"Boot Image: {state.boot_image}") + print(f"Created: {state.created_at}") + print(f"Updated: {state.updated_at}") + + # Display gateway URLs if enabled + # Try to load from .instance_info first (actual deployed values) + app = self.load_app_config() + if not app.gateway_enabled: + return + + instance_id_seed = None + app_id = None + + # Try to read from shared/.instance_info (actual deployed values) + instance_info_path = self._get_shared_dir() / ".instance_info" + if instance_info_path.exists(): + try: + with open(instance_info_path, 'r') as f: + instance_info_data = json.load(f) + instance_id_seed = instance_info_data.get("instance_id_seed") + app_id = instance_info_data.get("app_id") + except Exception as e: + logger.debug(f"Failed to read .instance_info: {e}") + + # Fallback to app.json if .instance_info not found or missing values + if not instance_id_seed or not app_id: + instance_id_seed = app.instance_id_seed + app_id = app.app_id + + if instance_id_seed and app_id: + instance_id = self._derive_instance_id(instance_id_seed, app_id) + gateway_urls = self._get_gateway_urls(app, instance_id) + if gateway_urls: + print("") + print("Gateway URLs:") + print(f" App URL: {gateway_urls.get('app_url', 'N/A')}") + print(f" Instance URL: {gateway_urls.get('instance_url', 'N/A')}") + + def logs(self, follow: bool = False, lines: int = 100) -> None: + """View serial console logs.""" + state = self.load_state() + if not state or not state.instance_name: + raise ValueError("No deployment found. Run 'dstack-cloud deploy' first.") + + if follow: + # Tail logs continuously + logger.info(f"Following serial output for {state.instance_name}...") + logger.info("Press Ctrl+C to stop") + + last_output = "" + while True: + try: + result = self._run_gcloud([ + "compute", "instances", "get-serial-port-output", + state.instance_name, + f"--zone={state.zone}", + f"--project={state.project}" + ], check=False) + + if result.returncode == 0: + output = result.stdout + if output != last_output: + # Print only new content + if last_output: + new_content = output[len(last_output):] + if new_content: + print(new_content, end="", flush=True) + else: + # First time, print last N lines + lines_list = output.split('\n') + print('\n'.join(lines_list[-lines:]), flush=True) + last_output = output + + time.sleep(2) + except KeyboardInterrupt: + print("\nStopped following logs.") + break + else: + # Get logs once + result = self._run_gcloud([ + "compute", "instances", "get-serial-port-output", + state.instance_name, + f"--zone={state.zone}", + f"--project={state.project}" + ]) + + output = result.stdout + lines_list = output.split('\n') + print('\n'.join(lines_list[-lines:])) + + def stop(self) -> None: + """Stop the VM.""" + state = self.load_state() + if not state or not state.instance_name: + raise ValueError("No deployment found. Run 'dstack-cloud deploy' first.") + + # Check if instance exists + result = self._run_gcloud([ + "compute", "instances", "describe", state.instance_name, + f"--zone={state.zone}", + f"--project={state.project}" + ], check=False) + if result.returncode != 0: + if "was not found" in result.stderr: + logger.info(f"Instance {state.instance_name} does not exist (already removed?).") + return + raise RuntimeError(f"gcloud command failed: {result.stderr}") + + logger.info(f"Stopping instance {state.instance_name}...") + self._run_gcloud([ + "compute", "instances", "stop", state.instance_name, + f"--zone={state.zone}", + f"--project={state.project}" + ]) + + state.status = "STOPPED" + self.save_state(state) + logger.info("Instance stopped.") + + def start(self) -> None: + """Start a stopped VM.""" + state = self.load_state() + if not state or not state.instance_name: + raise ValueError("No deployment found. Run 'dstack-cloud deploy' first.") + + # Check if instance exists + result = self._run_gcloud([ + "compute", "instances", "describe", state.instance_name, + f"--zone={state.zone}", + f"--project={state.project}" + ], check=False) + if result.returncode != 0: + if "was not found" in result.stderr: + raise ValueError( + f"Instance {state.instance_name} does not exist. " + f"Run 'dstack-cloud deploy' to create it." + ) + raise RuntimeError(f"gcloud command failed: {result.stderr}") + + logger.info(f"Starting instance {state.instance_name}...") + self._run_gcloud([ + "compute", "instances", "start", state.instance_name, + f"--zone={state.zone}", + f"--project={state.project}" + ]) + + # Update state with new IP + self.status() + logger.info("Instance started.") + + def remove(self, keep_images: bool = False) -> None: + """Remove the VM and cleanup.""" + state = self.load_state() + if not state or not state.instance_name: + logger.info("No deployment found.") + return + + # Delete instance + logger.info(f"Deleting instance {state.instance_name}...") + self._run_gcloud([ + "compute", "instances", "delete", state.instance_name, + f"--zone={state.zone}", + f"--project={state.project}", + "--quiet" + ], check=False) + + if not keep_images and state.shared_image: + # Delete shared disk image + logger.info(f"Deleting shared disk image {state.shared_image}...") + self._run_gcloud([ + "compute", "images", "delete", state.shared_image, + f"--project={state.project}", + "--quiet" + ], check=False) + + # Clear state + state.status = "REMOVED" + state.external_ip = "" + state.internal_ip = "" + self.save_state(state) + + logger.info("Instance removed.") + + def list_deployments(self, project: Optional[str] = None) -> None: + """List all dstack deployments in a project.""" + if not project: + # Try to get from config + try: + config = self.load_gcp_config() + project = config.project + except FileNotFoundError: + # Try global config + global_config = self._load_global_config() + project = global_config.get("gcp", {}).get("project", "") + + if not project: + raise ValueError("Project is required. Specify with --project or configure it.") + + result = self._run_gcloud([ + "compute", "instances", "list", + f"--project={project}", + "--filter=name~^dstack-", + "--format=table(name,zone,status,networkInterfaces[0].accessConfigs[0].natIP:label=EXTERNAL_IP,creationTimestamp)" + ], capture=False) + + def _get_firewall_rule_name(self, instance_name: str, port: int, protocol: str, + action: str = "allow") -> str: + """Generate firewall rule name for an instance port.""" + return f"{instance_name}-{action}-{protocol}-{port}" + + def _parse_port_spec(self, port_spec: str) -> tuple: + """Parse port specification like '8080' or '53/udp'. + + Returns: + tuple: (port: int, protocol: str) + """ + if "/" in port_spec: + port_str, protocol = port_spec.split("/", 1) + protocol = protocol.lower() + if protocol not in ("tcp", "udp"): + raise ValueError(f"Invalid protocol '{protocol}'. Must be 'tcp' or 'udp'.") + else: + port_str = port_spec + protocol = "tcp" + + try: + port = int(port_str) + except ValueError: + raise ValueError(f"Invalid port number '{port_str}'.") + + if not (1 <= port <= 65535): + raise ValueError(f"Port {port} out of range (1-65535).") + + return port, protocol + + def _get_project_for_firewall(self) -> str: + """Get project ID for firewall operations.""" + # Try state first + state = self.load_state() + if state and state.project: + return state.project + + # Try config + try: + config = self.load_gcp_config() + if config.project: + return config.project + except FileNotFoundError: + pass + + # Try global config + global_config = self._load_global_config() + project = global_config.get("gcp", {}).get("project", "") + + if not project: + raise ValueError("Project is required. Deploy first or specify with --project.") + + return project + + def _get_instance_name_for_firewall(self) -> str: + """Get instance name for firewall operations.""" + state = self.load_state() + if state and state.instance_name: + return state.instance_name + + try: + config = self.load_gcp_config() + if config.instance_name: + return config.instance_name + except FileNotFoundError: + pass + + raise ValueError("Instance name is required. Deploy first or specify with --instance.") + + def _ensure_instance_tag(self, instance_name: str, project: str) -> str: + """Ensure instance has the firewall tag, return the tag name.""" + instance_tag = f"fw-{instance_name}" + + state = self.load_state() + zone = state.zone if state and state.zone else "" + if not zone: + try: + config = self.load_gcp_config() + zone = config.zone + except FileNotFoundError: + pass + if not zone: + global_config = self._load_global_config() + zone = global_config.get("gcp", {}).get("zone", "us-central1-a") + + result = self._run_gcloud([ + "compute", "instances", "describe", instance_name, + f"--project={project}", + f"--zone={zone}", + "--format=value(tags.items)" + ], check=False) + + current_tags = result.stdout.strip().split(";") if result.stdout.strip() else [] + current_tags = [t.strip() for t in current_tags if t.strip()] + + if instance_tag not in current_tags: + logger.info(f"Adding tag '{instance_tag}' to instance '{instance_name}'...") + self._run_gcloud([ + "compute", "instances", "add-tags", instance_name, + f"--project={project}", + f"--zone={zone}", + f"--tags={instance_tag}" + ]) + logger.info(f"Added tag '{instance_tag}' to instance") + else: + logger.debug(f"Instance already has tag '{instance_tag}'") + + return instance_tag + + def fw_allow(self, port_spec: str, + source_ranges: Optional[List[str]] = None, + instance_name: Optional[str] = None, + project: Optional[str] = None) -> None: + """Add firewall rule to open a port for the instance.""" + port, protocol = self._parse_port_spec(port_spec) + project = project or self._get_project_for_firewall() + instance_name = instance_name or self._get_instance_name_for_firewall() + + if source_ranges is None: + source_ranges = ["0.0.0.0/0"] + + # Always ensure instance has the tag first + instance_tag = self._ensure_instance_tag(instance_name, project) + + rule_name = self._get_firewall_rule_name(instance_name, port, protocol) + + # Check if rule already exists + result = self._run_gcloud([ + "compute", "firewall-rules", "describe", rule_name, + f"--project={project}" + ], check=False) + + if result.returncode == 0: + logger.info(f"Firewall rule '{rule_name}' already exists") + return + + # Create firewall rule targeting this instance's tag + logger.info(f"Creating firewall rule '{rule_name}'...") + self._run_gcloud([ + "compute", "firewall-rules", "create", rule_name, + f"--project={project}", + f"--allow={protocol}:{port}", + f"--source-ranges={','.join(source_ranges)}", + f"--target-tags={instance_tag}", + f"--description=Allow {protocol.upper()} port {port} for {instance_name}" + ]) + + logger.info(f"Opened {protocol.upper()} port {port} for instance '{instance_name}'") + + def fw_deny(self, port_spec: str, + source_ranges: Optional[List[str]] = None, + instance_name: Optional[str] = None, + project: Optional[str] = None) -> None: + """Create a deny firewall rule to block traffic on a port.""" + port, protocol = self._parse_port_spec(port_spec) + project = project or self._get_project_for_firewall() + instance_name = instance_name or self._get_instance_name_for_firewall() + + if source_ranges is None: + source_ranges = ["0.0.0.0/0"] + + # Always ensure instance has the tag first + instance_tag = self._ensure_instance_tag(instance_name, project) + + rule_name = self._get_firewall_rule_name(instance_name, port, protocol, action="deny") + + # Check if rule already exists + result = self._run_gcloud([ + "compute", "firewall-rules", "describe", rule_name, + f"--project={project}" + ], check=False) + + if result.returncode == 0: + logger.info(f"Firewall rule '{rule_name}' already exists") + return + + # Create deny firewall rule with high priority (low number = high priority) + logger.info(f"Creating deny firewall rule '{rule_name}'...") + self._run_gcloud([ + "compute", "firewall-rules", "create", rule_name, + f"--project={project}", + "--action=DENY", + f"--rules={protocol}:{port}", + f"--source-ranges={','.join(source_ranges)}", + f"--target-tags={instance_tag}", + "--priority=900", + f"--description=Deny {protocol.upper()} port {port} for {instance_name}" + ]) + + logger.info(f"Blocked {protocol.upper()} port {port} for instance '{instance_name}'") + + def fw_remove(self, port_spec: str, + instance_name: Optional[str] = None, + project: Optional[str] = None) -> None: + """Remove a firewall rule (allow or deny) for a port.""" + port, protocol = self._parse_port_spec(port_spec) + project = project or self._get_project_for_firewall() + instance_name = instance_name or self._get_instance_name_for_firewall() + + # Try to delete both allow and deny rules + deleted = False + for action in ["allow", "deny"]: + rule_name = self._get_firewall_rule_name(instance_name, port, protocol, action=action) + + # Check if rule exists + result = self._run_gcloud([ + "compute", "firewall-rules", "describe", rule_name, + f"--project={project}" + ], check=False) + + if result.returncode == 0: + # Delete the rule + logger.info(f"Deleting firewall rule '{rule_name}'...") + self._run_gcloud([ + "compute", "firewall-rules", "delete", rule_name, + f"--project={project}", + "--quiet" + ]) + logger.info(f"Removed {action} rule for {protocol.upper()} port {port}") + deleted = True + + if not deleted: + logger.info(f"No firewall rules found for {protocol.upper()} port {port} on instance '{instance_name}'") + + def fw_list(self, instance_name: Optional[str] = None, + project: Optional[str] = None) -> None: + """List firewall rules for an instance.""" + project = project or self._get_project_for_firewall() + + if instance_name: + # List rules for specific instance + instance_tag = f"fw-{instance_name}" + filter_expr = f"name~^{instance_name}-allow- OR targetTags:{instance_tag}" + else: + # Try to get instance name from state + try: + instance_name = self._get_instance_name_for_firewall() + filter_expr = f"name~^{instance_name}-allow-" + except ValueError: + # List all dstack-related firewall rules + filter_expr = "name~^dstack-" + + logger.info(f"Firewall rules for project '{project}':") + self._run_gcloud([ + "compute", "firewall-rules", "list", + f"--project={project}", + f"--filter={filter_expr}", + "--format=table(name,direction,priority,allowed[].map().firewall_rule().list():label=ALLOW,sourceRanges.list():label=SRC_RANGES,targetTags.list():label=TARGET_TAGS)" + ], capture=False) + + + def image_sync(self, image_ref: str, registry_url: str = None, + cosign_verify: bool = True) -> None: + """Sync an image from public registry to customer GCP Artifact Registry. + + image_ref: e.g. "docker.io/vendor/workload:v2" or "ghcr.io/vendor/launcher:v1" + Pulls by digest, verifies cosign signature, re-pushes to GCP AR. + """ + # Get registry URL from config or arg + if not registry_url: + registry_url = self._load_platform_config(None, None).get("gcp_ar_url", "") + if not registry_url: + raise ValueError("gcp_ar_url not set in platform config or --registry-url not given") + + # Verify cosign signature (if enabled) + if cosign_verify: + platform_config = self._load_platform_config(None, None) + cosign_pubkey = platform_config.get("cosign_pubkey", "") + if cosign_pubkey: + with tempfile.NamedTemporaryFile(mode='w', suffix='.pub', delete=False) as f: + f.write(cosign_pubkey) + pubkey_file = f.name + try: + print(f"verifying cosign signature for {image_ref}...") + result = subprocess.run( + ["cosign", "verify", "--key", pubkey_file, image_ref], + capture_output=True, text=True + ) + if result.returncode != 0: + raise RuntimeError(f"cosign verification failed: {result.stderr}") + print("cosign signature verified.") + finally: + os.unlink(pubkey_file) + else: + print("warning: cosign_pubkey not configured, skipping signature verification") + + # Determine destination ref in GCP AR + # image_ref might be "registry/vendor/workload:v2", destination: "{registry_url}/workload:v2" + image_name = image_ref.split("/")[-1] # e.g. "workload:v2" + dest_ref = f"{registry_url}/{image_name}" + + # Copy image: try skopeo first, fall back to docker pull+tag+push + skopeo_available = subprocess.run(["which", "skopeo"], capture_output=True).returncode == 0 + if skopeo_available: + print(f"copying {image_ref} -> {dest_ref} via skopeo...") + result = subprocess.run( + ["skopeo", "copy", f"docker://{image_ref}", f"docker://{dest_ref}"], + capture_output=True, text=True + ) + if result.returncode != 0: + raise RuntimeError(f"skopeo copy failed: {result.stderr}") + else: + print(f"copying {image_ref} -> {dest_ref} via docker pull+tag+push...") + for cmd in [ + ["docker", "pull", image_ref], + ["docker", "tag", image_ref, dest_ref], + ["docker", "push", dest_ref], + ]: + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"command {cmd[0]} failed: {result.stderr}") + + print(f"image synced: {dest_ref}") + + def kms_deploy(self, customer_id: str, zone: str = None, + machine_type: str = None) -> None: + """Deploy a KMS CVM with the sidecar+kms docker-compose app. + + Creates a Confidential VM running the kms docker-compose app. + Uses the same GCP project/region as existing deploy() method. + """ + try: + import yaml + except ImportError: + raise RuntimeError("pyyaml is required for kms deploy: pip install pyyaml") + + zone = zone or self.zone + machine_type = machine_type or "n2d-standard-4" + + # Build app-compose for KMS + kms_compose = { + "services": { + "sidecar": { + "image": "ghcr.io/phala-network/kms-sidecar:latest", + "volumes": [ + "kms-volume:/kms", + "/var/run/dstack.sock:/var/run/dstack.sock" + ], + "ports": ["8001:8001", "8002:8002"], + "environment": ["PLATFORM_PUBKEY=${PLATFORM_PUBKEY}"], + "healthcheck": { + "test": ["CMD-SHELL", "wget -qO- http://localhost:8001/healthz || exit 1"], + "interval": "5s", + "timeout": "3s", + "retries": 60, + "start_period": "5s", + }, + }, + "kms": { + "image": "ghcr.io/phala-network/dstack-kms:latest", + "volumes": [ + "kms-volume:/kms", + "/var/run/dstack.sock:/var/run/dstack.sock" + ], + "ports": ["8000:8000"], + "depends_on": {"sidecar": {"condition": "service_healthy"}}, + "environment": ["AUTH_WEBHOOK_URL=http://sidecar:8001"], + }, + }, + "volumes": {"kms-volume": {}}, + } + kms_compose_str = yaml.dump(kms_compose, default_flow_style=False) + + print(f"deploying KMS CVM for customer {customer_id} in {zone}...") + print("kms_compose:") + print(kms_compose_str) + print("TODO: call existing deploy() with KMS compose app, STANDARD disk, no preemptible") + + +def main(): + parser = argparse.ArgumentParser( + description="Multi-cloud VM lifecycle management tool", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=f""" +Examples: + # Create a new project + dstack-cloud new myproject + + # Edit global configuration + dstack-cloud config-edit + + # Download OS image + dstack-cloud pull {DEFAULT_OS_IMAGE} + + # Deploy VM + dstack-cloud deploy + + # Check status + dstack-cloud status + + # View logs + dstack-cloud logs --follow + + # Stop/Start/Remove + dstack-cloud stop + dstack-cloud start + dstack-cloud remove + + # Firewall management + dstack-cloud fw allow 8080 # Allow TCP port 8080 + dstack-cloud fw allow 53/udp # Allow UDP port 53 + dstack-cloud fw allow 443 -s 10.0.0.0/8 # Allow port 443 from specific range + dstack-cloud fw deny 22 # Block TCP port 22 + dstack-cloud fw deny 22 -s 0.0.0.0/0 # Block port 22 from all sources + dstack-cloud fw remove 8080 # Remove firewall rule for port 8080 + dstack-cloud fw list # List firewall rules +""" + ) + + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + parser.add_argument("-C", "--directory", type=str, help="Change to directory before running") + + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # new command + new_parser = subparsers.add_parser("new", help="Create a new project") + new_parser.add_argument("name", type=str, help="Project name") + + # App configuration options + new_parser.add_argument("--os-image", type=str, help=f"OS image (e.g., {DEFAULT_OS_IMAGE})") + new_parser.add_argument("--app-id", type=str, help="Application ID (40 hex chars)") + new_parser.add_argument("--gateway-enabled", "--gw", dest="gateway_enabled", action="store_true", help="Enable dstack-gateway") + new_parser.add_argument("--no-gateway-enabled", "--no-gw", dest="gateway_enabled", action="store_false") + new_parser.set_defaults(gateway_enabled=None) + new_parser.add_argument("--key-provider", "--kp", type=str, choices=["kms", "local", "tpm", "none"], help="Key provider type") + new_parser.add_argument("--storage-fs", "--fs", dest="storage_fs", type=str, choices=["ext4", "zfs"], help="Storage filesystem") + new_parser.add_argument("--secure-time", action="store_true", help="Enable secure time synchronization") + new_parser.add_argument("--no-secure-time", dest="secure_time", action="store_false") + new_parser.set_defaults(secure_time=None) + new_parser.add_argument("--no-instance-id", action="store_true", help="Disable instance ID generation") + + # GCP configuration options + new_parser.add_argument("--project", "-p", type=str, help="GCP project ID") + new_parser.add_argument("--zone", "-z", type=str, help="GCP zone (e.g., us-central1-a)") + new_parser.add_argument("--instance-name", type=str, help="GCP instance name") + new_parser.add_argument("--machine-type", "-m", type=str, help="Machine type (e.g., c3-standard-4)") + new_parser.add_argument("--data-size", type=int, help="Data disk size in GB") + + # config-edit command + subparsers.add_parser("config-edit", help="Edit global configuration") + + # prepare command + subparsers.add_parser("prepare", help="Generate shared files") + + # pull command + pull_parser = subparsers.add_parser("pull", help="Download OS image") + pull_parser.add_argument("image", type=str, help=f"OS image name (e.g., {DEFAULT_OS_IMAGE}) or absolute URL (e.g., https://example.com/image-uki.tar.gz)") + + # deploy command + deploy_parser = subparsers.add_parser("deploy", help="Deploy VM to cloud") + deploy_parser.add_argument("--delete", "-d", action="store_true", + help="Delete existing instance first") + deploy_parser.add_argument("--force-boot-image", action="store_true", + help="Force re-upload boot image") + + # status command + subparsers.add_parser("status", help="Check deployment status") + + # logs command + logs_parser = subparsers.add_parser("logs", help="View serial console logs") + logs_parser.add_argument("--follow", "-f", action="store_true", help="Follow log output") + logs_parser.add_argument("--lines", "-n", type=int, default=100, help="Number of lines to show") + + # stop command + subparsers.add_parser("stop", help="Stop the VM") + + # start command + subparsers.add_parser("start", help="Start a stopped VM") + + # remove command + remove_parser = subparsers.add_parser("remove", help="Remove the VM and cleanup") + remove_parser.add_argument("--keep-images", action="store_true", + help="Keep disk images in GCP") + + # list command + list_parser = subparsers.add_parser("list", help="List all deployments") + list_parser.add_argument("--project", "-p", type=str, help="GCP project ID") + + # fw command group + fw_parser = subparsers.add_parser("fw", help="Firewall management") + fw_subparsers = fw_parser.add_subparsers(dest="fw_command", help="Firewall commands") + + # fw allow + fw_allow_parser = fw_subparsers.add_parser("allow", help="Open a port for the instance") + fw_allow_parser.add_argument("port", type=str, help="Port to open (e.g., 8080, 53/udp)") + fw_allow_parser.add_argument("--source", "-s", type=str, action="append", + dest="source_ranges", + help="Source IP ranges (default: 0.0.0.0/0). Can be specified multiple times.") + fw_allow_parser.add_argument("--instance", "-i", type=str, help="Instance name (default: from state)") + fw_allow_parser.add_argument("--project", "-p", type=str, help="GCP project ID") + + # fw deny + fw_deny_parser = fw_subparsers.add_parser("deny", help="Block traffic on a port") + fw_deny_parser.add_argument("port", type=str, help="Port to block (e.g., 8080, 53/udp)") + fw_deny_parser.add_argument("--source", "-s", type=str, action="append", + dest="source_ranges", + help="Source IP ranges to block (default: 0.0.0.0/0). Can be specified multiple times.") + fw_deny_parser.add_argument("--instance", "-i", type=str, help="Instance name (default: from state)") + fw_deny_parser.add_argument("--project", "-p", type=str, help="GCP project ID") + + # fw remove + fw_remove_parser = fw_subparsers.add_parser("remove", help="Remove a firewall rule") + fw_remove_parser.add_argument("port", type=str, help="Port to remove rule for (e.g., 8080, 53/udp)") + fw_remove_parser.add_argument("--instance", "-i", type=str, help="Instance name (default: from state)") + fw_remove_parser.add_argument("--project", "-p", type=str, help="GCP project ID") + + # fw list + fw_list_parser = fw_subparsers.add_parser("list", help="List firewall rules for instance") + fw_list_parser.add_argument("--instance", "-i", type=str, help="Instance name (default: from state)") + fw_list_parser.add_argument("--project", "-p", type=str, help="GCP project ID") + + # image command group + image_parser = subparsers.add_parser("image", help="Container image management") + image_subparsers = image_parser.add_subparsers(dest="image_command", help="Image commands") + + # image sync + image_sync_parser = image_subparsers.add_parser( + "sync", + help="Sync an image from public registry to GCP Artifact Registry" + ) + image_sync_parser.add_argument("image_ref", type=str, + help="Source image reference (e.g., ghcr.io/vendor/workload:v1)") + image_sync_parser.add_argument("--registry-url", type=str, + help="Destination GCP AR registry URL (overrides platform config gcp_ar_url)") + image_sync_parser.add_argument("--no-cosign-verify", dest="cosign_verify", + action="store_false", default=True, + help="Skip cosign signature verification") + + # kms command group + kms_parser = subparsers.add_parser("kms", help="KMS courier operations (air-gapped provisioning)") + kms_subparsers = kms_parser.add_subparsers(dest="kms_command", help="KMS commands") + + # kms attest + kms_attest_parser = kms_subparsers.add_parser( + "attest", + help="Provision KMS via courier protocol (first-time setup)" + ) + kms_attest_parser.add_argument("--customer-id", required=True, + help="Customer identifier registered with the vendor platform") + kms_attest_parser.add_argument("--sidecar-url", required=True, + help="URL of the in-VPC KMS courier sidecar (e.g., http://10.0.0.5:8001)") + kms_attest_parser.add_argument("--platform-url", + help="Vendor platform base URL (overrides config/env)") + kms_attest_parser.add_argument("--api-key", + help="Vendor platform API key (overrides config/env)") + + # kms sync-auth + kms_sync_auth_parser = kms_subparsers.add_parser( + "sync-auth", + help="Renew AuthBundle for a provisioned KMS instance" + ) + kms_sync_auth_parser.add_argument("--customer-id", required=True, + help="Customer identifier registered with the vendor platform") + kms_sync_auth_parser.add_argument("--sidecar-url", required=True, + help="URL of the in-VPC KMS courier sidecar (e.g., http://10.0.0.5:8001)") + kms_sync_auth_parser.add_argument("--platform-url", + help="Vendor platform base URL (overrides config/env)") + kms_sync_auth_parser.add_argument("--api-key", + help="Vendor platform API key (overrides config/env)") + + # kms deploy + kms_deploy_parser = kms_subparsers.add_parser( + "deploy", + help="Deploy a KMS CVM with sidecar+kms docker-compose app" + ) + kms_deploy_parser.add_argument("--customer-id", required=True, + help="Customer identifier for the KMS deployment") + kms_deploy_parser.add_argument("--zone", "-z", type=str, + help="GCP zone (overrides config)") + kms_deploy_parser.add_argument("--machine-type", "-m", type=str, + help="Machine type (default: n2d-standard-4)") + + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + work_dir = args.directory if args.directory else None + manager = CloudDeploymentManager(work_dir) + + try: + if args.command == "new": + manager.new( + args.name, + os_image=args.os_image, + app_id=args.app_id, + gateway_enabled=args.gateway_enabled, + key_provider=args.key_provider, + storage_fs=args.storage_fs, + secure_time=args.secure_time, + no_instance_id=args.no_instance_id, + project=args.project, + zone=args.zone, + instance_name=args.instance_name, + machine_type=args.machine_type, + data_size=args.data_size + ) + elif args.command == "config-edit": + manager.config_edit() + elif args.command == "prepare": + manager.prepare() + elif args.command == "pull": + manager.pull(args.image) + elif args.command == "deploy": + manager.deploy( + delete_existing=args.delete, + force_boot_image=args.force_boot_image + ) + elif args.command == "status": + manager.status() + elif args.command == "logs": + manager.logs(follow=args.follow, lines=args.lines) + elif args.command == "stop": + manager.stop() + elif args.command == "start": + manager.start() + elif args.command == "remove": + manager.remove(keep_images=args.keep_images) + elif args.command == "list": + manager.list_deployments(project=args.project) + elif args.command == "fw": + if args.fw_command == "allow": + manager.fw_allow( + port_spec=args.port, + source_ranges=args.source_ranges, + instance_name=args.instance, + project=args.project + ) + elif args.fw_command == "deny": + manager.fw_deny( + port_spec=args.port, + source_ranges=args.source_ranges, + instance_name=args.instance, + project=args.project + ) + elif args.fw_command == "remove": + manager.fw_remove( + port_spec=args.port, + instance_name=args.instance, + project=args.project + ) + elif args.fw_command == "list": + manager.fw_list( + instance_name=args.instance, + project=args.project + ) + else: + fw_parser.print_help() + elif args.command == "image": + if args.image_command == "sync": + manager.image_sync( + image_ref=args.image_ref, + registry_url=args.registry_url, + cosign_verify=args.cosign_verify, + ) + else: + image_parser.print_help() + elif args.command == "kms": + if args.kms_command == "attest": + manager.kms_attest( + customer_id=args.customer_id, + sidecar_url=args.sidecar_url, + platform_url=args.platform_url, + api_key=args.api_key, + ) + elif args.kms_command == "sync-auth": + manager.kms_sync_auth( + customer_id=args.customer_id, + sidecar_url=args.sidecar_url, + platform_url=args.platform_url, + api_key=args.api_key, + ) + elif args.kms_command == "deploy": + manager.kms_deploy( + customer_id=args.customer_id, + zone=args.zone, + machine_type=args.machine_type, + ) + else: + kms_parser.print_help() + else: + parser.print_help() + except Exception as e: + logger.error(str(e)) + if args.verbose: + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tpm-attest/Cargo.toml b/tpm-attest/Cargo.toml new file mode 100644 index 000000000..931b5b631 --- /dev/null +++ b/tpm-attest/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: © 2025 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "tpm-attest" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +hex = { workspace = true, features = ["alloc"] } +serde = { workspace = true, features = ["derive"] } +serde-human-bytes.workspace = true +serde_json = { workspace = true, features = ["std"] } +sha2 = { workspace = true, features = ["oid"] } +tempfile.workspace = true +tracing.workspace = true +fs-err.workspace = true +tpm-types.workspace = true +dstack-types.workspace = true +tpm2.workspace = true +scale.workspace = true diff --git a/tpm-attest/src/esapi.rs b/tpm-attest/src/esapi.rs new file mode 100644 index 000000000..7b5c41850 --- /dev/null +++ b/tpm-attest/src/esapi.rs @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +use crate::{PcrSelection, PcrValue}; +use anyhow::{bail, Result}; +use tpm2::{TpmAlgId, TpmContext as RawTpmContext, TpmlPcrSelection, TpmsNvPublic}; + +pub struct EsapiContext { + context: RawTpmContext, +} + +impl EsapiContext { + /// Create a new ESAPI context with the given TCTI path + pub fn new(tcti_path: Option<&str>) -> Result { + let context = RawTpmContext::new(tcti_path)?; + Ok(Self { context }) + } + + // ==================== NV Operations ==================== + + /// Check if an NV index exists + pub fn nv_exists(&mut self, index: u32) -> Result { + self.context.nv_exists(index) + } + + /// Read NV public area (to determine the defined size, attributes, etc.) + pub fn nv_read_public(&mut self, index: u32) -> Result { + self.context.nv_read_public(index) + } + + /// Read data from an NV index + pub fn nv_read(&mut self, index: u32) -> Result>> { + self.context.nv_read(index) + } + + /// Write data to an NV index + pub fn nv_write(&mut self, index: u32, data: &[u8]) -> Result { + self.context.nv_write(index, data) + } + + /// Define a new NV index + pub fn nv_define(&mut self, index: u32, size: usize, owner_read_write: bool) -> Result { + self.context.nv_define(index, size, owner_read_write) + } + + /// Undefine (delete) an NV index + pub fn nv_undefine(&mut self, index: u32) -> Result { + self.context.nv_undefine(index) + } + + // ==================== PCR Operations ==================== + + /// Read PCR values for the given selection + pub fn pcr_read(&mut self, pcr_selection: &PcrSelection) -> Result> { + let hash_alg = Self::parse_hash_alg(&pcr_selection.bank)?; + let tpm_selection = TpmlPcrSelection::single(hash_alg, &pcr_selection.pcrs); + + let values = self.context.pcr_read(&tpm_selection)?; + + Ok(values + .into_iter() + .map(|(index, value)| PcrValue { + index, + algorithm: pcr_selection.bank.clone(), + value, + }) + .collect()) + } + + /// Extend a PCR with a hash value + pub fn pcr_extend(&mut self, pcr: u32, hash: &[u8], bank: &str) -> Result<()> { + let hash_alg = Self::parse_hash_alg(bank)?; + self.context.pcr_extend(pcr, hash, hash_alg) + } + + // ==================== Random Number Generation ==================== + + /// Generate random bytes using the TPM's hardware RNG + pub fn get_random(&mut self) -> Result<[u8; N]> { + self.context.get_random_array::() + } + + // ==================== Primary Key Operations ==================== + + /// Check if a persistent handle exists + pub fn handle_exists(&mut self, handle: u32) -> Result { + self.context.handle_exists(handle) + } + + /// Ensure a persistent primary key exists at the given handle + pub fn ensure_primary_key(&mut self, handle: u32) -> Result { + self.context.ensure_primary_key(handle) + } + + // ==================== Seal/Unseal Operations ==================== + + /// Seal data to TPM with PCR policy + pub fn seal( + &mut self, + data: &[u8], + parent_handle: u32, + pcr_selection: &PcrSelection, + ) -> Result<(Vec, Vec)> { + let hash_alg = Self::parse_hash_alg(&pcr_selection.bank)?; + let tpm_selection = TpmlPcrSelection::single(hash_alg, &pcr_selection.pcrs); + + self.context + .seal(data, parent_handle, &tpm_selection, hash_alg) + } + + /// Unseal data from TPM with PCR policy + pub fn unseal( + &mut self, + pub_bytes: &[u8], + priv_bytes: &[u8], + parent_handle: u32, + pcr_selection: &PcrSelection, + ) -> Result> { + let hash_alg = Self::parse_hash_alg(&pcr_selection.bank)?; + let tpm_selection = TpmlPcrSelection::single(hash_alg, &pcr_selection.pcrs); + + self.context.unseal( + pub_bytes, + priv_bytes, + parent_handle, + &tpm_selection, + hash_alg, + ) + } + + // ==================== Helper Functions ==================== + + fn parse_hash_alg(bank: &str) -> Result { + match bank { + "sha256" => Ok(TpmAlgId::Sha256), + "sha384" => Ok(TpmAlgId::Sha384), + "sha512" => Ok(TpmAlgId::Sha512), + "sha1" => Ok(TpmAlgId::Sha1), + _ => bail!("unsupported hash algorithm: {}", bank), + } + } +} diff --git a/tpm-attest/src/gcp_ak.rs b/tpm-attest/src/gcp_ak.rs new file mode 100644 index 000000000..56a703135 --- /dev/null +++ b/tpm-attest/src/gcp_ak.rs @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! GCP vTPM pre-provisioned AK loading +//! +//! This module provides native Rust implementation for loading GCP's +//! pre-provisioned Attestation Key without C library dependencies. + +use std::str::FromStr; + +use anyhow::{Context as _, Result}; +use tracing::debug; + +use crate::{PcrSelection, PcrValue, TpmEventLog, TpmQuote}; +use tpm2::{tpm_rh, TpmAlgId, TpmContext, TpmlPcrSelection}; + +/// GCP vTPM NV indices for pre-provisioned AK +pub mod gcp_nv_index { + /// RSA AK certificate (DER format) + pub const AK_RSA_CERT: u32 = 0x01C10000; + /// RSA AK template (TPM2B_PUBLIC format) + pub const AK_RSA_TEMPLATE: u32 = 0x01C10001; + /// ECC AK certificate (DER format) + pub const AK_ECC_CERT: u32 = 0x01C10002; + /// ECC AK template (TPM2B_PUBLIC format) + pub const AK_ECC_TEMPLATE: u32 = 0x01C10003; +} + +/// Loaded AK information +pub struct LoadedAk { + pub context: TpmContext, + pub handle: u32, + pub cert_nv_index: u32, +} + +/// Load GCP pre-provisioned ECC AK +/// +/// This function: +/// 1. Reads the AK template from NV index 0x01C10003 +/// 2. Creates a primary key under Endorsement hierarchy with the template +/// 3. TPM deterministically recreates the same key pair (same template + same parent) +pub fn load_gcp_ak_ecc(tcti_path: Option<&str>) -> Result { + debug!("loading GCP pre-provisioned ECC AK..."); + + let mut context = TpmContext::new(tcti_path)?; + + // Read AK template from NV + let template_bytes = context + .nv_read(gcp_nv_index::AK_ECC_TEMPLATE)? + .ok_or_else(|| anyhow::anyhow!("ECC AK template not found at NV 0x01C10003"))?; + + debug!( + "read ECC AK template from NV: {} bytes", + template_bytes.len() + ); + + // Create primary key under Endorsement hierarchy + let (handle, _public) = + context.create_primary_from_template(tpm_rh::ENDORSEMENT, &template_bytes)?; + + debug!( + "✓ successfully loaded GCP pre-provisioned ECC AK (handle: 0x{:08x})", + handle + ); + + Ok(LoadedAk { + context, + handle, + cert_nv_index: gcp_nv_index::AK_ECC_CERT, + }) +} + +/// Load GCP pre-provisioned RSA AK +/// +/// This function: +/// 1. Reads the AK template from NV index 0x01C10001 +/// 2. Creates a primary key under Endorsement hierarchy with the template +/// 3. TPM deterministically recreates the same key pair (same template + same parent) +pub fn load_gcp_ak_rsa(tcti_path: Option<&str>) -> Result { + debug!("loading GCP pre-provisioned RSA AK..."); + + let mut context = TpmContext::new(tcti_path)?; + + // Read AK template from NV + let template_bytes = context + .nv_read(gcp_nv_index::AK_RSA_TEMPLATE)? + .ok_or_else(|| anyhow::anyhow!("RSA AK template not found at NV 0x01C10001"))?; + + debug!( + "read RSA AK template from NV: {} bytes", + template_bytes.len() + ); + + // Create primary key under Endorsement hierarchy + let (handle, _public) = + context.create_primary_from_template(tpm_rh::ENDORSEMENT, &template_bytes)?; + + debug!( + "✓ successfully loaded GCP pre-provisioned RSA AK (handle: 0x{:08x})", + handle + ); + + Ok(LoadedAk { + context, + handle, + cert_nv_index: gcp_nv_index::AK_RSA_CERT, + }) +} + +/// Key algorithm preference for quote generation +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyAlgorithm { + /// Prefer ECC, fallback to RSA + Auto, + /// Use ECC only (fails if not available) + Ecc, + /// Use RSA only (fails if not available) + Rsa, +} + +impl FromStr for KeyAlgorithm { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "auto" => Ok(KeyAlgorithm::Auto), + "ecc" | "ecdsa" => Ok(KeyAlgorithm::Ecc), + "rsa" | "rsassa" => Ok(KeyAlgorithm::Rsa), + _ => anyhow::bail!("invalid key algorithm: {s}. Use 'auto', 'ecc', or 'rsa'"), + } + } +} + +/// Generate a TPM quote using GCP pre-provisioned AK (prefers ECC) +pub fn create_quote_with_gcp_ak( + tcti_path: Option<&str>, + qualifying_data: &[u8; 32], + pcr_selection: &PcrSelection, +) -> Result { + create_quote_with_gcp_ak_algo( + tcti_path, + qualifying_data, + pcr_selection, + KeyAlgorithm::Auto, + ) +} + +/// Generate a TPM quote using GCP pre-provisioned AK with manual algorithm selection +pub fn create_quote_with_gcp_ak_algo( + tcti_path: Option<&str>, + qualifying_data: &[u8; 32], + pcr_selection: &PcrSelection, + key_algo: KeyAlgorithm, +) -> Result { + let platform = dstack_types::Platform::detect().context("Unsupported platform")?; + + debug!("generating TPM quote with GCP pre-provisioned AK..."); + + // Load GCP pre-provisioned AK based on algorithm preference + let mut loaded_ak = match key_algo { + KeyAlgorithm::Auto => { + // Try ECC first (better performance), fallback to RSA + match load_gcp_ak_ecc(tcti_path) { + Ok(ak) => { + debug!("✓ using ECC AK for quote"); + ak + } + Err(e) => { + debug!("ECC AK not available, falling back to RSA: {e}"); + let ak = load_gcp_ak_rsa(tcti_path)?; + debug!("✓ using RSA AK for quote"); + ak + } + } + } + KeyAlgorithm::Ecc => { + let ak = load_gcp_ak_ecc(tcti_path).context( + "failed to load ECC AK (use --key-algo=rsa or --key-algo=auto for fallback)", + )?; + debug!("✓ using ECC AK for quote"); + ak + } + KeyAlgorithm::Rsa => { + let ak = load_gcp_ak_rsa(tcti_path).context("failed to load RSA AK")?; + debug!("✓ using RSA AK for quote"); + ak + } + }; + + // Convert hash algorithm + let hash_alg = match pcr_selection.bank.as_str() { + "sha256" => TpmAlgId::Sha256, + "sha384" => TpmAlgId::Sha384, + "sha512" => TpmAlgId::Sha512, + _ => anyhow::bail!( + "unsupported hash algorithm: {bank}", + bank = pcr_selection.bank + ), + }; + + // Build PCR selection + let tpm_pcr_selection = TpmlPcrSelection::single(hash_alg, &pcr_selection.pcrs); + + // Generate quote + debug!("calling TPM Quote command..."); + let (message, signature) = + loaded_ak + .context + .quote(loaded_ak.handle, qualifying_data, &tpm_pcr_selection)?; + + debug!("✓ quote generated successfully"); + + // Read PCR values + let pcr_values_raw = loaded_ak.context.pcr_read(&tpm_pcr_selection)?; + let pcr_values: Vec = pcr_values_raw + .into_iter() + .map(|(index, value)| PcrValue { + index, + algorithm: pcr_selection.bank.clone(), + value, + }) + .collect(); + + // Read AK certificate from NV + let ak_cert = loaded_ak + .context + .nv_read(loaded_ak.cert_nv_index)? + .ok_or_else(|| { + anyhow::anyhow!( + "AK certificate not found at NV 0x{:08x}", + loaded_ak.cert_nv_index + ) + })?; + + debug!( + "✓ AK certificate read from NV 0x{:08x}: {} bytes", + loaded_ak.cert_nv_index, + ak_cert.len() + ); + + // Flush the AK handle + let _ = loaded_ak.context.flush_context(loaded_ak.handle); + + let event_log = TpmEventLog::from_kernel_file() + .context("Failed to read TPM event log")? + .events; + + Ok(TpmQuote { + message, + signature, + pcr_values, + ak_cert, + platform, + event_log, + }) +} diff --git a/tpm-attest/src/lib.rs b/tpm-attest/src/lib.rs new file mode 100644 index 000000000..10e179fdc --- /dev/null +++ b/tpm-attest/src/lib.rs @@ -0,0 +1,336 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM 2.0 Attestation Library +//! +//! This module provides functionality for generating TPM attestation quotes +//! on the device side. It handles PCR operations, sealing, unsealing, NV storage, +//! and quote generation. +//! +//! This follows the same architecture as tdx-attest: device-side attestation only. +//! For quote verification, see the tpm-qvl crate. + +use anyhow::{bail, Context, Result}; +use scale::{Decode, Encode}; +use std::path::Path; +use tracing::{debug, warn}; + +// Re-export tpm-types +pub use tpm_types::{PcrSelection, PcrValue, TpmEvent, TpmEventLog, TpmQuote}; + +mod esapi; +use esapi::EsapiContext; + +pub const PRIMARY_KEY_HANDLE: u32 = 0x81000100; +pub const SEALED_NV_INDEX: u32 = 0x01801101; + +/// PCR selection for DStack +/// 0: The firmware version and NonHostInfo (representing the memory encryption technology) +/// 2: The uki image (kernel + initrd + initramfs) +/// 14: The app compose hash +const APP_PCR: u32 = 14; +pub fn dstack_pcr_policy() -> PcrSelection { + PcrSelection::sha256(&[0, 2, APP_PCR]) +} + +pub struct TpmContext { + tcti: String, +} + +impl std::fmt::Debug for TpmContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TpmContext") + .field("tcti", &self.tcti) + .finish() + } +} + +#[derive(Debug, Clone)] +pub struct SealedBlob { + pub data: Vec, +} + +impl SealedBlob { + pub fn new(data: Vec) -> Self { + Self { data } + } + + pub fn from_parts(pub_data: &[u8], priv_data: &[u8]) -> Self { + let data = (pub_data, priv_data).encode(); + Self { data } + } + + pub fn split(&self) -> Result<(Vec, Vec)> { + if self.data.len() < 4 { + bail!("sealed blob too small"); + } + let (pub_data, priv_data) = Decode::decode(&mut &self.data[..])?; + Ok((pub_data, priv_data)) + } +} + +impl TpmContext { + pub fn open(tcti: Option<&str>) -> Result { + match tcti { + Some(t) => Self::new(t), + None => Self::detect(), + } + } + + pub fn detect() -> Result { + let tcti = if Path::new("/dev/tpmrm0").exists() { + "/dev/tpmrm0" + } else if Path::new("/dev/tpm0").exists() { + "/dev/tpm0" + } else { + bail!("TPM device not found"); + }; + Self::new(tcti) + } + + pub fn new(tcti: &str) -> Result { + Ok(Self { + tcti: tcti.to_string(), + }) + } + + fn create_esapi_context(&self) -> Result { + EsapiContext::new(Some(&self.tcti)) + } + + pub fn nv_exists(&self, index: u32) -> Result { + let mut ctx = self.create_esapi_context()?; + ctx.nv_exists(index) + } + + pub fn nv_define(&self, index: u32, size: usize, _attributes: &str) -> Result { + let mut ctx = self.create_esapi_context()?; + ctx.nv_define(index, size, true) + } + + pub fn nv_undefine(&self, index: u32) -> Result { + let mut ctx = self.create_esapi_context()?; + ctx.nv_undefine(index) + } + + pub fn nv_read(&self, index: u32) -> Result>> { + let mut ctx = self.create_esapi_context()?; + ctx.nv_read(index) + } + + pub fn nv_write(&self, index: u32, data: &[u8]) -> Result { + let mut ctx = self.create_esapi_context()?; + ctx.nv_write(index, data) + } + + pub fn handle_exists(&self, handle: u32) -> Result { + let mut ctx = self.create_esapi_context()?; + ctx.handle_exists(handle) + } + + pub fn ensure_primary_key(&self, handle: u32) -> Result { + let mut ctx = self.create_esapi_context()?; + ctx.ensure_primary_key(handle) + } + + pub fn pcr_extend(&self, pcr: u32, hash: &[u8], bank: &str) -> Result<()> { + let mut ctx = self.create_esapi_context()?; + ctx.pcr_extend(pcr, hash, bank) + } + + pub fn pcr_extend_sha256(&self, pcr: u32, hash: &[u8; 32]) -> Result<()> { + self.pcr_extend(pcr, hash, "sha256") + } + + pub fn dump_pcr_values(&self, selection: &PcrSelection) { + match self + .create_esapi_context() + .and_then(|mut ctx| ctx.pcr_read(selection)) + { + Ok(values) => { + debug!("PCR values ({}):", selection.to_arg()); + for pv in values { + debug!(" PCR[{}] = {}", pv.index, hex::encode(&pv.value)); + } + } + Err(e) => { + warn!("failed to read PCR values: {e}"); + } + } + } + + pub fn get_random(&self) -> Result<[u8; N]> { + let mut ctx = self.create_esapi_context()?; + ctx.get_random::() + } + + pub fn seal( + &self, + data: &[u8], + nv_index: u32, + parent_handle: u32, + pcr_selection: &PcrSelection, + ) -> Result<()> { + let mut ctx = self.create_esapi_context()?; + + ctx.ensure_primary_key(parent_handle)?; + + let (pub_bytes, priv_bytes) = ctx.seal(data, parent_handle, pcr_selection)?; + + let sealed_blob = SealedBlob::from_parts(&pub_bytes, &priv_bytes); + + let needed_size = sealed_blob.data.len(); + if ctx.nv_exists(nv_index)? { + let nv_public = ctx.nv_read_public(nv_index)?; + if (nv_public.data_size as usize) < needed_size { + ctx.nv_undefine(nv_index)?; + ctx.nv_define(nv_index, needed_size, true)?; + } + } else { + ctx.nv_define(nv_index, needed_size, true)?; + } + + ctx.nv_write(nv_index, &sealed_blob.data)?; + + debug!("sealed data to NV index 0x{nv_index:08x}"); + Ok(()) + } + + pub fn unseal_to_vec( + &self, + nv_index: u32, + parent_handle: u32, + pcr_selection: &PcrSelection, + ) -> Result>> { + let mut ctx = self.create_esapi_context()?; + + let sealed_data = match ctx.nv_read(nv_index)? { + Some(data) => data, + None => return Ok(None), + }; + + let sealed_blob = SealedBlob::new(sealed_data); + let (pub_bytes, priv_bytes) = match sealed_blob.split() { + Ok(v) => v, + Err(e) => { + warn!("sealed blob in NV index 0x{nv_index:08x} is invalid ({e}); regenerating"); + let _ = ctx.nv_undefine(nv_index); + return Ok(None); + } + }; + + let data = ctx.unseal(&pub_bytes, &priv_bytes, parent_handle, pcr_selection)?; + + debug!("unsealed data from NV index 0x{nv_index:08x}"); + Ok(Some(data)) + } + + pub fn unseal( + &self, + nv_index: u32, + parent_handle: u32, + pcr_selection: &PcrSelection, + ) -> Result> { + match self.unseal_to_vec(nv_index, parent_handle, pcr_selection)? { + Some(data) => { + let array: [u8; N] = data + .try_into() + .ok() + .context("unsealed data size mismatch")?; + Ok(Some(array)) + } + None => Ok(None), + } + } + + pub fn create_quote( + &self, + qualifying_data: &[u8; 32], + pcr_selection: &PcrSelection, + ) -> Result { + gcp_ak::create_quote_with_gcp_ak(Some(&self.tcti), qualifying_data, pcr_selection) + } + + pub fn create_quote_with_algo( + &self, + qualifying_data: &[u8; 32], + pcr_selection: &PcrSelection, + key_algo: KeyAlgorithm, + ) -> Result { + gcp_ak::create_quote_with_gcp_ak_algo( + Some(&self.tcti), + qualifying_data, + pcr_selection, + key_algo, + ) + } + + pub fn read_ak_cert(&self) -> Result>> { + const AK_RSA_CERT_NV_INDEX: u32 = 0x01C10000; + const AK_ECC_CERT_NV_INDEX: u32 = 0x01C10002; + + let mut ctx = self.create_esapi_context()?; + + if let Some(cert) = ctx.nv_read(AK_RSA_CERT_NV_INDEX)? { + debug!( + "read AK certificate from NV index 0x{AK_RSA_CERT_NV_INDEX:08x} ({} bytes)", + cert.len() + ); + return Ok(Some(cert)); + } + + if let Some(cert) = ctx.nv_read(AK_ECC_CERT_NV_INDEX)? { + debug!( + "read AK certificate from NV index 0x{AK_ECC_CERT_NV_INDEX:08x} ({} bytes)", + cert.len() + ); + return Ok(Some(cert)); + } + + warn!("AK certificate not found in TPM NV storage (expected on GCP vTPM)"); + Ok(None) + } + + pub fn read_event_log(&self, pcr_index: u32) -> Result> { + let event_log = + TpmEventLog::from_kernel_file().context("Failed to read TPM Event Log from kernel")?; + + Ok(event_log.filter_by_pcr(pcr_index)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pcr_selection_to_string() { + let sel = PcrSelection::sha256(&[0, 1, 2, 7]); + assert_eq!(sel.to_arg(), "sha256:0,1,2,7"); + } + + #[test] + fn test_sealed_blob_split() { + let pub_data = vec![0x01, 0x02, 0x03, 0x04, 0x05]; + let priv_data = vec![0xAA, 0xBB, 0xCC]; + + let blob = SealedBlob::from_parts(&pub_data, &priv_data); + let (pub_part, priv_part) = blob.split().unwrap(); + + assert_eq!(pub_part, pub_data); + assert_eq!(priv_part, priv_data); + } + + #[test] + fn test_default_pcr_policy() { + let policy = dstack_pcr_policy(); + assert_eq!(policy.to_arg(), "sha256:0,2,14"); + } +} + +mod gcp_ak; +pub use gcp_ak::{ + create_quote_with_gcp_ak, create_quote_with_gcp_ak_algo, gcp_nv_index, load_gcp_ak_ecc, + load_gcp_ak_rsa, KeyAlgorithm, +}; diff --git a/tpm-attest/tests/tpm_quote_sample.bin b/tpm-attest/tests/tpm_quote_sample.bin new file mode 100644 index 000000000..4866ce803 Binary files /dev/null and b/tpm-attest/tests/tpm_quote_sample.bin differ diff --git a/tpm-qvl/Cargo.toml b/tpm-qvl/Cargo.toml new file mode 100644 index 000000000..4276e7bb1 --- /dev/null +++ b/tpm-qvl/Cargo.toml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: © 2025 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "tpm-qvl" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +hex.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["std"] } +tracing.workspace = true +dstack-types.workspace = true + +# Cryptographic verification +p256 = { workspace = true, features = ["ecdsa", "pem"] } +rsa.workspace = true +x509-parser.workspace = true +nom.workspace = true +base64.workspace = true +sha2 = { workspace = true, features = ["oid"] } + +# Certificate chain verification +rustls-pki-types.workspace = true +dcap-qvl-webpki = { workspace = true, features = ["alloc", "rustcrypto"] } +pem.workspace = true + +# TPM quote data structures +tpm-types.workspace = true + +# CRL download (optional) +reqwest = { workspace = true, features = ["blocking"], optional = true } +tokio = { workspace = true, features = ["rt"], optional = true } + +[features] +default = ["crl-download"] +crl-download = ["reqwest", "tokio"] diff --git a/tpm-qvl/certs/AWS_NitroEnclaves_Root-G1.pem b/tpm-qvl/certs/AWS_NitroEnclaves_Root-G1.pem new file mode 100644 index 000000000..221cc0b1d --- /dev/null +++ b/tpm-qvl/certs/AWS_NitroEnclaves_Root-G1.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICETCCAZagAwIBAgIRAPkxdWgbkK/hHUbMtOTn+FYwCgYIKoZIzj0EAwMwSTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMRswGQYD +VQQDDBJhd3Mubml0cm8tZW5jbGF2ZXMwHhcNMTkxMDI4MTMyODA1WhcNNDkxMDI4 +MTQyODA1WjBJMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQL +DANBV1MxGzAZBgNVBAMMEmF3cy5uaXRyby1lbmNsYXZlczB2MBAGByqGSM49AgEG +BSuBBAAiA2IABPwCVOumCMHzaHDimtqQvkY4MpJzbolL//Zy2YlES1BR5TSksfbb +48C8WBoyt7F2Bw7eEtaaP+ohG2bnUs990d0JX28TcPQXCEPZ3BABIeTPYwEoCWZE +h8l5YoQwTcU/9KNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUkCW1DdkF +R+eWw5b6cp3PmanfS5YwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYC +MQCjfy+Rocm9Xue4YnwWmNJVA44fA0P5W2OpYow9OYCVRaEevL8uO1XYru5xtMPW +rfMCMQCi85sWBbJwKKXdS6BptQFuZbT73o/gBh1qUxl/nNr12UO8Yfwr6wPLb+6N +IwLz3/Y= +-----END CERTIFICATE----- diff --git a/tpm-qvl/certs/gcp-root-ca.pem b/tpm-qvl/certs/gcp-root-ca.pem new file mode 100644 index 000000000..080fdeafb --- /dev/null +++ b/tpm-qvl/certs/gcp-root-ca.pem @@ -0,0 +1,35 @@ +-----BEGIN CERTIFICATE----- +MIIGATCCA+mgAwIBAgIUAKZdpPnjKPOANcOnPU9yQyvfFdwwDQYJKoZIhvcNAQEL +BQAwfjELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcT +DU1vdW50YWluIFZpZXcxEzARBgNVBAoTCkdvb2dsZSBMTEMxFTATBgNVBAsTDEdv +b2dsZSBDbG91ZDEWMBQGA1UEAxMNRUsvQUsgQ0EgUm9vdDAgFw0yMjA3MDgwMDQw +MzRaGA8yMTIyMDcwODA1NTcyM1owfjELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNh +bGlmb3JuaWExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcxEzARBgNVBAoTCkdvb2ds +ZSBMTEMxFTATBgNVBAsTDEdvb2dsZSBDbG91ZDEWMBQGA1UEAxMNRUsvQUsgQ0Eg +Um9vdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJ0l9VCoyJZLSol8 +KyhNpbS7pBnuicE6ptrdtxAWIR2TnLxSgxNFiR7drtofxI0ruceoCIpsa9NHIKrz +3sM/N/E8mFNHiJAuyVf3pPpmDpLJZQ1qe8yHkpGSs3Kj3s5YYWtEecCVfzNs4MtK +vGfA+WKB49A6Noi8R9R1GonLIN6wSXX3kP1ibRn0NGgdqgfgRe5HC3kKAhjZ6scT +8Eb1SGlaByGzE5WoGTnNbyifkyx9oUZxXVJsqv2q611W3apbPxcgev8z5JXQUbrr +Q7EbO0StK1DsKRsKLuD+YLxjrBRQ4UeIN5WHp6G0vgYiOptHm6YKZxQemO/kVMLR +zsm1AYH7eNOFekcBIKRjSqpk5m4ud04qum6f0hBj3iE/Pe+DvIbVhLh9ItAunISG +QPA9dYEgfA/qWir+pU7LV3phpLeGhull8G/zYmQhF3heg0buIR70aavzT8iLAQrx +VMNRZJEGMwIN/tq8YiT3+3EZIcSqq6GAGjiuVw3NIsXC3+CuSJGQ5GbDp49Lc6VW +PHeWeFvwSUGgxKXq5r1+PRsoYgK6S4hhecgXEX5c7Rta6TcFlEFb0XK9fpy1dr89 +LeFGxUBpdDvKxDRLMm3FQen8rmR/PSReEcJsaqbUP/q7Pc7k0RfF9Mb6AfPZfnqg +pYJQ+IFSr9EjRSW1wPcL03zoTP47AgMBAAGjdTBzMA4GA1UdDwEB/wQEAwIBBjAQ +BgNVHSUECTAHBgVngQUIATAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRJ50pb +Vin1nXm3pjA8A7KP5xTdTDAfBgNVHSMEGDAWgBRJ50pbVin1nXm3pjA8A7KP5xTd +TDANBgkqhkiG9w0BAQsFAAOCAgEAlfHRvOB3CJoLTl1YG/AvjGoZkpNMyp5X5je1 +ICCQ68b296En9hIUlcYY/+nuEPSPUjDA3izwJ8DAfV4REgpQzqoh6XhR3TgyfHXj +J6DC7puzEgtzF1+wHShUpBoe3HKuL4WhB3rvwk2SEsudBu92o9BuBjcDJ/GW5GRt +pD/H71HAE8rI9jJ41nS0FvkkjaX0glsntMVUXiwcta8GI0QOE2ijsJBwk41uQGt0 +YOj2SGlEwNAC5DBTB5kZ7+6X9xGE6/c+M3TAA0ONoX18rNfif94cCx/mPYOs8pUk +ANRAQ4aTRBvpBrryGT8R1ahTBkMeRQG3tdsLHRT8fJCFUANd5WLWsi83005y/WuM +z8/gFKc0PL+F+MubCsJ1ODPTRscH93QlS4zEMg5hDAIks+fDoRJ2QiROqo7GAqbT +c7STKfGcr9+pa63na7f3oy1sZPWPdxB8tx5z3lghiPP3ktQx/yK/1Fwf1hgxJHFy +/2UcaGuOXRRRTPyEnppZp82Kigs9aPHWtaVm2/LrXX2fvT9iM/k0CovNAj8rztHx +sUEoA0xJnSOJNPpe9PRdjsTj7/u3Xu6hQLNNidBHgI3Hcmi704HMMd/3yZ424OOr +S32ylpeU1oeQHFrLE6hYX4/ttMETbmESIKd2rTgstPotSvkuB5TljbKYPR+lq7hQ +av16U4E= +-----END CERTIFICATE----- diff --git a/tpm-qvl/src/collateral.rs b/tpm-qvl/src/collateral.rs new file mode 100644 index 000000000..615f6dcbd --- /dev/null +++ b/tpm-qvl/src/collateral.rs @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Collateral retrieval module +//! +//! This module implements the first step of dcap-qvl architecture: +//! extracting certificate chain information and downloading CRLs. + +use anyhow::{bail, Context, Result}; +use tracing::{debug, warn}; +use x509_parser::{extensions::DistributionPointName, prelude::*}; + +use tpm_types::TpmQuote; + +use crate::{get_root_ca, verify::VerifiedReport, QuoteCollateral}; + +pub async fn get_collateral_and_verify(quote: &TpmQuote) -> Result { + let root_ca_pem = get_root_ca(quote.platform).context("failed to get root CA")?; + let collateral = get_collateral(quote, root_ca_pem).await?; + crate::verify::verify_quote_with_ca(quote, &collateral, root_ca_pem).map_err(Into::into) +} + +pub async fn get_collateral(quote: &TpmQuote, root_ca_pem: &str) -> Result { + // Collateral fetching uses synchronous (blocking) HTTP. Run it on the + // blocking pool so it never stalls (or panics on) the async runtime worker. + let ak_cert = quote.ak_cert.clone(); + let root_ca = root_ca_pem.to_string(); + tokio::task::spawn_blocking(move || get_collateral_blocking(&ak_cert, &root_ca)) + .await + .context("collateral fetch task panicked")? +} + +fn get_collateral_blocking(ak_cert_der: &[u8], root_ca_pem: &str) -> Result { + debug!("fetching quote collateral (intermediate cert chain + CRLs)"); + + debug!("AK certificate (leaf) found: {} bytes", ak_cert_der.len()); + + // Build certificate chain from device (via AIA) + let chain_ders = build_cert_chain(ak_cert_der)?; + // Download CRLs from device-provided cert chain + let crls = download_crls_for_certs(&chain_ders)?; + + // Download CRL from verifier-provided root CA + let root_ca_crl = { + let root_ca_der = + extract_certs_webpki(root_ca_pem.as_bytes()).context("failed to parse root CA PEM")?; + if root_ca_der.len() != 1 { + bail!("expected 1 root CA, found {}", root_ca_der.len()); + } + download_crl_for_cert(&root_ca_der[0])? + }; + + debug!( + "✓ collateral fetched: {} intermediate CRL(s), root CA CRL: {}", + crls.len(), + if root_ca_crl.is_some() { "yes" } else { "no" } + ); + let cert_chain_pem = ders_to_pem(&chain_ders)?; + Ok(QuoteCollateral { + cert_chain_pem, + crls, + root_ca_crl, + }) +} + +/// Build certificate chain by following AIA links (stops before root) +fn build_cert_chain(leaf_cert_der: &[u8]) -> Result>> { + let mut chain_ders = Vec::new(); + chain_ders.push(leaf_cert_der.to_vec()); + let mut current_cert_der = leaf_cert_der.to_vec(); + + loop { + let Some(url) = extract_aia_ca_issuers(¤t_cert_der)? else { + debug!("no AIA found - reached end of AIA chain"); + break; + }; + debug!("downloading parent cert from: {url}"); + let parent_der = download_cert(&url)?; + // Stop if we hit a self-signed cert (root CA) + if is_self_signed(&parent_der)? { + debug!("found self-signed cert - stopping (root CA should be provided by verifier)"); + break; + } + chain_ders.push(parent_der.clone()); + current_cert_der = parent_der; + } + + debug!("built chain with {} certificate(s)", chain_ders.len()); + Ok(chain_ders) +} + +/// Download CRLs for given certificates +fn download_crls_for_certs(certs: &[Vec]) -> Result>> { + debug!("downloading CRLs from device-provided cert chain..."); + + let mut crls = Vec::new(); + + for cert_der in certs { + let Some(crl) = download_crl_for_cert(cert_der).context("failed to download CRL")? else { + continue; + }; + crls.push(crl); + } + Ok(crls) +} + +/// Download CRL for verifier-provided root CA +fn download_crl_for_cert(cert: &[u8]) -> Result>> { + let crl_urls = extract_crl_urls(cert)?; + if crl_urls.is_empty() { + debug!("verifier root CA has no CRL DP - will skip root CA CRL check"); + return Ok(None); + } + + download_first_available_crl(&crl_urls).map(Some) +} + +/// Download first available CRL from a list of URLs +fn download_first_available_crl(urls: &[String]) -> Result> { + for url in urls { + debug!("downloading CRL from {url}"); + match download_crl(url) { + Ok(crl) => { + return Ok(crl); + } + Err(e) => { + warn!("✗ failed to download CRL from {url}: {e:?}"); + continue; + } + } + } + bail!("failed to download CRL") +} + +/// Convert DER certificates to PEM format +fn ders_to_pem(ders: &[Vec]) -> Result { + let mut pem = String::new(); + for der in ders.iter() { + pem.push_str(&der_to_pem(der, "CERTIFICATE")?); + } + Ok(pem) +} + +/// Check if certificate is self-signed +fn is_self_signed(cert_der: &[u8]) -> Result { + let (_, cert) = X509Certificate::from_der(cert_der).context("failed to parse certificate")?; + Ok(cert.subject() == cert.issuer()) +} + +fn extract_certs_webpki(cert_pem: &[u8]) -> Result>> { + use ::pem::parse_many; + + let pem_items = parse_many(cert_pem).context("failed to parse PEM")?; + + let certs = pem_items + .into_iter() + .map(|pem| rustls_pki_types::CertificateDer::from(pem.into_contents())) + .collect(); + + Ok(certs) +} + +fn download_crl(url: &str) -> Result> { + debug!("downloading CRL from {url}"); + + let response = + reqwest::blocking::get(url).context(format!("failed to download CRL from {url}"))?; + + if !response.status().is_success() { + bail!("CRL download failed with status: {}", response.status()); + } + + let crl_bytes = response + .bytes() + .context("failed to read CRL response body")? + .to_vec(); + + debug!("downloaded {} bytes CRL from {}", crl_bytes.len(), url); + + Ok(crl_bytes) +} + +fn extract_crl_urls(cert_der: &[u8]) -> Result> { + use x509_parser::extensions::ParsedExtension; + + let (_, cert) = X509Certificate::from_der(cert_der).context("failed to parse certificate")?; + + let mut crl_urls = Vec::new(); + + for ext in cert.extensions() { + let ParsedExtension::CRLDistributionPoints(crl_dist_points) = ext.parsed_extension() else { + continue; + }; + for dist_point in crl_dist_points.points.iter() { + let Some(dist_point_name) = &dist_point.distribution_point else { + continue; + }; + + let DistributionPointName::FullName(names) = dist_point_name else { + continue; + }; + for name in names.iter() { + let x509_parser::extensions::GeneralName::URI(uri) = name else { + continue; + }; + crl_urls.push(uri.to_string()); + debug!("found CRL URL: {uri}"); + } + } + } + + if crl_urls.is_empty() { + debug!("no CRL Distribution Points found in certificate"); + } + + Ok(crl_urls) +} + +fn extract_aia_ca_issuers(cert_der: &[u8]) -> Result> { + use x509_parser::extensions::ParsedExtension; + + let (_, cert) = X509Certificate::from_der(cert_der).context("failed to parse certificate")?; + + for ext in cert.extensions() { + let ParsedExtension::AuthorityInfoAccess(aia) = ext.parsed_extension() else { + continue; + }; + + for access_desc in &aia.accessdescs { + const OID_CA_ISSUERS: &[u64] = &[1, 3, 6, 1, 5, 5, 7, 48, 2]; + let oid_bytes: Vec = match access_desc.access_method.iter() { + Some(iter) => iter.collect(), + None => continue, + }; + + if oid_bytes == OID_CA_ISSUERS { + if let x509_parser::extensions::GeneralName::URI(uri) = &access_desc.access_location + { + debug!("found AIA CA Issuers URL: {uri}"); + return Ok(Some(uri.to_string())); + } + } + } + } + + debug!("no AIA CA Issuers URL found in certificate"); + Ok(None) +} + +fn download_cert(url: &str) -> Result> { + debug!("downloading certificate from {url}"); + + let response = reqwest::blocking::get(url) + .context(format!("failed to download certificate from {url}"))?; + + if !response.status().is_success() { + bail!( + "certificate download failed with status: {}", + response.status() + ); + } + + let cert_bytes = response + .bytes() + .context("failed to read certificate response body")? + .to_vec(); + + debug!( + "downloaded {} bytes certificate from {}", + cert_bytes.len(), + url + ); + + Ok(cert_bytes) +} + +fn der_to_pem(der: &[u8], label: &str) -> Result { + use base64::Engine; + + let b64 = base64::engine::general_purpose::STANDARD.encode(der); + + let mut pem = format!("-----BEGIN {label}-----\n"); + for chunk in b64.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(chunk)?); + pem.push('\n'); + } + pem.push_str(&format!("-----END {label}-----\n")); + + Ok(pem) +} diff --git a/tpm-qvl/src/lib.rs b/tpm-qvl/src/lib.rs new file mode 100644 index 000000000..2fe2b47de --- /dev/null +++ b/tpm-qvl/src/lib.rs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM Quote Verification Library (QVL) +//! +//! This module provides quote verification and collateral management for TPM attestation. +//! It follows the dcap-qvl architecture for Intel TDX verification. +//! +//! # Architecture +//! - **Step 1**: `get_collateral()` - Extract cert chain and download CRLs +//! - **Step 2**: `verify_quote()` - Verify quote with collateral +//! +//! This crate is designed to run on the verifier side, while tpm-attest runs on the device side. + +use anyhow::{bail, Result}; +use dstack_types::Platform; +use serde::{Deserialize, Serialize}; + +/// GCP TPM Root CA certificate (embedded, valid 2022-2122) +/// +/// Subject: CN=EK/AK CA Root, OU=Google Cloud, O=Google LLC, L=Mountain View, ST=California, C=US +/// Valid: 2022-07-08 to 2122-07-08 (100 years) +pub const GCP_ROOT_CA: &str = include_str!("../certs/gcp-root-ca.pem"); + +/// Get TPM root CA certificate for the given platform +pub fn get_root_ca(platform: Platform) -> Result<&'static str> { + match platform { + Platform::Gcp => Ok(GCP_ROOT_CA), + Platform::NitroEnclave => { + bail!("Nitro Enclave uses NSM attestation, not TPM. Use nsm-qvl instead.") + } + Platform::Dstack => bail!("dstack platform does not use TPM attestation"), + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuoteCollateral { + /// Intermediate certificate chain (PEM format) from device + /// Does NOT include root CA (which must be provided independently by verifier) + pub cert_chain_pem: String, + /// All CRLs extracted from device-provided cert chain + pub crls: Vec>, + /// Root CA CRL extracted from verifier-provided root CA + pub root_ca_crl: Option>, +} + +#[derive(Debug)] +pub struct VerificationError { + pub status: VerificationStatus, + pub error: anyhow::Error, +} + +impl std::fmt::Display for VerificationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "verification failed: {}", self.error) + } +} + +impl std::error::Error for VerificationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.error.source() + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VerificationStatus { + pub ak_verified: bool, + pub signature_verified: bool, + pub pcr_verified: bool, +} + +#[cfg(feature = "crl-download")] +pub use collateral::{get_collateral, get_collateral_and_verify}; + +pub use verify::verify_quote; + +pub mod verify; + +#[cfg(feature = "crl-download")] +pub mod collateral; diff --git a/tpm-qvl/src/verify.rs b/tpm-qvl/src/verify.rs new file mode 100644 index 000000000..21c31167a --- /dev/null +++ b/tpm-qvl/src/verify.rs @@ -0,0 +1,689 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM Quote Verification Module + +use ::pem::parse_many; +use anyhow::{anyhow, bail, Context, Result}; +use dstack_types::Platform; +use p256::ecdsa::{signature::hazmat::PrehashVerifier, Signature, VerifyingKey}; +use rsa::RsaPublicKey; +use sha2::{Digest, Sha256}; +use tracing::{debug, warn}; +use x509_parser::prelude::*; + +use rustls_pki_types::{CertificateDer, UnixTime}; +use webpki::{BorrowedCertRevocationList, CertRevocationList, EndEntityCert}; + +use tpm_types::{PcrValue, TpmEvent, TpmQuote}; + +use crate::{get_root_ca, QuoteCollateral, VerificationError, VerificationStatus}; + +#[derive(Clone)] +pub struct VerifiedReport { + pub attest: TpmAttest, + pub platform: Platform, + pub pcr_values: Vec, +} + +impl VerifiedReport { + pub fn get_pcr(&self, index: u32) -> Result> { + self.pcr_values + .iter() + .find(|p| p.index == index) + .map(|p| p.value.clone()) + .ok_or(anyhow!("PCR {} not found", index)) + } +} + +#[derive(Debug)] +enum PublicKey { + Rsa(RsaPublicKey), + Ecc(VerifyingKey), +} + +/// Verify quote with collateral and library-bundled root CA +pub fn verify_quote( + quote: &TpmQuote, + collateral: &QuoteCollateral, +) -> Result { + let ca = get_root_ca(quote.platform).map_err(|e| VerificationError { + status: VerificationStatus::default(), + error: e, + })?; + verify_quote_with_ca(quote, collateral, ca) +} + +/// Verify quote with collateral and user-provided root CA (recommended for security) +/// +/// The root CA is provided by the verifier as an independent trust anchor, +/// not derived from device-provided collateral. This prevents attacks where +/// a malicious device provides a fake certificate chain including a fake root CA. +pub fn verify_quote_with_ca( + quote: &TpmQuote, + collateral: &QuoteCollateral, + root_ca_pem: &str, +) -> Result { + let mut status = VerificationStatus::default(); + + let attest = match parse_tpm_attest("e.message) { + Ok(a) => a, + Err(e) => { + return Err(VerificationError { + status, + error: e.context("failed to parse TPMS_ATTEST"), + }); + } + }; + + let attested_pcr_indices: Vec = attest + .attested_quote_info + .pcr_selections + .iter() + .flat_map(|s| s.pcr_indices.iter().copied()) + .collect(); + let provided_pcr_indices: Vec = quote.pcr_values.iter().map(|p| p.index).collect(); + + if attested_pcr_indices != provided_pcr_indices { + return Err(VerificationError { + status, + error: anyhow!( + "PCR selection mismatch: TPMS_ATTEST has {:?}, but pcr_values has {:?}", + attested_pcr_indices, + provided_pcr_indices + ), + }); + } + + // compute_pcr_digest() and the event-log replay below assume the SHA-256 PCR + // bank. Reject other banks explicitly instead of silently failing with a + // confusing "PCR digest mismatch". + const TPM_ALG_SHA256: u16 = 0x000B; + if let Some(sel) = attest + .attested_quote_info + .pcr_selections + .iter() + .find(|s| s.hash_alg != TPM_ALG_SHA256) + { + return Err(VerificationError { + status, + error: anyhow!( + "unsupported PCR bank hash_alg {:#06x}; only SHA-256 (0x000b) is supported", + sel.hash_alg + ), + }); + } + + let computed_pcr_digest = + compute_pcr_digest("e.pcr_values).map_err(|e| VerificationError { + status: status.clone(), + error: e, + })?; + if attest.attested_quote_info.pcr_digest != computed_pcr_digest { + return Err(VerificationError { + status, + error: anyhow!("PCR digest mismatch"), + }); + } + + verify_event_log("e.pcr_values, "e.event_log).map_err(|e| VerificationError { + status: status.clone(), + error: e.context("event log verification failed"), + })?; + debug!("✓ Event Log replay verification successful"); + + status.pcr_verified = true; + + let ak_public_key = match extract_ak_public_key_from_cert("e.ak_cert) { + Ok(key) => { + debug!("extracted AK public key from certificate"); + key + } + Err(e) => { + return Err(VerificationError { + status, + error: e.context("failed to extract AK public key from certificate"), + }); + } + }; + + match verify_signature_with_key("e.message, "e.signature, &ak_public_key) { + Ok(true) => status.signature_verified = true, + Ok(false) => { + return Err(VerificationError { + status, + error: anyhow!("signature verification failed"), + }); + } + Err(e) => { + return Err(VerificationError { + status, + error: e.context("signature verification error"), + }); + } + } + + match verify_ak_chain_with_collateral("e.ak_cert, collateral, root_ca_pem) { + Ok(()) => {} + Err(e) => { + return Err(VerificationError { + status, + error: e.context("AK certificate chain verification error"), + }); + } + } + + Ok(VerifiedReport { + attest, + platform: quote.platform, + pcr_values: quote.pcr_values.clone(), + }) +} + +#[derive(Debug, Clone)] +pub struct TpmAttest { + pub magic: u32, + pub type_: u16, + pub qualified_signer: Vec, + pub qualified_data: Vec, + pub clock_info: ClockInfo, + pub firmware_version: u64, + pub attested_quote_info: QuoteInfo, +} + +#[derive(Debug, Clone)] +pub struct ClockInfo { + pub clock: u64, + pub reset_count: u32, + pub restart_count: u32, + pub safe: u8, +} + +/// PCR selection entry from TPM quote +#[derive(Debug, Clone)] +pub struct PcrSelection { + /// Hash algorithm (e.g., 0x000B for SHA-256) + pub hash_alg: u16, + /// Selected PCR indices + pub pcr_indices: Vec, +} + +#[derive(Debug, Clone)] +pub struct QuoteInfo { + /// PCR selections from the quote + pub pcr_selections: Vec, + /// PCR digest + pub pcr_digest: Vec, +} + +fn parse_tpm_attest(data: &[u8]) -> Result { + use nom::bytes::complete::take; + use nom::number::complete::{be_u16, be_u32, be_u64, be_u8}; + use nom::IResult; + + fn parse_sized_buffer(input: &[u8]) -> IResult<&[u8], Vec> { + let (input, size) = be_u16(input)?; + let (input, data) = take(size)(input)?; + Ok((input, data.to_vec())) + } + + fn parse_attest(input: &[u8]) -> IResult<&[u8], TpmAttest> { + let (input, magic) = be_u32(input)?; + let (input, type_) = be_u16(input)?; + let (input, qualified_signer) = parse_sized_buffer(input)?; + let (input, qualified_data) = parse_sized_buffer(input)?; + + let (input, clock) = be_u64(input)?; + let (input, reset_count) = be_u32(input)?; + let (input, restart_count) = be_u32(input)?; + let (input, safe) = be_u8(input)?; + + let (input, firmware_version) = be_u64(input)?; + + let (input, pcr_select_count) = be_u32(input)?; + + let mut pcr_selections = Vec::new(); + let mut current_input = input; + for _ in 0..pcr_select_count { + let (input, hash_alg) = be_u16(current_input)?; + let (input, sizeof_select) = be_u8(input)?; + let (input, pcr_bitmap) = take(sizeof_select)(input)?; + + // Parse PCR bitmap into indices + let mut pcr_indices = Vec::new(); + for (byte_idx, &byte) in pcr_bitmap.iter().enumerate() { + for bit_idx in 0..8 { + if (byte & (1 << bit_idx)) != 0 { + pcr_indices.push((byte_idx * 8 + bit_idx) as u32); + } + } + } + + pcr_selections.push(PcrSelection { + hash_alg, + pcr_indices, + }); + + current_input = input; + } + + let input = current_input; + let (input, pcr_digest) = parse_sized_buffer(input)?; + + Ok(( + input, + TpmAttest { + magic, + type_, + qualified_signer, + qualified_data, + clock_info: ClockInfo { + clock, + reset_count, + restart_count, + safe, + }, + firmware_version, + attested_quote_info: QuoteInfo { + pcr_selections, + pcr_digest, + }, + }, + )) + } + + let (_, attest) = parse_attest(data).map_err(|e| anyhow!("parse error: {e}"))?; + + if attest.magic != 0xff544347 { + bail!("invalid magic number: 0x{magic:08x}", magic = attest.magic); + } + + if attest.type_ != 0x8018 { + bail!("invalid attest type: 0x{type_:04x}", type_ = attest.type_); + } + + Ok(attest) +} + +fn compute_pcr_digest(pcr_values: &[PcrValue]) -> Result> { + let mut hasher = Sha256::new(); + for pcr in pcr_values { + hasher.update(&pcr.value); + } + Ok(hasher.finalize().to_vec()) +} + +fn verify_event_log(pcr_values: &[PcrValue], event_log: &[TpmEvent]) -> Result<()> { + for pcr in pcr_values { + let pcr_events: Vec<&TpmEvent> = event_log + .iter() + .filter(|e| e.pcr_index == pcr.index) + .collect(); + + if pcr_events.is_empty() { + continue; + } + + // Replay PCR extension to verify Event Log matches quote + let mut replayed_pcr = vec![0u8; 32]; + for event in &pcr_events { + let mut hasher = Sha256::new(); + hasher.update(&replayed_pcr); + hasher.update(&event.digest); + replayed_pcr = hasher.finalize().to_vec(); + } + + if replayed_pcr != pcr.value { + bail!( + "PCR {} replay mismatch: expected {}, got {}", + pcr.index, + hex::encode(&pcr.value), + hex::encode(&replayed_pcr) + ); + } + + debug!( + "✓ PCR {} replay verification successful ({} events)", + pcr.index, + pcr_events.len() + ); + + // For PCR 2: Extract Event 28 (UKI measurement) for image verification + // NOTE: Extracting the 3rd event (index 2) is GCP OVMF-specific behavior. + // On GCP, PCR 2 events are: [0]=EV_SEPARATOR, [1]=EV_EFI_GPT_EVENT, + // [2]=UKI (Event 28), [3]=Linux kernel (Event 41) + // Other platforms may have different event ordering. + if pcr.index == 2 && pcr_events.len() >= 3 { + let uki_digest = hex::encode(&pcr_events[2].digest); + debug!("Event 28 (UKI hash): {}", uki_digest); + debug!("To verify image: compare this against expected UKI Authenticode hash"); + } + } + + Ok(()) +} + +fn extract_ak_public_key_from_cert(ak_cert_der: &[u8]) -> Result { + let (_, cert) = + X509Certificate::from_der(ak_cert_der).context("failed to parse AK certificate")?; + + let spki = cert.public_key(); + + let algo_oid = &spki.algorithm.algorithm; + + const OID_RSA_ENCRYPTION: &[u64] = &[1, 2, 840, 113549, 1, 1, 1]; + const OID_EC_PUBLIC_KEY: &[u64] = &[1, 2, 840, 10045, 2, 1]; + + let oid_bytes: Vec = algo_oid + .iter() + .ok_or_else(|| anyhow::anyhow!("invalid OID"))? + .collect(); + + if oid_bytes == OID_RSA_ENCRYPTION { + use rsa::pkcs1::DecodeRsaPublicKey; + use rsa::traits::PublicKeyParts; + + let public_key = RsaPublicKey::from_pkcs1_der(spki.subject_public_key.data.as_ref()) + .context("failed to decode RSA public key from certificate")?; + + debug!( + "extracted RSA AK public key from certificate ({} bits)", + public_key.size() * 8 + ); + + Ok(PublicKey::Rsa(public_key)) + } else if oid_bytes == OID_EC_PUBLIC_KEY { + let public_key_bytes = spki.subject_public_key.data.as_ref(); + + let verifying_key = VerifyingKey::from_sec1_bytes(public_key_bytes) + .context("failed to decode ECC public key from certificate")?; + + debug!("extracted ECC P-256 AK public key from certificate"); + + Ok(PublicKey::Ecc(verifying_key)) + } else { + bail!("unsupported public key algorithm: {:?}", oid_bytes); + } +} + +fn verify_signature_with_key( + message: &[u8], + signature: &[u8], + public_key: &PublicKey, +) -> Result { + if signature.len() < 4 { + bail!("signature too short: {} bytes", signature.len()); + } + + let sig_alg = u16::from_be_bytes([signature[0], signature[1]]); + let hash_alg = u16::from_be_bytes([signature[2], signature[3]]); + + if hash_alg != 0x000B { + bail!("unsupported hash algorithm: 0x{hash_alg:04x}"); + } + + let actual_signature = &signature[4..]; + + debug!( + "message ({} bytes): {}", + message.len(), + hex::encode(message) + ); + debug!( + "signature ({} bytes): {}", + actual_signature.len(), + hex::encode(actual_signature) + ); + + let mut hasher = Sha256::new(); + hasher.update(message); + let message_hash = hasher.finalize(); + + debug!("message hash: {}", hex::encode(message_hash)); + + match public_key { + PublicKey::Rsa(rsa_key) => { + if sig_alg != 0x0014 { + bail!("expected RSASSA (0x0014), got 0x{sig_alg:04x}"); + } + + if actual_signature.len() < 2 { + bail!("RSA signature too short for size field"); + } + let rsa_sig_size = + u16::from_be_bytes([actual_signature[0], actual_signature[1]]) as usize; + if actual_signature.len() < 2 + rsa_sig_size { + bail!("RSA signature too short for signature data"); + } + let rsa_sig_data = &actual_signature[2..2 + rsa_sig_size]; + + debug!("RSA signature parsed: {rsa_sig_size} bytes"); + + let padding = rsa::Pkcs1v15Sign::new::(); + match rsa_key.verify(padding, &message_hash, rsa_sig_data) { + Ok(_) => { + debug!("✓ RSA signature verification successful"); + Ok(true) + } + Err(e) => { + warn!("RSA signature verification failed: {e}"); + Ok(false) + } + } + } + PublicKey::Ecc(ecc_key) => { + if sig_alg != 0x0018 { + bail!("expected ECDSA (0x0018), got 0x{sig_alg:04x}"); + } + + if actual_signature.len() < 2 { + bail!("ECDSA signature too short for signatureR size"); + } + let r_size = u16::from_be_bytes([actual_signature[0], actual_signature[1]]) as usize; + if actual_signature.len() < 2 + r_size { + bail!("ECDSA signature too short for signatureR data"); + } + let r_data = &actual_signature[2..2 + r_size]; + + let s_offset = 2 + r_size; + if actual_signature.len() < s_offset + 2 { + bail!("ECDSA signature too short for signatureS size"); + } + let s_size = + u16::from_be_bytes([actual_signature[s_offset], actual_signature[s_offset + 1]]) + as usize; + if actual_signature.len() < s_offset + 2 + s_size { + bail!("ECDSA signature too short for signatureS data"); + } + let s_data = &actual_signature[s_offset + 2..s_offset + 2 + s_size]; + + let mut sig_bytes = Vec::with_capacity(r_size + s_size); + sig_bytes.extend_from_slice(r_data); + sig_bytes.extend_from_slice(s_data); + + debug!("ECDSA signature parsed: r={r_size} bytes, s={s_size} bytes",); + + let signature = + Signature::from_slice(&sig_bytes).context("failed to parse ECDSA signature")?; + + match ecc_key.verify_prehash(&message_hash, &signature) { + Ok(_) => { + debug!("✓ ECC signature verification successful"); + Ok(true) + } + Err(e) => { + warn!("ECC signature verification failed: {e}"); + Ok(false) + } + } + } + } +} + +fn extract_certs_webpki(cert_pem: &[u8]) -> Result>> { + let pem_items = parse_many(cert_pem).context("failed to parse PEM")?; + + let certs = pem_items + .into_iter() + .map(|pem| CertificateDer::from(pem.into_contents())) + .collect(); + + Ok(certs) +} + +fn verify_ak_chain_with_collateral( + ak_cert_der: &[u8], + collateral: &QuoteCollateral, + root_ca_pem: &str, +) -> Result<()> { + debug!( + "verifying AK certificate chain with webpki ({} bytes leaf, {} intermediate CRLs, root CRL: {})", + ak_cert_der.len(), + collateral.crls.len(), + if collateral.root_ca_crl.is_some() { "yes" } else { "no" } + ); + + let ak_cert_der_owned = CertificateDer::from(ak_cert_der.to_vec()); + let ak_cert = + EndEntityCert::try_from(&ak_cert_der_owned).context("failed to parse AK certificate")?; + + // Load intermediate certs from device-provided collateral + let intermediate_certs = extract_certs_webpki(collateral.cert_chain_pem.as_bytes())?; + + debug!( + "loaded {} intermediate certificate(s) from collateral", + intermediate_certs.len() + ); + for (i, cert_der) in intermediate_certs.iter().enumerate() { + if let Ok((_, cert)) = X509Certificate::from_der(cert_der.as_ref()) { + debug!( + " intermediate[{i}]: subject={}, issuer={}", + cert.subject(), + cert.issuer() + ); + } + } + + // Load root CA from verifier-provided trust anchor (CRITICAL: independent from device) + let root_ca_certs = extract_certs_webpki(root_ca_pem.as_bytes())?; + if root_ca_certs.is_empty() { + bail!("failed to parse root CA PEM - no certificates found"); + } + let root_cert_der = &root_ca_certs[0]; + + if let Ok((_, cert)) = X509Certificate::from_der(root_cert_der.as_ref()) { + debug!( + "trust anchor (verifier-provided): subject={}, issuer={}", + cert.subject(), + cert.issuer() + ); + } + + let trust_anchor = webpki::anchor_from_trusted_cert(root_cert_der) + .context("failed to create trust anchor from verifier root CA")?; + + debug!( + "trust anchor created, {} intermediate(s)", + intermediate_certs.len() + ); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("failed to get current time")?; + let time = UnixTime::since_unix_epoch(now); + + let trust_anchors = [trust_anchor]; + + // Check root CA against CRL if CRL was provided + if let Some(root_ca_crl) = &collateral.root_ca_crl { + debug!("checking root CA against its CRL (dcap-qvl-webpki)"); + let crl_refs = vec![root_ca_crl.as_slice()]; + webpki::check_single_cert_crl(root_cert_der.as_ref(), &crl_refs, time) + .context("root CA revoked or invalid CRL")?; + debug!("✓ root CA CRL check passed"); + } else { + debug!("root CA has no CRL - skipping root CA CRL check"); + } + + let result = if !collateral.crls.is_empty() { + debug!( + "parsing {} intermediate CRL(s) for revocation checking", + collateral.crls.len() + ); + let crls: Vec = collateral + .crls + .iter() + .enumerate() + .map(|(i, der)| { + BorrowedCertRevocationList::from_der(der) + .map(|crl| crl.into()) + .with_context(|| format!("failed to parse intermediate CRL #{i}")) + }) + .collect::>>()?; + let crl_refs: Vec<&CertRevocationList> = crls.iter().collect(); + + debug!("creating revocation options (CRL enforcement)"); + let revocation_builder = webpki::RevocationOptionsBuilder::new(&crl_refs) + .map_err(|_| anyhow::anyhow!("failed to create RevocationOptionsBuilder"))?; + + let revocation = revocation_builder + .with_depth(webpki::RevocationCheckDepth::Chain) + .with_status_policy(webpki::UnknownStatusPolicy::Allow) + .with_expiration_policy(webpki::ExpirationPolicy::Enforce) + .build(); + + debug!("verifying certificate chain with CRL revocation checking"); + + const TCG_KP_AIK_CERTIFICATE: &[u8] = &[0x67, 0x81, 0x05, 0x08, 0x01]; + let key_usage = webpki::KeyUsage::required_if_present(TCG_KP_AIK_CERTIFICATE); + + ak_cert + .verify_for_usage( + webpki::ALL_VERIFICATION_ALGS, + &trust_anchors, + &intermediate_certs, + time, + key_usage, + Some(revocation), + None, + ) + .context("certificate chain verification failed") + } else { + debug!("no CRLs available (no certificates have CRL Distribution Points)"); + debug!("verifying certificate chain WITHOUT CRL checking"); + + const TCG_KP_AIK_CERTIFICATE: &[u8] = &[0x67, 0x81, 0x05, 0x08, 0x01]; + let key_usage = webpki::KeyUsage::required_if_present(TCG_KP_AIK_CERTIFICATE); + + ak_cert + .verify_for_usage( + webpki::ALL_VERIFICATION_ALGS, + &trust_anchors, + &intermediate_certs, + time, + key_usage, + None, + None, + ) + .context("certificate chain verification failed") + }; + + match result { + Ok(_) => { + if collateral.crls.is_empty() { + debug!("✓ AK certificate chain verification successful (webpki, no CRLs)"); + } else { + debug!( + "✓ AK certificate chain verification successful (webpki + {} intermediate CRL(s))", + collateral.crls.len() + ); + } + Ok(()) + } + Err(e) => { + warn!("✗ AK certificate chain verification failed: {e:?}"); + Err(e) + } + } +} diff --git a/tpm-types/Cargo.toml b/tpm-types/Cargo.toml new file mode 100644 index 000000000..cb81483ca --- /dev/null +++ b/tpm-types/Cargo.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: © 2025 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "tpm-types" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde-human-bytes.workspace = true +dstack-types.workspace = true +scale = { workspace = true, features = ["derive"] } +cc-eventlog.workspace = true diff --git a/tpm-types/src/lib.rs b/tpm-types/src/lib.rs new file mode 100644 index 000000000..18dd23f28 --- /dev/null +++ b/tpm-types/src/lib.rs @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM Types - Common TPM-related type definitions +//! +//! This crate contains type definitions shared across TPM-related crates: +//! - tpm-attest (device side - generates quotes) +//! - tpm-qvl (verifier side - verifies quotes) +//! - ra-tls (uses TPM quotes in attestation) + +use dstack_types::Platform; +use scale::{Decode, Encode}; +use serde::{Deserialize, Serialize}; +use serde_human_bytes as hex_bytes; + +/// TPM Quote structure containing attestation data +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] +pub struct TpmQuote { + /// TPMS_ATTEST message + #[serde(with = "hex_bytes")] + pub message: Vec, + + /// Quote signature + #[serde(with = "hex_bytes")] + pub signature: Vec, + + /// PCR values included in the quote + pub pcr_values: Vec, + + /// Attestation Key (AK) certificate (DER format) + #[serde(with = "hex_bytes")] + pub ak_cert: Vec, + + /// Platform where quote was generated + pub platform: Platform, + + /// Event Log (optional, used for PCR replay verification) + pub event_log: Vec, +} + +impl TpmQuote { + pub fn from_scale(mut input: &[u8]) -> Result { + Self::decode(&mut input) + } + + pub fn to_scale(&self) -> Vec { + self.encode() + } +} + +/// PCR (Platform Configuration Register) value +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] +pub struct PcrValue { + /// PCR index (0-23) + pub index: u32, + + /// Hash algorithm (e.g., "sha256", "sha384") + pub algorithm: String, + + /// PCR value (hash) + #[serde(with = "hex_bytes")] + pub value: Vec, +} + +/// PCR selection specifying which PCRs to include +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PcrSelection { + /// Hash bank (e.g., "sha256") + pub bank: String, + + /// List of PCR indices + pub pcrs: Vec, +} + +impl PcrSelection { + pub fn new(bank: &str, pcrs: &[u32]) -> Self { + Self { + bank: bank.to_string(), + pcrs: pcrs.to_vec(), + } + } + + pub fn sha256(pcrs: &[u32]) -> Self { + Self::new("sha256", pcrs) + } + + pub fn to_arg(&self) -> String { + let pcr_list: Vec = self.pcrs.iter().map(|p| p.to_string()).collect(); + format!( + "{}:{pcr_list_joined}", + self.bank, + pcr_list_joined = pcr_list.join(",") + ) + } +} + +impl Default for PcrSelection { + fn default() -> Self { + Self::sha256(&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + } +} + +// Re-export TPM Event types from cc-eventlog +pub use cc_eventlog::tpm::{TpmEvent, TpmEventLog}; diff --git a/tpm2/Cargo.toml b/tpm2/Cargo.toml new file mode 100644 index 000000000..f51a07896 --- /dev/null +++ b/tpm2/Cargo.toml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: © 2025 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "tpm2" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "Pure Rust TPM 2.0 implementation" +keywords = ["tpm", "tpm2", "security", "attestation"] +categories = ["cryptography", "hardware-support"] + +[dependencies] +anyhow.workspace = true +sha2 = { workspace = true, features = ["oid"] } +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[[bin]] +name = "tpm2-test" +path = "src/bin/tpm2-test.rs" + +[dependencies.hex] +workspace = true +features = ["alloc"] diff --git a/tpm2/src/bin/tpm2-test.rs b/tpm2/src/bin/tpm2-test.rs new file mode 100644 index 000000000..6afb2d827 --- /dev/null +++ b/tpm2/src/bin/tpm2-test.rs @@ -0,0 +1,952 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM 2.0 Test CLI +//! +//! A simple CLI tool to test TPM 2.0 operations on real hardware. +//! +//! Usage: +//! tpm2-test [command] +//! +//! Commands: +//! info - Show TPM device info +//! random - Generate random bytes +//! pcr-read - Read PCR values +//! pcr-extend - Test PCR extend +//! nv-test - Test NV read/write operations +//! nv-full - Full NV test (define/write/read/undefine) +//! primary - Test primary key creation +//! evict - Test EvictControl (persistent key) +//! seal - Test seal/unseal operations (no PCR policy) +//! seal-pcr - Test seal/unseal operations with PCR policy +//! quote - Generate a TPM quote with RSA AK (requires GCP vTPM) +//! quote-ecc - Generate a TPM quote with ECC AK (requires GCP vTPM) +//! all - Run all tests + +use std::env; +use tpm2::{tpm_rh, ResponseBuffer, TpmAlgId, TpmContext, TpmlPcrSelection, TpmtPublic, Unmarshal}; + +fn main() { + let args: Vec = env::args().collect(); + let command = args.get(1).map(|s| s.as_str()).unwrap_or("all"); + + println!("=== TPM 2.0 Pure Rust Test Tool ===\n"); + + match command { + "info" => test_info(), + "random" => test_random(), + "pcr-read" => test_pcr_read(), + "pcr-extend" => test_pcr_extend(), + "nv-test" => test_nv_operations(), + "nv-full" => test_nv_full(), + "primary" => test_primary_key(), + "evict" => test_evict_control(), + "seal" => test_seal_unseal(), + "quote" => test_quote_rsa(), + "quote-ecc" => test_quote_ecc(), + "seal-pcr" => test_seal_unseal_with_pcr(), + "all" => { + test_info(); + test_random(); + test_pcr_read(); + test_primary_key(); + test_nv_operations(); + test_seal_unseal(); + test_seal_unseal_with_pcr(); + test_quote_rsa(); + test_quote_ecc(); + } + _ => { + eprintln!("Unknown command: {}", command); + eprintln!("Available commands: info, random, pcr-read, pcr-extend, nv-test, nv-full, primary, evict, seal, seal-pcr, seal-nv, quote, quote-ecc, all"); + std::process::exit(1); + } + } +} + +fn test_info() { + println!("--- Test: Device Info ---"); + + match TpmContext::new(None) { + Ok(ctx) => { + println!("✓ TPM device opened: {}", ctx.device_path()); + } + Err(e) => { + println!("✗ Failed to open TPM device: {}", e); + } + } + println!(); +} + +fn test_random() { + println!("--- Test: Random Number Generation ---"); + + let mut ctx = match TpmContext::new(None) { + Ok(ctx) => ctx, + Err(e) => { + println!("✗ Failed to open TPM: {}", e); + return; + } + }; + + // Test getting 32 random bytes + match ctx.get_random(32) { + Ok(bytes) => { + println!("✓ Generated 32 random bytes:"); + println!(" {}", hex::encode(&bytes)); + } + Err(e) => { + println!("✗ GetRandom failed: {}", e); + } + } + + // Test getting 64 random bytes (tests chunking) + match ctx.get_random(64) { + Ok(bytes) => { + println!("✓ Generated 64 random bytes:"); + println!(" {}...", &hex::encode(&bytes)[..64]); + } + Err(e) => { + println!("✗ GetRandom (64 bytes) failed: {}", e); + } + } + println!(); +} + +fn test_pcr_read() { + println!("--- Test: PCR Read ---"); + + let mut ctx = match TpmContext::new(None) { + Ok(ctx) => ctx, + Err(e) => { + println!("✗ Failed to open TPM: {}", e); + return; + } + }; + + // Read PCRs 0, 1, 2, 7 + let pcr_selection = TpmlPcrSelection::single(TpmAlgId::Sha256, &[0, 1, 2, 7]); + + match ctx.pcr_read(&pcr_selection) { + Ok(values) => { + println!("✓ Read {} PCR values:", values.len()); + for (idx, value) in values { + println!(" PCR[{}] = {}", idx, hex::encode(&value)); + } + } + Err(e) => { + println!("✗ PCR_Read failed: {}", e); + } + } + println!(); +} + +fn test_primary_key() { + println!("--- Test: Primary Key ---"); + + let mut ctx = match TpmContext::new(None) { + Ok(ctx) => ctx, + Err(e) => { + println!("✗ Failed to open TPM: {}", e); + return; + } + }; + + // Check if a persistent handle exists + let test_handle: u32 = 0x81000100; + + match ctx.handle_exists(test_handle) { + Ok(exists) => { + println!(" Handle 0x{:08x} exists: {}", test_handle, exists); + } + Err(e) => { + println!("✗ ReadPublic failed: {}", e); + } + } + + // Try to create a transient primary key + println!(" Creating transient primary key under Owner hierarchy..."); + let template = tpm2::TpmtPublic::rsa_storage_key(); + match ctx.create_primary(tpm_rh::OWNER, &template) { + Ok((handle, public)) => { + println!("✓ Created primary key:"); + println!(" Handle: 0x{:08x}", handle); + println!(" Public size: {} bytes", public.len()); + + // Flush the transient handle + if let Err(e) = ctx.flush_context(handle) { + println!(" Warning: Failed to flush handle: {}", e); + } else { + println!(" Flushed transient handle"); + } + } + Err(e) => { + println!("✗ CreatePrimary failed: {}", e); + } + } + println!(); +} + +fn test_nv_operations() { + println!("--- Test: NV Operations ---"); + + let mut ctx = match TpmContext::new(None) { + Ok(ctx) => ctx, + Err(e) => { + println!("✗ Failed to open TPM: {}", e); + return; + } + }; + + // Test NV index (use a test index in the owner range) + let test_nv_index: u32 = 0x01800100; + + // Check if NV index exists + match ctx.nv_exists(test_nv_index) { + Ok(exists) => { + println!(" NV index 0x{:08x} exists: {}", test_nv_index, exists); + + if exists { + // Try to read it + match ctx.nv_read(test_nv_index) { + Ok(Some(data)) => { + println!("✓ Read {} bytes from NV", data.len()); + if data.len() <= 64 { + println!(" Data: {}", hex::encode(&data)); + } + } + Ok(None) => { + println!(" NV index exists but couldn't read (auth required?)"); + } + Err(e) => { + println!("✗ NV_Read failed: {}", e); + } + } + } + } + Err(e) => { + println!("✗ NV_ReadPublic failed: {}", e); + } + } + + // Try to read GCP AK certificate (if on GCP) + let gcp_ak_cert_index: u32 = 0x01C10000; + println!( + "\n Checking GCP AK certificate at 0x{:08x}...", + gcp_ak_cert_index + ); + + match ctx.nv_exists(gcp_ak_cert_index) { + Ok(true) => { + println!(" GCP AK certificate NV index exists!"); + match ctx.nv_read(gcp_ak_cert_index) { + Ok(Some(data)) => { + println!("✓ Read GCP AK certificate: {} bytes", data.len()); + } + Ok(None) => { + println!(" Couldn't read certificate data"); + } + Err(e) => { + println!("✗ Failed to read certificate: {}", e); + } + } + } + Ok(false) => { + println!(" GCP AK certificate not found (not on GCP vTPM?)"); + } + Err(e) => { + println!("✗ NV check failed: {}", e); + } + } + println!(); +} + +fn test_quote_rsa() { + println!("--- Test: Quote Generation with RSA AK (GCP vTPM) ---"); + + let mut ctx = match TpmContext::new(None) { + Ok(ctx) => ctx, + Err(e) => { + println!("✗ Failed to open TPM: {}", e); + return; + } + }; + + // Check if GCP AK template exists + let gcp_ak_template_index: u32 = 0x01C10001; // RSA AK template + + match ctx.nv_exists(gcp_ak_template_index) { + Ok(true) => { + println!(" GCP AK template found, attempting to load AK..."); + + // Read template + match ctx.nv_read(gcp_ak_template_index) { + Ok(Some(template)) => { + println!(" Read AK template: {} bytes", template.len()); + + // Create primary with template + match ctx.create_primary_from_template(tpm_rh::ENDORSEMENT, &template) { + Ok((handle, _public)) => { + println!("✓ Loaded GCP AK: handle 0x{:08x}", handle); + + // Generate quote + let qualifying_data = [0u8; 32]; // Test nonce + let pcr_selection = + TpmlPcrSelection::single(TpmAlgId::Sha256, &[0, 2, 14]); + + match ctx.quote(handle, &qualifying_data, &pcr_selection) { + Ok((quoted, signature)) => { + println!("✓ Generated quote:"); + println!(" Quoted size: {} bytes", quoted.len()); + println!(" Signature size: {} bytes", signature.len()); + + match verify_quote_pcr_digest(&mut ctx, "ed, &pcr_selection) + { + Ok(()) => println!( + "✓ Quote PCR digest matches current PCR values" + ), + Err(e) => println!( + "✗ Quote PCR digest verification failed: {}", + e + ), + } + } + Err(e) => { + println!("✗ Quote failed: {}", e); + } + } + + // Flush handle + let _ = ctx.flush_context(handle); + } + Err(e) => { + println!("✗ Failed to load AK: {}", e); + } + } + } + Ok(None) => { + println!(" Couldn't read AK template"); + } + Err(e) => { + println!("✗ Failed to read template: {}", e); + } + } + } + Ok(false) => { + println!(" GCP AK template not found (not on GCP vTPM)"); + println!(" Skipping quote test"); + } + Err(e) => { + println!("✗ NV check failed: {}", e); + } + } + println!(); +} + +fn test_quote_ecc() { + println!("--- Test: Quote Generation with ECC AK (GCP vTPM) ---"); + + let mut ctx = match TpmContext::new(None) { + Ok(ctx) => ctx, + Err(e) => { + println!("✗ Failed to open TPM: {}", e); + return; + } + }; + + // Check if GCP ECC AK template exists + let gcp_ak_template_index: u32 = 0x01C10003; // ECC AK template + + match ctx.nv_exists(gcp_ak_template_index) { + Ok(true) => { + println!(" GCP ECC AK template found, attempting to load AK..."); + + // Read template + match ctx.nv_read(gcp_ak_template_index) { + Ok(Some(template)) => { + println!(" Read ECC AK template: {} bytes", template.len()); + + // Create primary with template + match ctx.create_primary_from_template(tpm_rh::ENDORSEMENT, &template) { + Ok((handle, _public)) => { + println!("✓ Loaded GCP ECC AK: handle 0x{:08x}", handle); + + // Generate quote + let qualifying_data = [0u8; 32]; // Test nonce + let pcr_selection = + TpmlPcrSelection::single(TpmAlgId::Sha256, &[0, 2, 14]); + + match ctx.quote(handle, &qualifying_data, &pcr_selection) { + Ok((quoted, signature)) => { + println!("✓ Generated ECC quote:"); + println!(" Quoted size: {} bytes", quoted.len()); + println!(" Signature size: {} bytes", signature.len()); + + match verify_quote_pcr_digest(&mut ctx, "ed, &pcr_selection) + { + Ok(()) => println!( + "✓ Quote PCR digest matches current PCR values" + ), + Err(e) => println!( + "✗ Quote PCR digest verification failed: {}", + e + ), + } + } + Err(e) => { + println!("✗ Quote failed: {}", e); + } + } + + // Flush handle + let _ = ctx.flush_context(handle); + } + Err(e) => { + println!("✗ Failed to load ECC AK: {}", e); + } + } + } + Ok(None) => { + println!(" Couldn't read ECC AK template"); + } + Err(e) => { + println!("✗ Failed to read template: {}", e); + } + } + } + Ok(false) => { + println!(" GCP ECC AK template not found (not on GCP vTPM)"); + println!(" Skipping ECC quote test"); + } + Err(e) => { + println!("✗ NV check failed: {}", e); + } + } + println!(); +} + +fn test_pcr_extend() { + println!("--- Test: PCR Extend ---"); + println!(" Note: This test extends PCR 23 which is typically resettable"); + + let mut ctx = match TpmContext::new(None) { + Ok(ctx) => ctx, + Err(e) => { + println!("✗ Failed to open TPM: {}", e); + return; + } + }; + + // Read PCR 23 before extend + let pcr_selection = TpmlPcrSelection::single(TpmAlgId::Sha256, &[23]); + let before = match ctx.pcr_read(&pcr_selection) { + Ok(values) => { + if let Some((_, value)) = values.first() { + println!(" PCR[23] before: {}", hex::encode(value)); + value.clone() + } else { + println!("✗ No PCR value returned"); + return; + } + } + Err(e) => { + println!("✗ PCR_Read failed: {}", e); + return; + } + }; + + // Extend PCR 23 with test data + let test_hash = [0x42u8; 32]; // Test hash value + match ctx.pcr_extend(23, &test_hash, TpmAlgId::Sha256) { + Ok(()) => { + println!("✓ PCR_Extend succeeded"); + } + Err(e) => { + println!("✗ PCR_Extend failed: {}", e); + return; + } + } + + // Read PCR 23 after extend + match ctx.pcr_read(&pcr_selection) { + Ok(values) => { + if let Some((_, value)) = values.first() { + println!(" PCR[23] after: {}", hex::encode(value)); + if value != &before { + println!("✓ PCR value changed as expected"); + } else { + println!("✗ PCR value did not change!"); + } + } + } + Err(e) => { + println!("✗ PCR_Read after extend failed: {}", e); + } + } + println!(); +} + +fn test_nv_full() { + println!("--- Test: Full NV Operations (Define/Write/Read/Undefine) ---"); + println!(" Warning: This test creates and deletes NV index 0x01800200"); + + let mut ctx = match TpmContext::new(None) { + Ok(ctx) => ctx, + Err(e) => { + println!("✗ Failed to open TPM: {}", e); + return; + } + }; + + let test_nv_index: u32 = 0x01800200; + let test_data = b"Hello TPM NV!"; + + // Clean up if index exists from previous failed test + if ctx.nv_exists(test_nv_index).unwrap_or(false) { + println!(" Cleaning up existing NV index..."); + let _ = ctx.nv_undefine(test_nv_index); + } + + // Define NV index + println!( + " Defining NV index 0x{:08x} with size {}...", + test_nv_index, + test_data.len() + ); + match ctx.nv_define(test_nv_index, test_data.len(), true) { + Ok(true) => { + println!("✓ NV_DefineSpace succeeded"); + } + Ok(false) => { + println!("✗ NV_DefineSpace returned false"); + return; + } + Err(e) => { + println!("✗ NV_DefineSpace failed: {}", e); + return; + } + } + + // Write to NV index + println!(" Writing {} bytes to NV...", test_data.len()); + match ctx.nv_write(test_nv_index, test_data) { + Ok(true) => { + println!("✓ NV_Write succeeded"); + } + Ok(false) => { + println!("✗ NV_Write returned false"); + } + Err(e) => { + println!("✗ NV_Write failed: {}", e); + } + } + + // Read from NV index + println!(" Reading from NV..."); + match ctx.nv_read(test_nv_index) { + Ok(Some(data)) => { + println!("✓ NV_Read succeeded: {} bytes", data.len()); + if data == test_data { + println!("✓ Data matches!"); + } else { + println!("✗ Data mismatch!"); + println!(" Expected: {:?}", String::from_utf8_lossy(test_data)); + println!(" Got: {:?}", String::from_utf8_lossy(&data)); + } + } + Ok(None) => { + println!("✗ NV_Read returned None"); + } + Err(e) => { + println!("✗ NV_Read failed: {}", e); + } + } + + // Undefine NV index + println!(" Undefining NV index..."); + match ctx.nv_undefine(test_nv_index) { + Ok(true) => { + println!("✓ NV_UndefineSpace succeeded"); + } + Ok(false) => { + println!("✗ NV_UndefineSpace returned false"); + } + Err(e) => { + println!("✗ NV_UndefineSpace failed: {}", e); + } + } + + // Verify it's gone + match ctx.nv_exists(test_nv_index) { + Ok(false) => { + println!("✓ NV index successfully removed"); + } + Ok(true) => { + println!("✗ NV index still exists after undefine!"); + } + Err(e) => { + println!("✗ NV check failed: {}", e); + } + } + println!(); +} + +fn test_evict_control() { + println!("--- Test: EvictControl (Persistent Key) ---"); + println!(" Warning: This test creates and removes persistent key at 0x81000200"); + + let mut ctx = match TpmContext::new(None) { + Ok(ctx) => ctx, + Err(e) => { + println!("✗ Failed to open TPM: {}", e); + return; + } + }; + + let persistent_handle: u32 = 0x81000200; + + // Clean up if handle exists from previous failed test + if ctx.handle_exists(persistent_handle).unwrap_or(false) { + println!(" Cleaning up existing persistent handle..."); + // Need to evict it first - create a dummy transient and evict to remove + let _ = ctx.evict_control(persistent_handle, persistent_handle); + } + + // Create a transient primary key + println!(" Creating transient primary key..."); + let template = TpmtPublic::rsa_storage_key(); + let (transient_handle, _public) = match ctx.create_primary(tpm_rh::OWNER, &template) { + Ok(result) => { + println!("✓ Created transient key: 0x{:08x}", result.0); + result + } + Err(e) => { + println!("✗ CreatePrimary failed: {}", e); + return; + } + }; + + // Make it persistent + println!(" Making key persistent at 0x{:08x}...", persistent_handle); + match ctx.evict_control(transient_handle, persistent_handle) { + Ok(true) => { + println!("✓ EvictControl succeeded - key is now persistent"); + } + Ok(false) => { + println!("✗ EvictControl returned false"); + let _ = ctx.flush_context(transient_handle); + return; + } + Err(e) => { + println!("✗ EvictControl failed: {}", e); + let _ = ctx.flush_context(transient_handle); + return; + } + } + + // Flush the transient handle (no longer needed) + let _ = ctx.flush_context(transient_handle); + + // Verify persistent handle exists + match ctx.handle_exists(persistent_handle) { + Ok(true) => { + println!("✓ Persistent handle exists"); + } + Ok(false) => { + println!("✗ Persistent handle not found!"); + return; + } + Err(e) => { + println!("✗ Handle check failed: {}", e); + return; + } + } + + // Remove the persistent key + println!(" Removing persistent key..."); + match ctx.evict_control(persistent_handle, persistent_handle) { + Ok(true) => { + println!("✓ Persistent key removed"); + } + Ok(false) => { + println!("✗ EvictControl (remove) returned false"); + } + Err(e) => { + println!("✗ EvictControl (remove) failed: {}", e); + } + } + + // Verify it's gone + match ctx.handle_exists(persistent_handle) { + Ok(false) => { + println!("✓ Persistent handle successfully removed"); + } + Ok(true) => { + println!("✗ Persistent handle still exists!"); + } + Err(_) => { + // Expected - handle doesn't exist + println!("✓ Persistent handle successfully removed"); + } + } + println!(); +} + +fn test_seal_unseal() { + println!("--- Test: Seal/Unseal Operations ---"); + + let mut ctx = match TpmContext::new(None) { + Ok(ctx) => ctx, + Err(e) => { + println!("✗ Failed to open TPM: {}", e); + return; + } + }; + + // First, ensure we have a primary key + println!(" Creating primary storage key..."); + let template = TpmtPublic::rsa_storage_key(); + let (parent_handle, _) = match ctx.create_primary(tpm_rh::OWNER, &template) { + Ok(result) => { + println!("✓ Created parent key: 0x{:08x}", result.0); + result + } + Err(e) => { + println!("✗ CreatePrimary failed: {}", e); + return; + } + }; + + // Data to seal + let secret_data = b"This is my secret data for TPM sealing test!"; + println!(" Sealing {} bytes of data...", secret_data.len()); + + // Seal the data without PCR policy (simpler test) + let empty_pcr_selection = TpmlPcrSelection::default(); + let (pub_blob, priv_blob) = match ctx.seal( + secret_data, + parent_handle, + &empty_pcr_selection, + TpmAlgId::Sha256, + ) { + Ok(result) => { + println!("✓ Seal succeeded:"); + println!(" Public blob: {} bytes", result.0.len()); + println!(" Private blob: {} bytes", result.1.len()); + result + } + Err(e) => { + println!("✗ Seal failed: {}", e); + let _ = ctx.flush_context(parent_handle); + return; + } + }; + + // Unseal the data (use same empty PCR selection as seal) + println!(" Unsealing data..."); + match ctx.unseal( + &pub_blob, + &priv_blob, + parent_handle, + &empty_pcr_selection, + TpmAlgId::Sha256, + ) { + Ok(unsealed) => { + println!("✓ Unseal succeeded: {} bytes", unsealed.len()); + if unsealed == secret_data { + println!("✓ Data matches original!"); + println!(" Content: {:?}", String::from_utf8_lossy(&unsealed)); + } else { + println!("✗ Data mismatch!"); + println!(" Expected: {:?}", String::from_utf8_lossy(secret_data)); + println!(" Got: {:?}", String::from_utf8_lossy(&unsealed)); + } + } + Err(e) => { + println!("✗ Unseal failed: {}", e); + } + } + + // Clean up + let _ = ctx.flush_context(parent_handle); + println!(); +} + +fn parse_quote_attestation(quoted: &[u8]) -> anyhow::Result<(TpmlPcrSelection, Vec)> { + let mut buf = ResponseBuffer::new(quoted); + + let magic = buf.get_u32()?; + let attest_type = buf.get_u16()?; + if magic != 0xff544347 { + anyhow::bail!("unexpected TPMS_ATTEST.magic: 0x{:08x}", magic); + } + if attest_type != 0x8018 { + anyhow::bail!("unexpected TPMS_ATTEST.type: 0x{:04x}", attest_type); + } + let _qualified_signer = buf.get_tpm2b()?; + let _extra_data = buf.get_tpm2b()?; + + let _clock = buf.get_u64()?; + let _reset_count = buf.get_u32()?; + let _restart_count = buf.get_u32()?; + let _safe = buf.get_u8()?; + + let _firmware_version = buf.get_u64()?; + + let pcr_select = TpmlPcrSelection::unmarshal(&mut buf)?; + let pcr_digest = buf.get_tpm2b()?; + + Ok((pcr_select, pcr_digest)) +} + +fn verify_quote_pcr_digest( + ctx: &mut TpmContext, + quoted: &[u8], + requested_selection: &TpmlPcrSelection, +) -> anyhow::Result<()> { + use sha2::{Digest, Sha256, Sha384, Sha512}; + + let (attested_selection, attested_digest) = parse_quote_attestation(quoted)?; + + if attested_selection.pcr_selections.len() != requested_selection.pcr_selections.len() { + anyhow::bail!("quote returned unexpected PCR selection count"); + } + for (a, r) in attested_selection + .pcr_selections + .iter() + .zip(requested_selection.pcr_selections.iter()) + { + if a.hash.to_u16() != r.hash.to_u16() || a.pcr_select != r.pcr_select { + anyhow::bail!("quote returned PCR selection different from request"); + } + } + + if requested_selection.pcr_selections.len() != 1 { + anyhow::bail!("quote PCR verification only supports a single PCR bank selection"); + } + let hash_alg = requested_selection.pcr_selections[0].hash; + + let mut values = ctx.pcr_read(requested_selection)?; + values.sort_by_key(|(idx, _)| *idx); + let mut concat = Vec::new(); + for (_, v) in values { + concat.extend_from_slice(&v); + } + + let computed = match hash_alg { + TpmAlgId::Sha256 => Sha256::digest(&concat).to_vec(), + TpmAlgId::Sha384 => Sha384::digest(&concat).to_vec(), + TpmAlgId::Sha512 => Sha512::digest(&concat).to_vec(), + _ => anyhow::bail!("unsupported hash algorithm for quote PCR digest verification"), + }; + if computed != attested_digest { + anyhow::bail!("pcrDigest mismatch"); + } + + Ok(()) +} + +fn test_seal_unseal_with_pcr() { + println!("--- Test: Seal/Unseal Operations with PCR Policy ---"); + println!(" This seals data bound to PCR[23] (SHA256)"); + + let mut ctx = match TpmContext::new(None) { + Ok(ctx) => ctx, + Err(e) => { + println!("✗ Failed to open TPM: {}", e); + return; + } + }; + + println!(" Creating primary storage key..."); + let template = TpmtPublic::rsa_storage_key(); + let (parent_handle, _) = match ctx.create_primary(tpm_rh::OWNER, &template) { + Ok(result) => { + println!("✓ Created parent key: 0x{:08x}", result.0); + result + } + Err(e) => { + println!("✗ CreatePrimary failed: {}", e); + return; + } + }; + + let secret_data = b"PCR protected secret data!"; + let pcr_selection = TpmlPcrSelection::single(TpmAlgId::Sha256, &[23]); + println!( + " Sealing {} bytes of data with PCR policy...", + secret_data.len() + ); + + let (pub_blob, priv_blob) = + match ctx.seal(secret_data, parent_handle, &pcr_selection, TpmAlgId::Sha256) { + Ok(result) => { + println!("✓ Seal succeeded with PCR policy"); + result + } + Err(e) => { + println!("✗ Seal failed: {}", e); + let _ = ctx.flush_context(parent_handle); + return; + } + }; + + println!(" Reading PCR values for verification..."); + match ctx.pcr_read(&pcr_selection) { + Ok(values) => { + for (idx, value) in values { + println!(" PCR[{}] = {}", idx, hex::encode(value)); + } + } + Err(e) => println!(" Warning: failed to read PCRs: {}", e), + } + + println!(" Attempting to unseal data (PCRs must match)..."); + let unsealed_ok = match ctx.unseal( + &pub_blob, + &priv_blob, + parent_handle, + &pcr_selection, + TpmAlgId::Sha256, + ) { + Ok(unsealed) => { + println!("✓ Unseal succeeded: {} bytes", unsealed.len()); + if unsealed == secret_data { + println!("✓ Data matches original!"); + println!(" Content: {:?}", String::from_utf8_lossy(&unsealed)); + } else { + println!("✗ Data mismatch!"); + } + true + } + Err(e) => { + println!("✗ Unseal failed (PCR mismatch?): {}", e); + false + } + }; + + if unsealed_ok { + println!(" Extending PCR 23 to ensure unseal fails in a different PCR environment..."); + let extend_value = [0xA5u8; 32]; + match ctx.pcr_extend(23, &extend_value, TpmAlgId::Sha256) { + Ok(()) => println!("✓ PCR_Extend succeeded"), + Err(e) => println!("✗ PCR_Extend failed: {}", e), + } + + println!(" Attempting to unseal again (must FAIL after PCR change)..."); + match ctx.unseal( + &pub_blob, + &priv_blob, + parent_handle, + &pcr_selection, + TpmAlgId::Sha256, + ) { + Ok(_) => println!("✗ Unseal unexpectedly succeeded after PCR change!"), + Err(_) => println!("✓ Unseal failed after PCR change (expected)"), + } + } + + let _ = ctx.flush_context(parent_handle); + println!(); +} diff --git a/tpm2/src/commands.rs b/tpm2/src/commands.rs new file mode 100644 index 000000000..4eb4b4e4b --- /dev/null +++ b/tpm2/src/commands.rs @@ -0,0 +1,648 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM 2.0 command implementations +//! +//! This module provides high-level TPM operations. + +use anyhow::{Context, Result}; +use tracing::debug; + +use super::constants::*; +use super::device::*; +use super::marshal::*; +use super::session::*; +use super::types::*; + +/// Pure Rust TPM context +pub struct TpmContext { + device: TpmDevice, +} + +impl TpmContext { + /// Create a new TPM context with the given device path + pub fn new(tcti_path: Option<&str>) -> Result { + let device = match tcti_path { + Some(path) => TpmDevice::open(path)?, + None => TpmDevice::detect()?, + }; + + Ok(Self { device }) + } + + /// Get the device path + pub fn device_path(&self) -> &str { + self.device.path() + } + + // ==================== NV Operations ==================== + + /// Check if an NV index exists + pub fn nv_exists(&mut self, index: u32) -> Result { + let mut cmd = TpmCommand::new(TpmCc::NvReadPublic); + cmd.add_handle(index); + + let response = self.device.execute(&cmd.finalize())?; + + // If successful, the NV index exists + Ok(response.is_success()) + } + + /// Read NV public area to get size + pub fn nv_read_public(&mut self, index: u32) -> Result { + let mut cmd = TpmCommand::new(TpmCc::NvReadPublic); + cmd.add_handle(index); + + let response = self.device.execute(&cmd.finalize())?; + response.ensure_success().context("NV_ReadPublic failed")?; + + let mut buf = response.data_buffer(); + let nv_public = Tpm2bNvPublic::unmarshal(&mut buf)?; + + Ok(nv_public.nv_public) + } + + /// Read data from an NV index + pub fn nv_read(&mut self, index: u32) -> Result>> { + // First get the NV public to know the size + let nv_public = match self.nv_read_public(index) { + Ok(p) => p, + Err(_) => return Ok(None), // NV index doesn't exist + }; + + let total_size = nv_public.data_size as usize; + let mut result = Vec::with_capacity(total_size); + let mut offset = 0u16; + + // Read in chunks (max ~1024 bytes per read) + const MAX_READ_SIZE: u16 = 1024; + + while (offset as usize) < total_size { + let remaining = total_size - offset as usize; + let read_size = (remaining as u16).min(MAX_READ_SIZE); + + let mut cmd = TpmCommand::with_sessions(TpmCc::NvRead); + // authHandle (owner for owner-readable NV) + cmd.add_handle(tpm_rh::OWNER); + // nvIndex + cmd.add_handle(index); + // Authorization area (null auth) + cmd.add_null_auth_area(); + // size + cmd.add_u16(read_size); + // offset + cmd.add_u16(offset); + + let response = self.device.execute(&cmd.finalize())?; + if !response.is_success() { + // Try with NV index as auth handle instead + let mut cmd = TpmCommand::with_sessions(TpmCc::NvRead); + cmd.add_handle(index); + cmd.add_handle(index); + cmd.add_null_auth_area(); + cmd.add_u16(read_size); + cmd.add_u16(offset); + + let response = self.device.execute(&cmd.finalize())?; + if !response.is_success() { + return Ok(None); + } + + let mut buf = response.skip_parameter_size()?; + let data = buf.get_tpm2b()?; + result.extend_from_slice(&data); + } else { + let mut buf = response.skip_parameter_size()?; + let data = buf.get_tpm2b()?; + result.extend_from_slice(&data); + } + + offset += read_size; + } + + Ok(Some(result)) + } + + /// Write data to an NV index + pub fn nv_write(&mut self, index: u32, data: &[u8]) -> Result { + const MAX_WRITE_SIZE: usize = 1024; + let mut offset = 0u16; + + while (offset as usize) < data.len() { + let remaining = data.len() - offset as usize; + let write_size = remaining.min(MAX_WRITE_SIZE); + let chunk = &data[offset as usize..offset as usize + write_size]; + + let mut cmd = TpmCommand::with_sessions(TpmCc::NvWrite); + // authHandle + cmd.add_handle(tpm_rh::OWNER); + // nvIndex + cmd.add_handle(index); + // Authorization area + cmd.add_null_auth_area(); + // data + cmd.add_tpm2b(chunk); + // offset + cmd.add_u16(offset); + + let response = self.device.execute(&cmd.finalize())?; + response + .ensure_success() + .with_context(|| format!("NV_Write failed at offset {}", offset))?; + + offset += write_size as u16; + } + + debug!("wrote {} bytes to NV index 0x{:08x}", data.len(), index); + Ok(true) + } + + /// Define a new NV index + pub fn nv_define(&mut self, index: u32, size: usize, owner_read_write: bool) -> Result { + let mut attributes = TpmaNv::new(); + if owner_read_write { + attributes = attributes.with_owner_write().with_owner_read(); + } + + let nv_public = TpmsNvPublic::new(index, size as u16, attributes); + + let mut cmd = TpmCommand::with_sessions(TpmCc::NvDefineSpace); + // authHandle (owner) + cmd.add_handle(tpm_rh::OWNER); + // Authorization area + cmd.add_null_auth_area(); + // auth (empty) + cmd.add_tpm2b_empty(); + // publicInfo + cmd.add(&Tpm2bNvPublic { nv_public }); + + let response = self.device.execute(&cmd.finalize())?; + + if response.is_success() { + debug!("defined NV index 0x{:08x} with size {}", index, size); + Ok(true) + } else { + Ok(false) + } + } + + /// Undefine (delete) an NV index + pub fn nv_undefine(&mut self, index: u32) -> Result { + let mut cmd = TpmCommand::with_sessions(TpmCc::NvUndefineSpace); + // authHandle (owner) + cmd.add_handle(tpm_rh::OWNER); + // nvIndex + cmd.add_handle(index); + // Authorization area + cmd.add_null_auth_area(); + + let response = self.device.execute(&cmd.finalize())?; + + if response.is_success() { + debug!("undefined NV index 0x{:08x}", index); + Ok(true) + } else { + Ok(false) + } + } + + // ==================== PCR Operations ==================== + + /// Read PCR values for the given selection + pub fn pcr_read(&mut self, pcr_selection: &TpmlPcrSelection) -> Result)>> { + let mut cmd = TpmCommand::new(TpmCc::PcrRead); + cmd.add(pcr_selection); + + let response = self.device.execute(&cmd.finalize())?; + response.ensure_success().context("PCR_Read failed")?; + + let mut buf = response.data_buffer(); + let _update_counter = buf.get_u32()?; + let pcr_selection_out = TpmlPcrSelection::unmarshal(&mut buf)?; + let digest_list = TpmlDigest::unmarshal(&mut buf)?; + + // Map digests to PCR indices + let mut result = Vec::new(); + let mut digest_idx = 0; + + for sel in &pcr_selection_out.pcr_selections { + for (byte_idx, &byte) in sel.pcr_select.iter().enumerate() { + for bit in 0..8 { + if byte & (1 << bit) != 0 { + let pcr_idx = (byte_idx * 8 + bit) as u32; + if digest_idx < digest_list.digests.len() { + result.push((pcr_idx, digest_list.digests[digest_idx].buffer.clone())); + digest_idx += 1; + } + } + } + } + } + + Ok(result) + } + + /// Read a single PCR value + pub fn pcr_read_single(&mut self, pcr_idx: u32, hash_alg: TpmAlgId) -> Result> { + let selection = TpmlPcrSelection::single(hash_alg, &[pcr_idx]); + let values = self.pcr_read(&selection)?; + + values + .into_iter() + .find(|(idx, _)| *idx == pcr_idx) + .map(|(_, v)| v) + .ok_or_else(|| anyhow::anyhow!("PCR {} not found in response", pcr_idx)) + } + + /// Extend a PCR with a hash value + pub fn pcr_extend(&mut self, pcr: u32, hash: &[u8], hash_alg: TpmAlgId) -> Result<()> { + let digest_values = TpmlDigestValues::single(TpmtHa { + hash_alg, + digest: hash.to_vec(), + }); + + let mut cmd = TpmCommand::with_sessions(TpmCc::PcrExtend); + // pcrHandle + cmd.add_handle(pcr); + // Authorization area + cmd.add_null_auth_area(); + // digests + cmd.add(&digest_values); + + let response = self.device.execute(&cmd.finalize())?; + response + .ensure_success() + .with_context(|| format!("PCR_Extend failed for PCR {}", pcr))?; + + debug!("extended PCR {}", pcr); + Ok(()) + } + + // ==================== Random Number Generation ==================== + + /// Generate random bytes using the TPM's hardware RNG + pub fn get_random(&mut self, num_bytes: usize) -> Result> { + let mut result = Vec::with_capacity(num_bytes); + + // TPM may return fewer bytes than requested, so loop + while result.len() < num_bytes { + let remaining = num_bytes - result.len(); + let request_size = remaining.min(48) as u16; // TPM typically limits to 48-64 bytes + + let mut cmd = TpmCommand::new(TpmCc::GetRandom); + cmd.add_u16(request_size); + + let response = self.device.execute(&cmd.finalize())?; + response.ensure_success().context("GetRandom failed")?; + + let mut buf = response.data_buffer(); + let random_bytes = buf.get_tpm2b()?; + result.extend_from_slice(&random_bytes); + } + + result.truncate(num_bytes); + Ok(result) + } + + /// Generate random bytes into a fixed-size array + pub fn get_random_array(&mut self) -> Result<[u8; N]> { + let bytes = self.get_random(N)?; + bytes + .try_into() + .map_err(|_| anyhow::anyhow!("unexpected random bytes length")) + } + + // ==================== Primary Key Operations ==================== + + /// Check if a persistent handle exists + pub fn handle_exists(&mut self, handle: u32) -> Result { + let mut cmd = TpmCommand::new(TpmCc::ReadPublic); + cmd.add_handle(handle); + + let response = self.device.execute(&cmd.finalize())?; + Ok(response.is_success()) + } + + /// Create a primary key in the specified hierarchy + pub fn create_primary( + &mut self, + hierarchy: u32, + template: &TpmtPublic, + ) -> Result<(u32, Vec)> { + let public = Tpm2bPublic::from_template(template); + + let mut cmd = TpmCommand::with_sessions(TpmCc::CreatePrimary); + // primaryHandle (hierarchy) + cmd.add_handle(hierarchy); + // Authorization area + cmd.add_null_auth_area(); + // inSensitive (empty) + cmd.add(&Tpm2bSensitiveCreate::empty()); + // inPublic + cmd.add(&public); + // outsideInfo (empty) + cmd.add_tpm2b_empty(); + // creationPCR (empty) + cmd.add(&TpmlPcrSelection::default()); + + let response = self.device.execute(&cmd.finalize())?; + response.ensure_success().context("CreatePrimary failed")?; + + // For commands with sessions, the response format is: + // - Handle (4 bytes) - BEFORE parameter size + // - Parameter size (4 bytes) + // - Parameters... + let mut buf = response.data_buffer(); + let handle = buf.get_u32()?; + + // Skip parameter size + let _param_size = buf.get_u32()?; + + let out_public = Tpm2bPublic::unmarshal(&mut buf)?; + + debug!("created primary key with handle 0x{:08x}", handle); + Ok((handle, out_public.public_area)) + } + + /// Create a primary key from raw public template bytes (for GCP AK) + pub fn create_primary_from_template( + &mut self, + hierarchy: u32, + template_bytes: &[u8], + ) -> Result<(u32, Vec)> { + let mut cmd = TpmCommand::with_sessions(TpmCc::CreatePrimary); + // primaryHandle (hierarchy) + cmd.add_handle(hierarchy); + // Authorization area + cmd.add_null_auth_area(); + // inSensitive (empty) + cmd.add(&Tpm2bSensitiveCreate::empty()); + // inPublic (raw template with size prefix) + cmd.add_tpm2b(template_bytes); + // outsideInfo (empty) + cmd.add_tpm2b_empty(); + // creationPCR (empty) + cmd.add(&TpmlPcrSelection::default()); + + let response = self.device.execute(&cmd.finalize())?; + response.ensure_success().context("CreatePrimary failed")?; + + // For commands with sessions, the response format is: + // - Handle (4 bytes) - BEFORE parameter size + // - Parameter size (4 bytes) + // - Parameters... + let mut buf = response.data_buffer(); + let handle = buf.get_u32()?; + + // Skip parameter size + let _param_size = buf.get_u32()?; + + let out_public = Tpm2bPublic::unmarshal(&mut buf)?; + + debug!("created primary key with handle 0x{:08x}", handle); + Ok((handle, out_public.public_area)) + } + + /// Make a key persistent at a given handle + pub fn evict_control(&mut self, object_handle: u32, persistent_handle: u32) -> Result { + let mut cmd = TpmCommand::with_sessions(TpmCc::EvictControl); + // auth (owner) + cmd.add_handle(tpm_rh::OWNER); + // objectHandle + cmd.add_handle(object_handle); + // Authorization area + cmd.add_null_auth_area(); + // persistentHandle + cmd.add_handle(persistent_handle); + + let response = self.device.execute(&cmd.finalize())?; + response.ensure_success().context("EvictControl failed")?; + + debug!("made key persistent at 0x{:08x}", persistent_handle); + Ok(true) + } + + /// Ensure a persistent primary key exists at the given handle + pub fn ensure_primary_key(&mut self, handle: u32) -> Result { + if self.handle_exists(handle)? { + return Ok(true); + } + + debug!("creating TPM primary key at 0x{:08x}...", handle); + let template = TpmtPublic::rsa_storage_key(); + let (transient, _) = self.create_primary(tpm_rh::OWNER, &template)?; + self.evict_control(transient, handle)?; + + // Flush the transient handle + self.flush_context(transient)?; + + Ok(true) + } + + /// Flush a context (handle) + pub fn flush_context(&mut self, handle: u32) -> Result<()> { + let mut cmd = TpmCommand::new(TpmCc::FlushContext); + cmd.add_handle(handle); + + let response = self.device.execute(&cmd.finalize())?; + response.ensure_success().context("FlushContext failed")?; + + Ok(()) + } + + // ==================== Seal/Unseal Operations ==================== + + /// Seal data to TPM with PCR policy + pub fn seal( + &mut self, + data: &[u8], + parent_handle: u32, + pcr_selection: &TpmlPcrSelection, + hash_alg: TpmAlgId, + ) -> Result<(Vec, Vec)> { + // Compute policy digest if PCR selection is not empty + let policy_digest = if pcr_selection.pcr_selections.is_empty() { + // No PCR policy - use empty authPolicy (zero length, not zero-filled) + vec![] + } else { + // First, compute the policy digest using a trial session + let trial_session = AuthSession::start_trial(&mut self.device, hash_alg)?; + + // Compute PCR digest + let pcr_digest = compute_pcr_digest(&mut self.device, pcr_selection, hash_alg)?; + + // Apply PCR policy to trial session + trial_session.policy_pcr(&mut self.device, &pcr_digest, pcr_selection)?; + + // Get the policy digest + let digest = trial_session.get_digest(&mut self.device)?; + + // Flush trial session + trial_session.flush(&mut self.device)?; + + digest + }; + + // Create sealed object template + let template = TpmtPublic::sealed_object(Tpm2bDigest::new(policy_digest)); + let public = Tpm2bPublic::from_template(&template); + + // Create the sealed object + let mut cmd = TpmCommand::with_sessions(TpmCc::Create); + // parentHandle + cmd.add_handle(parent_handle); + // Authorization area + cmd.add_null_auth_area(); + // inSensitive (contains the data to seal) + cmd.add(&Tpm2bSensitiveCreate::with_data(data.to_vec())); + // inPublic + cmd.add(&public); + // outsideInfo (empty) + cmd.add_tpm2b_empty(); + // creationPCR (empty) + cmd.add(&TpmlPcrSelection::default()); + + let response = self.device.execute(&cmd.finalize())?; + response.ensure_success().context("Create (seal) failed")?; + + let mut buf = response.skip_parameter_size()?; + let out_private = Tpm2bPrivate::unmarshal(&mut buf)?; + let out_public = Tpm2bPublic::unmarshal(&mut buf)?; + + debug!("sealed {} bytes to TPM with PCR policy", data.len()); + + Ok((out_public.public_area, out_private.buffer)) + } + + /// Unseal data from TPM with PCR policy + pub fn unseal( + &mut self, + pub_bytes: &[u8], + priv_bytes: &[u8], + parent_handle: u32, + pcr_selection: &TpmlPcrSelection, + hash_alg: TpmAlgId, + ) -> Result> { + // Load the sealed object + let mut cmd = TpmCommand::with_sessions(TpmCc::Load); + // parentHandle + cmd.add_handle(parent_handle); + // Authorization area + cmd.add_null_auth_area(); + // inPrivate + cmd.add_tpm2b(priv_bytes); + // inPublic + cmd.add_tpm2b(pub_bytes); + + let response = self.device.execute(&cmd.finalize())?; + response.ensure_success().context("Load failed")?; + + // For commands with sessions, handle comes BEFORE parameter size + let mut buf = response.data_buffer(); + let object_handle = buf.get_u32()?; + let _param_size = buf.get_u32()?; // Skip parameter size + + debug!("loaded sealed object with handle 0x{:08x}", object_handle); + + // Unseal - use policy session if PCR selection is not empty + let response = if pcr_selection.pcr_selections.is_empty() { + // No PCR policy - use null auth + let mut cmd = TpmCommand::with_sessions(TpmCc::Unseal); + cmd.add_handle(object_handle); + cmd.add_null_auth_area(); + self.device.execute(&cmd.finalize())? + } else { + // Start a policy session + let policy_session = AuthSession::start_policy(&mut self.device, hash_alg)?; + + // Compute and apply PCR policy + let pcr_digest = compute_pcr_digest(&mut self.device, pcr_selection, hash_alg)?; + policy_session.policy_pcr(&mut self.device, &pcr_digest, pcr_selection)?; + + // Unseal with policy session + let mut cmd = TpmCommand::with_sessions(TpmCc::Unseal); + cmd.add_handle(object_handle); + cmd.add_policy_auth(policy_session.handle); + + let response = self.device.execute(&cmd.finalize())?; + let _ = policy_session.flush(&mut self.device); + response + }; + + // Clean up object handle + let _ = self.flush_context(object_handle); + + if !response.is_success() { + anyhow::bail!( + "Unseal failed with TPM error: 0x{:08x}", + response.response_code + ); + } + + let mut buf = response.skip_parameter_size()?; + let data = buf.get_tpm2b()?; + + debug!("unsealed {} bytes from TPM", data.len()); + Ok(data) + } + + // ==================== Quote Operations ==================== + + /// Generate a TPM quote + pub fn quote( + &mut self, + sign_handle: u32, + qualifying_data: &[u8], + pcr_selection: &TpmlPcrSelection, + ) -> Result<(Vec, Vec)> { + let mut cmd = TpmCommand::with_sessions(TpmCc::Quote); + // signHandle + cmd.add_handle(sign_handle); + // Authorization area + cmd.add_null_auth_area(); + // qualifyingData + cmd.add_tpm2b(qualifying_data); + // inScheme (NULL - use key's default scheme) + cmd.add(&TpmtSigScheme::null()); + // PCRselect + cmd.add(pcr_selection); + + let response = self.device.execute(&cmd.finalize())?; + response.ensure_success().context("Quote failed")?; + + let mut buf = response.skip_parameter_size()?; + let quoted = buf.get_tpm2b()?; // TPM2B_ATTEST + let signature = buf.get_remaining(); // TPMT_SIGNATURE + + debug!("generated TPM quote"); + Ok((quoted, signature)) + } + + /// Read public area of a key + pub fn read_public(&mut self, handle: u32) -> Result> { + let mut cmd = TpmCommand::new(TpmCc::ReadPublic); + cmd.add_handle(handle); + + let response = self.device.execute(&cmd.finalize())?; + response.ensure_success().context("ReadPublic failed")?; + + let mut buf = response.data_buffer(); + let out_public = Tpm2bPublic::unmarshal(&mut buf)?; + + Ok(out_public.public_area) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pcr_selection() { + let sel = TpmsPcrSelection::sha256(&[0, 1, 2, 7]); + assert_eq!(sel.hash, TpmAlgId::Sha256); + // PCR 0, 1, 2, 7 = bits 0, 1, 2, 7 = 0b10000111 = 0x87 + assert_eq!(sel.pcr_select[0], 0x87); + } +} diff --git a/tpm2/src/constants.rs b/tpm2/src/constants.rs new file mode 100644 index 000000000..c562fc019 --- /dev/null +++ b/tpm2/src/constants.rs @@ -0,0 +1,380 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM 2.0 constants and command codes + +/// TPM 2.0 Command Codes (TPM_CC) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum TpmCc { + NvDefineSpace = 0x0000012A, + NvUndefineSpace = 0x00000122, + NvRead = 0x0000014E, + NvWrite = 0x00000137, + NvReadPublic = 0x00000169, + PcrRead = 0x0000017E, + PcrExtend = 0x00000182, + GetRandom = 0x0000017B, + CreatePrimary = 0x00000131, + Create = 0x00000153, + Load = 0x00000157, + Unseal = 0x0000015E, + Quote = 0x00000158, + StartAuthSession = 0x00000176, + PolicyPcr = 0x0000017F, + PolicyGetDigest = 0x00000189, + FlushContext = 0x00000165, + EvictControl = 0x00000120, + ReadPublic = 0x00000173, + GetCapability = 0x0000017A, +} + +impl TpmCc { + pub fn to_u32(self) -> u32 { + self as u32 + } +} + +/// TPM 2.0 Response Codes (TPM_RC) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum TpmRc { + Success = 0x00000000, + // Format 0 errors + Initialize = 0x00000100, + Failure = 0x00000101, + // Format 1 errors (parameter errors) + Value = 0x00000184, + Handle = 0x0000008B, + // NV errors + NvDefined = 0x0000014C, + NvNotDefined = 0x0000014B, + NvLocked = 0x00000148, + NvRange = 0x00000146, + // Auth errors + AuthFail = 0x0000098E, + PolicyFail = 0x0000099D, + // PCR errors + Locality = 0x00000107, +} + +impl TpmRc { + pub fn from_u32(code: u32) -> Self { + match code { + 0x00000000 => TpmRc::Success, + 0x00000100 => TpmRc::Initialize, + 0x00000101 => TpmRc::Failure, + 0x00000184 => TpmRc::Value, + 0x0000008B => TpmRc::Handle, + 0x0000014C => TpmRc::NvDefined, + 0x0000014B => TpmRc::NvNotDefined, + 0x00000148 => TpmRc::NvLocked, + 0x00000146 => TpmRc::NvRange, + 0x0000098E => TpmRc::AuthFail, + 0x0000099D => TpmRc::PolicyFail, + 0x00000107 => TpmRc::Locality, + _ => TpmRc::Failure, // Unknown error + } + } + + pub fn is_success(self) -> bool { + matches!(self, TpmRc::Success) + } +} + +/// TPM 2.0 Algorithm IDs (TPM_ALG_ID) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +pub enum TpmAlgId { + Null = 0x0010, + Sha1 = 0x0004, + Sha256 = 0x000B, + Sha384 = 0x000C, + Sha512 = 0x000D, + Rsa = 0x0001, + Ecc = 0x0023, + Aes = 0x0006, + Cfb = 0x0043, + RsaSsa = 0x0014, + RsaPss = 0x0016, + EcDsa = 0x0018, + KeyedHash = 0x0008, + SymCipher = 0x0025, +} + +impl TpmAlgId { + pub fn to_u16(self) -> u16 { + self as u16 + } + + pub fn from_u16(v: u16) -> Option { + match v { + 0x0010 => Some(TpmAlgId::Null), + 0x0004 => Some(TpmAlgId::Sha1), + 0x000B => Some(TpmAlgId::Sha256), + 0x000C => Some(TpmAlgId::Sha384), + 0x000D => Some(TpmAlgId::Sha512), + 0x0001 => Some(TpmAlgId::Rsa), + 0x0023 => Some(TpmAlgId::Ecc), + 0x0006 => Some(TpmAlgId::Aes), + 0x0043 => Some(TpmAlgId::Cfb), + 0x0014 => Some(TpmAlgId::RsaSsa), + 0x0016 => Some(TpmAlgId::RsaPss), + 0x0018 => Some(TpmAlgId::EcDsa), + 0x0008 => Some(TpmAlgId::KeyedHash), + 0x0025 => Some(TpmAlgId::SymCipher), + _ => None, + } + } + + pub fn digest_size(self) -> usize { + match self { + TpmAlgId::Sha1 => 20, + TpmAlgId::Sha256 => 32, + TpmAlgId::Sha384 => 48, + TpmAlgId::Sha512 => 64, + _ => 0, + } + } +} + +/// TPM 2.0 Handle Types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum TpmHt { + Pcr = 0x00, + NvIndex = 0x01, + HmacSession = 0x02, + PolicySession = 0x03, + Permanent = 0x40, + Transient = 0x80, + Persistent = 0x81, +} + +/// TPM 2.0 Permanent Handles +pub mod tpm_rh { + pub const OWNER: u32 = 0x40000001; + pub const NULL: u32 = 0x40000007; + pub const ENDORSEMENT: u32 = 0x4000000B; + pub const PLATFORM: u32 = 0x4000000C; + pub const PW: u32 = 0x40000009; // Password authorization +} + +/// TPM 2.0 Session Types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum TpmSe { + Hmac = 0x00, + Policy = 0x01, + Trial = 0x03, +} + +/// TPM 2.0 Startup Types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +pub enum TpmSu { + Clear = 0x0000, + State = 0x0001, +} + +/// TPM 2.0 Capability Types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum TpmCap { + Handles = 0x00000001, + Commands = 0x00000002, + PpCommands = 0x00000003, + AuditCommands = 0x00000004, + Pcrs = 0x00000005, + TpmProperties = 0x00000006, + PcrProperties = 0x00000007, + EccCurves = 0x00000008, + AuthPolicies = 0x00000009, +} + +/// TPM 2.0 Object Attributes +#[derive(Debug, Clone, Copy, Default)] +pub struct TpmaObject(pub u32); + +impl TpmaObject { + pub const FIXED_TPM: u32 = 1 << 1; + pub const ST_CLEAR: u32 = 1 << 2; + pub const FIXED_PARENT: u32 = 1 << 4; + pub const SENSITIVE_DATA_ORIGIN: u32 = 1 << 5; + pub const USER_WITH_AUTH: u32 = 1 << 6; + pub const ADMIN_WITH_POLICY: u32 = 1 << 7; + pub const NO_DA: u32 = 1 << 10; + pub const ENCRYPTED_DUPLICATION: u32 = 1 << 11; + pub const RESTRICTED: u32 = 1 << 16; + pub const DECRYPT: u32 = 1 << 17; + pub const SIGN_ENCRYPT: u32 = 1 << 18; + + pub fn new() -> Self { + Self(0) + } + + pub fn with_fixed_tpm(mut self) -> Self { + self.0 |= Self::FIXED_TPM; + self + } + + pub fn with_fixed_parent(mut self) -> Self { + self.0 |= Self::FIXED_PARENT; + self + } + + pub fn with_sensitive_data_origin(mut self) -> Self { + self.0 |= Self::SENSITIVE_DATA_ORIGIN; + self + } + + pub fn with_user_with_auth(mut self) -> Self { + self.0 |= Self::USER_WITH_AUTH; + self + } + + pub fn with_admin_with_policy(mut self) -> Self { + self.0 |= Self::ADMIN_WITH_POLICY; + self + } + + pub fn with_restricted(mut self) -> Self { + self.0 |= Self::RESTRICTED; + self + } + + pub fn with_decrypt(mut self) -> Self { + self.0 |= Self::DECRYPT; + self + } + + pub fn with_sign_encrypt(mut self) -> Self { + self.0 |= Self::SIGN_ENCRYPT; + self + } +} + +/// TPM 2.0 NV Attributes +#[derive(Debug, Clone, Copy, Default)] +pub struct TpmaNv(pub u32); + +impl TpmaNv { + pub const PP_WRITE: u32 = 1 << 0; + pub const OWNER_WRITE: u32 = 1 << 1; + pub const AUTH_WRITE: u32 = 1 << 2; + pub const POLICY_WRITE: u32 = 1 << 3; + pub const PP_READ: u32 = 1 << 16; + pub const OWNER_READ: u32 = 1 << 17; + pub const AUTH_READ: u32 = 1 << 18; + pub const POLICY_READ: u32 = 1 << 19; + pub const NO_DA: u32 = 1 << 25; + pub const ORDERLY: u32 = 1 << 26; + pub const CLEAR_STCLEAR: u32 = 1 << 27; + pub const READ_LOCKED: u32 = 1 << 28; + pub const WRITTEN: u32 = 1 << 29; + pub const PLATFORM_CREATE: u32 = 1 << 30; + pub const READ_STCLEAR: u32 = 1 << 31; + + pub fn new() -> Self { + Self(0) + } + + pub fn with_owner_write(mut self) -> Self { + self.0 |= Self::OWNER_WRITE; + self + } + + pub fn with_owner_read(mut self) -> Self { + self.0 |= Self::OWNER_READ; + self + } + + pub fn with_auth_write(mut self) -> Self { + self.0 |= Self::AUTH_WRITE; + self + } + + pub fn with_auth_read(mut self) -> Self { + self.0 |= Self::AUTH_READ; + self + } +} + +/// TPM 2.0 Session Attributes +#[derive(Debug, Clone, Copy, Default)] +pub struct TpmaSa(pub u8); + +impl TpmaSa { + pub const CONTINUE_SESSION: u8 = 1 << 0; + pub const AUDIT_EXCLUSIVE: u8 = 1 << 1; + pub const AUDIT_RESET: u8 = 1 << 2; + pub const DECRYPT: u8 = 1 << 5; + pub const ENCRYPT: u8 = 1 << 6; + pub const AUDIT: u8 = 1 << 7; + + pub fn new() -> Self { + Self(0) + } + + pub fn with_continue_session(mut self) -> Self { + self.0 |= Self::CONTINUE_SESSION; + self + } +} + +/// TPM command header tag +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +pub enum TpmSt { + NoSessions = 0x8001, + Sessions = 0x8002, + RspCommand = 0x00C4, +} + +impl TpmSt { + pub fn to_u16(self) -> u16 { + self as u16 + } + + pub fn from_u16(v: u16) -> Option { + match v { + 0x8001 => Some(TpmSt::NoSessions), + 0x8002 => Some(TpmSt::Sessions), + 0x00C4 => Some(TpmSt::RspCommand), + _ => None, + } + } +} + +/// ECC Curve IDs +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +pub enum TpmEccCurve { + None = 0x0000, + NistP256 = 0x0003, + NistP384 = 0x0004, + NistP521 = 0x0005, +} + +impl TpmEccCurve { + pub fn to_u16(self) -> u16 { + self as u16 + } +} + +/// RSA Key Bits +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +pub enum RsaKeyBits { + Rsa1024 = 1024, + Rsa2048 = 2048, + Rsa3072 = 3072, + Rsa4096 = 4096, +} + +impl RsaKeyBits { + pub fn to_u16(self) -> u16 { + self as u16 + } +} diff --git a/tpm2/src/device.rs b/tpm2/src/device.rs new file mode 100644 index 000000000..f57a7d1ff --- /dev/null +++ b/tpm2/src/device.rs @@ -0,0 +1,306 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM device communication layer +//! +//! Provides low-level communication with TPM devices via /dev/tpmrm0 or /dev/tpm0. + +use anyhow::{bail, Context, Result}; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::Path; + +use super::constants::*; +use super::marshal::*; + +/// Maximum TPM command/response size +const TPM_MAX_COMMAND_SIZE: usize = 4096; + +/// TPM device handle +pub struct TpmDevice { + file: File, + path: String, +} + +impl TpmDevice { + /// Open a TPM device + pub fn open(path: &str) -> Result { + // Strip "device:" prefix if present + let device_path = path.strip_prefix("device:").unwrap_or(path); + + let file = OpenOptions::new() + .read(true) + .write(true) + .open(device_path) + .with_context(|| format!("failed to open TPM device: {}", device_path))?; + + Ok(Self { + file, + path: device_path.to_string(), + }) + } + + /// Detect and open the default TPM device + pub fn detect() -> Result { + if Path::new("/dev/tpmrm0").exists() { + Self::open("/dev/tpmrm0") + } else if Path::new("/dev/tpm0").exists() { + Self::open("/dev/tpm0") + } else { + bail!("TPM device not found") + } + } + + /// Get the device path + pub fn path(&self) -> &str { + &self.path + } + + /// Send a command to the TPM and receive the response + pub fn transmit(&mut self, command: &[u8]) -> Result> { + // Write command + self.file + .write_all(command) + .context("failed to write TPM command")?; + + // Read response + let mut response = vec![0u8; TPM_MAX_COMMAND_SIZE]; + let n = self + .file + .read(&mut response) + .context("failed to read TPM response")?; + + response.truncate(n); + Ok(response) + } + + /// Execute a TPM command and parse the response + pub fn execute(&mut self, command: &[u8]) -> Result { + let response_bytes = self.transmit(command)?; + TpmResponse::parse(&response_bytes) + } +} + +/// TPM command builder +pub struct TpmCommand { + buf: CommandBuffer, +} + +impl TpmCommand { + /// Create a new command without sessions + pub fn new(command_code: TpmCc) -> Self { + let mut buf = CommandBuffer::with_capacity(256); + + // Header: tag (2) + size (4) + command code (4) + buf.put_u16(TpmSt::NoSessions.to_u16()); + buf.put_u32(0); // Size placeholder + buf.put_u32(command_code.to_u32()); + + Self { buf } + } + + /// Create a new command with sessions + pub fn with_sessions(command_code: TpmCc) -> Self { + let mut buf = CommandBuffer::with_capacity(256); + + // Header: tag (2) + size (4) + command code (4) + buf.put_u16(TpmSt::Sessions.to_u16()); + buf.put_u32(0); // Size placeholder + buf.put_u32(command_code.to_u32()); + + Self { buf } + } + + /// Add a handle to the command + pub fn add_handle(&mut self, handle: u32) { + self.buf.put_u32(handle); + } + + /// Add raw bytes to the command + pub fn add_bytes(&mut self, data: &[u8]) { + self.buf.put_bytes(data); + } + + /// Add a u8 value + pub fn add_u8(&mut self, v: u8) { + self.buf.put_u8(v); + } + + /// Add a u16 value + pub fn add_u16(&mut self, v: u16) { + self.buf.put_u16(v); + } + + /// Add a u32 value + pub fn add_u32(&mut self, v: u32) { + self.buf.put_u32(v); + } + + /// Add a TPM2B structure + pub fn add_tpm2b(&mut self, data: &[u8]) { + self.buf.put_tpm2b(data); + } + + /// Add an empty TPM2B structure + pub fn add_tpm2b_empty(&mut self) { + self.buf.put_tpm2b_empty(); + } + + /// Add a marshallable structure + pub fn add(&mut self, value: &T) { + value.marshal(&mut self.buf); + } + + /// Add password authorization session (null auth) + pub fn add_null_auth_area(&mut self) { + // Authorization area size (4 bytes) + // Session handle (4) + nonce (2) + attributes (1) + auth (2) = 9 bytes minimum + let auth_size: u32 = 4 + 2 + 1 + 2; // 9 bytes for null auth + + self.buf.put_u32(auth_size); + self.buf.put_u32(tpm_rh::PW); // Password session handle + self.buf.put_u16(0); // Empty nonce + self.buf.put_u8(0); // Session attributes (continue = 0) + self.buf.put_u16(0); // Empty auth value + } + + /// Add a policy session authorization + pub fn add_policy_auth(&mut self, session_handle: u32) { + let auth_size: u32 = 4 + 2 + 1 + 2; + + self.buf.put_u32(auth_size); + self.buf.put_u32(session_handle); + self.buf.put_u16(0); // Empty nonce + self.buf.put_u8(TpmaSa::CONTINUE_SESSION); // Continue session + self.buf.put_u16(0); // Empty auth value + } + + /// Finalize the command and return the bytes + pub fn finalize(mut self) -> Vec { + // Update the size field + let size = self.buf.len() as u32; + self.buf.update_u32(2, size); + self.buf.into_vec() + } + + /// Get current buffer for inspection + pub fn buffer(&self) -> &CommandBuffer { + &self.buf + } +} + +/// TPM response parser +#[derive(Debug)] +pub struct TpmResponse { + pub tag: TpmSt, + pub response_code: u32, + pub data: Vec, +} + +impl TpmResponse { + /// Parse a TPM response + pub fn parse(response: &[u8]) -> Result { + if response.len() < 10 { + bail!("TPM response too short: {} bytes", response.len()); + } + + let mut buf = ResponseBuffer::new(response); + + let tag_raw = buf.get_u16()?; + let tag = TpmSt::from_u16(tag_raw) + .ok_or_else(|| anyhow::anyhow!("invalid response tag: 0x{:04x}", tag_raw))?; + + let size = buf.get_u32()? as usize; + if response.len() < size { + bail!( + "TPM response size mismatch: expected {}, got {}", + size, + response.len() + ); + } + + let response_code = buf.get_u32()?; + + // Remaining data after header + let data = response[10..size].to_vec(); + + Ok(Self { + tag, + response_code, + data, + }) + } + + /// Check if the response indicates success + pub fn is_success(&self) -> bool { + self.response_code == 0 + } + + /// Get error description + pub fn error_description(&self) -> String { + if self.is_success() { + "success".to_string() + } else { + format!("TPM error: 0x{:08x}", self.response_code) + } + } + + /// Ensure the response is successful + pub fn ensure_success(&self) -> Result<()> { + if self.is_success() { + Ok(()) + } else { + bail!("{}", self.error_description()) + } + } + + /// Get a response buffer for parsing the data + pub fn data_buffer(&self) -> ResponseBuffer<'_> { + ResponseBuffer::new(&self.data) + } + + /// Skip the parameter size field (for commands with sessions) + pub fn skip_parameter_size(&self) -> Result> { + let mut buf = self.data_buffer(); + if self.tag == TpmSt::Sessions { + let _param_size = buf.get_u32()?; + } + Ok(buf) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_command_builder() { + let mut cmd = TpmCommand::new(TpmCc::GetRandom); + cmd.add_u16(32); // Request 32 random bytes + + let bytes = cmd.finalize(); + + // Check header + assert_eq!(&bytes[0..2], &[0x80, 0x01]); // TPM_ST_NO_SESSIONS + assert_eq!(&bytes[6..10], &[0x00, 0x00, 0x01, 0x7B]); // TPM_CC_GetRandom + + // Check size + let size = u32::from_be_bytes([bytes[2], bytes[3], bytes[4], bytes[5]]); + assert_eq!(size as usize, bytes.len()); + } + + #[test] + fn test_response_parse() { + // Minimal success response + let response = vec![ + 0x80, 0x01, // TPM_ST_NO_SESSIONS + 0x00, 0x00, 0x00, 0x0A, // Size = 10 + 0x00, 0x00, 0x00, 0x00, // TPM_RC_SUCCESS + ]; + + let parsed = TpmResponse::parse(&response).unwrap(); + assert!(parsed.is_success()); + assert!(parsed.data.is_empty()); + } +} diff --git a/tpm2/src/lib.rs b/tpm2/src/lib.rs new file mode 100644 index 000000000..ab5db337e --- /dev/null +++ b/tpm2/src/lib.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Pure Rust TPM 2.0 implementation +//! +//! This crate provides TPM 2.0 commands, communicating directly with the TPM +//! device without C library dependencies. +//! +//! ## Features +//! +//! - **Cross-compilation friendly**: Easy to cross-compile for different targets +//! - **Direct device communication**: Talks directly to `/dev/tpmrm0` or `/dev/tpm0` +//! +//! ## Supported Commands +//! +//! - NV operations: `NV_Read`, `NV_Write`, `NV_DefineSpace`, `NV_UndefineSpace` +//! - PCR operations: `PCR_Read`, `PCR_Extend` +//! - Key operations: `CreatePrimary`, `Create`, `Load`, `EvictControl` +//! - Sealing: `Seal`, `Unseal` with PCR policy +//! - Attestation: `Quote` +//! - Random: `GetRandom` +//! - Sessions: Policy sessions for PCR-based authorization +//! +//! ## Example +//! +//! ```no_run +//! use tpm2::TpmContext; +//! +//! let mut ctx = TpmContext::new(None)?; // Auto-detect TPM device +//! let random_bytes = ctx.get_random(32)?; +//! # Ok::<(), anyhow::Error>(()) +//! ``` + +mod commands; +mod constants; +mod device; +mod marshal; +mod session; +mod types; + +pub use commands::TpmContext; +pub use constants::*; +pub use types::*; + +// Re-export device for advanced usage +pub use device::{TpmCommand, TpmDevice, TpmResponse}; +pub use marshal::{CommandBuffer, Marshal, ResponseBuffer, Unmarshal}; +pub use session::AuthSession; diff --git a/tpm2/src/marshal.rs b/tpm2/src/marshal.rs new file mode 100644 index 000000000..e46eedd51 --- /dev/null +++ b/tpm2/src/marshal.rs @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM 2.0 marshalling/unmarshalling utilities +//! +//! Provides serialization and deserialization for TPM structures. + +use anyhow::{bail, Result}; + +/// Buffer for building TPM commands +#[derive(Debug, Default)] +pub struct CommandBuffer { + data: Vec, +} + +impl CommandBuffer { + pub fn new() -> Self { + Self { data: Vec::new() } + } + + pub fn with_capacity(capacity: usize) -> Self { + Self { + data: Vec::with_capacity(capacity), + } + } + + pub fn put_u8(&mut self, v: u8) { + self.data.push(v); + } + + pub fn put_u16(&mut self, v: u16) { + self.data.extend_from_slice(&v.to_be_bytes()); + } + + pub fn put_u32(&mut self, v: u32) { + self.data.extend_from_slice(&v.to_be_bytes()); + } + + pub fn put_u64(&mut self, v: u64) { + self.data.extend_from_slice(&v.to_be_bytes()); + } + + pub fn put_bytes(&mut self, bytes: &[u8]) { + self.data.extend_from_slice(bytes); + } + + /// Put a TPM2B structure (2-byte size prefix + data) + pub fn put_tpm2b(&mut self, data: &[u8]) { + self.put_u16(data.len() as u16); + self.put_bytes(data); + } + + /// Put an empty TPM2B structure + pub fn put_tpm2b_empty(&mut self) { + self.put_u16(0); + } + + pub fn len(&self) -> usize { + self.data.len() + } + + pub fn is_empty(&self) -> bool { + self.data.is_empty() + } + + pub fn as_bytes(&self) -> &[u8] { + &self.data + } + + pub fn into_vec(self) -> Vec { + self.data + } + + /// Update a u32 at a specific position (for size fields) + pub fn update_u32(&mut self, pos: usize, v: u32) { + self.data[pos..pos + 4].copy_from_slice(&v.to_be_bytes()); + } +} + +/// Buffer for parsing TPM responses +#[derive(Debug)] +pub struct ResponseBuffer<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> ResponseBuffer<'a> { + pub fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + pub fn remaining(&self) -> usize { + self.data.len().saturating_sub(self.pos) + } + + pub fn position(&self) -> usize { + self.pos + } + + pub fn get_u8(&mut self) -> Result { + if self.pos >= self.data.len() { + bail!("buffer underflow reading u8"); + } + let v = self.data[self.pos]; + self.pos += 1; + Ok(v) + } + + pub fn get_u16(&mut self) -> Result { + if self.pos + 2 > self.data.len() { + bail!("buffer underflow reading u16"); + } + let v = u16::from_be_bytes([self.data[self.pos], self.data[self.pos + 1]]); + self.pos += 2; + Ok(v) + } + + pub fn get_u32(&mut self) -> Result { + if self.pos + 4 > self.data.len() { + bail!("buffer underflow reading u32"); + } + let v = u32::from_be_bytes([ + self.data[self.pos], + self.data[self.pos + 1], + self.data[self.pos + 2], + self.data[self.pos + 3], + ]); + self.pos += 4; + Ok(v) + } + + pub fn get_u64(&mut self) -> Result { + if self.pos + 8 > self.data.len() { + bail!("buffer underflow reading u64"); + } + let v = u64::from_be_bytes([ + self.data[self.pos], + self.data[self.pos + 1], + self.data[self.pos + 2], + self.data[self.pos + 3], + self.data[self.pos + 4], + self.data[self.pos + 5], + self.data[self.pos + 6], + self.data[self.pos + 7], + ]); + self.pos += 8; + Ok(v) + } + + pub fn get_bytes(&mut self, len: usize) -> Result> { + if self.pos + len > self.data.len() { + bail!( + "buffer underflow reading {} bytes (remaining: {})", + len, + self.remaining() + ); + } + let v = self.data[self.pos..self.pos + len].to_vec(); + self.pos += len; + Ok(v) + } + + /// Get a TPM2B structure (2-byte size prefix + data) + pub fn get_tpm2b(&mut self) -> Result> { + let size = self.get_u16()? as usize; + self.get_bytes(size) + } + + /// Get remaining bytes + pub fn get_remaining(&mut self) -> Vec { + let v = self.data[self.pos..].to_vec(); + self.pos = self.data.len(); + v + } + + /// Skip bytes + pub fn skip(&mut self, len: usize) -> Result<()> { + if self.pos + len > self.data.len() { + bail!("buffer underflow skipping {} bytes", len); + } + self.pos += len; + Ok(()) + } + + /// Peek at bytes without advancing position + pub fn peek_bytes(&self, len: usize) -> Result<&[u8]> { + if self.pos + len > self.data.len() { + bail!("buffer underflow peeking {} bytes", len); + } + Ok(&self.data[self.pos..self.pos + len]) + } +} + +/// Trait for types that can be marshalled to TPM format +pub trait Marshal { + fn marshal(&self, buf: &mut CommandBuffer); + + fn to_bytes(&self) -> Vec { + let mut buf = CommandBuffer::new(); + self.marshal(&mut buf); + buf.into_vec() + } +} + +/// Trait for types that can be unmarshalled from TPM format +pub trait Unmarshal: Sized { + fn unmarshal(buf: &mut ResponseBuffer) -> Result; + + fn from_bytes(data: &[u8]) -> Result { + let mut buf = ResponseBuffer::new(data); + Self::unmarshal(&mut buf) + } +} + +// Implement Marshal for primitive types +impl Marshal for u8 { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u8(*self); + } +} + +impl Marshal for u16 { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u16(*self); + } +} + +impl Marshal for u32 { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u32(*self); + } +} + +impl Marshal for u64 { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u64(*self); + } +} + +// Implement Unmarshal for primitive types +impl Unmarshal for u8 { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + buf.get_u8() + } +} + +impl Unmarshal for u16 { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + buf.get_u16() + } +} + +impl Unmarshal for u32 { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + buf.get_u32() + } +} + +impl Unmarshal for u64 { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + buf.get_u64() + } +} diff --git a/tpm2/src/session.rs b/tpm2/src/session.rs new file mode 100644 index 000000000..b706990a7 --- /dev/null +++ b/tpm2/src/session.rs @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM 2.0 session management + +use anyhow::{Context, Result}; + +use super::constants::*; +use super::device::*; +use super::marshal::*; +use super::types::*; + +/// Authorization session handle +#[derive(Debug, Clone, Copy)] +pub struct AuthSession { + pub handle: u32, + pub session_type: TpmSe, + pub hash_alg: TpmAlgId, +} + +impl AuthSession { + /// Start a new authorization session + pub fn start(device: &mut TpmDevice, session_type: TpmSe, hash_alg: TpmAlgId) -> Result { + // TPM2_StartAuthSession command + let mut cmd = TpmCommand::new(TpmCc::StartAuthSession); + + const ZERO_NONCE: [u8; 16] = [0u8; 16]; + + // tpmKey (TPM_RH_NULL for unbound session) + cmd.add_handle(tpm_rh::NULL); + // bind (TPM_RH_NULL for unbound session) + cmd.add_handle(tpm_rh::NULL); + // nonceCaller (16-byte nonce as required by TPM spec) + cmd.add_tpm2b(&ZERO_NONCE); + // encryptedSalt (empty - no salt) + cmd.add_tpm2b_empty(); + // sessionType + cmd.add_u8(session_type as u8); + // symmetric (AES-128-CFB, matches TPM default expectation) + cmd.add(&TpmtSymDef::aes_128_cfb()); + // authHash + cmd.add_u16(hash_alg.to_u16()); + + let cmd_bytes = cmd.finalize(); + tracing::debug!("StartAuthSession command: {} bytes", cmd_bytes.len()); + let response = device.execute(&cmd_bytes)?; + if !response.is_success() { + anyhow::bail!( + "StartAuthSession failed with TPM error: 0x{:08x}", + response.response_code + ); + } + + let mut buf = response.data_buffer(); + let handle = buf.get_u32()?; + let _nonce_tpm = buf.get_tpm2b()?; // nonceTPM + + Ok(Self { + handle, + session_type, + hash_alg, + }) + } + + /// Start a policy session + pub fn start_policy(device: &mut TpmDevice, hash_alg: TpmAlgId) -> Result { + Self::start(device, TpmSe::Policy, hash_alg) + } + + /// Start a trial policy session (for computing policy digest) + pub fn start_trial(device: &mut TpmDevice, hash_alg: TpmAlgId) -> Result { + Self::start(device, TpmSe::Trial, hash_alg) + } + + /// Apply PCR policy to this session + pub fn policy_pcr( + &self, + device: &mut TpmDevice, + pcr_digest: &[u8], + pcr_selection: &TpmlPcrSelection, + ) -> Result<()> { + let mut cmd = TpmCommand::new(TpmCc::PolicyPcr); + + // policySession + cmd.add_handle(self.handle); + // pcrDigest + cmd.add_tpm2b(pcr_digest); + // pcrs + cmd.add(pcr_selection); + + let response = device.execute(&cmd.finalize())?; + response.ensure_success().context("PolicyPCR failed")?; + + Ok(()) + } + + /// Get the current policy digest + pub fn get_digest(&self, device: &mut TpmDevice) -> Result> { + let mut cmd = TpmCommand::new(TpmCc::PolicyGetDigest); + cmd.add_handle(self.handle); + + let response = device.execute(&cmd.finalize())?; + response + .ensure_success() + .context("PolicyGetDigest failed")?; + + let mut buf = response.data_buffer(); + let digest = buf.get_tpm2b()?; + + Ok(digest) + } + + /// Flush (close) this session + pub fn flush(self, device: &mut TpmDevice) -> Result<()> { + let mut cmd = TpmCommand::new(TpmCc::FlushContext); + cmd.add_handle(self.handle); + + let response = device.execute(&cmd.finalize())?; + response.ensure_success().context("FlushContext failed")?; + + Ok(()) + } +} + +/// Compute the PCR digest for a given PCR selection +pub fn compute_pcr_digest( + device: &mut TpmDevice, + pcr_selection: &TpmlPcrSelection, + hash_alg: TpmAlgId, +) -> Result> { + use sha2::{Digest, Sha256, Sha384, Sha512}; + + // Read PCR values + let pcr_values = read_pcr_values(device, pcr_selection)?; + + // Concatenate all PCR values + let mut concat = Vec::new(); + for value in &pcr_values { + concat.extend_from_slice(value); + } + + // Hash the concatenated values + let digest = match hash_alg { + TpmAlgId::Sha256 => { + let mut hasher = Sha256::new(); + hasher.update(&concat); + hasher.finalize().to_vec() + } + TpmAlgId::Sha384 => { + let mut hasher = Sha384::new(); + hasher.update(&concat); + hasher.finalize().to_vec() + } + TpmAlgId::Sha512 => { + let mut hasher = Sha512::new(); + hasher.update(&concat); + hasher.finalize().to_vec() + } + _ => anyhow::bail!("unsupported hash algorithm for PCR digest"), + }; + + Ok(digest) +} + +/// Read PCR values for a selection +fn read_pcr_values( + device: &mut TpmDevice, + pcr_selection: &TpmlPcrSelection, +) -> Result>> { + let mut cmd = TpmCommand::new(TpmCc::PcrRead); + cmd.add(pcr_selection); + + let response = device.execute(&cmd.finalize())?; + response.ensure_success().context("PCR_Read failed")?; + + let mut buf = response.data_buffer(); + let _update_counter = buf.get_u32()?; + let _pcr_selection_out = TpmlPcrSelection::unmarshal(&mut buf)?; + let digest_list = TpmlDigest::unmarshal(&mut buf)?; + + Ok(digest_list.digests.into_iter().map(|d| d.buffer).collect()) +} diff --git a/tpm2/src/types.rs b/tpm2/src/types.rs new file mode 100644 index 000000000..65d9c709f --- /dev/null +++ b/tpm2/src/types.rs @@ -0,0 +1,876 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! TPM 2.0 data types + +use anyhow::{bail, Result}; + +use super::constants::*; +use super::marshal::*; + +/// TPM2B_DIGEST - Variable length digest +#[derive(Debug, Clone, Default)] +pub struct Tpm2bDigest { + pub buffer: Vec, +} + +impl Tpm2bDigest { + pub fn new(data: Vec) -> Self { + Self { buffer: data } + } + + pub fn empty() -> Self { + Self { buffer: Vec::new() } + } +} + +impl Marshal for Tpm2bDigest { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_tpm2b(&self.buffer); + } +} + +impl Unmarshal for Tpm2bDigest { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + Ok(Self { + buffer: buf.get_tpm2b()?, + }) + } +} + +/// TPM2B_DATA - Variable length data +#[derive(Debug, Clone, Default)] +pub struct Tpm2bData { + pub buffer: Vec, +} + +impl Tpm2bData { + pub fn new(data: Vec) -> Self { + Self { buffer: data } + } + + pub fn empty() -> Self { + Self { buffer: Vec::new() } + } +} + +impl Marshal for Tpm2bData { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_tpm2b(&self.buffer); + } +} + +impl Unmarshal for Tpm2bData { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + Ok(Self { + buffer: buf.get_tpm2b()?, + }) + } +} + +/// TPM2B_SENSITIVE_DATA - Sensitive data for sealing +#[derive(Debug, Clone, Default)] +pub struct Tpm2bSensitiveData { + pub buffer: Vec, +} + +impl Tpm2bSensitiveData { + pub fn new(data: Vec) -> Self { + Self { buffer: data } + } + + pub fn empty() -> Self { + Self { buffer: Vec::new() } + } +} + +impl Marshal for Tpm2bSensitiveData { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_tpm2b(&self.buffer); + } +} + +/// TPM2B_AUTH - Authorization value +#[derive(Debug, Clone, Default)] +pub struct Tpm2bAuth { + pub buffer: Vec, +} + +impl Tpm2bAuth { + pub fn new(data: Vec) -> Self { + Self { buffer: data } + } + + pub fn empty() -> Self { + Self { buffer: Vec::new() } + } +} + +impl Marshal for Tpm2bAuth { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_tpm2b(&self.buffer); + } +} + +impl Unmarshal for Tpm2bAuth { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + Ok(Self { + buffer: buf.get_tpm2b()?, + }) + } +} + +/// TPM2B_NONCE - Nonce value +pub type Tpm2bNonce = Tpm2bDigest; + +/// TPM2B_MAX_NV_BUFFER - NV buffer +#[derive(Debug, Clone, Default)] +pub struct Tpm2bMaxNvBuffer { + pub buffer: Vec, +} + +impl Tpm2bMaxNvBuffer { + pub fn new(data: Vec) -> Self { + Self { buffer: data } + } +} + +impl Marshal for Tpm2bMaxNvBuffer { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_tpm2b(&self.buffer); + } +} + +impl Unmarshal for Tpm2bMaxNvBuffer { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + Ok(Self { + buffer: buf.get_tpm2b()?, + }) + } +} + +/// TPMS_PCR_SELECTION - PCR selection for a single hash algorithm +#[derive(Debug, Clone)] +pub struct TpmsPcrSelection { + pub hash: TpmAlgId, + pub pcr_select: Vec, // Bitmap of selected PCRs +} + +impl TpmsPcrSelection { + pub fn new(hash: TpmAlgId, pcrs: &[u32]) -> Self { + // Calculate required size (at least 3 bytes for PCR 0-23) + let max_pcr = pcrs.iter().max().copied().unwrap_or(0); + let size = ((max_pcr / 8) + 1).max(3) as usize; + let mut pcr_select = vec![0u8; size]; + + for &pcr in pcrs { + let byte_idx = (pcr / 8) as usize; + let bit_idx = pcr % 8; + if byte_idx < pcr_select.len() { + pcr_select[byte_idx] |= 1 << bit_idx; + } + } + + Self { hash, pcr_select } + } + + pub fn sha256(pcrs: &[u32]) -> Self { + Self::new(TpmAlgId::Sha256, pcrs) + } +} + +impl Marshal for TpmsPcrSelection { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u16(self.hash.to_u16()); + buf.put_u8(self.pcr_select.len() as u8); + buf.put_bytes(&self.pcr_select); + } +} + +impl Unmarshal for TpmsPcrSelection { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + let hash_alg = buf.get_u16()?; + let hash = TpmAlgId::from_u16(hash_alg) + .ok_or_else(|| anyhow::anyhow!("unknown hash algorithm: 0x{:04x}", hash_alg))?; + let size = buf.get_u8()? as usize; + let pcr_select = buf.get_bytes(size)?; + Ok(Self { hash, pcr_select }) + } +} + +/// TPML_PCR_SELECTION - List of PCR selections +#[derive(Debug, Clone, Default)] +pub struct TpmlPcrSelection { + pub pcr_selections: Vec, +} + +impl TpmlPcrSelection { + pub fn new(selections: Vec) -> Self { + Self { + pcr_selections: selections, + } + } + + pub fn single(hash: TpmAlgId, pcrs: &[u32]) -> Self { + Self { + pcr_selections: vec![TpmsPcrSelection::new(hash, pcrs)], + } + } +} + +impl Marshal for TpmlPcrSelection { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u32(self.pcr_selections.len() as u32); + for sel in &self.pcr_selections { + sel.marshal(buf); + } + } +} + +impl Unmarshal for TpmlPcrSelection { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + let count = buf.get_u32()? as usize; + let mut pcr_selections = Vec::with_capacity(count); + for _ in 0..count { + pcr_selections.push(TpmsPcrSelection::unmarshal(buf)?); + } + Ok(Self { pcr_selections }) + } +} + +/// TPML_DIGEST - List of digests +#[derive(Debug, Clone, Default)] +pub struct TpmlDigest { + pub digests: Vec, +} + +impl Unmarshal for TpmlDigest { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + let count = buf.get_u32()? as usize; + let mut digests = Vec::with_capacity(count); + for _ in 0..count { + digests.push(Tpm2bDigest::unmarshal(buf)?); + } + Ok(Self { digests }) + } +} + +/// TPMS_NV_PUBLIC - NV index public area +#[derive(Debug, Clone)] +pub struct TpmsNvPublic { + pub nv_index: u32, + pub name_alg: TpmAlgId, + pub attributes: TpmaNv, + pub auth_policy: Tpm2bDigest, + pub data_size: u16, +} + +impl TpmsNvPublic { + pub fn new(nv_index: u32, data_size: u16, attributes: TpmaNv) -> Self { + Self { + nv_index, + name_alg: TpmAlgId::Sha256, + attributes, + auth_policy: Tpm2bDigest::empty(), + data_size, + } + } +} + +impl Marshal for TpmsNvPublic { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u32(self.nv_index); + buf.put_u16(self.name_alg.to_u16()); + buf.put_u32(self.attributes.0); + self.auth_policy.marshal(buf); + buf.put_u16(self.data_size); + } +} + +impl Unmarshal for TpmsNvPublic { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + let nv_index = buf.get_u32()?; + let name_alg_raw = buf.get_u16()?; + let name_alg = TpmAlgId::from_u16(name_alg_raw) + .ok_or_else(|| anyhow::anyhow!("unknown algorithm: 0x{:04x}", name_alg_raw))?; + let attributes = TpmaNv(buf.get_u32()?); + let auth_policy = Tpm2bDigest::unmarshal(buf)?; + let data_size = buf.get_u16()?; + Ok(Self { + nv_index, + name_alg, + attributes, + auth_policy, + data_size, + }) + } +} + +/// TPM2B_NV_PUBLIC - NV public with size prefix +#[derive(Debug, Clone)] +pub struct Tpm2bNvPublic { + pub nv_public: TpmsNvPublic, +} + +impl Marshal for Tpm2bNvPublic { + fn marshal(&self, buf: &mut CommandBuffer) { + let mut inner = CommandBuffer::new(); + self.nv_public.marshal(&mut inner); + buf.put_tpm2b(inner.as_bytes()); + } +} + +impl Unmarshal for Tpm2bNvPublic { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + let size = buf.get_u16()? as usize; + if size == 0 { + bail!("empty NV public"); + } + let data = buf.get_bytes(size)?; + let mut inner = ResponseBuffer::new(&data); + let nv_public = TpmsNvPublic::unmarshal(&mut inner)?; + Ok(Self { nv_public }) + } +} + +/// TPMT_SYM_DEF - Symmetric algorithm definition +#[derive(Debug, Clone, Copy)] +pub struct TpmtSymDef { + pub algorithm: TpmAlgId, + pub key_bits: u16, + pub mode: TpmAlgId, +} + +impl TpmtSymDef { + pub fn null() -> Self { + Self { + algorithm: TpmAlgId::Null, + key_bits: 0, + mode: TpmAlgId::Null, + } + } + + pub fn aes_128_cfb() -> Self { + Self { + algorithm: TpmAlgId::Aes, + key_bits: 128, + mode: TpmAlgId::Cfb, + } + } +} + +impl Marshal for TpmtSymDef { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u16(self.algorithm.to_u16()); + if self.algorithm != TpmAlgId::Null { + buf.put_u16(self.key_bits); + buf.put_u16(self.mode.to_u16()); + } + } +} + +impl Unmarshal for TpmtSymDef { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + let alg = buf.get_u16()?; + let algorithm = TpmAlgId::from_u16(alg) + .ok_or_else(|| anyhow::anyhow!("unknown algorithm: 0x{:04x}", alg))?; + if algorithm == TpmAlgId::Null { + Ok(Self::null()) + } else { + let key_bits = buf.get_u16()?; + let mode_raw = buf.get_u16()?; + let mode = TpmAlgId::from_u16(mode_raw) + .ok_or_else(|| anyhow::anyhow!("unknown mode: 0x{:04x}", mode_raw))?; + Ok(Self { + algorithm, + key_bits, + mode, + }) + } + } +} + +/// TPMT_SYM_DEF_OBJECT - Symmetric definition for objects +pub type TpmtSymDefObject = TpmtSymDef; + +/// TPMS_SCHEME_HASH - Hash scheme +#[derive(Debug, Clone, Copy)] +pub struct TpmsSchemeHash { + pub hash_alg: TpmAlgId, +} + +/// TPMT_RSA_SCHEME - RSA signature scheme +#[derive(Debug, Clone, Copy)] +pub struct TpmtRsaScheme { + pub scheme: TpmAlgId, + pub hash_alg: Option, +} + +impl TpmtRsaScheme { + pub fn null() -> Self { + Self { + scheme: TpmAlgId::Null, + hash_alg: None, + } + } + + pub fn rsassa(hash: TpmAlgId) -> Self { + Self { + scheme: TpmAlgId::RsaSsa, + hash_alg: Some(hash), + } + } +} + +impl Marshal for TpmtRsaScheme { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u16(self.scheme.to_u16()); + if let Some(hash) = self.hash_alg { + buf.put_u16(hash.to_u16()); + } + } +} + +/// TPMT_ECC_SCHEME - ECC signature scheme +#[derive(Debug, Clone, Copy)] +pub struct TpmtEccScheme { + pub scheme: TpmAlgId, + pub hash_alg: Option, +} + +impl TpmtEccScheme { + pub fn null() -> Self { + Self { + scheme: TpmAlgId::Null, + hash_alg: None, + } + } + + pub fn ecdsa(hash: TpmAlgId) -> Self { + Self { + scheme: TpmAlgId::EcDsa, + hash_alg: Some(hash), + } + } +} + +impl Marshal for TpmtEccScheme { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u16(self.scheme.to_u16()); + if let Some(hash) = self.hash_alg { + buf.put_u16(hash.to_u16()); + } + } +} + +/// TPMT_SIG_SCHEME - Signature scheme (for Quote) +#[derive(Debug, Clone, Copy)] +pub struct TpmtSigScheme { + pub scheme: TpmAlgId, + pub hash_alg: Option, +} + +impl TpmtSigScheme { + pub fn null() -> Self { + Self { + scheme: TpmAlgId::Null, + hash_alg: None, + } + } +} + +impl Marshal for TpmtSigScheme { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u16(self.scheme.to_u16()); + if let Some(hash) = self.hash_alg { + buf.put_u16(hash.to_u16()); + } + } +} + +/// TPMS_RSA_PARMS - RSA key parameters +#[derive(Debug, Clone)] +pub struct TpmsRsaParms { + pub symmetric: TpmtSymDefObject, + pub scheme: TpmtRsaScheme, + pub key_bits: u16, + pub exponent: u32, +} + +impl TpmsRsaParms { + pub fn storage_key() -> Self { + Self { + symmetric: TpmtSymDef::aes_128_cfb(), + scheme: TpmtRsaScheme::null(), + key_bits: 2048, + exponent: 0, // Default exponent (65537) + } + } +} + +impl Marshal for TpmsRsaParms { + fn marshal(&self, buf: &mut CommandBuffer) { + self.symmetric.marshal(buf); + self.scheme.marshal(buf); + buf.put_u16(self.key_bits); + buf.put_u32(self.exponent); + } +} + +/// TPMS_ECC_PARMS - ECC key parameters +#[derive(Debug, Clone)] +pub struct TpmsEccParms { + pub symmetric: TpmtSymDefObject, + pub scheme: TpmtEccScheme, + pub curve_id: TpmEccCurve, + pub kdf: TpmAlgId, +} + +impl Marshal for TpmsEccParms { + fn marshal(&self, buf: &mut CommandBuffer) { + self.symmetric.marshal(buf); + self.scheme.marshal(buf); + buf.put_u16(self.curve_id.to_u16()); + buf.put_u16(self.kdf.to_u16()); // KDF scheme (usually NULL) + } +} + +/// TPMS_KEYEDHASH_PARMS - Keyed hash parameters (for sealed data) +#[derive(Debug, Clone, Copy)] +pub struct TpmsKeyedHashParms { + pub scheme: TpmAlgId, +} + +impl TpmsKeyedHashParms { + pub fn null() -> Self { + Self { + scheme: TpmAlgId::Null, + } + } +} + +impl Marshal for TpmsKeyedHashParms { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u16(self.scheme.to_u16()); + } +} + +/// TPMT_PUBLIC - Public area template +#[derive(Debug, Clone)] +pub struct TpmtPublic { + pub type_alg: TpmAlgId, + pub name_alg: TpmAlgId, + pub object_attributes: TpmaObject, + pub auth_policy: Tpm2bDigest, + pub parameters: TpmtPublicParms, + pub unique: TpmtPublicUnique, +} + +/// TPMU_PUBLIC_PARMS - Public parameters union +#[derive(Debug, Clone)] +pub enum TpmtPublicParms { + Rsa(TpmsRsaParms), + Ecc(TpmsEccParms), + KeyedHash(TpmsKeyedHashParms), +} + +impl Marshal for TpmtPublicParms { + fn marshal(&self, buf: &mut CommandBuffer) { + match self { + TpmtPublicParms::Rsa(p) => p.marshal(buf), + TpmtPublicParms::Ecc(p) => p.marshal(buf), + TpmtPublicParms::KeyedHash(p) => p.marshal(buf), + } + } +} + +/// TPMU_PUBLIC_ID - Unique identifier union +#[derive(Debug, Clone)] +pub enum TpmtPublicUnique { + Rsa(Vec), // TPM2B_PUBLIC_KEY_RSA + Ecc(Vec, Vec), // TPMS_ECC_POINT (x, y) + KeyedHash(Vec), // TPM2B_DIGEST +} + +impl Marshal for TpmtPublicUnique { + fn marshal(&self, buf: &mut CommandBuffer) { + match self { + TpmtPublicUnique::Rsa(n) => buf.put_tpm2b(n), + TpmtPublicUnique::Ecc(x, y) => { + buf.put_tpm2b(x); + buf.put_tpm2b(y); + } + TpmtPublicUnique::KeyedHash(d) => buf.put_tpm2b(d), + } + } +} + +impl TpmtPublic { + /// Create an RSA storage key template (SRK) + pub fn rsa_storage_key() -> Self { + Self { + type_alg: TpmAlgId::Rsa, + name_alg: TpmAlgId::Sha256, + object_attributes: TpmaObject::new() + .with_fixed_tpm() + .with_fixed_parent() + .with_sensitive_data_origin() + .with_user_with_auth() + .with_restricted() + .with_decrypt(), + auth_policy: Tpm2bDigest::empty(), + parameters: TpmtPublicParms::Rsa(TpmsRsaParms::storage_key()), + unique: TpmtPublicUnique::Rsa(Vec::new()), + } + } + + /// Create a sealed data object template + pub fn sealed_object(policy_digest: Tpm2bDigest) -> Self { + // If policy_digest is empty, use userWithAuth; otherwise use adminWithPolicy + let object_attributes = if policy_digest.buffer.is_empty() { + TpmaObject::new() + .with_fixed_tpm() + .with_fixed_parent() + .with_user_with_auth() + } else { + TpmaObject::new() + .with_fixed_tpm() + .with_fixed_parent() + .with_admin_with_policy() + }; + + Self { + type_alg: TpmAlgId::KeyedHash, + name_alg: TpmAlgId::Sha256, + object_attributes, + auth_policy: policy_digest, + parameters: TpmtPublicParms::KeyedHash(TpmsKeyedHashParms::null()), + unique: TpmtPublicUnique::KeyedHash(Vec::new()), + } + } +} + +impl Marshal for TpmtPublic { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u16(self.type_alg.to_u16()); + buf.put_u16(self.name_alg.to_u16()); + buf.put_u32(self.object_attributes.0); + self.auth_policy.marshal(buf); + self.parameters.marshal(buf); + self.unique.marshal(buf); + } +} + +/// TPM2B_PUBLIC - Public area with size prefix +#[derive(Debug, Clone)] +pub struct Tpm2bPublic { + pub public_area: Vec, // Raw marshalled TPMT_PUBLIC +} + +impl Tpm2bPublic { + pub fn from_template(template: &TpmtPublic) -> Self { + Self { + public_area: template.to_bytes(), + } + } +} + +impl Marshal for Tpm2bPublic { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_tpm2b(&self.public_area); + } +} + +impl Unmarshal for Tpm2bPublic { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + let public_area = buf.get_tpm2b()?; + Ok(Self { public_area }) + } +} + +/// TPM2B_PRIVATE - Private area +#[derive(Debug, Clone)] +pub struct Tpm2bPrivate { + pub buffer: Vec, +} + +impl Tpm2bPrivate { + pub fn new(data: Vec) -> Self { + Self { buffer: data } + } +} + +impl Marshal for Tpm2bPrivate { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_tpm2b(&self.buffer); + } +} + +impl Unmarshal for Tpm2bPrivate { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + Ok(Self { + buffer: buf.get_tpm2b()?, + }) + } +} + +/// TPM2B_SENSITIVE_CREATE - Sensitive data for object creation +#[derive(Debug, Clone, Default)] +pub struct Tpm2bSensitiveCreate { + pub user_auth: Tpm2bAuth, + pub data: Tpm2bSensitiveData, +} + +impl Tpm2bSensitiveCreate { + pub fn with_data(data: Vec) -> Self { + Self { + user_auth: Tpm2bAuth::empty(), + data: Tpm2bSensitiveData::new(data), + } + } + + pub fn empty() -> Self { + Self::default() + } +} + +impl Marshal for Tpm2bSensitiveCreate { + fn marshal(&self, buf: &mut CommandBuffer) { + // First marshal the inner structure + let mut inner = CommandBuffer::new(); + self.user_auth.marshal(&mut inner); + self.data.marshal(&mut inner); + // Then wrap with size + buf.put_tpm2b(inner.as_bytes()); + } +} + +/// TPMS_ATTEST - Attestation structure (returned by Quote) +#[derive(Debug, Clone)] +pub struct TpmsAttest { + pub raw: Vec, // Keep raw bytes for signature verification +} + +impl Unmarshal for TpmsAttest { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + // For Quote, we need the raw bytes for verification + // The structure is variable length, so we capture everything + let raw = buf.get_remaining(); + Ok(Self { raw }) + } +} + +/// TPM2B_ATTEST - Attestation data with size prefix +#[derive(Debug, Clone)] +pub struct Tpm2bAttest { + pub attestation_data: Vec, +} + +impl Unmarshal for Tpm2bAttest { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + let attestation_data = buf.get_tpm2b()?; + Ok(Self { attestation_data }) + } +} + +/// TPMT_SIGNATURE - Signature structure +#[derive(Debug, Clone)] +pub struct TpmtSignature { + pub raw: Vec, // Keep raw bytes for verification +} + +impl Unmarshal for TpmtSignature { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + // Signature format depends on algorithm, capture remaining bytes + let raw = buf.get_remaining(); + Ok(Self { raw }) + } +} + +/// TPMT_TK_CREATION - Creation ticket +#[derive(Debug, Clone)] +pub struct TpmtTkCreation { + pub tag: u16, + pub hierarchy: u32, + pub digest: Tpm2bDigest, +} + +impl Unmarshal for TpmtTkCreation { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + let tag = buf.get_u16()?; + let hierarchy = buf.get_u32()?; + let digest = Tpm2bDigest::unmarshal(buf)?; + Ok(Self { + tag, + hierarchy, + digest, + }) + } +} + +/// TPM2B_CREATION_DATA - Creation data +#[derive(Debug, Clone)] +pub struct Tpm2bCreationData { + pub data: Vec, +} + +impl Unmarshal for Tpm2bCreationData { + fn unmarshal(buf: &mut ResponseBuffer) -> Result { + let data = buf.get_tpm2b()?; + Ok(Self { data }) + } +} + +/// TPMS_SENSITIVE_CREATE - Inner sensitive create structure +#[derive(Debug, Clone, Default)] +pub struct TpmsSensitiveCreate { + pub user_auth: Tpm2bAuth, + pub data: Tpm2bSensitiveData, +} + +/// TPMT_HA - Hash value with algorithm +#[derive(Debug, Clone)] +pub struct TpmtHa { + pub hash_alg: TpmAlgId, + pub digest: Vec, +} + +impl TpmtHa { + pub fn sha256(digest: Vec) -> Self { + Self { + hash_alg: TpmAlgId::Sha256, + digest, + } + } +} + +impl Marshal for TpmtHa { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u16(self.hash_alg.to_u16()); + buf.put_bytes(&self.digest); + } +} + +/// TPML_DIGEST_VALUES - List of digest values for PCR extend +#[derive(Debug, Clone)] +pub struct TpmlDigestValues { + pub digests: Vec, +} + +impl TpmlDigestValues { + pub fn single(digest: TpmtHa) -> Self { + Self { + digests: vec![digest], + } + } +} + +impl Marshal for TpmlDigestValues { + fn marshal(&self, buf: &mut CommandBuffer) { + buf.put_u32(self.digests.len() as u32); + for d in &self.digests { + d.marshal(buf); + } + } +} diff --git a/verifier/Cargo.toml b/verifier/Cargo.toml index cbec4d683..1f0513ef4 100644 --- a/verifier/Cargo.toml +++ b/verifier/Cargo.toml @@ -43,6 +43,9 @@ dstack-mr.workspace = true dcap-qvl.workspace = true cc-eventlog.workspace = true sha2.workspace = true +tpm-qvl.workspace = true +tpm-types.workspace = true +nsm-attest.workspace = true ez-hash.workspace = true serde-human-bytes.workspace = true hex-literal.workspace = true diff --git a/verifier/builder/Dockerfile b/verifier/builder/Dockerfile index b58c8cdcb..06070f9db 100644 --- a/verifier/builder/Dockerfile +++ b/verifier/builder/Dockerfile @@ -23,7 +23,7 @@ RUN apt-get update && \ ca-certificates \ curl && \ rm -rf /var/lib/apt/lists/* /var/log/* /var/cache/ldconfig/aux-cache -RUN git clone ${DSTACK_SRC_URL} && \ +RUN git clone ${DSTACK_SRC_URL} dstack && \ cd dstack && \ git checkout ${DSTACK_REV} RUN rustup target add x86_64-unknown-linux-musl diff --git a/verifier/src/verification.rs b/verifier/src/verification.rs index 0cc91bdac..8ff4804af 100644 --- a/verifier/src/verification.rs +++ b/verifier/src/verification.rs @@ -12,8 +12,10 @@ use anyhow::{anyhow, bail, Context, Result}; use cc_eventlog::TdxEvent; use dstack_mr::{RtmrLog, TdxMeasurementDetails, TdxMeasurements}; use dstack_types::VmConfig; +use hex_literal::hex; use ra_tls::attestation::{ - Attestation, AttestationQuote, VerifiedAttestation, VersionedAttestation, + Attestation, AttestationQuote, DstackVerifiedReport, NitroPcrs, TpmQuote, VerifiedAttestation, + VersionedAttestation, }; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; @@ -498,15 +500,19 @@ impl CvmVerifier { .decode_vm_config(&vm_config) .context("Failed to decode VM config")?; match &attestation.quote { + AttestationQuote::DstackGcpTdx(quote) => { + self.verify_os_image_hash_for_gcp_tdx(&vm_config, "e.tpm_quote) + .await?; + } AttestationQuote::DstackTdx(_) => { self.verify_os_image_hash_for_dstack_tdx(&vm_config, attestation, debug, details) .await?; } - AttestationQuote::DstackGcpTdx | AttestationQuote::DstackNitroEnclave => { - bail!( - "Unsupported attestation quote: {:?}", - attestation.quote.mode() - ); + AttestationQuote::DstackNitroEnclave(_) => { + let DstackVerifiedReport::DstackNitroEnclave(report) = &attestation.report else { + bail!("internal error: nitro quote without a verified nitro report"); + }; + self.verify_os_image_hash_for_nitro_enclave(&vm_config, &report.pcrs)?; } } Ok(vm_config) @@ -637,6 +643,85 @@ impl CvmVerifier { } } + /// Verify Nitro Enclave OS image hash using the signature-verified NSM PCRs. + /// + /// For Nitro: + /// 1. PCR0/1/2 come from the EIF build (code + kernel + app) in production mode. + /// 2. In debug mode AWS zeroes PCR0/1/2, so there is no measurement of the + /// actual code; we refuse to authorize such enclaves. + /// 3. The computed image hash is compared against vm_config.os_image_hash. + fn verify_os_image_hash_for_nitro_enclave( + &self, + vm_config: &VmConfig, + pcrs: &NitroPcrs, + ) -> Result<()> { + // Reject debug-mode enclaves outright: their zeroed PCRs measure nothing, + // so accepting them would let arbitrary code run under attestation. + if pcrs.is_debug() { + bail!("nitro enclave is in debug mode (PCR0/1/2 are zeroed); refusing to verify"); + } + let os_image_hash = pcrs.image_hash(); + // Compare with expected os_image_hash from vm_config + if os_image_hash != vm_config.os_image_hash { + bail!( + "os_image_hash mismatch: expected={}, computed={}", + hex::encode(&vm_config.os_image_hash), + hex::encode(&os_image_hash) + ); + } + Ok(()) + } + + async fn verify_os_image_hash_for_gcp_tdx( + &self, + vm_config: &VmConfig, + tpm_quote: &TpmQuote, + ) -> Result<()> { + // Verify PCR 0 (GCP OVMF firmware) + const EXPECTED_PCR0: [u8; 32] = + hex!("0cca9ec161b09288802e5a112255d21340ed5b797f5fe29cecccfd8f67b9f802"); + + let pcr0 = tpm_quote + .pcr_values + .iter() + .find(|p| p.index == 0) + .context("PCR 0 not found in TPM quote")?; + + // Get expected UKI hash from os_image_hash (which should be set to UKI Authenticode hash) + let expected_uki_hash = &vm_config.os_image_hash; + + let pcr2_events: Vec<_> = tpm_quote + .event_log + .iter() + .filter(|e| e.pcr_index == 2) + .collect(); + debug!("PCR 2 Event Log contains {} events", pcr2_events.len()); + // Extract Event 28 (3rd event, 0-indexed as 2) + // NOTE: This is GCP OVMF-specific behavior + let event_28_digest = { + if pcr0.value != EXPECTED_PCR0 { + bail!( + "PCR 0 mismatch: expected GCP OVMF v2, got {}", + hex::encode(&pcr0.value) + ); + } + &pcr2_events.get(2).context("Event 28 not found")?.digest + }; + + if event_28_digest != expected_uki_hash { + bail!( + "UKI hash mismatch: expected={}, actual={}", + hex::encode(expected_uki_hash), + hex::encode(event_28_digest) + ); + } + debug!( + "✓ UKI hash verified from PCR 2 Event Log (Event 28), digest: {}", + hex::encode(event_28_digest) + ); + Ok(()) + } + pub async fn download_image(&self, hex_os_image_hash: &str, dst_dir: &Path) -> Result<()> { let url = self .download_url diff --git a/vmm/src/vmm-cli.py b/vmm/src/vmm-cli.py index e0090231e..290ee2440 100755 --- a/vmm/src/vmm-cli.py +++ b/vmm/src/vmm-cli.py @@ -1654,9 +1654,9 @@ def _patched_format_help(): ) compose_parser.add_argument( "--key-provider", - choices=["none", "kms", "local"], + choices=["none", "kms", "local", "tpm"], default=None, - help="Override key provider type (none, kms, local)", + help="Override key provider type (none, kms, local, or tpm)", ) compose_parser.add_argument( "--key-provider-id", diff --git a/vmm/ui/src/components/CreateVmDialog.ts b/vmm/ui/src/components/CreateVmDialog.ts index 90ab797d8..22f473435 100644 --- a/vmm/ui/src/components/CreateVmDialog.ts +++ b/vmm/ui/src/components/CreateVmDialog.ts @@ -138,6 +138,7 @@ const CreateVmDialogComponent = { +