diff --git a/Cargo.lock b/Cargo.lock index aeb3f28..7cd1789 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,6 +107,7 @@ dependencies = [ "base64", "enclave-types", "hex", + "http-body-util", "pontifex", "serde", "serde_json", @@ -278,7 +279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", - "rand", + "rand 0.8.7", ] [[package]] @@ -429,12 +430,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -585,6 +598,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -652,6 +676,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -664,6 +694,16 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "coset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb98d5e9155e2cf7cd942c8b3033097d4563b6fb0a00b9caecb74669555c058" +dependencies = [ + "ciborium", + "ciborium-io", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -722,6 +762,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -729,7 +781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -843,6 +895,30 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "deranged" version = "0.5.8" @@ -870,7 +946,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", ] [[package]] @@ -906,6 +995,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", +] + [[package]] name = "educe" version = "0.6.0" @@ -924,6 +1026,24 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "enclave-match-e2e" version = "0.1.0" @@ -937,6 +1057,7 @@ dependencies = [ "serde_bytes", "sha2", "tokio", + "verifier-client", ] [[package]] @@ -1001,7 +1122,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1105,6 +1226,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1127,6 +1258,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -1284,8 +1421,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1323,6 +1463,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "half" version = "1.8.3" @@ -1386,6 +1537,12 @@ dependencies = [ "serde", ] +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + [[package]] name = "histogram_equalization" version = "0.2.4" @@ -1398,6 +1555,15 @@ dependencies = [ "yuv", ] +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.2" @@ -1461,6 +1627,23 @@ dependencies = [ "pin-project-lite", "smallvec 1.15.2", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -1469,13 +1652,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1502,12 +1693,116 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec 1.15.2", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec 1.15.2", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.6" @@ -1548,7 +1843,7 @@ dependencies = [ "itertools 0.12.1", "nalgebra", "num", - "rand", + "rand 0.8.7", "rand_distr", "rayon", ] @@ -1636,6 +1931,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "itertools" version = "0.10.5" @@ -1791,6 +2092,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "litrs" version = "1.0.0" @@ -1821,6 +2128,12 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "maplit" version = "1.0.2" @@ -1925,7 +2238,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1984,7 +2297,7 @@ dependencies = [ "noisy_float", "num-integer", "num-traits", - "rand", + "rand 0.8.7", ] [[package]] @@ -2059,7 +2372,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2207,6 +2520,18 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "palette" version = "0.7.6" @@ -2266,6 +2591,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2331,7 +2665,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.7", ] [[package]] @@ -2433,6 +2767,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2457,6 +2800,15 @@ dependencies = [ "num-integer", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -2538,6 +2890,62 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -2561,7 +2969,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2571,7 +2990,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2583,6 +3002,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.4.3" @@ -2590,7 +3015,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand", + "rand 0.8.7", +] + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", ] [[package]] @@ -2619,7 +3053,7 @@ dependencies = [ "once_cell", "paste", "profiling", - "rand", + "rand 0.8.7", "rand_chacha", "simd_helpers", "system-deps", @@ -2728,18 +3162,80 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - -[[package]] -name = "rmp" -version = "0.8.15" +name = "reqwest" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "num-traits", + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", ] [[package]] @@ -2752,6 +3248,12 @@ dependencies = [ "serde", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2785,7 +3287,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -2881,6 +3418,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + [[package]] name = "secure-enclave" version = "0.1.0" @@ -2893,7 +3443,7 @@ dependencies = [ "hex", "image", "pontifex", - "rand", + "rand 0.8.7", "serde", "serde_bytes", "serde_json", @@ -3109,6 +3659,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simba" version = "0.8.1" @@ -3168,9 +3728,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -3244,6 +3820,20 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "system-deps" @@ -3296,7 +3886,7 @@ dependencies = [ "blake3", "eyre", "num-bigint", - "rand", + "rand 0.8.7", "serde", "taceo-ark-babyjubjub", "taceo-ark-serde-compat", @@ -3413,6 +4003,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.12.0" @@ -3428,6 +4028,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tokio" version = "1.52.3" @@ -3441,7 +4062,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3455,6 +4076,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-vsock" version = "0.7.2" @@ -3526,12 +4157,15 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.13.0", "bytes", + "futures-util", "http", "http-body", "pin-project-lite", + "tower", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -3739,7 +4373,7 @@ checksum = "121d3d224c806ba3d941f4bb50943ad33b59d1da5ae704d0e4e76d2808221f96" dependencies = [ "getrandom 0.2.17", "log", - "rand", + "rand 0.8.7", "rand_distr", "rustfft", "tract-nnef", @@ -3755,6 +4389,12 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -3810,6 +4450,31 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utils" version = "0.1.0" @@ -3865,6 +4530,29 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "verifier-client" +version = "0.1.0" +dependencies = [ + "aws-nitro-enclaves-nsm-api", + "axum", + "base64", + "ciborium", + "coset", + "hex", + "hex-literal", + "p384", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "url", + "webpki", + "x509-cert", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -3897,6 +4585,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3916,6 +4613,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -3948,6 +4655,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wide" version = "0.7.33" @@ -3964,7 +4710,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -4026,6 +4772,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4035,6 +4790,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -4044,6 +4863,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + [[package]] name = "xattr" version = "1.6.1" @@ -4054,6 +4891,29 @@ dependencies = [ "rustix", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + [[package]] name = "yuv" version = "0.8.16" @@ -4083,6 +4943,27 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" @@ -4103,6 +4984,39 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 480ee34..42fa9c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,11 @@ [workspace] -members = ["api", "e2e/enclave-match-e2e", "secure-enclave", "shared/enclave-types"] +members = [ + "api", + "client/verifier-client", + "e2e/enclave-match-e2e", + "secure-enclave", + "shared/enclave-types", +] resolver = "3" [workspace.package] @@ -13,23 +19,34 @@ publish = false anyhow = "1.0" ark-babyjubjub = { package = "taceo-ark-babyjubjub", version = "0.5" } async-trait = "0.1" +# Attestation crates are pinned to bedrock's versions so the two verifiers cannot drift. +aws-nitro-enclaves-nsm-api = { version = "0.4", default-features = false } axum = "0.8" base64 = "0.22" ciborium = "0.2" +coset = "0.4.2" crypto_box = { version = "0.9.1", default-features = false, features = ["getrandom"] } eddsa-babyjubjub = { package = "taceo-eddsa-babyjubjub", version = "0.5" } enclave-types = { path = "shared/enclave-types" } face-engine = { git = "https://github.com/worldcoin/biometric-engines", rev = "face-engine-v2.16.0", default-features = false, features = ["tract"] } hex = { version = "0.4", default-features = false, features = ["alloc"] } +hex-literal = "1.1" image = { version = "=0.25.6", default-features = false, features = ["jpeg", "png", "webp"] } +p384 = { version = "0.13", default-features = false, features = ["ecdsa", "sha384"] } pontifex = { version = "1.1.2", default-features = false } rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } serde_bytes = "0.11" serde_json = "1.0" sha2 = { version = "0.10", default-features = false } +thiserror = "2" tokio = { version = "1.48", features = ["macros", "net", "rt-multi-thread", "signal", "time"] } tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = { version = "2", features = ["serde"] } +verifier-client = { path = "client/verifier-client" } +webpki = "0.22" +x509-cert = "0.2.5" diff --git a/README.md b/README.md index e2745ce..5f824c0 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ Rust workspace for the embedding verifier API and secure enclave. ```text embedding-verifier/ -├── api/ # Axum HTTP API -└── secure-enclave/ # Secure enclave process +├── api/ # Axum HTTP API (the untrusted host) +├── client/verifier-client/ # Attestation-verifying client +└── secure-enclave/ # Secure enclave process ``` ## Development @@ -22,13 +23,54 @@ cargo build cargo test --all # Run the API on http://localhost:8000 -RUST_LOG=info cargo run --bin api +# ENCLAVE_CID and ENCLAVE_PORT are required; the process panics without them. +RUST_LOG=info ENCLAVE_CID=16 ENCLAVE_PORT=1000 cargo run --bin api curl http://localhost:8000/health # Run the secure enclave placeholder RUST_LOG=info cargo run --bin secure-enclave ``` +## Enclave assignment + +`POST /v1/enclave-assignment` returns the enclave's encryption-key attestation and nothing +else: + +```json +{ "attestation": "" } +``` + +The enclave's identity (`module_id`) and expiry (the leaf certificate's `notAfter`) are read +from the document *after* verifying it, never from fields the untrusted host could set. + +`verifier-client` verifies the document — the COSE signature, the certificate chain up to the +pinned AWS Nitro root, and the expected measurements. It is configured by a JSON file, in the +shape `world-id-protocol` uses for an authenticator: + +```json +{ + "host_url": "http://localhost:8000", + "allowed_pcr_configs": [ + [{ "index": 0, "value": "" }] + ], + "max_attestation_age_millis": 3600000, + "allow_debug_measurements": false +} +``` + +Only `host_url` and `allowed_pcr_configs` are required; the rest have defaults. A +configuration that pins no measurements is rejected — with nothing pinned, verification only +proves a document came from *some* enclave. A `--debug-mode` enclave reports all-zero PCRs and +its memory is readable from the parent instance, so it is rejected unless +`allow_debug_measurements` is set. + +`enclave-match-e2e` reads that file from `VERIFIER_CONFIG` and fetches its encryption key +through the host, exercising the assignment route and the client together: + +```bash +VERIFIER_CONFIG=./client.json cargo run --bin enclave-match-e2e -- +``` + ## Nitro-enabled development host Use an Amazon Linux 2023 EC2 instance type that supports Nitro Enclaves and launch it with diff --git a/api/Cargo.toml b/api/Cargo.toml index 85fd6e7..7402b27 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -25,5 +25,6 @@ tracing.workspace = true tracing-subscriber.workspace = true [dev-dependencies] +http-body-util = "0.1" serde_json.workspace = true tower.workspace = true diff --git a/api/src/error.rs b/api/src/error.rs new file mode 100644 index 0000000..f1e0470 --- /dev/null +++ b/api/src/error.rs @@ -0,0 +1,304 @@ +//! Universal error handling for the API. +//! +//! Every route returns [`AppError`], so status codes, response bodies and logging are decided +//! in one place. Enclave failures map differently per route, since the same enclave error +//! means different things depending on what was asked, so each route gets its own constructor +//! rather than a blanket `From` impl. + +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use enclave_types::EnclaveError; +use serde::Serialize; + +use crate::enclave::EnclaveClientError; + +/// Error envelope returned to clients. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiErrorResponse { + /// Whether the client should retry the request. + allow_retry: bool, + /// Error details. + error: ErrorBody, +} + +/// Machine-readable code and human-readable message. +#[derive(Debug, Serialize)] +struct ErrorBody { + /// Stable identifier a client can branch on. + code: &'static str, + /// Description for a human reading logs or a response. + message: &'static str, +} + +/// An API failure, with the status and body to return for it. +#[derive(Debug)] +pub struct AppError { + status: StatusCode, + code: &'static str, + message: &'static str, + allow_retry: bool, + /// Extra context for logs. Never serialized, since it may name internals. + detail: Option, +} + +impl AppError { + /// Creates an error with the given status and body. + #[must_use] + pub const fn new( + status: StatusCode, + code: &'static str, + message: &'static str, + allow_retry: bool, + ) -> Self { + Self { + status, + code, + message, + allow_retry, + detail: None, + } + } + + /// Attaches context that is logged but not returned to the client. + #[must_use] + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// The status this error will return. Exposed for tests and callers that branch on it. + #[must_use] + pub const fn status(&self) -> StatusCode { + self.status + } + + /// The machine-readable code this error will return. + #[must_use] + pub const fn code(&self) -> &'static str { + self.code + } + + /// Maps an enclave failure on the assignment route. + /// + /// Match-path errors cannot arise from an attestation request, so reaching one means the + /// enclave answered a request it was not asked. That is a host bug, not retryable + /// unavailability. + #[must_use] + pub fn enclave_assignment(error: &EnclaveClientError) -> Self { + match error { + EnclaveClientError::Timeout | EnclaveClientError::Transport(_) => { + Self::enclave_unreachable(error) + } + EnclaveClientError::Operation(operation) => match operation { + EnclaveError::NotReady + | EnclaveError::SecureModuleNotInitialized + | EnclaveError::AttestationFailed => Self::enclave_not_ready(*operation), + EnclaveError::DecryptFailed + | EnclaveError::MalformedMatchPayload + | EnclaveError::InvalidHashesJson + | EnclaveError::ThumbnailHashMismatch + | EnclaveError::MatchBelowThreshold + | EnclaveError::InvalidImage + | EnclaveError::EmbeddingGenerationFailed + | EnclaveError::EmbeddingComparisonFailed => Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "Internal server error", + false, + ) + .with_detail(format!( + "unexpected enclave error on assignment: {operation:?}" + )), + }, + } + } + + /// Maps an enclave failure on the match route. + #[must_use] + pub fn enclave_match(error: &EnclaveClientError) -> Self { + match error { + EnclaveClientError::Timeout | EnclaveClientError::Transport(_) => { + Self::enclave_unreachable(error) + } + EnclaveClientError::Operation(operation) => match operation { + EnclaveError::DecryptFailed + | EnclaveError::MalformedMatchPayload + | EnclaveError::InvalidHashesJson + | EnclaveError::InvalidImage => Self::new( + StatusCode::BAD_REQUEST, + "invalid_request", + "The match request could not be processed", + false, + ) + .with_detail(format!("{operation:?}")), + // Well-formed request, but the match itself did not hold. + EnclaveError::ThumbnailHashMismatch + | EnclaveError::MatchBelowThreshold + | EnclaveError::EmbeddingGenerationFailed => Self::new( + StatusCode::UNPROCESSABLE_ENTITY, + "match_failed", + "The match did not hold", + false, + ) + .with_detail(format!("{operation:?}")), + EnclaveError::EmbeddingComparisonFailed => Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "Internal server error", + true, + ) + .with_detail(format!("{operation:?}")), + EnclaveError::NotReady + | EnclaveError::SecureModuleNotInitialized + | EnclaveError::AttestationFailed => Self::enclave_not_ready(*operation), + }, + } + } + + /// The request never reached a working enclave. + fn enclave_unreachable(error: &EnclaveClientError) -> Self { + match error { + EnclaveClientError::Timeout => Self::new( + StatusCode::GATEWAY_TIMEOUT, + "enclave_timeout", + "The enclave did not answer in time", + true, + ), + EnclaveClientError::Transport(detail) => Self::new( + StatusCode::SERVICE_UNAVAILABLE, + "enclave_unreachable", + "The enclave is unreachable", + true, + ) + .with_detail(detail.clone()), + EnclaveClientError::Operation(_) => unreachable!("caller matched a transport failure"), + } + } + + /// The enclave answered but cannot serve requests yet. + fn enclave_not_ready(operation: EnclaveError) -> Self { + Self::new( + StatusCode::SERVICE_UNAVAILABLE, + "enclave_not_ready", + "The enclave is not ready", + true, + ) + .with_detail(format!("{operation:?}")) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + if self.status.is_server_error() { + tracing::error!( + code = self.code, + status = %self.status, + detail = self.detail.as_deref().unwrap_or_default(), + dependency = "secure-enclave", + "request failed" + ); + } else { + tracing::warn!( + code = self.code, + status = %self.status, + detail = self.detail.as_deref().unwrap_or_default(), + "request rejected" + ); + } + + let body = ApiErrorResponse { + allow_retry: self.allow_retry, + error: ErrorBody { + code: self.code, + message: self.message, + }, + }; + + (self.status, Json(body)).into_response() + } +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + use enclave_types::EnclaveError; + + use super::AppError; + use crate::enclave::EnclaveClientError; + + #[test] + fn assignment_maps_transport_failures_to_retryable_statuses() { + for (error, status, code) in [ + ( + EnclaveClientError::Timeout, + StatusCode::GATEWAY_TIMEOUT, + "enclave_timeout", + ), + ( + EnclaveClientError::Transport("boom".to_string()), + StatusCode::SERVICE_UNAVAILABLE, + "enclave_unreachable", + ), + ] { + let mapped = AppError::enclave_assignment(&error); + assert_eq!(mapped.status(), status); + assert_eq!(mapped.code(), code); + assert!(mapped.allow_retry, "{code} should be retryable"); + } + } + + #[test] + fn both_routes_agree_that_a_not_ready_enclave_is_retryable() { + for operation in [ + EnclaveError::NotReady, + EnclaveError::SecureModuleNotInitialized, + EnclaveError::AttestationFailed, + ] { + let error = EnclaveClientError::Operation(operation); + + for mapped in [ + AppError::enclave_assignment(&error), + AppError::enclave_match(&error), + ] { + assert_eq!(mapped.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(mapped.code(), "enclave_not_ready"); + assert!(mapped.allow_retry); + } + } + } + + /// The same enclave error means different things depending on what was asked, which is + /// why the mapping is per route rather than a blanket `From` impl. + #[test] + fn a_match_error_is_a_client_error_on_matches_and_a_host_bug_on_assignment() { + let error = EnclaveClientError::Operation(EnclaveError::DecryptFailed); + + assert_eq!( + AppError::enclave_match(&error).status(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + AppError::enclave_assignment(&error).status(), + StatusCode::INTERNAL_SERVER_ERROR + ); + } + + #[test] + fn match_failures_are_unprocessable_and_not_retryable() { + for operation in [ + EnclaveError::ThumbnailHashMismatch, + EnclaveError::MatchBelowThreshold, + EnclaveError::EmbeddingGenerationFailed, + ] { + let mapped = AppError::enclave_match(&EnclaveClientError::Operation(operation)); + + assert_eq!(mapped.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(mapped.code(), "match_failed"); + assert!(!mapped.allow_retry, "a failed match will fail again"); + } + } +} diff --git a/api/src/lib.rs b/api/src/lib.rs index 9a44877..2c8ae93 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -9,6 +9,7 @@ )] pub mod enclave; +pub mod error; pub mod routes; pub mod server; pub mod types; diff --git a/api/src/routes/enclave_assignment.rs b/api/src/routes/enclave_assignment.rs new file mode 100644 index 0000000..3470160 --- /dev/null +++ b/api/src/routes/enclave_assignment.rs @@ -0,0 +1,37 @@ +use axum::{Json, extract::State}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::Serialize; + +use crate::error::AppError; +use crate::types::AppState; + +/// The enclave assigned to a client, as an attestation document. +/// +/// The document already carries the enclave's identity and expiry, and the client verifies it +/// before trusting either, so the host relays opaque bytes and adds no fields of its own. +#[derive(Debug, Serialize)] +pub struct EnclaveAssignmentResponse { + attestation: String, +} + +/// Assigns this host's enclave by returning its encryption-key attestation. +/// +/// # Errors +/// +/// Returns [`AppError`] if the enclave is unreachable or cannot attest. +pub async fn handler( + State(state): State, +) -> Result, AppError> { + // TODO: Cache the attestation document, invalidating on enclave reconnect, and bound the + // entry's lifetime by the document certificate's validity. Until then every request costs + // an NSM attestation, so this route must not carry production traffic uncapped. + let response = state + .enclave_client() + .get_enclave_keys() + .await + .map_err(|error| AppError::enclave_assignment(&error))?; + + Ok(Json(EnclaveAssignmentResponse { + attestation: STANDARD.encode(response.encryption_key_attestation), + })) +} diff --git a/api/src/routes/enclave_keys.rs b/api/src/routes/enclave_keys.rs deleted file mode 100644 index a934391..0000000 --- a/api/src/routes/enclave_keys.rs +++ /dev/null @@ -1,34 +0,0 @@ -use axum::{Json, extract::State, http::StatusCode}; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use serde::Serialize; - -use crate::types::AppState; - -/// One attestation document per boot-scoped enclave public key. -/// -/// Both are public and relayed unsealed; the host cannot read or verify either. -#[derive(Debug, Serialize)] -pub struct EnclaveKeysResponse { - encryption_key_attestation: String, - signing_key_attestation: String, -} - -pub async fn handler( - State(state): State, -) -> Result, StatusCode> { - // TODO: Cache the attestation documents, invalidating on enclave reconnect, and bound - // the entry's lifetime by the document certificate's validity. - let response = state - .enclave_client() - .get_enclave_keys() - .await - .map_err(|error| { - tracing::error!(?error, "failed to fetch enclave key attestations"); - StatusCode::SERVICE_UNAVAILABLE - })?; - - Ok(Json(EnclaveKeysResponse { - encryption_key_attestation: STANDARD.encode(response.encryption_key_attestation), - signing_key_attestation: STANDARD.encode(response.signing_key_attestation), - })) -} diff --git a/api/src/routes/matches.rs b/api/src/routes/matches.rs index 7bda02d..774ecd7 100644 --- a/api/src/routes/matches.rs +++ b/api/src/routes/matches.rs @@ -1,8 +1,8 @@ use axum::{Json, body::Bytes, extract::State, http::StatusCode}; -use enclave_types::{self as enclave, EnclaveError}; +use enclave_types::{self as enclave}; use serde::Serialize; -use crate::enclave::EnclaveClientError; +use crate::error::AppError; use crate::types::AppState; /// A match statement rendered for HTTP clients. @@ -56,13 +56,21 @@ impl From for MatchResponse { /// /// The request body is the raw sealed-box ciphertext (`application/octet-stream`); the host /// relays it opaquely and never inspects it. +/// +/// # Errors +/// +/// Returns [`AppError`] if the body is empty or the enclave rejects the request. pub async fn handler( State(state): State, body: Bytes, -) -> Result, StatusCode> { +) -> Result, AppError> { if body.is_empty() { - tracing::warn!("match request had an empty body"); - return Err(StatusCode::BAD_REQUEST); + return Err(AppError::new( + StatusCode::BAD_REQUEST, + "invalid_request", + "The match request body was empty", + false, + )); } let response = state @@ -71,176 +79,7 @@ pub async fn handler( sealed_payload: body.to_vec(), }) .await - .map_err(|error| { - let status = status_for(&error); - if status.is_server_error() { - tracing::error!(?error, %status, "match request failed"); - } else { - tracing::warn!(?error, %status, "match request rejected"); - } - status - })?; + .map_err(|error| AppError::enclave_match(&error))?; Ok(Json(response.into())) } - -/// Maps an enclave-client failure to an HTTP status. -const fn status_for(error: &EnclaveClientError) -> StatusCode { - match error { - EnclaveClientError::Operation(operation) => match operation { - EnclaveError::DecryptFailed - | EnclaveError::MalformedMatchPayload - | EnclaveError::InvalidHashesJson - | EnclaveError::InvalidImage => StatusCode::BAD_REQUEST, - // Well-formed request, but the match itself did not hold. - EnclaveError::ThumbnailHashMismatch - | EnclaveError::MatchBelowThreshold - | EnclaveError::EmbeddingGenerationFailed => StatusCode::UNPROCESSABLE_ENTITY, - EnclaveError::EmbeddingComparisonFailed => StatusCode::INTERNAL_SERVER_ERROR, - EnclaveError::NotReady - | EnclaveError::SecureModuleNotInitialized - | EnclaveError::AttestationFailed => StatusCode::SERVICE_UNAVAILABLE, - }, - EnclaveClientError::Timeout => StatusCode::GATEWAY_TIMEOUT, - EnclaveClientError::Transport(_) => StatusCode::SERVICE_UNAVAILABLE, - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use async_trait::async_trait; - use axum::{body::Bytes, extract::State, http::StatusCode}; - use enclave_types::{self as enclave, EnclaveError, GetEnclaveKeysResponse}; - - use super::{handler, status_for}; - use crate::enclave::{EnclaveClient, EnclaveClientError}; - use crate::types::{AppState, Environment}; - - struct StubEnclaveClient { - result: Result, - } - - #[async_trait] - impl EnclaveClient for StubEnclaveClient { - async fn health(&self) -> Result<(), EnclaveClientError> { - Ok(()) - } - - async fn get_enclave_keys(&self) -> Result { - Ok(GetEnclaveKeysResponse { - encryption_key_attestation: Vec::new(), - signing_key_attestation: Vec::new(), - }) - } - - async fn run_match( - &self, - request: enclave::MatchRequest, - ) -> Result { - assert_eq!(request.sealed_payload, b"sealed"); - self.result.clone() - } - } - - fn state_returning(result: Result) -> AppState { - AppState::new( - Environment::Development, - Arc::new(StubEnclaveClient { result }), - ) - } - - fn sample_response() -> enclave::MatchResponse { - enclave::MatchResponse { - statement: enclave::MatchStatement { - version: 1, - live_image_hash: [1u8; 32], - credential_claim: [2u8; 32], - challenger_image_hash: [3u8; 32], - match_coefficient: 1.0, - }, - signature: vec![7u8; 64], - } - } - - #[tokio::test] - async fn forwards_sealed_payload_and_serializes_statement_as_hex() { - let state = state_returning(Ok(sample_response())); - - let response = handler(State(state), Bytes::from_static(b"sealed")) - .await - .expect("valid match should return a statement") - .0; - - assert_eq!(response.statement.version, 1); - assert_eq!( - response.statement.match_coefficient.to_bits(), - 1.0f32.to_bits() - ); - - let json = serde_json::to_value(&response).expect("response should serialize"); - assert_eq!( - json["statement"]["live_image_hash"], - hex::encode([1u8; 32]).as_str() - ); - assert_eq!( - json["statement"]["credential_claim"], - hex::encode([2u8; 32]).as_str() - ); - assert_eq!( - json["statement"]["challenger_image_hash"], - hex::encode([3u8; 32]).as_str() - ); - assert_eq!(json["signature"], hex::encode([7u8; 64]).as_str()); - } - - #[tokio::test] - async fn rejects_empty_body_with_bad_request() { - let state = state_returning(Ok(sample_response())); - - let status = handler(State(state), Bytes::new()) - .await - .expect_err("an empty body should be rejected"); - - assert_eq!(status, StatusCode::BAD_REQUEST); - } - - #[test] - fn status_mapping_is_exhaustive_and_classified() { - assert_eq!( - status_for(&EnclaveClientError::Operation(EnclaveError::DecryptFailed)), - StatusCode::BAD_REQUEST - ); - assert_eq!( - status_for(&EnclaveClientError::Operation( - EnclaveError::MalformedMatchPayload - )), - StatusCode::BAD_REQUEST - ); - assert_eq!( - status_for(&EnclaveClientError::Operation( - EnclaveError::InvalidHashesJson - )), - StatusCode::BAD_REQUEST - ); - assert_eq!( - status_for(&EnclaveClientError::Operation( - EnclaveError::MatchBelowThreshold - )), - StatusCode::UNPROCESSABLE_ENTITY - ); - assert_eq!( - status_for(&EnclaveClientError::Operation(EnclaveError::NotReady)), - StatusCode::SERVICE_UNAVAILABLE - ); - assert_eq!( - status_for(&EnclaveClientError::Timeout), - StatusCode::GATEWAY_TIMEOUT - ); - assert_eq!( - status_for(&EnclaveClientError::Transport("boom".to_string())), - StatusCode::SERVICE_UNAVAILABLE - ); - } -} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index 13d81a7..88c44f8 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -1,6 +1,6 @@ //! HTTP route definitions. -mod enclave_keys; +mod enclave_assignment; mod health; mod matches; mod readiness; @@ -17,6 +17,6 @@ pub fn handler() -> Router { Router::new() .route("/health", get(health::handler)) .route("/ready", get(readiness::handler)) - .route("/v1/enclave/keys", get(enclave_keys::handler)) + .route("/v1/enclave-assignment", post(enclave_assignment::handler)) .route("/v1/matches", post(matches::handler)) } diff --git a/api/tests/common/mod.rs b/api/tests/common/mod.rs new file mode 100644 index 0000000..82963af --- /dev/null +++ b/api/tests/common/mod.rs @@ -0,0 +1,46 @@ +//! Test doubles shared across the host's integration tests. + +use std::sync::Arc; + +use api::enclave::{EnclaveClient, EnclaveClientError}; +use api::types::{AppState, Environment}; +use async_trait::async_trait; +use enclave_types::{GetEnclaveKeysResponse, MatchRequest, MatchResponse}; + +/// An [`EnclaveClient`] answering from fixed results. +/// +/// Unconfigured operations panic, so a route calling the wrong one fails loudly. +#[derive(Default)] +pub struct StubEnclaveClient { + pub keys: Option>, + pub match_result: Option>, + pub expected_sealed_payload: Option>, +} + +#[async_trait] +impl EnclaveClient for StubEnclaveClient { + async fn health(&self) -> Result<(), EnclaveClientError> { + Ok(()) + } + + async fn get_enclave_keys(&self) -> Result { + self.keys + .clone() + .expect("route requested enclave keys but the stub was not configured to answer") + } + + async fn run_match(&self, request: MatchRequest) -> Result { + if let Some(expected) = &self.expected_sealed_payload { + assert_eq!(&request.sealed_payload, expected); + } + + self.match_result + .clone() + .expect("route ran a match but the stub was not configured to answer") + } +} + +/// Builds an [`AppState`] backed by `client`. +pub fn state_with(client: StubEnclaveClient) -> AppState { + AppState::new(Environment::Development, Arc::new(client)) +} diff --git a/api/tests/routes.rs b/api/tests/routes.rs new file mode 100644 index 0000000..3fd89d8 --- /dev/null +++ b/api/tests/routes.rs @@ -0,0 +1,219 @@ +//! Route tests driven through the real router. +//! +//! Requests go through `routes::handler()`, so the path and method each route is registered +//! under are covered alongside its behaviour. + +mod common; + +use api::enclave::EnclaveClientError; +use api::routes; +use api::types::AppState; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use common::{StubEnclaveClient, state_with}; +use enclave_types::{EnclaveError, GetEnclaveKeysResponse}; +use http_body_util::BodyExt as _; +use serde_json::Value; +use tower::ServiceExt as _; + +/// Sends `request` through the router and returns the status and decoded JSON body. +async fn send(state: AppState, request: Request) -> (StatusCode, Value) { + let response = routes::handler() + .with_state(state) + .oneshot(request) + .await + .expect("the router should answer"); + + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .expect("the body should be readable") + .to_bytes(); + + let body = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).expect("responses should be JSON") + }; + + (status, body) +} + +fn assignment_request() -> Request { + Request::builder() + .method(Method::POST) + .uri("/v1/enclave-assignment") + .body(Body::empty()) + .expect("request should be valid") +} + +fn keys(encryption: Vec, signing: Vec) -> StubEnclaveClient { + StubEnclaveClient { + keys: Some(Ok(GetEnclaveKeysResponse { + encryption_key_attestation: encryption, + signing_key_attestation: signing, + })), + ..StubEnclaveClient::default() + } +} + +#[tokio::test] +async fn assignment_returns_the_encryption_key_attestation_and_nothing_else() { + let state = state_with(keys(vec![1, 2, 3], vec![4, 5, 6])); + + let (status, body) = send(state, assignment_request()).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["attestation"], "AQID"); + assert_eq!( + body.as_object().map(serde_json::Map::len), + Some(1), + "the assignment must expose the attestation and nothing else" + ); +} + +#[tokio::test] +async fn assignment_is_not_reachable_by_get() { + let state = state_with(keys(vec![1, 2, 3], vec![4, 5, 6])); + + let request = Request::builder() + .method(Method::GET) + .uri("/v1/enclave-assignment") + .body(Body::empty()) + .expect("request should be valid"); + + let (status, _) = send(state, request).await; + + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED); +} + +/// Enclave keys are not exposed as their own route. The signing-key attestation belongs to +/// the Key Registry, and the encryption key is only served as part of an assignment. +#[tokio::test] +async fn enclave_keys_are_not_served_as_a_route() { + let state = state_with(keys(vec![1, 2, 3], vec![4, 5, 6])); + + let request = Request::builder() + .method(Method::GET) + .uri("/v1/enclave/keys") + .body(Body::empty()) + .expect("request should be valid"); + + let (status, _) = send(state, request).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn assignment_surfaces_enclave_failures_as_structured_errors() { + let cases = [ + ( + EnclaveClientError::Timeout, + StatusCode::GATEWAY_TIMEOUT, + "enclave_timeout", + true, + ), + ( + EnclaveClientError::Transport("boom".to_string()), + StatusCode::SERVICE_UNAVAILABLE, + "enclave_unreachable", + true, + ), + ( + EnclaveClientError::Operation(EnclaveError::NotReady), + StatusCode::SERVICE_UNAVAILABLE, + "enclave_not_ready", + true, + ), + ( + EnclaveClientError::Operation(EnclaveError::DecryptFailed), + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + false, + ), + ]; + + for (error, expected_status, expected_code, retryable) in cases { + let state = state_with(StubEnclaveClient { + keys: Some(Err(error)), + ..StubEnclaveClient::default() + }); + + let (status, body) = send(state, assignment_request()).await; + + assert_eq!(status, expected_status, "for {expected_code}"); + assert_eq!(body["error"]["code"], expected_code); + assert_eq!(body["allowRetry"], retryable, "for {expected_code}"); + } +} + +#[tokio::test] +async fn matches_forwards_the_sealed_payload_and_returns_hex_fields() { + let state = state_with(StubEnclaveClient { + match_result: Some(Ok(enclave_types::MatchResponse { + statement: enclave_types::MatchStatement { + version: 1, + live_image_hash: [1u8; 32], + credential_claim: [2u8; 32], + challenger_image_hash: [3u8; 32], + match_coefficient: 1.0, + }, + signature: vec![7u8; 64], + })), + expected_sealed_payload: Some(b"sealed".to_vec()), + ..StubEnclaveClient::default() + }); + + let request = Request::builder() + .method(Method::POST) + .uri("/v1/matches") + .body(Body::from("sealed")) + .expect("request should be valid"); + + let (status, body) = send(state, request).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["statement"]["version"], 1); + assert_eq!(body["statement"]["live_image_hash"], hex::encode([1u8; 32])); + assert_eq!(body["signature"], hex::encode([7u8; 64])); +} + +#[tokio::test] +async fn matches_rejects_an_empty_body() { + let state = state_with(StubEnclaveClient::default()); + + let request = Request::builder() + .method(Method::POST) + .uri("/v1/matches") + .body(Body::empty()) + .expect("request should be valid"); + + let (status, body) = send(state, request).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "invalid_request"); +} + +/// The same enclave error is a client error here and a host bug on assignment. +#[tokio::test] +async fn matches_treats_a_decrypt_failure_as_a_client_error() { + let state = state_with(StubEnclaveClient { + match_result: Some(Err(EnclaveClientError::Operation( + EnclaveError::DecryptFailed, + ))), + ..StubEnclaveClient::default() + }); + + let request = Request::builder() + .method(Method::POST) + .uri("/v1/matches") + .body(Body::from("sealed")) + .expect("request should be valid"); + + let (status, body) = send(state, request).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "invalid_request"); +} diff --git a/client/verifier-client/Cargo.toml b/client/verifier-client/Cargo.toml new file mode 100644 index 0000000..7a1a80b --- /dev/null +++ b/client/verifier-client/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "verifier-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +aws-nitro-enclaves-nsm-api.workspace = true +base64.workspace = true +ciborium.workspace = true +coset.workspace = true +hex = { workspace = true, features = ["serde"] } +p384.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +url.workspace = true +webpki.workspace = true +x509-cert.workspace = true + +[dev-dependencies] +axum.workspace = true +hex-literal.workspace = true +sha2.workspace = true +tokio.workspace = true diff --git a/client/verifier-client/NOTICE b/client/verifier-client/NOTICE new file mode 100644 index 0000000..cb7cf55 --- /dev/null +++ b/client/verifier-client/NOTICE @@ -0,0 +1,42 @@ +This crate contains code derived from third-party software. + +-------------------------------------------------------------------------------- + +src/nitro/mod.rs +src/nitro/types.rs +src/nitro/tests.rs +src/nitro/testdata/real_attestation_doc.b64 + +Derived from the `nitro_enclave` module of https://github.com/worldcoin/bedrock +(bedrock/src/nitro_enclave/), with modifications. See the module documentation +for the list of changes. + +MIT License + +Copyright (c) 2025 Tools for Humanity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- + +certs/aws_nitro_root_g1.der + +The AWS Nitro Attestation PKI root certificate, published by Amazon Web +Services at https://aws-nitro-enclaves.amazonaws.com/AWS_NitroEnclaves_Root-G1.zip +and redistributed unmodified as a trust anchor. diff --git a/client/verifier-client/certs/aws_nitro_root_g1.der b/client/verifier-client/certs/aws_nitro_root_g1.der new file mode 100644 index 0000000..994d128 Binary files /dev/null and b/client/verifier-client/certs/aws_nitro_root_g1.der differ diff --git a/client/verifier-client/src/client.rs b/client/verifier-client/src/client.rs new file mode 100644 index 0000000..16e1f73 --- /dev/null +++ b/client/verifier-client/src/client.rs @@ -0,0 +1,112 @@ +//! HTTP client for the embedding verifier host. + +use std::time::SystemTime; + +use serde::Deserialize; + +use crate::config::Config; +use crate::nitro::{EnclaveAttestationError, EnclaveAttestationVerifier, VerifiedAttestation}; + +/// Path of the assignment endpoint. +const ASSIGNMENT_PATH: &str = "/v1/enclave-assignment"; + +/// Failures while calling the host. +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + /// The HTTP client could not be constructed. + #[error("failed to build HTTP client: {0}")] + Transport(#[source] reqwest::Error), + + /// The request failed, timed out, or the body could not be read. + #[error("request to the host failed: {0}")] + Request(#[source] reqwest::Error), + + /// The host answered with a non-success status. + #[error("host returned HTTP {0}")] + Status(u16), + + /// The response was not the JSON the endpoint is specified to return. + #[error("response was not valid JSON: {0}")] + MalformedResponse(#[source] reqwest::Error), + + /// An attestation document did not verify. + #[error(transparent)] + Attestation(#[from] EnclaveAttestationError), +} + +/// The host's assignment response. +#[derive(Debug, Deserialize)] +struct EnclaveAssignmentResponse { + attestation: String, +} + +/// Calls the face verifier host and verifies the attestation documents it relays. +/// +/// Nothing is returned until the enclave that produced it has been verified, so callers +/// cannot accidentally use an unattested key. +#[derive(Debug)] +pub struct FaceVerifierClient { + config: Config, + http: reqwest::Client, + verifier: EnclaveAttestationVerifier, +} + +impl FaceVerifierClient { + /// Builds a client from `config`. + /// + /// # Errors + /// + /// Returns [`ClientError`] if the HTTP client cannot be built. + pub fn new(config: Config) -> Result { + let http = reqwest::Client::builder() + .connect_timeout(config.connect_timeout()) + .timeout(config.request_timeout()) + .build() + .map_err(ClientError::Transport)?; + + Ok(Self { + verifier: config.verifier(), + http, + config, + }) + } + + /// Requests an assignment and returns it only if its attestation verifies. + /// + /// No retry: the endpoint costs an NSM attestation per call, and the spec already has the + /// authenticator re-assigning when a match fails. + /// + /// # Errors + /// + /// Returns [`ClientError`] if the request fails, the host answers with an error status, + /// or the attestation document does not verify. + pub async fn request_assignment( + &self, + now: SystemTime, + ) -> Result { + let url = format!( + "{}{ASSIGNMENT_PATH}", + self.config.host_url().as_str().trim_end_matches('/') + ); + let response = self + .http + .post(url) + .send() + .await + .map_err(ClientError::Request)?; + + let status = response.status(); + if !status.is_success() { + return Err(ClientError::Status(status.as_u16())); + } + + let assignment: EnclaveAssignmentResponse = response + .json() + .await + .map_err(ClientError::MalformedResponse)?; + + Ok(self + .verifier + .verify_base64(assignment.attestation.trim(), now)?) + } +} diff --git a/client/verifier-client/src/config.rs b/client/verifier-client/src/config.rs new file mode 100644 index 0000000..d5d18c6 --- /dev/null +++ b/client/verifier-client/src/config.rs @@ -0,0 +1,213 @@ +//! Configuration for the verifier client. +//! +//! Follows the shape `world-id-protocol` uses for authenticator configuration. Configuration +//! is explicit — nothing is read from the environment. + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::nitro::{EnclaveAttestationVerifier, PcrMeasurement}; + +/// Default freshness bound, matching the few-hour lifetime of a Nitro certificate. +const fn default_max_attestation_age_millis() -> u64 { + 60 * 60 * 1000 +} + +const fn default_connect_timeout_millis() -> u64 { + 5_000 +} + +const fn default_request_timeout_millis() -> u64 { + 10_000 +} + +/// Failures while building a [`Config`]. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + /// A field was not usable. + #[error("invalid {attribute}: {reason}")] + InvalidInput { + /// Which field. + attribute: String, + /// Why it was rejected. + reason: String, + }, + + /// The JSON could not be parsed. + #[error("failed to parse config: {0}")] + Serialization(String), +} + +/// Configuration to interact with an embedding verifier host. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Config { + /// Base URL of the host, e.g. `https://verifier.example.com`. + host_url: Url, + /// Measurements to trust. A document is accepted if it matches any one configuration in + /// full, which lets several enclave versions be trusted at once during a rollout. + allowed_pcr_configs: Vec>, + /// How old an attestation document's own timestamp may be. + #[serde(default = "default_max_attestation_age_millis")] + max_attestation_age_millis: u64, + /// Whether to accept `--debug-mode` enclaves, whose measurements are all zero and whose + /// memory the parent instance can read. Development only. + #[serde(default)] + allow_debug_measurements: bool, + /// Bound on establishing a connection. + #[serde(default = "default_connect_timeout_millis")] + connect_timeout_millis: u64, + /// Bound on a whole request. + #[serde(default = "default_request_timeout_millis")] + request_timeout_millis: u64, +} + +impl Config { + /// Instantiates a configuration with the default bounds. + /// + /// # Errors + /// + /// Returns an error if `host_url` is not a valid URL, or if no measurements are given — + /// an empty policy would accept any genuine Nitro enclave, including somebody else's. + pub fn new( + host_url: &str, + allowed_pcr_configs: Vec>, + ) -> Result { + let host_url = Url::parse(host_url).map_err(|error| ConfigError::InvalidInput { + attribute: "host_url".to_string(), + reason: error.to_string(), + })?; + + let config = Self { + host_url, + allowed_pcr_configs, + max_attestation_age_millis: default_max_attestation_age_millis(), + allow_debug_measurements: false, + connect_timeout_millis: default_connect_timeout_millis(), + request_timeout_millis: default_request_timeout_millis(), + }; + config.validate()?; + + Ok(config) + } + + /// Bounds how old an attestation document's own timestamp may be. + #[must_use] + pub fn with_max_attestation_age(mut self, max_age: Duration) -> Self { + self.max_attestation_age_millis = u64::try_from(max_age.as_millis()).unwrap_or(u64::MAX); + self + } + + /// Accepts `--debug-mode` enclaves, whose memory the parent instance can read. + /// + /// Development only. + #[must_use] + pub const fn allowing_debug_measurements(mut self) -> Self { + self.allow_debug_measurements = true; + self + } + + /// Loads a configuration from JSON. + /// + /// # Errors + /// + /// Returns an error if the JSON is invalid or the resulting configuration is not usable. + pub fn from_json(json: &str) -> Result { + let config: Self = serde_json::from_str(json) + .map_err(|error| ConfigError::Serialization(error.to_string()))?; + config.validate()?; + + Ok(config) + } + + /// Rejects configurations that would verify nothing. + fn validate(&self) -> Result<(), ConfigError> { + // An empty set pins nothing and would match every enclave, so reject it even when + // other configurations sit beside it. + if self.allowed_pcr_configs.is_empty() || self.allowed_pcr_configs.iter().any(Vec::is_empty) + { + return Err(ConfigError::InvalidInput { + attribute: "allowed_pcr_configs".to_string(), + reason: "every configuration must pin at least one measurement, otherwise it \ + would accept any Nitro enclave" + .to_string(), + }); + } + + Ok(()) + } + + /// Builds a verifier applying this configuration's measurement policy. + #[must_use] + pub fn verifier(&self) -> EnclaveAttestationVerifier { + let verifier = EnclaveAttestationVerifier::new( + self.allowed_pcr_configs.clone(), + self.max_attestation_age_millis, + ); + + if self.allow_debug_measurements { + return verifier.allowing_debug_measurements(); + } + + verifier + } + + /// The host to call. + #[must_use] + pub const fn host_url(&self) -> &Url { + &self.host_url + } + + /// Bound on establishing a connection. + #[must_use] + pub const fn connect_timeout(&self) -> Duration { + Duration::from_millis(self.connect_timeout_millis) + } + + /// Bound on a whole request. + #[must_use] + pub const fn request_timeout(&self) -> Duration { + Duration::from_millis(self.request_timeout_millis) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{Config, ConfigError}; + use crate::nitro::PcrMeasurement; + + fn pcrs() -> Vec> { + vec![vec![PcrMeasurement::new(0, [0xabu8; 48])]] + } + + #[test] + fn rejects_a_configuration_that_pins_nothing() { + let error = Config::new("http://localhost:8000", Vec::new()) + .expect_err("an empty policy must fail closed"); + + assert!(matches!(error, ConfigError::InvalidInput { .. })); + } + + #[test] + fn rejects_an_invalid_host_url() { + let error = + Config::new("not a url", pcrs()).expect_err("an unparseable URL must be rejected"); + + assert!(matches!(error, ConfigError::InvalidInput { .. })); + } + + #[test] + fn round_trips_through_json_with_defaults_applied() { + let json = r#"{ + "host_url": "http://localhost:8000", + "allowed_pcr_configs": [[{ "index": 0, "value": "abcd" }]] + }"#; + + let config = Config::from_json(json).expect("config should parse"); + + assert_eq!(config.request_timeout(), Duration::from_secs(10)); + } +} diff --git a/client/verifier-client/src/lib.rs b/client/verifier-client/src/lib.rs new file mode 100644 index 0000000..3780ce5 --- /dev/null +++ b/client/verifier-client/src/lib.rs @@ -0,0 +1,20 @@ +//! Client for the embedding verifier's enclave-assignment flow. +//! +//! Fetches an assignment, verifies the AWS Nitro attestation document it carries, and yields +//! the enclave's encryption public key. The host is untrusted, so the enclave's identity, +//! measurements and public key are all read from the signed document. + +#![deny( + clippy::all, + clippy::pedantic, + clippy::nursery, + missing_docs, + dead_code +)] + +pub mod client; +pub mod config; +pub mod nitro; + +pub use client::{ClientError, FaceVerifierClient}; +pub use config::{Config, ConfigError}; diff --git a/client/verifier-client/src/nitro/mod.rs b/client/verifier-client/src/nitro/mod.rs new file mode 100644 index 0000000..4121f5b --- /dev/null +++ b/client/verifier-client/src/nitro/mod.rs @@ -0,0 +1,395 @@ +//! AWS Nitro Enclave attestation verification. +//! +//! Ported from `worldcoin/bedrock` (`bedrock/src/nitro_enclave/mod.rs`), MIT © Tools for +//! Humanity, which ships in World App — the authenticator that calls +//! `POST /v1/enclave-assignment`. Both sides therefore run the same logic rather than two +//! readings of the AWS spec. +//! +//! Follows +//! +//! Licence and copyright notice: see `client/verifier-client/NOTICE`. + +use std::borrow::Cow; +use std::time::{SystemTime, UNIX_EPOCH}; + +use aws_nitro_enclaves_nsm_api::api::AttestationDoc; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use coset::{AsCborValue, CoseSign1}; +use p384::ecdsa::{Signature, VerifyingKey, signature::Verifier as _}; +use webpki::{EndEntityCert, TrustAnchor}; +use x509_cert::{Certificate, der::Decode}; + +/// Types for enclave verification. +pub mod types; + +#[cfg(test)] +mod tests; + +pub use types::{ + EnclaveAttestationError, EnclaveAttestationResult, PcrMeasurement, VerifiedAttestation, +}; + +/// The AWS Nitro Attestation PKI root, from +/// . +/// +/// Pinned by SHA-256 against the fingerprint AWS publishes for the certificate (not the zip); +/// a test asserts it. Valid until 2049-10-28. +pub const AWS_NITRO_ROOT_CERT: &[u8] = include_bytes!("../../certs/aws_nitro_root_g1.der"); + +/// Nitro COSE signatures are ECDSA over P-384, so raw `r || s` is always 96 bytes. +const P384_SIGNATURE_LENGTH: usize = 96; + +/// Verifies AWS Nitro Enclave attestation documents. +#[derive(Debug, Clone)] +pub struct EnclaveAttestationVerifier { + allowed_pcr_configs: Vec>, + root_certificate: Cow<'static, [u8]>, + max_age_millis: u64, + allow_debug_measurements: bool, +} + +impl EnclaveAttestationVerifier { + /// Creates a verifier trusting any enclave that matches one of `allowed_pcr_configs`. + /// + /// `max_age_millis` bounds how old a document's own timestamp may be. + #[must_use] + pub const fn new(allowed_pcr_configs: Vec>, max_age_millis: u64) -> Self { + Self { + allowed_pcr_configs, + root_certificate: Cow::Borrowed(AWS_NITRO_ROOT_CERT), + max_age_millis, + allow_debug_measurements: false, + } + } + + /// Accepts enclaves whose measurements are all zero, i.e. run with `--debug-mode`. + /// + /// Their memory is readable from the parent instance. Development only. + #[must_use] + pub const fn allowing_debug_measurements(mut self) -> Self { + self.allow_debug_measurements = true; + self + } + + /// Replaces the pinned trust anchor. Test-only escape hatch for negative cases. + #[cfg(test)] + #[must_use] + fn with_root_certificate(mut self, root_certificate: Vec) -> Self { + self.root_certificate = Cow::Owned(root_certificate); + self + } + + /// Verifies a base64-encoded attestation document. + /// + /// # Errors + /// + /// Returns [`EnclaveAttestationError`] if the input is not valid base64, or if + /// verification fails for any reason. + pub fn verify_base64( + &self, + attestation_doc_base64: &str, + now: SystemTime, + ) -> EnclaveAttestationResult { + let bytes = STANDARD.decode(attestation_doc_base64).map_err(|error| { + EnclaveAttestationError::AttestationDocumentParseError(format!( + "failed to decode base64 attestation document: {error}" + )) + })?; + + self.verify(&bytes, now) + } + + /// Verifies a raw COSE-encoded attestation document. + /// + /// Fails closed: nothing is returned until the signature, the chain, the measurements and + /// the freshness all check out. + /// + /// # Errors + /// + /// Returns [`EnclaveAttestationError`] describing the first check that failed. + pub fn verify( + &self, + attestation_doc_bytes: &[u8], + now: SystemTime, + ) -> EnclaveAttestationResult { + let now_millis = unix_millis(now)?; + + // 1. Syntactical validation. + let cose_sign1 = Self::parse_cose_sign1(attestation_doc_bytes)?; + let attestation = Self::parse_cbor_payload(&cose_sign1)?; + + // 2. Semantic validation. + let leaf_cert = self.verify_certificate_chain(&attestation, now_millis)?; + + // 3. Cryptographic validation. + Self::verify_cose_signature(&cose_sign1, &leaf_cert)?; + self.validate_pcr_values(&attestation)?; + self.check_attestation_freshness(&attestation, now_millis)?; + let public_key = Self::extract_public_key(&attestation)?; + + Ok(VerifiedAttestation { + enclave_public_key: public_key, + module_id: attestation.module_id, + timestamp_millis: attestation.timestamp, + pcrs: attestation + .pcrs + .into_iter() + .map(|(index, value)| (index, value.into_vec())) + .collect(), + }) + } + + fn parse_cose_sign1(bytes: &[u8]) -> EnclaveAttestationResult { + if bytes.is_empty() { + return Err(EnclaveAttestationError::AttestationDocumentParseError( + "empty attestation document".to_string(), + )); + } + + // Reject anything that is not a CBOR array before handing it to the decoder. + let first_byte = bytes[0]; + if !(0x80..=0x97).contains(&first_byte) && first_byte != 0x9f { + return Err(EnclaveAttestationError::AttestationDocumentParseError( + format!( + "invalid CBOR magic byte: expected array marker (0x80-0x97 or 0x9f), got {first_byte:#04x}" + ), + )); + } + + let cbor_value: ciborium::Value = ciborium::from_reader(bytes).map_err(|error| { + EnclaveAttestationError::AttestationDocumentParseError(format!( + "failed to parse CBOR: {error}" + )) + })?; + + CoseSign1::from_cbor_value(cbor_value).map_err(|error| { + EnclaveAttestationError::AttestationDocumentParseError(format!( + "failed to parse COSE Sign1: {error}" + )) + }) + } + + fn parse_cbor_payload(cose_sign1: &CoseSign1) -> EnclaveAttestationResult { + let payload = cose_sign1.payload.as_ref().ok_or_else(|| { + EnclaveAttestationError::AttestationDocumentParseError( + "missing payload in COSE Sign1".to_string(), + ) + })?; + + ciborium::from_reader::(payload.as_slice()).map_err(|error| { + EnclaveAttestationError::AttestationDocumentParseError(format!( + "failed to parse attestation document: {error}" + )) + }) + } + + /// Validates the chain from the leaf up to the pinned root and returns the leaf. + /// + /// `cabundle` is root-first, so element 0 is the root we already pin. The TLS-server + /// entry point is webpki 0.22's only chain validator; it also requires the `serverAuth` + /// EKU, which Nitro leaf certificates carry. No DNS name is checked. + fn verify_certificate_chain( + &self, + attestation: &AttestationDoc, + now_millis: u64, + ) -> EnclaveAttestationResult { + let trust_anchor = + TrustAnchor::try_from_cert_der(self.root_certificate.as_ref()).map_err(|error| { + EnclaveAttestationError::AttestationChainInvalid(format!( + "failed to create trust anchor from root certificate: {error}" + )) + })?; + + let intermediate_certs: Vec<&[u8]> = attestation + .cabundle + .iter() + .skip(1) + .map(|cert| cert.as_slice()) + .collect(); + + let end_entity_cert = + EndEntityCert::try_from(attestation.certificate.as_slice()).map_err(|error| { + EnclaveAttestationError::AttestationChainInvalid(format!( + "failed to parse leaf certificate: {error}" + )) + })?; + + end_entity_cert + .verify_is_valid_tls_server_cert( + &[&webpki::ECDSA_P384_SHA384], + &webpki::TlsServerTrustAnchors(&[trust_anchor]), + &intermediate_certs, + webpki::Time::from_seconds_since_unix_epoch(now_millis / 1_000), + ) + .map_err(|error| { + EnclaveAttestationError::AttestationChainInvalid(format!( + "certificate chain validation failed: {error}" + )) + })?; + + Certificate::from_der(&attestation.certificate).map_err(|error| { + EnclaveAttestationError::AttestationChainInvalid(format!( + "failed to parse leaf certificate for return: {error}" + )) + }) + } + + fn verify_cose_signature( + cose_sign1: &CoseSign1, + leaf_cert: &Certificate, + ) -> EnclaveAttestationResult<()> { + let spki = &leaf_cert.tbs_certificate.subject_public_key_info; + let public_key_bytes = spki.subject_public_key.as_bytes().ok_or_else(|| { + EnclaveAttestationError::AttestationSignatureInvalid( + "failed to extract public key bytes".to_string(), + ) + })?; + + let verifying_key = VerifyingKey::from_sec1_bytes(public_key_bytes).map_err(|error| { + EnclaveAttestationError::AttestationSignatureInvalid(format!( + "failed to parse P-384 public key: {error}" + )) + })?; + + let signature = &cose_sign1.signature; + if signature.len() != P384_SIGNATURE_LENGTH { + return Err(EnclaveAttestationError::AttestationSignatureInvalid( + format!( + "invalid signature length: expected {P384_SIGNATURE_LENGTH} bytes, got {}", + signature.len() + ), + )); + } + + // Sig_structure per RFC 8152 §4.4, with no external AAD. + let sig_structure = cose_sign1.tbs_data(&[]); + + let ecdsa_signature = Signature::try_from(signature.as_slice()).map_err(|error| { + EnclaveAttestationError::AttestationSignatureInvalid(format!( + "failed to parse ECDSA signature (need {P384_SIGNATURE_LENGTH} raw bytes): {error}" + )) + })?; + + verifying_key + .verify(&sig_structure, &ecdsa_signature) + .map_err(|error| { + EnclaveAttestationError::AttestationSignatureInvalid(format!( + "signature verification failed: {error}" + )) + }) + } + + fn validate_pcr_values(&self, attestation: &AttestationDoc) -> EnclaveAttestationResult<()> { + if attestation.pcrs.is_empty() { + return Err(EnclaveAttestationError::CodeUntrusted( + "document carries no PCRs".to_string(), + )); + } + + if !self.allow_debug_measurements + && attestation + .pcrs + .values() + .all(|value| value.iter().all(|byte| *byte == 0)) + { + return Err(EnclaveAttestationError::DebugMeasurements); + } + + if self.allowed_pcr_configs.is_empty() { + return Err(EnclaveAttestationError::CodeUntrusted( + "no allowed PCR configurations".to_string(), + )); + } + + let expected_pcr_length = expected_pcr_length(attestation.digest); + + for allowed_pcr_measurements in &self.allowed_pcr_configs { + // `all()` is vacuously true over an empty set, which would accept any enclave. + if allowed_pcr_measurements.is_empty() { + continue; + } + + let all_match = allowed_pcr_measurements.iter().all(|measurement| { + attestation + .pcrs + .get(&(measurement.index as usize)) + .is_some_and(|value| { + value.len() == expected_pcr_length + && value.as_slice() == measurement.value.as_slice() + }) + }); + + if all_match { + return Ok(()); + } + } + + Err(EnclaveAttestationError::CodeUntrusted( + "no allowed PCR configuration matched".to_string(), + )) + } + + fn check_attestation_freshness( + &self, + attestation: &AttestationDoc, + now_millis: u64, + ) -> EnclaveAttestationResult<()> { + let age = now_millis + .checked_sub(attestation.timestamp) + .ok_or_else(|| { + EnclaveAttestationError::AttestationInvalidTimestamp(format!( + "attestation timestamp is {}ms in the future", + attestation.timestamp.saturating_sub(now_millis) + )) + })?; + + if age > self.max_age_millis { + return Err(EnclaveAttestationError::AttestationStale { + age_millis: age, + max_age: self.max_age_millis, + }); + } + + Ok(()) + } + + fn extract_public_key(attestation: &AttestationDoc) -> EnclaveAttestationResult> { + let key = attestation.public_key.as_ref().ok_or_else(|| { + EnclaveAttestationError::InvalidEnclavePublicKey( + "no public key in attestation document".to_string(), + ) + })?; + + Ok(key.to_vec()) + } +} + +/// Expected PCR length for the digest the document says it used. +const fn expected_pcr_length(digest: aws_nitro_enclaves_nsm_api::api::Digest) -> usize { + use aws_nitro_enclaves_nsm_api::api::Digest; + + match digest { + Digest::SHA256 => 32, + Digest::SHA384 => 48, + Digest::SHA512 => 64, + } +} + +/// Converts a wall-clock instant to milliseconds since the Unix epoch. +fn unix_millis(now: SystemTime) -> EnclaveAttestationResult { + let millis = now + .duration_since(UNIX_EPOCH) + .map_err(|error| { + EnclaveAttestationError::AttestationInvalidTimestamp(format!( + "clock is before the Unix epoch: {error}" + )) + })? + .as_millis(); + + u64::try_from(millis).map_err(|error| { + EnclaveAttestationError::AttestationInvalidTimestamp(format!( + "clock does not fit in milliseconds since the epoch: {error}" + )) + }) +} diff --git a/client/verifier-client/src/nitro/testdata/real_attestation_doc.b64 b/client/verifier-client/src/nitro/testdata/real_attestation_doc.b64 new file mode 100644 index 0000000..b98611d --- /dev/null +++ b/client/verifier-client/src/nitro/testdata/real_attestation_doc.b64 @@ -0,0 +1 @@ +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 diff --git a/client/verifier-client/src/nitro/tests.rs b/client/verifier-client/src/nitro/tests.rs new file mode 100644 index 0000000..40c7af9 --- /dev/null +++ b/client/verifier-client/src/nitro/tests.rs @@ -0,0 +1,285 @@ +//! Tests for the Nitro attestation verifier. +//! +//! Cases and the real attestation document are ported from `worldcoin/bedrock` +//! (`bedrock/src/nitro_enclave/tests.rs`), MIT © Tools for Humanity. +//! +//! Licence and copyright notice: see `client/verifier-client/NOTICE`. +//! +//! The fixture's certificate chain expired in September 2025, so tests pin `now` to the +//! document's own timestamp, which falls inside its validity window. + +// `Duration::from_days` / `from_hours`, which clippy suggests here, are unstable on 1.97. +#![allow(clippy::duration_suboptimal_units)] + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use hex_literal::hex; + +use super::{ + AWS_NITRO_ROOT_CERT, EnclaveAttestationError, EnclaveAttestationVerifier, PcrMeasurement, +}; + +const REAL_ATTESTATION_DOC_BASE64: &str = include_str!("testdata/real_attestation_doc.b64"); + +/// Ten years, so the fixture is never rejected for staleness. +const GENEROUS_MAX_AGE_MILLIS: u64 = 10 * 365 * 24 * 60 * 60 * 1000; + +fn real_document() -> Vec { + STANDARD + .decode(REAL_ATTESTATION_DOC_BASE64.trim()) + .expect("fixture should be valid base64") +} + +/// The PCRs the fixture's enclave reported. +fn fixture_pcr_config() -> Vec { + vec![ + PcrMeasurement::new( + 0, + hex!( + "108b32466f5dc0a9971e0bc8e3e4074e7821bb2dcad3841bdec9a08b30f173386f0394a01486df181f316b39443dab34" + ), + ), + PcrMeasurement::new( + 1, + hex!( + "4b4d5b3661b3efc12920900c80e126e4ce783c522de6c02a2a5bf7af3a2b9327b86776f188e4be1c1c404a129dbda493" + ), + ), + PcrMeasurement::new( + 2, + hex!( + "08c6b2cba2d0c0ab63f3533cb44e092fb211775323cd62cd571f871e127ae1844f0e948a54ba58ecd29fbe03a64d5edc" + ), + ), + PcrMeasurement::new( + 8, + hex!( + "b38251662033340b540c2d7e5f49e7ec6d10afcb5f17c72132e20a7f0a54576dc4d2c6ce062ed2ed2b6ae01815d69c8d" + ), + ), + ] +} + +fn verifier() -> EnclaveAttestationVerifier { + EnclaveAttestationVerifier::new(vec![fixture_pcr_config()], GENEROUS_MAX_AGE_MILLIS) +} + +fn attestation_doc() -> aws_nitro_enclaves_nsm_api::api::AttestationDoc { + let bytes = real_document(); + let cose = EnclaveAttestationVerifier::parse_cose_sign1(&bytes).expect("fixture should parse"); + EnclaveAttestationVerifier::parse_cbor_payload(&cose).expect("fixture should decode") +} + +/// The instant the fixture was produced, so `now` falls inside its certificate window. +fn fixture_instant() -> SystemTime { + UNIX_EPOCH + Duration::from_millis(attestation_doc().timestamp) +} + +#[test] +fn verifies_a_real_attestation_document() { + let verified = verifier() + .verify(&real_document(), fixture_instant()) + .expect("the fixture should verify against the pinned AWS root"); + + assert!(verified.module_id.contains("-enc")); + assert_eq!(verified.enclave_public_key.len(), 32); + assert_eq!( + verified.pcrs.get(&0).map(Vec::as_slice), + Some(fixture_pcr_config()[0].value.as_slice()) + ); +} + +/// Corrupts only the signature: the document still parses and still chains to the AWS root, +/// so this is what proves signature verification actually runs. +#[test] +fn rejects_a_corrupted_cose_signature() { + let bytes = real_document(); + let ciborium::Value::Array(mut fields) = + ciborium::from_reader::(bytes.as_slice()).expect("fixture is CBOR") + else { + panic!("a COSE_Sign1 document is a CBOR array"); + }; + + let ciborium::Value::Bytes(signature) = &mut fields[3] else { + panic!("the fourth COSE_Sign1 field is the signature"); + }; + signature[0] ^= 0xff; + + let mut tampered = Vec::new(); + ciborium::into_writer(&ciborium::Value::Array(fields), &mut tampered) + .expect("re-encoding should succeed"); + + let error = verifier() + .verify(&tampered, fixture_instant()) + .expect_err("a corrupted signature must not verify"); + + assert!( + matches!( + error, + EnclaveAttestationError::AttestationSignatureInvalid(_) + ), + "expected a signature failure, got: {error}" + ); +} + +#[test] +fn rejects_a_document_under_a_different_root() { + // The fixture's leaf is a valid certificate, but not the AWS root. + let not_the_root = attestation_doc().certificate.to_vec(); + + let error = verifier() + .with_root_certificate(not_the_root) + .verify(&real_document(), fixture_instant()) + .expect_err("a chain that does not reach the pinned root must not verify"); + + assert!( + matches!(error, EnclaveAttestationError::AttestationChainInvalid(_)), + "unexpected error: {error}" + ); +} + +#[test] +fn rejects_an_expired_certificate_chain() { + let much_later = fixture_instant() + Duration::from_secs(365 * 24 * 60 * 60); + + let error = verifier() + .verify(&real_document(), much_later) + .expect_err("an expired chain must not verify"); + + assert!( + matches!(error, EnclaveAttestationError::AttestationChainInvalid(_)), + "unexpected error: {error}" + ); +} + +#[test] +fn rejects_measurements_that_match_no_allowed_configuration() { + let absent_index = { + // Index 20 is valid but not carried by this document. + let mut config = fixture_pcr_config(); + config.push(PcrMeasurement::new(20, [0x11u8; 48])); + vec![config] + }; + + let cases = [ + ( + "a wrong value", + vec![vec![PcrMeasurement::new(0, [0xabu8; 48])]], + ), + ("a pinned index the document omits", absent_index), + ("nothing pinned at all", Vec::new()), + ]; + + for (label, configs) in cases { + let error = EnclaveAttestationVerifier::new(configs, GENEROUS_MAX_AGE_MILLIS) + .verify(&real_document(), fixture_instant()) + .expect_err(&format!("{label} must fail closed")); + + assert!( + matches!(error, EnclaveAttestationError::CodeUntrusted(_)), + "{label}: unexpected error: {error}" + ); + } +} + +#[test] +fn accepts_a_document_matching_any_one_of_several_configurations() { + // A rollout trusts the outgoing and incoming enclave versions at once. + let configs = vec![ + vec![PcrMeasurement::new(0, [0xabu8; 48])], + fixture_pcr_config(), + ]; + + EnclaveAttestationVerifier::new(configs, GENEROUS_MAX_AGE_MILLIS) + .verify(&real_document(), fixture_instant()) + .expect("matching the second configuration should be enough"); +} + +#[test] +fn rejects_a_stale_document() { + let max_age_millis = 60_000; + let later = fixture_instant() + Duration::from_millis(max_age_millis * 2); + + let error = EnclaveAttestationVerifier::new(vec![fixture_pcr_config()], max_age_millis) + .verify(&real_document(), later) + .expect_err("a document older than the policy allows must not verify"); + + assert!( + matches!(error, EnclaveAttestationError::AttestationStale { .. }), + "unexpected error: {error}" + ); +} + +#[test] +fn rejects_a_document_timestamped_in_the_future() { + let earlier = fixture_instant() - Duration::from_secs(1); + + let error = verifier() + .verify(&real_document(), earlier) + .expect_err("a document from the future must not verify"); + + assert!( + matches!( + error, + EnclaveAttestationError::AttestationInvalidTimestamp(_) + ), + "unexpected error: {error}" + ); +} + +#[test] +fn rejects_empty_and_non_cbor_input() { + let now = fixture_instant(); + + for (label, bytes) in [ + ("empty", Vec::new()), + ("not CBOR at all", b"hello, world".to_vec()), + ("a CBOR map rather than an array", vec![0xa1, 0x01, 0x02]), + ] { + let error = verifier() + .verify(&bytes, now) + .expect_err(&format!("{label} input should be rejected")); + + assert!( + matches!( + error, + EnclaveAttestationError::AttestationDocumentParseError(_) + ), + "{label} should fail parsing, got: {error}" + ); + } +} + +#[test] +fn pins_the_aws_nitro_root_certificate() { + use sha2::{Digest as _, Sha256}; + + // The fingerprint AWS publishes at + // https://docs.aws.amazon.com/enclaves/latest/user/verify-root.html + let expected = hex!("641a0321a3e244efe456463195d606317ed7cdcc3c1756e09893f3c68f79bb5b"); + + assert_eq!( + Sha256::digest(AWS_NITRO_ROOT_CERT).as_slice(), + expected.as_slice(), + "the vendored trust anchor must be the certificate AWS publishes" + ); +} + +#[test] +fn rejects_an_empty_configuration_rather_than_matching_it_vacuously() { + // An empty set of measurements pins nothing, so `all()` over it is vacuously true. + // Sitting beside a real config it must not become a blanket accept. + // Paired with a config that does NOT match, so accepting can only come from the empty one. + let configs = vec![Vec::new(), vec![PcrMeasurement::new(0, [0xabu8; 48])]]; + + let error = EnclaveAttestationVerifier::new(configs, GENEROUS_MAX_AGE_MILLIS) + .verify(&real_document(), fixture_instant()) + .expect_err("an empty configuration must never match"); + + assert!( + matches!(error, EnclaveAttestationError::CodeUntrusted(_)), + "unexpected error: {error}" + ); +} diff --git a/client/verifier-client/src/nitro/types.rs b/client/verifier-client/src/nitro/types.rs new file mode 100644 index 0000000..4735ae4 --- /dev/null +++ b/client/verifier-client/src/nitro/types.rs @@ -0,0 +1,91 @@ +//! Types for AWS Nitro Enclave attestation verification. +//! +//! Ported from `worldcoin/bedrock` (`bedrock/src/nitro_enclave/types.rs`), MIT © Tools for +//! Humanity. Variant names are kept so the two can be diffed. +//! +//! Licence and copyright notice: see `client/verifier-client/NOTICE`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Errors that can occur during enclave attestation verification. +#[derive(Debug, thiserror::Error)] +pub enum EnclaveAttestationError { + /// Failed to parse the attestation document. + #[error("failed to parse attestation document: {0}")] + AttestationDocumentParseError(String), + + /// Certificate chain validation failed. + #[error("certificate chain validation failed: {0}")] + AttestationChainInvalid(String), + + /// COSE signature verification failed. + #[error("signature verification failed: {0}")] + AttestationSignatureInvalid(String), + + /// The measurements did not match any allowed configuration. + #[error("enclave code not trusted: {0}")] + CodeUntrusted(String), + + /// Every PCR was zero, which means a `--debug-mode` enclave whose memory the parent + /// instance can read. + #[error("attestation reports zeroed measurements, which means a debug-mode enclave")] + DebugMeasurements, + + /// The attestation document is older than the caller allows. + #[error("attestation is too old: {age_millis}ms (max: {max_age}ms)")] + AttestationStale { + /// Observed age in milliseconds. + age_millis: u64, + /// Configured maximum age in milliseconds. + max_age: u64, + }, + + /// The attestation timestamp could not be interpreted. + #[error("invalid timestamp: {0}")] + AttestationInvalidTimestamp(String), + + /// The attested public key was absent or the wrong shape. + #[error("invalid enclave public key: {0}")] + InvalidEnclavePublicKey(String), +} + +/// Result type for enclave attestation operations. +pub type EnclaveAttestationResult = Result; + +/// One expected PCR measurement. Serializes with the value as hex. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PcrMeasurement { + /// Index of the PCR measurement. + pub index: u32, + /// Expected value. + #[serde(with = "hex::serde")] + pub value: Vec, +} + +impl PcrMeasurement { + /// Creates a new [`PcrMeasurement`]. + #[must_use] + pub fn new(index: u32, value: impl Into>) -> Self { + Self { + index, + value: value.into(), + } + } +} + +/// An attestation document whose signature, chain and measurements have all been verified. +/// +/// Every field is read from the signed document, so nothing depends on the untrusted host. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedAttestation { + /// The attested public key. + pub enclave_public_key: Vec, + /// NSM module id, e.g. `i-0abc…-enc0123…`. Identifies the enclave for one boot. + pub module_id: String, + /// When the document was produced, in milliseconds since the Unix epoch. + pub timestamp_millis: u64, + /// Every PCR the document carried. + pub pcrs: BTreeMap>, +} diff --git a/client/verifier-client/tests/assignment.rs b/client/verifier-client/tests/assignment.rs new file mode 100644 index 0000000..477057c --- /dev/null +++ b/client/verifier-client/tests/assignment.rs @@ -0,0 +1,120 @@ +//! End-to-end tests for the assignment client, over real HTTP. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::Router; +use axum::http::StatusCode; +use axum::routing::post; +use hex_literal::hex; +use verifier_client::nitro::PcrMeasurement; +use verifier_client::{ClientError, Config, FaceVerifierClient}; + +const REAL_ATTESTATION_DOC_BASE64: &str = + include_str!("../src/nitro/testdata/real_attestation_doc.b64"); + +/// When the fixture was produced (2025-09-23T11:56:49.915Z). Its chain is valid only for a +/// few hours around this instant, so tests pin the clock here. +const FIXTURE_TIMESTAMP_MILLIS: u64 = 1_758_628_609_915; + +fn fixture_instant() -> SystemTime { + UNIX_EPOCH + Duration::from_millis(FIXTURE_TIMESTAMP_MILLIS) +} + +fn config(base_url: &str) -> Config { + let pcrs = vec![PcrMeasurement::new( + 0, + hex!( + "108b32466f5dc0a9971e0bc8e3e4074e7821bb2dcad3841bdec9a08b30f173386f0394a01486df181f316b39443dab34" + ), + )]; + + Config::new(base_url, vec![pcrs]) + .expect("config should be valid") + // Ten years, so the fixture is never rejected for staleness. + .with_max_attestation_age(Duration::from_secs(10 * 365 * 24 * 60 * 60)) +} + +/// Serves `router` on an ephemeral port and returns its base URL. +async fn serve(router: Router) -> String { + let listener = tokio::net::TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .await + .expect("should bind an ephemeral port"); + let address = listener + .local_addr() + .expect("listener should have an address"); + + tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("stub should run"); + }); + + format!("http://{address}") +} + +/// A stub host answering assignments as the real one does. +async fn serve_assignment(attestation: &str) -> String { + let body = serde_json::json!({ "attestation": attestation.trim() }); + + serve(Router::new().route( + "/v1/enclave-assignment", + post(move || { + let body = body.clone(); + async move { axum::Json(body) } + }), + )) + .await +} + +#[tokio::test] +async fn fetches_and_verifies_an_assignment_over_http() { + let base_url = serve_assignment(REAL_ATTESTATION_DOC_BASE64).await; + + let verified = FaceVerifierClient::new(config(&base_url)) + .expect("client should build") + .request_assignment(fixture_instant()) + .await + .expect("a well-formed assignment should verify"); + + assert_eq!(verified.timestamp_millis, FIXTURE_TIMESTAMP_MILLIS); + assert!(verified.module_id.contains("-enc")); + assert_eq!(verified.enclave_public_key.len(), 32); +} + +#[tokio::test] +async fn rejects_an_assignment_whose_attestation_does_not_verify() { + // A syntactically fine response carrying a document signed by nobody. + let base_url = serve_assignment("hEBAQEA=").await; + + let error = FaceVerifierClient::new(config(&base_url)) + .expect("client should build") + .request_assignment(fixture_instant()) + .await + .expect_err("an unverifiable document must not be accepted"); + + assert!( + matches!(error, ClientError::Attestation(_)), + "unexpected error: {error}" + ); +} + +#[tokio::test] +async fn surfaces_a_host_error_status_rather_than_retrying() { + let base_url = serve(Router::new().route( + "/v1/enclave-assignment", + post(|| async { StatusCode::SERVICE_UNAVAILABLE }), + )) + .await; + + let error = FaceVerifierClient::new(config(&base_url)) + .expect("client should build") + .request_assignment(fixture_instant()) + .await + .expect_err("a 503 should surface to the caller"); + + assert!( + matches!(error, ClientError::Status(503)), + "unexpected error: {error}" + ); +} diff --git a/deny.toml b/deny.toml index 6202726..881cb74 100644 --- a/deny.toml +++ b/deny.toml @@ -28,10 +28,22 @@ allow = [ # Permissive, OSI-approved, and no more restrictive than BSD-3-Clause below. "BSD-2-Clause", "BSD-3-Clause", + # Reached via webpki-roots, the Mozilla CA bundle rustls uses in the verifier client. + "CDLA-Permissive-2.0", + # Reached via webpki, which validates the Nitro attestation chain. Matches + # worldcoin/bedrock, whose verifier this port is derived from. + "ISC", "MIT", "Unicode-3.0", ] +# cargo-deny cannot infer webpki's ISC license from its text. Copied from bedrock's deny.toml. +[[licenses.clarify]] +name = "webpki" +version = "0.22.4" +expression = "ISC" +license-files = [{ path = "LICENSE", hash = 0x001c7e6c }] + [sources] unknown-registry = "deny" unknown-git = "deny" diff --git a/e2e/enclave-match-e2e/Cargo.toml b/e2e/enclave-match-e2e/Cargo.toml index 87302a6..d7f4a39 100644 --- a/e2e/enclave-match-e2e/Cargo.toml +++ b/e2e/enclave-match-e2e/Cargo.toml @@ -16,3 +16,4 @@ serde.workspace = true serde_bytes.workspace = true sha2.workspace = true tokio.workspace = true +verifier-client.workspace = true diff --git a/e2e/enclave-match-e2e/src/main.rs b/e2e/enclave-match-e2e/src/main.rs index a8da41e..8a42f3d 100644 --- a/e2e/enclave-match-e2e/src/main.rs +++ b/e2e/enclave-match-e2e/src/main.rs @@ -1,10 +1,11 @@ -use std::{env, fs, path::PathBuf}; +use std::{env, fs, path::PathBuf, time::SystemTime}; use anyhow::{Context, Result, anyhow, ensure}; use enclave_types::{GetEnclaveKeysRequest, MatchRequest}; -use pontifex::{SecureModule, client::ConnectionDetails}; +use pontifex::client::ConnectionDetails; use serde::Serialize; use sha2::{Digest, Sha256}; +use verifier_client::{Config, FaceVerifierClient}; const DEFAULT_ENCLAVE_PORT: u32 = 1000; const DEFAULT_MATCH_THRESHOLD: f32 = 0.9; @@ -38,17 +39,28 @@ async fn main() -> Result<()> { let match_threshold = optional_f32("MATCH_THRESHOLD", DEFAULT_MATCH_THRESHOLD)?; let connection = ConnectionDetails::new(enclave_cid, enclave_port); + let config = load_config()?; + let verifier = config.verifier(); + let keys_response = pontifex::client::send(connection, &GetEnclaveKeysRequest) .await .context("failed to call the enclave keys route")? .map_err(|error| anyhow!("enclave rejected the enclave-keys request: {error:?}"))?; - let encryption_key = - attested_public_key(&keys_response.encryption_key_attestation, "encryption")?; + + let encryption_key = FaceVerifierClient::new(config) + .context("failed to build the assignment client")? + .request_assignment(SystemTime::now()) + .await + .context("enclave assignment did not verify")? + .enclave_public_key; ensure!( encryption_key.len() == 32, "attested encryption public key was not 32 bytes" ); - let signing_key = attested_public_key(&keys_response.signing_key_attestation, "signing")?; + let signing_key = verifier + .verify(&keys_response.signing_key_attestation, SystemTime::now()) + .context("the signing-key attestation document did not verify")? + .enclave_public_key; ensure!( signing_key.len() == 32, "attested signing public key was not 32 bytes" @@ -97,19 +109,14 @@ async fn main() -> Result<()> { Ok(()) } -/// Extracts the `public_key` an attestation document commits to. -/// -/// Parsing only: the COSE signature, the chain to the AWS Nitro root, and the expected -/// PCRs are a real client's job, and this harness is not one. -fn attested_public_key(document: &[u8], label: &str) -> Result> { - let attestation = SecureModule::parse_raw_attestation_doc(document).map_err(|error| { - anyhow!("failed to parse the {label}-key attestation document: {error:?}") - })?; - let public_key = attestation - .public_key - .with_context(|| format!("{label}-key attestation did not contain a public key"))?; - - Ok(public_key.into_vec()) +/// Loads the client configuration named by `VERIFIER_CONFIG`. Schema is in the README. +fn load_config() -> Result { + let path = env::var("VERIFIER_CONFIG") + .context("VERIFIER_CONFIG must name a JSON client configuration file")?; + let json = fs::read_to_string(&path) + .with_context(|| format!("failed to read the client config at {path}"))?; + + Config::from_json(&json).with_context(|| format!("{path} is not a valid client config")) } struct ImagePaths {