diff --git a/Cargo.lock b/Cargo.lock index c8e7775b..99831f7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,12 +77,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -128,7 +122,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 1.0.63", "time", ] @@ -155,6 +149,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "asn1_der" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4858a9d740c5007a9069007c3b4e91152d0506f13c1b31dd49051fd537656156" + [[package]] name = "async-stream" version = "0.3.6" @@ -461,11 +461,11 @@ dependencies = [ "http-body 1.0.1", "httparse", "hyper 0.14.30", - "hyper-rustls", + "hyper-rustls 0.24.2", "once_cell", "pin-project-lite", "pin-utils", - "rustls", + "rustls 0.21.12", "tokio", "tracing", ] @@ -612,10 +612,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" dependencies = [ "futures-core", - "getrandom", + "getrandom 0.2.15", "instant", "pin-project-lite", - "rand", + "rand 0.8.5", "tokio", ] @@ -634,6 +634,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base58ck" version = "0.1.0" @@ -684,7 +690,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36915bbaca237c626689b5bd14d02f2ba7a5a359d30a2a08be697392e3718079" dependencies = [ - "thiserror", + "thiserror 1.0.63", ] [[package]] @@ -828,6 +834,18 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake2" version = "0.10.6" @@ -855,6 +873,29 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "bstr" version = "1.12.0" @@ -872,6 +913,12 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + [[package]] name = "byteorder" version = "1.5.0" @@ -947,17 +994,16 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -1009,6 +1055,41 @@ dependencies = [ "digest", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1034,11 +1115,35 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" @@ -1046,6 +1151,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[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.6" @@ -1053,7 +1170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -1155,6 +1272,65 @@ dependencies = [ "generic-array", ] +[[package]] +name = "dcap-qvl" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e7842b81018f3b991dc65ec0a95ff347332de58478c4ac43459095af00cc89" +dependencies = [ + "anyhow", + "asn1_der", + "base64 0.22.1", + "borsh", + "byteorder", + "chrono", + "const-oid", + "dcap-qvl-webpki", + "der", + "derive_more 2.1.1", + "futures", + "hex", + "log", + "parity-scale-codec", + "pem", + "reqwest 0.12.28", + "ring", + "rustls-pki-types", + "scale-info", + "serde", + "serde-human-bytes", + "serde_json", + "tracing", + "urlencoding", + "wasm-bindgen-futures", + "x509-cert", +] + +[[package]] +name = "dcap-qvl-webpki" +version = "0.103.4+dcap.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0af040afe66c4f26ca05f308482d98bd75a35a80a227d877c2e28c9947a9fa6" +dependencies = [ + "ring", + "rsa", + "rustls-pki-types", + "untrusted", +] + +[[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-parser" version = "8.2.0" @@ -1169,6 +1345,17 @@ dependencies = [ "rusticata-macros", ] +[[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.106", +] + [[package]] name = "deranged" version = "0.3.11" @@ -1178,6 +1365,49 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.106", + "unicode-xid", +] + [[package]] name = "diesel" version = "2.2.2" @@ -1240,6 +1470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1275,6 +1506,20 @@ dependencies = [ "syn 2.0.106", ] +[[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", + "spki", +] + [[package]] name = "ecow" version = "0.2.2" @@ -1290,6 +1535,27 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[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", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.34" @@ -1299,6 +1565,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1331,12 +1609,28 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "fnv" version = "1.0.7" @@ -1360,13 +1654,19 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.31" @@ -1470,6 +1770,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1485,6 +1786,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "ghash" version = "0.5.1" @@ -1516,11 +1831,22 @@ dependencies = [ "parking_lot", "portable-atomic", "quanta", - "rand", + "rand 0.8.5", "smallvec", "spinning_top", ] +[[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 = "h2" version = "0.3.26" @@ -1613,6 +1939,61 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1746,14 +2127,31 @@ dependencies = [ "http 0.2.12", "hyper 0.14.30", "log", - "rustls", + "rustls 0.21.12", "rustls-native-certs", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", ] [[package]] -name = "hyper-tls" +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.1.0", + "hyper 1.7.0", + "hyper-util", + "rustls 0.23.36", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-tls" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" @@ -1954,19 +2352,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1983,6 +2371,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "indexmap" version = "2.11.4" @@ -2023,6 +2422,18 @@ dependencies = [ "libc", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.7", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -2047,9 +2458,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -2082,7 +2493,7 @@ dependencies = [ "ciborium", "hmac", "lazy_static", - "rand_core", + "rand_core 0.6.4", "secp256k1", "serde", "serde_json", @@ -2106,6 +2517,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -2143,9 +2557,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "matchers" @@ -2230,6 +2650,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "multer" version = "3.1.0" @@ -2332,6 +2769,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2347,6 +2800,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2354,6 +2818,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2364,15 +2829,15 @@ checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" dependencies = [ "base64 0.13.1", "chrono", - "getrandom", + "getrandom 0.2.15", "http 0.2.12", - "rand", + "rand 0.8.5", "reqwest 0.11.27", "serde", "serde_json", "serde_path_to_error", "sha2", - "thiserror", + "thiserror 1.0.63", "url", ] @@ -2396,9 +2861,13 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "opaque-debug" @@ -2431,13 +2900,15 @@ dependencies = [ "cbc", "chacha20poly1305", "chrono", + "dcap-qvl", "diesel", "diesel-derive-enum", "dotenv", "futures", "generic-array", - "getrandom", + "getrandom 0.2.15", "hex", + "hkdf", "hmac", "hyper 0.14.30", "hyper-tls 0.5.0", @@ -2446,8 +2917,9 @@ dependencies = [ "lazy_static", "oauth2", "once_cell", + "p384", "password-auth", - "rand_core", + "rand_core 0.6.4", "rcgen", "regex", "reqwest 0.11.27", @@ -2458,8 +2930,9 @@ dependencies = [ "serde_cbor", "serde_json", "sha2", + "sha3", "subtle", - "thiserror", + "thiserror 1.0.63", "tiktoken-rs", "tokio", "tokio-stream", @@ -2531,6 +3004,46 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[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 = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -2561,9 +3074,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524" dependencies = [ "argon2", - "getrandom", + "getrandom 0.2.15", "password-hash", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2573,7 +3086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2587,11 +3100,20 @@ dependencies = [ "serde", ] +[[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.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" @@ -2625,6 +3147,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.30" @@ -2684,6 +3227,24 @@ dependencies = [ "vcpkg", ] +[[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-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2730,6 +3291,61 @@ dependencies = [ "winapi", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls 0.23.36", + "socket2 0.6.0", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls 0.23.36", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.0", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.41" @@ -2739,6 +3355,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r2d2" version = "0.8.10" @@ -2750,6 +3372,12 @@ dependencies = [ "scheduled-thread-pool", ] +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -2757,8 +3385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -2768,7 +3406,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -2777,7 +3425,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -2876,7 +3533,7 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.30", - "hyper-rustls", + "hyper-rustls 0.24.2", "hyper-tls 0.5.0", "ipnet", "js-sys", @@ -2886,7 +3543,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", + "rustls 0.21.12", "rustls-pemfile", "serde", "serde_json", @@ -2895,36 +3552,43 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 0.25.4", "winreg", ] [[package]] name = "reqwest" -version = "0.12.23" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", + "hickory-resolver", "http 1.1.0", "http-body 1.0.1", "http-body-util", "hyper 1.7.0", + "hyper-rustls 0.27.7", "hyper-tls 0.6.0", "hyper-util", "js-sys", "log", "native-tls", + "once_cell", "percent-encoding", "pin-project-lite", + "quinn", + "rustls 0.23.36", "rustls-pki-types", "serde", "serde_json", @@ -2932,13 +3596,15 @@ dependencies = [ "sync_wrapper 1.0.1", "tokio", "tokio-native-tls", + "tokio-rustls 0.26.4", "tower 0.5.2", - "tower-http 0.6.6", + "tower-http 0.6.8", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.6", ] [[package]] @@ -2950,10 +3616,26 @@ dependencies = [ "ecow", "governor", "maybe-async", - "rand", - "reqwest 0.12.23", + "rand 0.8.5", + "reqwest 0.12.28", "serde", - "thiserror", + "thiserror 1.0.63", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", ] [[package]] @@ -2964,13 +3646,34 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.15", "libc", "spin", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -2983,6 +3686,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3022,10 +3731,24 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", "ring", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + [[package]] name = "rustls-native-certs" version = "0.6.3" @@ -3049,9 +3772,13 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.9.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] [[package]] name = "rustls-webpki" @@ -3063,6 +3790,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3075,6 +3813,31 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "scale-info" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" +dependencies = [ + "bitvec", + "cfg-if", + "derive_more 1.0.0", + "parity-scale-codec", + "scale-info-derive", +] + +[[package]] +name = "scale-info-derive" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "schannel" version = "0.1.23" @@ -3109,6 +3872,20 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "secp256k1" version = "0.29.0" @@ -3116,7 +3893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e0cc0f1cf93f4969faf3ea1c7d8a9faed25918d96affa959720823dfe86d4f3" dependencies = [ "bitcoin_hashes 0.14.0", - "rand", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -3169,6 +3946,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-human-bytes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a091af6294712930d01e375cce513e4ac416f823e033e8991ec4e5d6e6ef4c0" +dependencies = [ + "base64 0.13.1", + "hex", + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.15" @@ -3210,15 +3998,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -3282,6 +4071,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 = "simple_asn1" version = "0.6.2" @@ -3290,7 +4089,7 @@ checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 1.0.63", "time", ] @@ -3344,6 +4143,16 @@ dependencies = [ "lock_api", ] +[[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.0" @@ -3443,6 +4252,18 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.12.0" @@ -3462,7 +4283,16 @@ version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.63", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -3476,6 +4306,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -3498,7 +4339,7 @@ dependencies = [ "fancy-regex", "lazy_static", "parking_lot", - "rustc-hash", + "rustc-hash 1.1.0", ] [[package]] @@ -3604,7 +4445,17 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls", + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.36", "tokio", ] @@ -3632,6 +4483,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -3681,9 +4562,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.6.0", "bytes", @@ -3783,12 +4664,6 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" -[[package]] -name = "unicode-bidi" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" - [[package]] name = "unicode-ident" version = "1.0.19" @@ -3804,6 +4679,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3828,14 +4709,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.2" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", - "idna 0.5.0", + "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -3862,7 +4744,7 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" dependencies = [ - "getrandom", + "getrandom 0.2.15", "serde", ] @@ -3872,7 +4754,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" dependencies = [ - "idna 1.0.3", + "idna", "once_cell", "regex", "serde", @@ -3946,39 +4828,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "wasm-bindgen" -version = "0.2.104" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" +name = "wasm-bindgen" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.106", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -3987,9 +4865,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3997,31 +4875,41 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn 2.0.106", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -4033,6 +4921,21 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -4064,6 +4967,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.48.0" @@ -4212,6 +5121,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" @@ -4222,6 +5140,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "write16" version = "1.0.0" @@ -4234,6 +5158,15 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -4241,11 +5174,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] +[[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", +] + [[package]] name = "x509-parser" version = "0.15.1" @@ -4259,7 +5203,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror", + "thiserror 1.0.63", "time", ] @@ -4385,3 +5329,9 @@ dependencies = [ "quote", "syn 2.0.106", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 714f347a..b6281b07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ dotenv = "0.15.0" aes-gcm = "0.10.1" aes-siv = "0.7" hmac = "0.12.1" +hkdf = "0.12" generic-array = "0.14" cbc = "0.1.2" secp256k1 = { version = "0.29.0", features = ["rand"] } @@ -50,6 +51,7 @@ tokio-stream = "0.1" async-stream = "0.3" bytes = "1.0" sha2 = { version = "0.10", default-features = false } +sha3 = "0.10" hex = "0.4.3" base64 = "0.22.1" vsock = "0.5.1" @@ -75,3 +77,7 @@ lazy_static = "1.4.0" subtle = "2.6.1" tiktoken-rs = "0.5" once_cell = "1.19" +dcap-qvl = { version = "0.3.12", default-features = false, features = ["std", "report", "ring"] } + +[dev-dependencies] +p384 = { version = "0.13", features = ["ecdsa", "pkcs8"] } diff --git a/docs/nitro-deploy.md b/docs/nitro-deploy.md index 8426a8ba..ea2c80dd 100644 --- a/docs/nitro-deploy.md +++ b/docs/nitro-deploy.md @@ -1328,6 +1328,134 @@ sudo systemctl restart vsock-tinfoil-router-inf9.service sudo systemctl restart vsock-tinfoil-router-inf10.service ``` +## Vsock Near.AI proxies +Create vsock proxy services so that the enclave can talk to Near.AI and its attestation dependencies (NVIDIA NRAS for GPU attestation and Intel PCS for TDX DCAP collateral). + +First configure the endpoints into their allowlist: + +```sh +sudo vim /etc/nitro_enclaves/vsock-proxy.yaml +``` + +Add these lines: +``` +- {address: cloud-api.near.ai, port: 443} +- {address: nras.attestation.nvidia.com, port: 443} +- {address: api.trustedservices.intel.com, port: 443} +- {address: certificates.trustedservices.intel.com, port: 443} +``` + +Restart the nitro vsock proxy service: +``` +sudo systemctl restart nitro-enclaves-vsock-proxy.service +``` + +#### Near.AI Cloud API +```sh +sudo vim /etc/systemd/system/vsock-near-cloud-api-proxy.service +``` + +Add the following content: +``` +[Unit] +Description=Vsock Near.AI Cloud API Proxy Service +After=network.target + +[Service] +User=root +ExecStart=/usr/bin/vsock-proxy 8042 cloud-api.near.ai 443 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +#### NVIDIA NRAS (GPU Attestation) +```sh +sudo vim /etc/systemd/system/vsock-near-nras-proxy.service +``` + +Add the following content: +``` +[Unit] +Description=Vsock NVIDIA NRAS Proxy Service +After=network.target + +[Service] +User=root +ExecStart=/usr/bin/vsock-proxy 8043 nras.attestation.nvidia.com 443 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +#### Intel PCS (DCAP Collateral) +```sh +sudo vim /etc/systemd/system/vsock-near-intel-pcs-proxy.service +``` + +Add the following content: +``` +[Unit] +Description=Vsock Intel PCS Proxy Service +After=network.target + +[Service] +User=root +ExecStart=/usr/bin/vsock-proxy 8044 api.trustedservices.intel.com 443 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +#### Intel Certificates (Root CA CRL) +```sh +sudo vim /etc/systemd/system/vsock-near-intel-certs-proxy.service +``` + +Add the following content: +``` +[Unit] +Description=Vsock Intel Certificates Proxy Service +After=network.target + +[Service] +User=root +ExecStart=/usr/bin/vsock-proxy 8045 certificates.trustedservices.intel.com 443 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Activate all the services: + +```sh +sudo systemctl daemon-reload +sudo systemctl enable vsock-near-cloud-api-proxy.service +sudo systemctl start vsock-near-cloud-api-proxy.service +sudo systemctl status vsock-near-cloud-api-proxy.service +sudo systemctl enable vsock-near-nras-proxy.service +sudo systemctl start vsock-near-nras-proxy.service +sudo systemctl status vsock-near-nras-proxy.service +sudo systemctl enable vsock-near-intel-pcs-proxy.service +sudo systemctl start vsock-near-intel-pcs-proxy.service +sudo systemctl status vsock-near-intel-pcs-proxy.service +sudo systemctl enable vsock-near-intel-certs-proxy.service +sudo systemctl start vsock-near-intel-certs-proxy.service +sudo systemctl status vsock-near-intel-certs-proxy.service +``` + +If you need to restart these services: +```sh +sudo systemctl restart vsock-near-cloud-api-proxy.service +sudo systemctl restart vsock-near-nras-proxy.service +sudo systemctl restart vsock-near-intel-pcs-proxy.service +sudo systemctl restart vsock-near-intel-certs-proxy.service +``` + ## KMS Key You need to create an AWS KMS key that the enclave can encrypt/decrypt things to. Name it according to your environment: @@ -1699,6 +1827,27 @@ INSERT INTO enclave_secrets (key, value) VALUES ('os_flags_base_url', decode('your_base64_string', 'base64')); ``` +#### Near.AI API Key + +After the DB is initialized, we need to store the Near.AI Cloud API key encrypted to the enclave KMS key. This key is used for authenticated requests to `cloud-api.near.ai`. + +```sh +echo -n "NEAR_API_KEY" | base64 -w 0 +``` + +Take that output and encrypt to the KMS key, from a machine that has encrypt access to the key: + +```sh +aws kms encrypt --key-id "KEY_ARN" --plaintext "BASE64_KEY" --query CiphertextBlob --output text +``` + +Take that encrypted base64 and insert it into the `enclave_secrets` table with key as `near_api_key` and value as the base64. + +```sql +INSERT INTO enclave_secrets (key, value) +VALUES ('near_api_key', decode('your_base64_string', 'base64')); +``` + ## Secrets Manager ### Postgresql diff --git a/entrypoint.sh b/entrypoint.sh index f4e56aaa..443b5aed 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -361,6 +361,13 @@ echo "127.0.0.31 router.inf9.tinfoil.sh" >> /etc/hosts echo "127.0.0.32 router.inf10.tinfoil.sh" >> /etc/hosts log "Added Tinfoil proxy domains to /etc/hosts" +# Add Near.AI + attestation hostnames to /etc/hosts +echo "127.0.0.34 cloud-api.near.ai" >> /etc/hosts +echo "127.0.0.35 nras.attestation.nvidia.com" >> /etc/hosts +echo "127.0.0.21 api.trustedservices.intel.com" >> /etc/hosts +echo "127.0.0.36 certificates.trustedservices.intel.com" >> /etc/hosts +log "Added Near.AI, NRAS, and Intel PCS domains to /etc/hosts" + # Add Kagi Search hostname to /etc/hosts echo "127.0.0.23 kagi.com" >> /etc/hosts log "Added Kagi Search domain to /etc/hosts" @@ -461,6 +468,18 @@ run_forever tf_tinfoil_atc python3 /app/traffic_forwarder.py 127.0.0.25 443 3 80 log "Starting Tinfoil Inference traffic forwarder" run_forever tf_tinfoil_inference python3 /app/traffic_forwarder.py 127.0.0.33 443 3 8041 & +log "Starting Near.AI Cloud API traffic forwarder" +run_forever tf_nearai_cloud_api python3 /app/traffic_forwarder.py 127.0.0.34 443 3 8042 & + +log "Starting NVIDIA NRAS traffic forwarder" +run_forever tf_nras python3 /app/traffic_forwarder.py 127.0.0.35 443 3 8043 & + +log "Starting Intel PCS traffic forwarder" +run_forever tf_intel_pcs python3 /app/traffic_forwarder.py 127.0.0.21 443 3 8044 & + +log "Starting Intel Certificates traffic forwarder" +run_forever tf_intel_certs python3 /app/traffic_forwarder.py 127.0.0.36 443 3 8045 & + log "Starting Tinfoil Router Inf4 traffic forwarder" run_forever tf_tinfoil_router_inf4 python3 /app/traffic_forwarder.py 127.0.0.26 443 3 8034 & @@ -653,6 +672,34 @@ else log "Tinfoil Inference connection failed" fi +log "Testing connection to Near.AI Cloud API:" +if timeout 5 bash -c ' StatusCode::PAYLOAD_TOO_LARGE, ApiError::NotFound => StatusCode::NOT_FOUND, ApiError::UnprocessableEntity => StatusCode::UNPROCESSABLE_ENTITY, + ApiError::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE, ApiError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, }; ( @@ -408,6 +414,7 @@ pub struct AppState { aws_credential_manager: Arc>>, enclave_key: Vec, proxy_router: Arc, + nearai_verifier: Arc, resend_api_key: Option, ephemeral_keys: Arc>>, session_states: Arc>>, @@ -430,6 +437,8 @@ pub struct AppStateBuilder { openai_api_key: Option, openai_api_base: Option, tinfoil_api_base: Option, + nearai_api_key: Option, + nearai_api_base: Option, jwt_secret: Option>, resend_api_key: Option, github_client_secret: Option, @@ -487,6 +496,16 @@ impl AppStateBuilder { self } + pub fn nearai_api_key(mut self, nearai_api_key: Option) -> Self { + self.nearai_api_key = nearai_api_key; + self + } + + pub fn nearai_api_base(mut self, nearai_api_base: String) -> Self { + self.nearai_api_base = Some(nearai_api_base); + self + } + pub fn jwt_secret(mut self, jwt_secret: Vec) -> Self { self.jwt_secret = Some(jwt_secret); self @@ -669,11 +688,25 @@ impl AppStateBuilder { // Initialize the AppleJwtVerifier let apple_jwt_verifier = Arc::new(AppleJwtVerifier::new()); + let nearai_api_base = self + .nearai_api_base + .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()); + + let nearai_verifier = Arc::new(crate::nearai::verifier::NearAiVerifier::new( + nearai_api_base.clone(), + self.nearai_api_key.clone(), + )); + nearai_verifier + .clone() + .spawn_periodic_verification(vec!["zai-org/GLM-5-FP8".to_string()]); + // Initialize the ProxyRouter let proxy_router = Arc::new(ProxyRouter::new( openai_api_base.clone(), self.openai_api_key.clone(), self.tinfoil_api_base.clone(), + Some(nearai_api_base.clone()), + self.nearai_api_key.clone(), )); let (cancellation_tx, _) = tokio::sync::broadcast::channel(1024); @@ -726,6 +759,7 @@ impl AppStateBuilder { aws_credential_manager, enclave_key, proxy_router, + nearai_verifier, resend_api_key: self.resend_api_key, ephemeral_keys: Arc::new(RwLock::new(HashMap::new())), session_states: Arc::new(tokio::sync::RwLock::new(HashMap::new())), @@ -1865,6 +1899,45 @@ async fn retrieve_openai_api_key( } } +async fn retrieve_near_api_key( + aws_credential_manager: Arc>>, + db: Arc, +) -> Result, Error> { + let creds = aws_credential_manager + .read() + .await + .clone() + .expect("non-local mode should have creds") + .get_credentials() + .await + .expect("non-local mode should have creds"); + + let existing_key = db.get_enclave_secret_by_key(NEAR_API_KEY_NAME)?; + let Some(ref encrypted_key) = existing_key else { + return Ok(None); + }; + + let base64_encrypted_key = general_purpose::STANDARD.encode(&encrypted_key.value); + + let decrypted_bytes = decrypt_with_kms( + &creds.region, + &creds.access_key_id, + &creds.secret_access_key, + &creds.token, + &base64_encrypted_key, + ) + .map_err(|e| Error::EncryptionError(e.to_string()))?; + + let key = String::from_utf8(decrypted_bytes) + .map_err(|e| Error::EncryptionError(format!("Failed to decode UTF-8: {}", e)))?; + + if key.trim().is_empty() { + Ok(None) + } else { + Ok(Some(key)) + } +} + async fn get_or_create_jwt_secret( app_mode: &AppMode, aws_credential_manager: Arc>>, @@ -2592,6 +2665,15 @@ async fn main() -> Result<(), Error> { // Tinfoil API base is always from environment (like OpenAI API base) let tinfoil_api_base = env::var("TINFOIL_API_BASE").ok(); + let nearai_api_base = + env::var("NEARAI_API_BASE").unwrap_or_else(|_| "https://cloud-api.near.ai".to_string()); + + let nearai_api_key = if app_mode != AppMode::Local { + retrieve_near_api_key(aws_credential_manager.clone(), db.clone()).await? + } else { + std::env::var("NEAR_API_KEY").ok() + }; + let jwt_secret = get_or_create_jwt_secret( &app_mode, aws_credential_manager.clone(), @@ -2725,6 +2807,8 @@ async fn main() -> Result<(), Error> { .openai_api_key(openai_api_key) .openai_api_base(openai_api_base) .tinfoil_api_base(tinfoil_api_base) + .nearai_api_key(nearai_api_key) + .nearai_api_base(nearai_api_base) .jwt_secret(jwt_secret) .resend_api_key(resend_api_key) .github_client_secret(github_client_secret.clone()) diff --git a/src/nearai/attestation.rs b/src/nearai/attestation.rs new file mode 100644 index 00000000..ebc7faca --- /dev/null +++ b/src/nearai/attestation.rs @@ -0,0 +1,376 @@ +use crate::nearai::error::NearAiError; +use crate::nearai::models::AttestationInfo; +use dcap_qvl::quote::{Report, TDReport10}; +use dcap_qvl::verify::ring::verify as verify_tdx; +use secp256k1::PublicKey; +use sha2::{Digest, Sha256}; +use sha3::Keccak256; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tracing::{debug, warn}; + +pub const INTEL_PCCS_URL: &str = "https://api.trustedservices.intel.com/tdx/certification/v4"; + +pub struct VerifiedTdxQuote { + pub report_data: [u8; 64], + pub mr_config_id: [u8; 48], +} + +fn build_collateral_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(Duration::from_secs(180)) + .connect_timeout(Duration::from_secs(10)) + .build() + .expect("reqwest client should build") +} + +pub async fn verify_tdx_quote(intel_quote_hex: &str) -> Result { + let quote_bytes = hex::decode(intel_quote_hex.trim_start_matches("0x"))?; + + let client = build_collateral_client(); + let collateral = + crate::nearai::collateral::get_collateral(&client, INTEL_PCCS_URL, "e_bytes) + .await + .map_err(|e| NearAiError::Tdx(format!("{e:#}")))?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| NearAiError::Tdx(e.to_string()))? + .as_secs(); + + let verified = + verify_tdx("e_bytes, &collateral, now).map_err(|e| NearAiError::Tdx(e.to_string()))?; + + debug!( + "Near.AI TDX verify: status={} platform_status={} qe_status={} advisories={}", + verified.status, + verified.platform_status.status, + verified.qe_status.status, + verified.advisory_ids.len() + ); + + // TCB status policy: accept statuses where the quote is cryptographically valid + // and the platform is genuinely running in a TDX enclave. OutOfDate and + // SWHardeningNeeded are common in production because cloud operators lag behind + // Intel's latest firmware updates. Only "Revoked" (key compromise) is fatal. + // We log warnings for any non-UpToDate status so we have visibility. + const ACCEPTED_STATUSES: &[&str] = &[ + "UpToDate", + "OutOfDate", + "SWHardeningNeeded", + "ConfigurationAndSWHardeningNeeded", + ]; + + if !ACCEPTED_STATUSES.contains(&verified.status.as_str()) { + return Err(NearAiError::Tdx(format!( + "TDX TCB status rejected: status={} platform_status={} qe_status={} advisories={:?}", + verified.status, + verified.platform_status.status, + verified.qe_status.status, + verified.advisory_ids + ))); + } + + if verified.status != "UpToDate" { + warn!( + "Near.AI TDX TCB is not UpToDate: status={} platform_status={} qe_status={} advisories={:?}", + verified.status, + verified.platform_status.status, + verified.qe_status.status, + verified.advisory_ids + ); + } + + extract_verified_tdx_quote(&verified.report) + .ok_or_else(|| NearAiError::Tdx("expected TDX TD report (TD10/TD15)".to_string())) +} + +fn extract_verified_tdx_quote(report: &Report) -> Option { + // TD15 extends TD10. For v1 we only need TD10-common fields. + let td10: &TDReport10 = match report { + Report::TD10(td10) => td10, + Report::TD15(td15) => &td15.base, + _ => return None, + }; + + Some(VerifiedTdxQuote { + report_data: td10.report_data, + mr_config_id: td10.mr_config_id, + }) +} + +pub fn verify_report_data_binding( + report_data: &[u8; 64], + signing_address: &str, + request_nonce: &[u8; 32], +) -> Result<(), NearAiError> { + let signing_address_bytes = parse_eth_address_bytes(signing_address)?; + let mut expected_addr_field = [0u8; 32]; + expected_addr_field[..signing_address_bytes.len()].copy_from_slice(&signing_address_bytes); + + if report_data[..32] != expected_addr_field { + return Err(NearAiError::Attestation( + "TDX report_data does not bind signing_address".to_string(), + )); + } + + if report_data[32..] != request_nonce[..] { + return Err(NearAiError::Attestation( + "TDX report_data does not embed request_nonce".to_string(), + )); + } + + Ok(()) +} + +pub fn normalize_secp256k1_pubkey_hex(pubkey_hex: &str) -> Result { + let bytes = hex::decode(pubkey_hex.trim_start_matches("0x"))?; + let normalized = match bytes.as_slice() { + [0x04, rest @ ..] if rest.len() == 64 => rest.to_vec(), + _ if bytes.len() == 64 => bytes, + _ => { + return Err(NearAiError::Attestation(format!( + "unexpected secp256k1 public key length: {}", + bytes.len() + ))) + } + }; + + Ok(hex::encode(normalized)) +} + +pub fn secp256k1_pubkey_from_64hex(pubkey_hex: &str) -> Result { + let key_bytes = hex::decode(pubkey_hex.trim_start_matches("0x"))?; + let key_65 = if key_bytes.len() == 65 { + key_bytes + } else if key_bytes.len() == 64 { + let mut prefixed = Vec::with_capacity(65); + prefixed.push(0x04); + prefixed.extend_from_slice(&key_bytes); + prefixed + } else { + return Err(NearAiError::Attestation(format!( + "unexpected secp256k1 public key length: {}", + key_bytes.len() + ))); + }; + + Ok(PublicKey::from_slice(&key_65)?) +} + +pub fn verify_signing_pubkey_matches_address( + signing_public_key_hex: &str, + signing_address: &str, +) -> Result { + let normalized_pubkey_hex = normalize_secp256k1_pubkey_hex(signing_public_key_hex)?; + let pubkey_bytes = hex::decode(&normalized_pubkey_hex)?; + + let derived_addr = ethereum_address_from_uncompressed_pubkey_64(&pubkey_bytes); + let expected_addr = parse_eth_address_bytes(signing_address)?; + + if derived_addr != expected_addr { + return Err(NearAiError::Attestation( + "signing_public_key does not match signing_address".to_string(), + )); + } + + Ok(normalized_pubkey_hex) +} + +pub fn verify_compose_manifest( + mr_config_id: &[u8; 48], + info: &AttestationInfo, +) -> Result<(), NearAiError> { + let tcb_info = info + .tcb_info + .as_ref() + .ok_or_else(|| NearAiError::Attestation("missing info.tcb_info".to_string()))?; + + let tcb_info_obj: serde_json::Value = if let Some(s) = tcb_info.as_str() { + serde_json::from_str(s)? + } else { + tcb_info.clone() + }; + + let app_compose = tcb_info_obj + .get("app_compose") + .and_then(|v| v.as_str()) + .ok_or_else(|| NearAiError::Attestation("missing tcb_info.app_compose".to_string()))?; + + let app_compose_obj: serde_json::Value = serde_json::from_str(app_compose).map_err(|e| { + NearAiError::Attestation(format!("tcb_info.app_compose is not valid JSON: {e}")) + })?; + let docker_compose_file = app_compose_obj + .get("docker_compose_file") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + NearAiError::Attestation("tcb_info.app_compose missing docker_compose_file".to_string()) + })?; + if docker_compose_file.trim().is_empty() { + return Err(NearAiError::Attestation( + "tcb_info.app_compose docker_compose_file is empty".to_string(), + )); + } + + let compose_hash = Sha256::digest(app_compose.as_bytes()); + let expected_prefix = format!("01{}", hex::encode(compose_hash)); + let mr_config_hex = hex::encode(mr_config_id); + + if !mr_config_hex.starts_with(&expected_prefix) { + return Err(NearAiError::Attestation( + "mr_config_id does not match app_compose sha256".to_string(), + )); + } + + Ok(()) +} + +fn parse_eth_address_bytes(addr: &str) -> Result<[u8; 20], NearAiError> { + let addr = addr.trim_start_matches("0x"); + let bytes = hex::decode(addr)?; + if bytes.len() != 20 { + return Err(NearAiError::Attestation(format!( + "expected 20-byte Ethereum address, got {} bytes", + bytes.len() + ))); + } + + Ok(bytes.try_into().expect("checked length is 20 bytes")) +} + +fn ethereum_address_from_uncompressed_pubkey_64(pubkey_64: &[u8]) -> [u8; 20] { + // Ethereum address is keccak256(uncompressed_pubkey_without_prefix)[12..]. + let hash = Keccak256::digest(pubkey_64); + hash[12..32].try_into().expect("slice length is 20 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + use dcap_qvl::quote::{TDReport10, TDReport15}; + use secp256k1::{PublicKey, Secp256k1, SecretKey}; + use serde_json::json; + + fn td10_with_fields(report_data: [u8; 64], mr_config_id: [u8; 48]) -> TDReport10 { + TDReport10 { + tee_tcb_svn: [0u8; 16], + mr_seam: [0u8; 48], + mr_signer_seam: [0u8; 48], + seam_attributes: [0u8; 8], + td_attributes: [0u8; 8], + xfam: [0u8; 8], + mr_td: [0u8; 48], + mr_config_id, + mr_owner: [0u8; 48], + mr_owner_config: [0u8; 48], + rt_mr0: [0u8; 48], + rt_mr1: [0u8; 48], + rt_mr2: [0u8; 48], + rt_mr3: [0u8; 48], + report_data, + } + } + + #[test] + fn test_extract_verified_tdx_quote_supports_td10_and_td15() { + let report_data = [0xAAu8; 64]; + let mr_config_id = [0xBBu8; 48]; + let td10 = td10_with_fields(report_data, mr_config_id); + + let r10 = Report::TD10(td10); + let extracted10 = extract_verified_tdx_quote(&r10).expect("td10 should extract"); + assert_eq!(extracted10.report_data, report_data); + assert_eq!(extracted10.mr_config_id, mr_config_id); + + let td15 = TDReport15 { + base: td10, + tee_tcb_svn2: [0u8; 16], + mr_service_td: [0u8; 48], + }; + let r15 = Report::TD15(td15); + let extracted15 = extract_verified_tdx_quote(&r15).expect("td15 should extract"); + assert_eq!(extracted15.report_data, report_data); + assert_eq!(extracted15.mr_config_id, mr_config_id); + } + + #[test] + fn test_verify_report_data_binding_ok_and_mismatch() { + let addr_bytes = [0x11u8; 20]; + let signing_address = format!("0x{}", hex::encode(addr_bytes)); + let nonce = [0x22u8; 32]; + + let mut report_data = [0u8; 64]; + report_data[..20].copy_from_slice(&addr_bytes); + report_data[32..].copy_from_slice(&nonce); + + assert!(verify_report_data_binding(&report_data, &signing_address, &nonce).is_ok()); + + let mut bad_addr = report_data; + bad_addr[0] ^= 0xff; + assert!(verify_report_data_binding(&bad_addr, &signing_address, &nonce).is_err()); + + let mut bad_nonce = report_data; + bad_nonce[63] ^= 0x01; + assert!(verify_report_data_binding(&bad_nonce, &signing_address, &nonce).is_err()); + } + + #[test] + fn test_pubkey_normalization_and_address_binding() { + let mut sk_bytes = [0u8; 32]; + sk_bytes[31] = 1; + let sk = SecretKey::from_slice(&sk_bytes).unwrap(); + let secp = Secp256k1::new(); + let pk = PublicKey::from_secret_key(&secp, &sk); + let pk_uncompressed = pk.serialize_uncompressed(); + + let pk_65_hex = hex::encode(pk_uncompressed); + let pk_64_hex = hex::encode(&pk_uncompressed[1..]); + + assert_eq!( + normalize_secp256k1_pubkey_hex(&pk_65_hex).unwrap(), + pk_64_hex + ); + assert_eq!( + normalize_secp256k1_pubkey_hex(&pk_64_hex).unwrap(), + pk_64_hex + ); + + assert!(secp256k1_pubkey_from_64hex(&pk_65_hex).is_ok()); + assert!(secp256k1_pubkey_from_64hex(&pk_64_hex).is_ok()); + + let derived_addr = ethereum_address_from_uncompressed_pubkey_64(&pk_uncompressed[1..]); + let signing_address = format!("0x{}", hex::encode(derived_addr)); + + let normalized = verify_signing_pubkey_matches_address(&pk_65_hex, &signing_address) + .expect("pubkey should match derived address"); + assert_eq!(normalized, pk_64_hex); + + let wrong_address = "0x0000000000000000000000000000000000000000"; + assert!(verify_signing_pubkey_matches_address(&pk_65_hex, wrong_address).is_err()); + } + + #[test] + fn test_verify_compose_manifest_accepts_object_and_string() { + let docker_compose_file = "version: '3'\nservices:\n app:\n image: example"; + let app_compose = json!({"docker_compose_file": docker_compose_file}).to_string(); + let tcb = json!({"app_compose": app_compose}); + + let info_obj = AttestationInfo { + tcb_info: Some(tcb.clone()), + }; + let info_str = AttestationInfo { + tcb_info: Some(serde_json::Value::String(tcb.to_string())), + }; + + let compose_hash = Sha256::digest(app_compose.as_bytes()); + let mut mr_config_id = [0u8; 48]; + mr_config_id[0] = 0x01; + mr_config_id[1..33].copy_from_slice(&compose_hash); + + assert!(verify_compose_manifest(&mr_config_id, &info_obj).is_ok()); + assert!(verify_compose_manifest(&mr_config_id, &info_str).is_ok()); + + let mut bad = mr_config_id; + bad[1] ^= 0x01; + assert!(verify_compose_manifest(&bad, &info_obj).is_err()); + } +} diff --git a/src/nearai/collateral.rs b/src/nearai/collateral.rs new file mode 100644 index 00000000..c80f52d9 --- /dev/null +++ b/src/nearai/collateral.rs @@ -0,0 +1,259 @@ +// Custom collateral fetching for Intel DCAP TDX quote verification. +// +// This replaces `dcap_qvl::collateral::get_collateral` because that function +// builds its own reqwest client with the `hickory-dns` feature enabled, which +// bypasses /etc/hosts and requires a real DNS server. Inside Nitro enclaves +// there is no DNS server -- all external hosts are resolved via /etc/hosts +// entries pointing to local vsock traffic forwarders. +// +// The logic here is adapted from dcap-qvl 0.3.12 collateral.rs: +// https://crates.io/crates/dcap-qvl/0.3.12 +// Source: src/collateral.rs (get_collateral, get_collateral_for_fmspc_impl, PcsEndpoints) +// +// We only replicate the HTTP fetching and QuoteCollateralV3 assembly. +// All cryptographic verification remains in dcap_qvl::verify::ring::verify. + +use crate::nearai::error::NearAiError; +use dcap_qvl::quote::Quote; +use dcap_qvl::QuoteCollateralV3; +use serde::Deserialize; +use tracing::debug; +use x509_parser::prelude::*; + +#[derive(Deserialize)] +struct TcbInfoResponse { + #[serde(rename = "tcbInfo")] + tcb_info: serde_json::Value, + signature: String, +} + +#[derive(Deserialize)] +struct QeIdentityResponse { + #[serde(rename = "enclaveIdentity")] + enclave_identity: serde_json::Value, + signature: String, +} + +fn url_decode_header(response: &reqwest::Response, name: &str) -> Result { + let value = response + .headers() + .get(name) + .ok_or_else(|| NearAiError::Tdx(format!("missing response header: {name}")))? + .to_str() + .map_err(|e| NearAiError::Tdx(format!("non-ascii header {name}: {e}")))?; + + // URL-decode the header value (Intel PCS percent-encodes certificate chains) + let decoded = percent_decode(value); + Ok(decoded) +} + +fn percent_decode(input: &str) -> String { + let mut result = Vec::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) { + result.push(hi << 4 | lo); + i += 3; + continue; + } + } + result.push(bytes[i]); + i += 1; + } + String::from_utf8(result).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).to_string()) +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +/// Fetch DCAP collateral from Intel PCS using the provided reqwest client. +/// +/// This is a drop-in replacement for `dcap_qvl::collateral::get_collateral` +/// that accepts an externally-built reqwest client (without hickory-dns). +pub async fn get_collateral( + client: &reqwest::Client, + pccs_url: &str, + quote_bytes: &[u8], +) -> Result { + let parsed_quote = Quote::parse(quote_bytes).map_err(|e| NearAiError::Tdx(format!("{e:#}")))?; + + let fmspc = hex::encode_upper( + parsed_quote + .fmspc() + .map_err(|e| NearAiError::Tdx(format!("{e:#}")))?, + ); + let ca = parsed_quote + .ca() + .map_err(|e| NearAiError::Tdx(format!("{e:#}")))?; + let tee = if parsed_quote.header.is_sgx() { + "sgx" + } else { + "tdx" + }; + + let base = pccs_url + .trim_end_matches('/') + .trim_end_matches("/sgx/certification/v4") + .trim_end_matches("/tdx/certification/v4"); + + let mk_url = + |t: &str, path: &str| -> String { format!("{}/{}/certification/v4/{}", base, t, path) }; + + // PCK CRL + let pckcrl_url = mk_url("sgx", &format!("pckcrl?ca={}&encoding=der", ca)); + let response = client + .get(&pckcrl_url) + .send() + .await + .map_err(|e| NearAiError::Tdx(format!("PCK CRL fetch failed: {e:#}")))?; + let pck_crl_issuer_chain = url_decode_header(&response, "SGX-PCK-CRL-Issuer-Chain")?; + let pck_crl = response + .bytes() + .await + .map_err(|e| NearAiError::Tdx(format!("PCK CRL read failed: {e:#}")))? + .to_vec(); + + // TCB Info + let tcb_url = mk_url(tee, &format!("tcb?fmspc={}", fmspc)); + let response = client + .get(&tcb_url) + .send() + .await + .map_err(|e| NearAiError::Tdx(format!("TCB Info fetch failed: {e:#}")))?; + let tcb_info_issuer_chain = url_decode_header(&response, "SGX-TCB-Info-Issuer-Chain") + .or_else(|_| url_decode_header(&response, "TCB-Info-Issuer-Chain"))?; + let raw_tcb_info = response + .text() + .await + .map_err(|e| NearAiError::Tdx(format!("TCB Info read failed: {e:#}")))?; + + // QE Identity + let qe_url = mk_url(tee, "qe/identity?update=standard"); + let response = client + .get(&qe_url) + .send() + .await + .map_err(|e| NearAiError::Tdx(format!("QE Identity fetch failed: {e:#}")))?; + let qe_identity_issuer_chain = + url_decode_header(&response, "SGX-Enclave-Identity-Issuer-Chain")?; + let raw_qe_identity = response + .text() + .await + .map_err(|e| NearAiError::Tdx(format!("QE Identity read failed: {e:#}")))?; + + // Root CA CRL -- try the PCCS rootcacrl endpoint first (works for caching + // services like Phala PCCS). If that fails (Intel PCS doesn't serve it), + // extract the CRL distribution point URL from the root certificate in the + // QE identity issuer chain and fetch it directly. + // See dcap-qvl 0.3.12 collateral.rs lines 353-382 for the original logic. + let root_ca_crl = { + let rootcacrl_url = mk_url("sgx", "rootcacrl"); + let rootcacrl_result = client.get(&rootcacrl_url).send().await; + match rootcacrl_result { + Ok(resp) if resp.status().is_success() => { + let bytes = resp + .bytes() + .await + .map_err(|e| NearAiError::Tdx(format!("Root CA CRL read failed: {e:#}")))?; + // PCCS returns hex-encoded CRL; try to hex-decode, otherwise use raw DER. + if let Ok(hex_str) = std::str::from_utf8(&bytes) { + hex::decode(hex_str).unwrap_or_else(|_| bytes.to_vec()) + } else { + bytes.to_vec() + } + } + _ => { + debug!("rootcacrl endpoint unavailable, extracting CRL URL from issuer chain"); + let crl_url = extract_root_ca_crl_url(&qe_identity_issuer_chain)?; + let resp = + client.get(&crl_url).send().await.map_err(|e| { + NearAiError::Tdx(format!("Root CA CRL fetch failed: {e:#}")) + })?; + if !resp.status().is_success() { + return Err(NearAiError::Tdx(format!( + "Root CA CRL fetch from {} returned {}", + crl_url, + resp.status() + ))); + } + resp.bytes() + .await + .map_err(|e| NearAiError::Tdx(format!("Root CA CRL read failed: {e:#}")))? + .to_vec() + } + } + }; + + // Parse TCB Info + let tcb_info_resp: TcbInfoResponse = serde_json::from_str(&raw_tcb_info)?; + let tcb_info = tcb_info_resp.tcb_info.to_string(); + let tcb_info_signature = hex::decode(&tcb_info_resp.signature) + .map_err(|e| NearAiError::Tdx(format!("TCB Info signature hex decode failed: {e}")))?; + + // Parse QE Identity + let qe_identity_resp: QeIdentityResponse = serde_json::from_str(&raw_qe_identity)?; + let qe_identity = qe_identity_resp.enclave_identity.to_string(); + let qe_identity_signature = hex::decode(&qe_identity_resp.signature) + .map_err(|e| NearAiError::Tdx(format!("QE Identity signature hex decode failed: {e}")))?; + + // PCK certificate chain (cert_type 5 embeds it in the quote) + let pck_certificate_chain = parsed_quote + .raw_cert_chain() + .ok() + .map(|chain| String::from_utf8_lossy(chain).to_string()); + + Ok(QuoteCollateralV3 { + pck_crl_issuer_chain, + root_ca_crl, + pck_crl, + tcb_info_issuer_chain, + tcb_info, + tcb_info_signature, + qe_identity_issuer_chain, + qe_identity, + qe_identity_signature, + pck_certificate_chain, + }) +} + +/// Extract the CRL distribution point URL from the root certificate in a PEM +/// issuer chain. The root cert is the last in the chain. This mirrors dcap-qvl's +/// `extract_crl_url` (collateral.rs lines 116-145). +fn extract_root_ca_crl_url(issuer_chain_pem: &str) -> Result { + let pem_certs: Vec<_> = Pem::iter_from_buffer(issuer_chain_pem.as_bytes()) + .collect::, _>>() + .map_err(|e| NearAiError::Tdx(format!("failed to parse PEM issuer chain: {e}")))?; + + let root_pem = pem_certs + .last() + .ok_or_else(|| NearAiError::Tdx("empty issuer chain".to_string()))?; + + let (_, cert) = X509Certificate::from_der(root_pem.contents.as_ref()) + .map_err(|e| NearAiError::Tdx(format!("failed to parse root cert DER: {e}")))?; + + for ext in cert.extensions() { + if let ParsedExtension::CRLDistributionPoints(cdp) = ext.parsed_extension() { + for dp in cdp.iter() { + if let Some(DistributionPointName::FullName(names)) = &dp.distribution_point { + for name in names { + if let GeneralName::URI(uri) = name { + return Ok(uri.to_string()); + } + } + } + } + } + } + + Err(NearAiError::Tdx( + "no CRL distribution point found in root certificate".to_string(), + )) +} diff --git a/src/nearai/e2ee.rs b/src/nearai/e2ee.rs new file mode 100644 index 00000000..6e4a7aa3 --- /dev/null +++ b/src/nearai/e2ee.rs @@ -0,0 +1,548 @@ +use crate::nearai::attestation::secp256k1_pubkey_from_64hex; +use crate::nearai::error::NearAiError; +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use hkdf::Hkdf; +use secp256k1::ecdh::shared_secret_point; +use secp256k1::{PublicKey, Secp256k1, SecretKey}; +use serde_json::Value; +use sha2::Sha256; +use tracing::{debug, trace, warn}; + +const HKDF_INFO: &[u8] = b"ecdsa_encryption"; +const MIN_CIPHERTEXT_BYTES: usize = 65 + 12 + 16; + +#[derive(Clone)] +pub struct NearAiResponseCrypto { + pub client_public_key_hex: String, + client_secret_key: SecretKey, +} + +impl NearAiResponseCrypto { + fn decrypt_field(&self, encrypted_hex: &str) -> Result { + let decrypted = decrypt_ecies_hex(encrypted_hex, &self.client_secret_key)?; + String::from_utf8(decrypted) + .map_err(|e| NearAiError::Crypto(format!("invalid UTF-8 plaintext: {e}"))) + } +} + +pub fn prepare_e2ee_request( + body: &mut Value, + model_public_key_hex: &str, +) -> Result { + trace!( + "Near.AI E2EE prepare: model_pubkey_hex len={} first16={}", + model_public_key_hex.len(), + &model_public_key_hex[..model_public_key_hex.len().min(16)] + ); + let model_pubkey = secp256k1_pubkey_from_64hex(model_public_key_hex)?; + + let secp = Secp256k1::new(); + let client_secret_key = generate_secp256k1_secret_key()?; + let client_pubkey = PublicKey::from_secret_key(&secp, &client_secret_key); + let client_pubkey_uncompressed = client_pubkey.serialize_uncompressed(); + let client_public_key_hex = hex::encode(&client_pubkey_uncompressed[1..]); // strip 0x04 + + trace!( + "Near.AI E2EE prepare: client_pubkey_hex len={} first16={}", + client_public_key_hex.len(), + &client_public_key_hex[..client_public_key_hex.len().min(16)] + ); + + // Encrypt messages[].content (string only). Non-string content is forwarded plaintext. + let Some(messages) = body.get_mut("messages").and_then(|v| v.as_array_mut()) else { + return Err(NearAiError::Crypto("missing messages array".to_string())); + }; + + let mut encrypted_count = 0usize; + let mut skipped_count = 0usize; + for (i, msg) in messages.iter_mut().enumerate() { + let Some(obj) = msg.as_object_mut() else { + continue; + }; + let role = obj + .get("role") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let Some(content_val) = obj.get_mut("content") else { + continue; + }; + + // Flatten text-only array content to a plain string before encryption. + // The responses API wraps text in [{"type":"text","text":"..."}] format, + // but Near.AI E2EE expects content to be a string. + if let Some(flattened) = try_flatten_text_content_array(content_val) { + trace!( + "Near.AI E2EE: flattened text-array content to string for messages[{}] role={}", + i, + role + ); + *content_val = Value::String(flattened); + } + + match content_val { + Value::String(s) => { + let plaintext_len = s.len(); + let plaintext = s.clone(); + let encrypted_hex = encrypt_ecies_hex(plaintext.as_bytes(), &model_pubkey)?; + trace!( + "Near.AI E2EE: encrypted messages[{}] role={} plaintext_len={} ciphertext_hex_len={}", + i, + role, + plaintext_len, + encrypted_hex.len() + ); + *content_val = Value::String(encrypted_hex); + encrypted_count += 1; + } + Value::Null => { + skipped_count += 1; + } + _ => { + return Err(NearAiError::Crypto(format!( + "messages[{}].content (role={}) contains non-text content (e.g. images). \ + Near.AI E2EE only supports text messages.", + i, role + ))); + } + } + } + + debug!( + "Near.AI E2EE prepare: encrypted={} skipped={} total={}", + encrypted_count, + skipped_count, + encrypted_count + skipped_count + ); + + if body.get("tools").is_some() + || body.get("tool_choice").is_some() + || body.get("functions").is_some() + || body.get("function_call").is_some() + { + // v1 limitation: tool schemas and tool-call arguments are forwarded plaintext. + warn!("Near.AI E2EE: leaving tool-related fields in plaintext (v1 limitation)"); + } + + Ok(NearAiResponseCrypto { + client_public_key_hex, + client_secret_key, + }) +} + +pub fn decrypt_chat_completion_json_in_place( + json: &mut Value, + crypto: &NearAiResponseCrypto, +) -> Result<(), NearAiError> { + let Some(choices) = json.get_mut("choices").and_then(|v| v.as_array_mut()) else { + trace!("Near.AI E2EE decrypt: no choices array in response, skipping"); + return Ok(()); + }; + + trace!("Near.AI E2EE decrypt: processing {} choices", choices.len()); + + for (i, choice) in choices.iter_mut().enumerate() { + let Some(choice_obj) = choice.as_object_mut() else { + continue; + }; + + if let Some(msg) = choice_obj.get_mut("message") { + trace!("Near.AI E2EE decrypt: decrypting choices[{}].message", i); + decrypt_message_like_in_place(msg, crypto)?; + } + + if let Some(delta) = choice_obj.get_mut("delta") { + trace!("Near.AI E2EE decrypt: decrypting choices[{}].delta", i); + decrypt_message_like_in_place(delta, crypto)?; + } + } + + Ok(()) +} + +fn decrypt_message_like_in_place( + val: &mut Value, + crypto: &NearAiResponseCrypto, +) -> Result<(), NearAiError> { + let Some(obj) = val.as_object_mut() else { + return Ok(()); + }; + + for field in ["content", "reasoning_content", "reasoning"] { + if let Some(v) = obj.get_mut(field) { + decrypt_string_field_in_place(v, crypto)?; + } + } + + Ok(()) +} + +fn decrypt_string_field_in_place( + val: &mut Value, + crypto: &NearAiResponseCrypto, +) -> Result<(), NearAiError> { + match val { + Value::Null => Ok(()), + Value::String(s) => { + if s.is_empty() { + return Ok(()); + } + // Strict: must be valid hex ciphertext and must decrypt. + if !looks_like_hex_ciphertext(s) { + debug!( + "Near.AI E2EE decrypt: field is not hex ciphertext, len={} first32='{}'", + s.len(), + &s[..s.len().min(32)] + ); + return Err(NearAiError::Crypto( + "Near.AI response field did not look like hex ciphertext".to_string(), + )); + } + trace!( + "Near.AI E2EE decrypt: decrypting field, ciphertext_hex_len={}", + s.len() + ); + let plaintext = crypto.decrypt_field(s)?; + trace!( + "Near.AI E2EE decrypt: decrypted OK, plaintext_len={}", + plaintext.len() + ); + *val = Value::String(plaintext); + Ok(()) + } + _ => { + trace!("Near.AI E2EE decrypt: unexpected non-string field type"); + Err(NearAiError::Crypto( + "Near.AI response field had unexpected non-string type".to_string(), + )) + } + } +} + +/// If content is an array where every element is `{"type":"text","text":"..."}`, +/// concatenate the text parts into a single string. Returns None if any element +/// is not a text part (e.g. images), meaning the content is genuinely multimodal. +fn try_flatten_text_content_array(val: &Value) -> Option { + let arr = val.as_array()?; + if arr.is_empty() { + return None; + } + + let mut parts = Vec::new(); + for item in arr { + let obj = item.as_object()?; + let typ = obj.get("type").and_then(|v| v.as_str())?; + if typ != "text" && typ != "input_text" { + return None; + } + let text = obj.get("text").and_then(|v| v.as_str())?; + parts.push(text); + } + + Some(parts.join("\n")) +} + +fn looks_like_hex_ciphertext(s: &str) -> bool { + if s.len() < (MIN_CIPHERTEXT_BYTES * 2) { + return false; + } + if !s.len().is_multiple_of(2) { + return false; + } + s.as_bytes().iter().all(|b| b.is_ascii_hexdigit()) +} + +fn encrypt_ecies_hex( + plaintext: &[u8], + recipient_pubkey: &PublicKey, +) -> Result { + let secp = Secp256k1::new(); + let ephemeral_secret_key = generate_secp256k1_secret_key()?; + let ephemeral_pubkey = PublicKey::from_secret_key(&secp, &ephemeral_secret_key); + let ephemeral_pubkey_uncompressed = ephemeral_pubkey.serialize_uncompressed(); + + let shared_point = shared_secret_point(recipient_pubkey, &ephemeral_secret_key); + let shared_x = &shared_point[..32]; + + let hk = Hkdf::::new(None, shared_x); + let mut aes_key = [0u8; 32]; + hk.expand(HKDF_INFO, &mut aes_key)?; + + let cipher = Aes256Gcm::new_from_slice(&aes_key)?; + let mut nonce_bytes = [0u8; 12]; + getrandom::getrandom(&mut nonce_bytes) + .map_err(|e| NearAiError::Crypto(format!("nonce generation failed: {e}")))?; + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher.encrypt(nonce, plaintext)?; + + let mut out = Vec::with_capacity( + ephemeral_pubkey_uncompressed.len() + nonce_bytes.len() + ciphertext.len(), + ); + out.extend_from_slice(&ephemeral_pubkey_uncompressed); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(hex::encode(out)) +} + +fn decrypt_ecies_hex( + ciphertext_hex: &str, + recipient_secret_key: &SecretKey, +) -> Result, NearAiError> { + let ciphertext = hex::decode(ciphertext_hex.trim())?; + if ciphertext.len() < MIN_CIPHERTEXT_BYTES { + return Err(NearAiError::Crypto("ciphertext too short".to_string())); + } + + let ephemeral_pubkey = PublicKey::from_slice(&ciphertext[..65])?; + let nonce_bytes = &ciphertext[65..77]; + let body = &ciphertext[77..]; + + let shared_point = shared_secret_point(&ephemeral_pubkey, recipient_secret_key); + let shared_x = &shared_point[..32]; + + let hk = Hkdf::::new(None, shared_x); + let mut aes_key = [0u8; 32]; + hk.expand(HKDF_INFO, &mut aes_key)?; + + let cipher = Aes256Gcm::new_from_slice(&aes_key)?; + let nonce = Nonce::from_slice(nonce_bytes); + Ok(cipher.decrypt(nonce, body)?) +} + +fn generate_secp256k1_secret_key() -> Result { + let mut sk_bytes = [0u8; 32]; + loop { + getrandom::getrandom(&mut sk_bytes) + .map_err(|e| NearAiError::Crypto(format!("secret key generation failed: {e}")))?; + if let Ok(sk) = SecretKey::from_slice(&sk_bytes) { + return Ok(sk); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fixed_secret_key(last_byte: u8) -> SecretKey { + let mut sk = [0u8; 32]; + sk[31] = last_byte; + SecretKey::from_slice(&sk).unwrap() + } + + #[test] + fn test_ecies_hex_roundtrip() { + let secp = Secp256k1::new(); + let recipient_sk = fixed_secret_key(2); + let recipient_pk = PublicKey::from_secret_key(&secp, &recipient_sk); + + let plaintext = b"hello world"; + let ciphertext_hex = encrypt_ecies_hex(plaintext, &recipient_pk).unwrap(); + assert!(looks_like_hex_ciphertext(&ciphertext_hex)); + + let decrypted = decrypt_ecies_hex(&ciphertext_hex, &recipient_sk).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_prepare_e2ee_request_encrypts_string_content() { + let secp = Secp256k1::new(); + let model_sk = fixed_secret_key(3); + let model_pk = PublicKey::from_secret_key(&secp, &model_sk); + let model_pk_uncompressed = model_pk.serialize_uncompressed(); + let model_pubkey_hex = hex::encode(&model_pk_uncompressed[1..]); + + let mut body = json!({ + "model": "zai-org/GLM-5-FP8", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "user", "content": ""} + ], + "tools": [{"type": "function", "function": {"name": "foo", "parameters": {}}}] + }); + + let crypto = prepare_e2ee_request(&mut body, &model_pubkey_hex).unwrap(); + + let c0 = body["messages"][0]["content"].as_str().unwrap(); + assert_ne!(c0, "hello"); + assert!(looks_like_hex_ciphertext(c0)); + + let c1 = body["messages"][1]["content"].as_str().unwrap(); + assert_ne!(c1, ""); + assert!(looks_like_hex_ciphertext(c1)); + + assert_eq!(crypto.client_public_key_hex.len(), 128); + assert!(crypto + .client_public_key_hex + .as_bytes() + .iter() + .all(|b| b.is_ascii_hexdigit())); + } + + #[test] + fn test_prepare_e2ee_request_rejects_multimodal_content() { + let secp = Secp256k1::new(); + let model_sk = fixed_secret_key(3); + let model_pk = PublicKey::from_secret_key(&secp, &model_sk); + let model_pk_uncompressed = model_pk.serialize_uncompressed(); + let model_pubkey_hex = hex::encode(&model_pk_uncompressed[1..]); + + let mut body = json!({ + "model": "zai-org/GLM-5-FP8", + "messages": [ + {"role": "user", "content": [ + {"type": "input_text", "text": "this is plaintext"}, + {"type": "input_image", "image_url": "https://example.com/foo.png"} + ]} + ] + }); + + let result = prepare_e2ee_request(&mut body, &model_pubkey_hex); + assert!(result.is_err(), "expected error for multimodal content"); + let msg = result.err().unwrap().to_string(); + assert!( + msg.contains("non-text content"), + "expected non-text content error, got: {msg}" + ); + } + + #[test] + fn test_prepare_e2ee_request_flattens_text_only_array_content() { + let secp = Secp256k1::new(); + let model_sk = fixed_secret_key(3); + let model_pk = PublicKey::from_secret_key(&secp, &model_sk); + let model_pk_uncompressed = model_pk.serialize_uncompressed(); + let model_pubkey_hex = hex::encode(&model_pk_uncompressed[1..]); + + // This is the format the responses API sends: text wrapped in array + let mut body = json!({ + "model": "zai-org/GLM-5-FP8", + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hey"}]}, + {"role": "user", "content": [{"type": "input_text", "text": "also text"}]} + ] + }); + + prepare_e2ee_request(&mut body, &model_pubkey_hex).unwrap(); + + // Both should have been flattened to strings and then encrypted + let c0 = body["messages"][0]["content"].as_str().unwrap(); + assert!( + looks_like_hex_ciphertext(c0), + "text-array content should be flattened and encrypted, got: {}", + &c0[..c0.len().min(40)] + ); + + let c1 = body["messages"][1]["content"].as_str().unwrap(); + assert!( + looks_like_hex_ciphertext(c1), + "input_text-array content should be flattened and encrypted, got: {}", + &c1[..c1.len().min(40)] + ); + } + + #[test] + fn test_try_flatten_text_content_array() { + // Single text part + let val = json!([{"type": "text", "text": "hello"}]); + assert_eq!( + try_flatten_text_content_array(&val), + Some("hello".to_string()) + ); + + // Multiple text parts + let val = json!([ + {"type": "text", "text": "hello"}, + {"type": "text", "text": "world"} + ]); + assert_eq!( + try_flatten_text_content_array(&val), + Some("hello\nworld".to_string()) + ); + + // input_text type also works + let val = json!([{"type": "input_text", "text": "hey"}]); + assert_eq!( + try_flatten_text_content_array(&val), + Some("hey".to_string()) + ); + + // Mixed with image -- not flattenable + let val = json!([ + {"type": "text", "text": "hello"}, + {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}} + ]); + assert_eq!(try_flatten_text_content_array(&val), None); + + // String content -- not an array + let val = json!("hello"); + assert_eq!(try_flatten_text_content_array(&val), None); + + // Empty array + let val = json!([]); + assert_eq!(try_flatten_text_content_array(&val), None); + + // Null + let val = json!(null); + assert_eq!(try_flatten_text_content_array(&val), None); + } + + #[test] + fn test_decrypt_chat_completion_json_in_place_decrypts_fields() { + let secp = Secp256k1::new(); + let model_sk = fixed_secret_key(4); + let model_pk = PublicKey::from_secret_key(&secp, &model_sk); + let model_pk_uncompressed = model_pk.serialize_uncompressed(); + let model_pubkey_hex = hex::encode(&model_pk_uncompressed[1..]); + + let mut req = json!({ + "model": "zai-org/GLM-5-FP8", + "messages": [{"role": "user", "content": "hello"}] + }); + let crypto = prepare_e2ee_request(&mut req, &model_pubkey_hex).unwrap(); + + let client_pk = PublicKey::from_secret_key(&secp, &crypto.client_secret_key); + let enc_content = encrypt_ecies_hex(b"hi", &client_pk).unwrap(); + let enc_reasoning = encrypt_ecies_hex(b"because", &client_pk).unwrap(); + + let mut resp = json!({ + "choices": [{ + "message": {"content": enc_content.clone(), "reasoning_content": enc_reasoning}, + "delta": {"content": enc_content} + }] + }); + + decrypt_chat_completion_json_in_place(&mut resp, &crypto).unwrap(); + + assert_eq!(resp["choices"][0]["message"]["content"], "hi"); + assert_eq!( + resp["choices"][0]["message"]["reasoning_content"], + "because" + ); + assert_eq!(resp["choices"][0]["delta"]["content"], "hi"); + } + + #[test] + fn test_decrypt_rejects_non_ciphertext_strings() { + let secp = Secp256k1::new(); + let model_sk = fixed_secret_key(5); + let model_pk = PublicKey::from_secret_key(&secp, &model_sk); + let model_pk_uncompressed = model_pk.serialize_uncompressed(); + let model_pubkey_hex = hex::encode(&model_pk_uncompressed[1..]); + + let mut req = json!({ + "model": "zai-org/GLM-5-FP8", + "messages": [{"role": "user", "content": "hello"}] + }); + let crypto = prepare_e2ee_request(&mut req, &model_pubkey_hex).unwrap(); + + let mut resp = json!({ + "choices": [{"message": {"content": "plaintext"}}] + }); + + assert!(decrypt_chat_completion_json_in_place(&mut resp, &crypto).is_err()); + } +} diff --git a/src/nearai/error.rs b/src/nearai/error.rs new file mode 100644 index 00000000..5f95a5e9 --- /dev/null +++ b/src/nearai/error.rs @@ -0,0 +1,71 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum NearAiError { + #[error("Near.AI is not configured")] + Unconfigured, + + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + #[error("HTTP {status} from {url}: {body}")] + HttpStatus { + url: String, + status: reqwest::StatusCode, + body: String, + }, + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Hex decode error: {0}")] + Hex(#[from] hex::FromHexError), + + #[error("Crypto error: {0}")] + Crypto(String), + + #[error("Attestation verification error: {0}")] + Attestation(String), + + #[error("TDX quote verification error: {0}")] + Tdx(String), + + #[error("NVIDIA NRAS verification error: {0}")] + Nras(String), + + #[error("JWT verification error: {0}")] + Jwt(String), + + #[error("NRAS JWT kid not found in JWKS: {0}")] + JwtKidNotFound(String), +} + +impl From for NearAiError { + fn from(e: secp256k1::Error) -> Self { + Self::Crypto(e.to_string()) + } +} + +impl From for NearAiError { + fn from(e: aes_gcm::Error) -> Self { + Self::Crypto(e.to_string()) + } +} + +impl From for NearAiError { + fn from(e: hkdf::InvalidLength) -> Self { + Self::Crypto(e.to_string()) + } +} + +impl From for NearAiError { + fn from(e: sha2::digest::InvalidLength) -> Self { + Self::Crypto(e.to_string()) + } +} + +impl From for NearAiError { + fn from(e: jsonwebtoken::errors::Error) -> Self { + Self::Jwt(e.to_string()) + } +} diff --git a/src/nearai/mod.rs b/src/nearai/mod.rs new file mode 100644 index 00000000..b23c3c49 --- /dev/null +++ b/src/nearai/mod.rs @@ -0,0 +1,7 @@ +mod attestation; +mod collateral; +pub mod e2ee; +pub mod error; +mod models; +mod nras; +pub mod verifier; diff --git a/src/nearai/models.rs b/src/nearai/models.rs new file mode 100644 index 00000000..069e8568 --- /dev/null +++ b/src/nearai/models.rs @@ -0,0 +1,33 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct AttestationReport { + pub gateway_attestation: AttestationBaseInfo, + + #[serde(default)] + pub model_attestations: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AttestationBaseInfo { + pub signing_address: String, + + #[serde(default)] + pub signing_public_key: Option, + + #[serde(default)] + pub signing_algo: Option, + + pub intel_quote: String, + + #[serde(default)] + pub nvidia_payload: Option, + + pub info: AttestationInfo, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AttestationInfo { + #[serde(default)] + pub tcb_info: Option, +} diff --git a/src/nearai/nras.rs b/src/nearai/nras.rs new file mode 100644 index 00000000..202eca56 --- /dev/null +++ b/src/nearai/nras.rs @@ -0,0 +1,403 @@ +use crate::nearai::error::NearAiError; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; +use serde::Deserialize; +use serde_json::Value; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const NRAS_GPU_VERIFIER_URL: &str = "https://nras.attestation.nvidia.com/v3/attest/gpu"; +pub const NRAS_JWKS_URL: &str = "https://nras.attestation.nvidia.com/.well-known/jwks.json"; +pub const NRAS_ISSUER_HOST: &str = "nras.attestation.nvidia.com"; +pub const NRAS_ISSUER_HTTPS: &str = "https://nras.attestation.nvidia.com"; + +#[derive(Debug, Clone, Deserialize)] +pub struct NrasJwks { + pub keys: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NrasJwk { + #[serde(default)] + pub kid: Option, + + #[serde(default)] + pub x: Option, + + #[serde(default)] + pub y: Option, +} + +pub async fn fetch_jwks(client: &reqwest::Client) -> Result { + let res = client.get(NRAS_JWKS_URL).send().await?; + let status = res.status(); + let text = res.text().await?; + if !status.is_success() { + return Err(NearAiError::HttpStatus { + url: NRAS_JWKS_URL.to_string(), + status, + body: text, + }); + } + + Ok(serde_json::from_str(&text)?) +} + +pub async fn verify_gpu_attestation( + client: &reqwest::Client, + jwks: &NrasJwks, + nvidia_payload: &Value, + request_nonce_hex: &str, +) -> Result<(), NearAiError> { + let payload_nonce = nvidia_payload + .get("nonce") + .and_then(|v| v.as_str()) + .ok_or_else(|| NearAiError::Nras("missing nvidia_payload.nonce".to_string()))?; + + if payload_nonce.to_lowercase() != request_nonce_hex.to_lowercase() { + return Err(NearAiError::Nras( + "GPU payload nonce does not match request_nonce".to_string(), + )); + } + + let res = client + .post(NRAS_GPU_VERIFIER_URL) + .header("Accept", "application/json") + .json(nvidia_payload) + .send() + .await?; + + let status = res.status(); + let text = res.text().await?; + if !status.is_success() { + return Err(NearAiError::HttpStatus { + url: NRAS_GPU_VERIFIER_URL.to_string(), + status, + body: text, + }); + } + + let body: Value = serde_json::from_str(&text)?; + let jwt = extract_jwt_from_nras_response(&body) + .ok_or_else(|| NearAiError::Nras("unexpected NRAS response format".to_string()))?; + + verify_nras_jwt(&jwt, jwks, request_nonce_hex) +} + +fn extract_jwt_from_nras_response(body: &Value) -> Option { + let arr = body.as_array()?; + + for item in arr { + let Some(pair) = item.as_array() else { + continue; + }; + if pair.len() < 2 { + continue; + } + if let Some(jwt) = pair[1].as_str() { + return Some(jwt.to_string()); + } + } + None +} + +pub fn verify_nras_jwt( + jwt: &str, + jwks: &NrasJwks, + request_nonce_hex: &str, +) -> Result<(), NearAiError> { + let header = decode_header(jwt)?; + let kid = header + .kid + .ok_or_else(|| NearAiError::Jwt("NRAS JWT missing kid".to_string()))?; + + let jwk = jwks + .keys + .iter() + .find(|k| k.kid.as_deref() == Some(kid.as_str())) + .ok_or_else(|| NearAiError::JwtKidNotFound(kid.clone()))?; + + let x = jwk + .x + .as_ref() + .ok_or_else(|| NearAiError::Jwt("NRAS JWK missing x".to_string()))?; + let y = jwk + .y + .as_ref() + .ok_or_else(|| NearAiError::Jwt("NRAS JWK missing y".to_string()))?; + + let key = DecodingKey::from_ec_components(x, y)?; + + let mut validation = Validation::new(Algorithm::ES384); + // NRAS has been observed to emit both `nras.attestation.nvidia.com` and + // `https://nras.attestation.nvidia.com` as `iss`. + validation.set_issuer(&[NRAS_ISSUER_HOST, NRAS_ISSUER_HTTPS]); + validation.validate_aud = false; + validation.validate_nbf = true; + validation.leeway = 60; + + let token = decode::(jwt, &key, &validation)?; + let claims = token.claims; + + if let Some(iat) = claims.get("iat").and_then(|v| v.as_i64()) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| NearAiError::Jwt(format!("system time error: {e}")))? + .as_secs() as i64; + let leeway = validation.leeway as i64; + + if iat > now + leeway { + return Err(NearAiError::Jwt( + "NRAS JWT iat is in the future".to_string(), + )); + } + + if let Some(exp) = claims.get("exp").and_then(|v| v.as_i64()) { + if iat > exp + leeway { + return Err(NearAiError::Jwt("NRAS JWT iat is after exp".to_string())); + } + } + } + + let verdict = match claims.get("x-nvidia-overall-att-result") { + Some(Value::Bool(b)) => *b, + Some(Value::String(s)) => s.eq_ignore_ascii_case("true"), + _ => false, + }; + if !verdict { + return Err(NearAiError::Nras( + "NRAS attestation verdict is false".to_string(), + )); + } + + let nonce_ok = match claims.get("eat_nonce") { + Some(Value::String(s)) => s.to_lowercase() == request_nonce_hex.to_lowercase(), + Some(Value::Array(arr)) => arr.iter().any(|v| { + v.as_str() + .is_some_and(|s| s.to_lowercase() == request_nonce_hex.to_lowercase()) + }), + _ => false, + }; + + if !nonce_ok { + return Err(NearAiError::Nras( + "NRAS eat_nonce does not match request_nonce".to_string(), + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + use p384::ecdsa::SigningKey; + use p384::pkcs8::EncodePrivateKey; + use rand_core::OsRng; + use serde_json::json; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn now_epoch_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_secs() + } + + fn build_jwks_and_jwt(kid: &str, claims: &serde_json::Value) -> (NrasJwks, String) { + let signing_key = SigningKey::random(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + + let pkcs8 = signing_key + .to_pkcs8_der() + .expect("pkcs8 encoding should succeed"); + let encoding_key = EncodingKey::from_ec_der(pkcs8.as_bytes()); + + let mut header = Header::new(Algorithm::ES384); + header.kid = Some(kid.to_string()); + + let token = encode(&header, claims, &encoding_key).expect("JWT encode should succeed"); + + let point = verifying_key.to_encoded_point(false); + let bytes = point.as_bytes(); + assert_eq!(bytes.len(), 97); + assert_eq!(bytes[0], 0x04); + + let x = &bytes[1..49]; + let y = &bytes[49..97]; + let x_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(x); + let y_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(y); + + let jwks = NrasJwks { + keys: vec![NrasJwk { + kid: Some(kid.to_string()), + x: Some(x_b64), + y: Some(y_b64), + }], + }; + + (jwks, token) + } + + #[test] + fn test_extract_jwt_from_nras_response() { + let body = json!([[0, "jwt1"], [1, "jwt2"]]); + assert_eq!(extract_jwt_from_nras_response(&body).unwrap(), "jwt1"); + + let body = json!([[], [0, 1], [0, "jwt3"]]); + assert_eq!(extract_jwt_from_nras_response(&body).unwrap(), "jwt3"); + + let body = json!({"not": "an array"}); + assert!(extract_jwt_from_nras_response(&body).is_none()); + } + + #[test] + fn test_verify_nras_jwt_kid_not_found() { + let header = json!({"alg": "ES384", "kid": "missing"}); + let payload = json!({"iss": NRAS_ISSUER_HOST, "exp": now_epoch_seconds() + 60}); + let token = format!( + "{}.{}.sig", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(header.to_string()), + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.to_string()) + ); + + let jwks = NrasJwks { keys: vec![] }; + let err = verify_nras_jwt(&token, &jwks, "nonce").unwrap_err(); + match err { + NearAiError::JwtKidNotFound(k) => assert_eq!(k, "missing"), + other => panic!("expected JwtKidNotFound, got {other:?}"), + } + } + + #[test] + fn test_verify_nras_jwt_valid_string_nonce() { + let nonce = "abcd1234"; + let now = now_epoch_seconds(); + let claims = json!({ + "iss": NRAS_ISSUER_HOST, + "exp": now + 60, + "iat": now, + "x-nvidia-overall-att-result": true, + "eat_nonce": nonce + }); + + let (jwks, token) = build_jwks_and_jwt("kid1", &claims); + verify_nras_jwt(&token, &jwks, nonce).unwrap(); + } + + #[test] + fn test_verify_nras_jwt_valid_array_nonce() { + let nonce = "abcd1234"; + let now = now_epoch_seconds(); + let claims = json!({ + "iss": NRAS_ISSUER_HOST, + "exp": now + 60, + "iat": now, + "x-nvidia-overall-att-result": true, + "eat_nonce": ["zzz", nonce] + }); + + let (jwks, token) = build_jwks_and_jwt("kid1", &claims); + verify_nras_jwt(&token, &jwks, nonce).unwrap(); + } + + #[test] + fn test_verify_nras_jwt_nonce_mismatch_fails() { + let now = now_epoch_seconds(); + let claims = json!({ + "iss": NRAS_ISSUER_HOST, + "exp": now + 60, + "iat": now, + "x-nvidia-overall-att-result": true, + "eat_nonce": "abcd1234" + }); + + let (jwks, token) = build_jwks_and_jwt("kid1", &claims); + assert!(verify_nras_jwt(&token, &jwks, "different").is_err()); + } + + #[test] + fn test_verify_nras_jwt_verdict_false_fails() { + let nonce = "abcd1234"; + let now = now_epoch_seconds(); + let claims = json!({ + "iss": NRAS_ISSUER_HOST, + "exp": now + 60, + "iat": now, + "x-nvidia-overall-att-result": false, + "eat_nonce": nonce + }); + + let (jwks, token) = build_jwks_and_jwt("kid1", &claims); + assert!(verify_nras_jwt(&token, &jwks, nonce).is_err()); + } + + #[test] + fn test_verify_nras_jwt_verdict_string_true_accepted() { + let nonce = "abcd1234"; + let now = now_epoch_seconds(); + let claims = json!({ + "iss": NRAS_ISSUER_HOST, + "exp": now + 60, + "iat": now, + "x-nvidia-overall-att-result": "true", + "eat_nonce": nonce + }); + + let (jwks, token) = build_jwks_and_jwt("kid1", &claims); + verify_nras_jwt(&token, &jwks, nonce).unwrap(); + } + + #[test] + fn test_verify_nras_jwt_verdict_string_false_rejected() { + let nonce = "abcd1234"; + let now = now_epoch_seconds(); + let claims = json!({ + "iss": NRAS_ISSUER_HOST, + "exp": now + 60, + "iat": now, + "x-nvidia-overall-att-result": "false", + "eat_nonce": nonce + }); + + let (jwks, token) = build_jwks_and_jwt("kid1", &claims); + assert!(verify_nras_jwt(&token, &jwks, nonce).is_err()); + } + + #[test] + fn test_verify_nras_jwt_nbf_in_future_fails() { + let nonce = "abcd1234"; + let now = now_epoch_seconds(); + let claims = json!({ + "iss": NRAS_ISSUER_HOST, + "exp": now + 7200, + "iat": now, + "nbf": now + 3600, + "x-nvidia-overall-att-result": true, + "eat_nonce": nonce + }); + + let (jwks, token) = build_jwks_and_jwt("kid1", &claims); + assert!(verify_nras_jwt(&token, &jwks, nonce).is_err()); + } + + #[test] + fn test_verify_nras_jwt_iat_in_future_fails() { + let nonce = "abcd1234"; + let now = now_epoch_seconds(); + let claims = json!({ + "iss": NRAS_ISSUER_HOST, + "exp": now + 7200, + "iat": now + 3600, + "x-nvidia-overall-att-result": true, + "eat_nonce": nonce + }); + + let (jwks, token) = build_jwks_and_jwt("kid1", &claims); + let err = verify_nras_jwt(&token, &jwks, nonce).unwrap_err(); + match err { + NearAiError::Jwt(s) => assert!(s.to_lowercase().contains("iat")), + other => panic!("expected Jwt error, got {other:?}"), + } + } +} diff --git a/src/nearai/verifier.rs b/src/nearai/verifier.rs new file mode 100644 index 00000000..ce0012e3 --- /dev/null +++ b/src/nearai/verifier.rs @@ -0,0 +1,1164 @@ +use crate::nearai::attestation::{ + verify_compose_manifest, verify_report_data_binding, verify_signing_pubkey_matches_address, + verify_tdx_quote, +}; +use crate::nearai::error::NearAiError; +use crate::nearai::models::{AttestationBaseInfo, AttestationReport}; +use crate::nearai::nras::{fetch_jwks, verify_gpu_attestation, NrasJwks, NRAS_JWKS_URL}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock}; +use tracing::{debug, error, info, trace, warn}; + +const VERIFICATION_TTL: Duration = Duration::from_secs(10 * 60); +const PERIODIC_REVERIFY_INTERVAL: Duration = Duration::from_secs(10 * 60); +const JWKS_TTL: Duration = Duration::from_secs(60 * 60); +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const HTTP_RETRY_ATTEMPTS: usize = 3; +const HTTP_RETRY_BASE_DELAY: Duration = Duration::from_millis(250); + +#[derive(Debug, Clone)] +pub struct VerifiedModelNode { + pub signing_public_key: String, +} + +#[derive(Debug, Clone)] +struct VerifiedModel { + nodes: Vec, + verified_at: Instant, +} + +#[derive(Debug, Clone)] +struct CachedJwks { + jwks: NrasJwks, + fetched_at: Instant, +} + +pub struct NearAiVerifier { + base_url: String, + api_key: Option, + http: reqwest::Client, + cache: RwLock>, + verify_lock: Mutex<()>, + jwks_cache: RwLock>, +} + +impl NearAiVerifier { + pub fn new(base_url: String, api_key: Option) -> Self { + let base_url = normalize_base_url(&base_url); + let http = reqwest::Client::builder() + .timeout(HTTP_REQUEST_TIMEOUT) + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .pool_idle_timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(100) + .user_agent("opensecret/nearai-verifier") + .build() + .expect("reqwest client should build"); + + Self { + base_url, + api_key, + http, + cache: RwLock::new(HashMap::new()), + verify_lock: Mutex::new(()), + jwks_cache: RwLock::new(None), + } + } + + pub fn is_configured(&self) -> bool { + self.api_key.as_ref().is_some_and(|k| !k.trim().is_empty()) + } + + pub fn spawn_periodic_verification(self: Arc, models: Vec) { + if !self.is_configured() { + warn!("Near.AI verifier not configured (missing API key); skipping preflight verification"); + return; + } + + tokio::spawn(async move { + info!("Near.AI preflight verification starting"); + + let mut last_state: HashMap = HashMap::new(); + + for model in &models { + match self.verify_and_cache_model(model).await { + Ok(_) => { + debug!( + "Near.AI preflight verification succeeded for model {}", + model + ); + + let prev = last_state.insert(model.clone(), true); + if prev != Some(true) { + info!( + "Near.AI verification state changed for model {}: {} -> verified", + model, + prev.map(|v| if v { "verified" } else { "failed" }) + .unwrap_or("unknown") + ); + } + } + Err(e) => { + error!( + "Near.AI preflight verification failed for model {}: {}", + model, e + ); + + let prev = last_state.insert(model.clone(), false); + if prev != Some(false) { + info!( + "Near.AI verification state changed for model {}: {} -> failed", + model, + prev.map(|v| if v { "verified" } else { "failed" }) + .unwrap_or("unknown") + ); + } + } + } + } + + loop { + tokio::time::sleep(PERIODIC_REVERIFY_INTERVAL).await; + for model in &models { + match self.verify_and_cache_model(model).await { + Ok(_) => { + debug!( + "Near.AI periodic re-verification succeeded for model {}", + model + ); + + let prev = last_state.insert(model.clone(), true); + if prev == Some(false) { + info!( + "Near.AI verification state changed for model {}: failed -> verified", + model + ); + } + } + Err(e) => { + error!( + "Near.AI periodic re-verification failed for model {}: {}", + model, e + ); + + let prev = last_state.insert(model.clone(), false); + if prev == Some(true) { + info!( + "Near.AI verification state changed for model {}: verified -> failed", + model + ); + } + } + } + } + } + }); + } + + pub async fn get_verified_model_node( + &self, + model: &str, + ) -> Result { + if !self.is_configured() { + return Err(NearAiError::Unconfigured); + } + + // Fast path: fresh cached model. + if let Some(node) = self.try_get_fresh_cached_node(model).await { + trace!( + "Near.AI verifier: cache hit for model={}, node_pubkey={}...", + model, + &node.signing_public_key[..node.signing_public_key.len().min(16)] + ); + return Ok(node); + } + + debug!( + "Near.AI verifier: cache miss for model={}, acquiring verify lock", + model + ); + + // Slow path: ensure only one verification runs at a time. + let _guard = self.verify_lock.lock().await; + + // Re-check after acquiring lock. + if let Some(node) = self.try_get_fresh_cached_node(model).await { + trace!( + "Near.AI verifier: cache hit after lock for model={}, node_pubkey={}...", + model, + &node.signing_public_key[..node.signing_public_key.len().min(16)] + ); + return Ok(node); + } + + info!("Near.AI verifier: verifying model={}", model); + self.verify_and_cache_model(model).await?; + + self.try_get_fresh_cached_node(model).await.ok_or_else(|| { + NearAiError::Attestation("no verified model nodes available".to_string()) + }) + } + + async fn try_get_fresh_cached_node(&self, model: &str) -> Option { + let cache = self.cache.read().await; + let verified = cache.get(model)?; + let age = verified.verified_at.elapsed(); + if age > VERIFICATION_TTL { + trace!( + "Near.AI verifier: cached model={} is stale (age={:.0}s, ttl={:.0}s)", + model, + age.as_secs_f64(), + VERIFICATION_TTL.as_secs_f64() + ); + return None; + } + + if verified.nodes.is_empty() { + trace!("Near.AI verifier: cached model={} has no nodes", model); + return None; + } + + let idx = random_node_index(verified.nodes.len()); + let node = &verified.nodes[idx]; + trace!( + "Near.AI verifier: selecting node {}/{} for model={}, pubkey={}..., cache_age={:.0}s", + idx + 1, + verified.nodes.len(), + model, + &node.signing_public_key[..node.signing_public_key.len().min(16)], + age.as_secs_f64() + ); + Some(verified.nodes[idx].clone()) + } + + pub async fn invalidate_model(&self, model: &str) { + let mut cache = self.cache.write().await; + cache.remove(model); + } + + async fn verify_and_cache_model(&self, model: &str) -> Result<(), NearAiError> { + let verified = self.verify_model(model).await?; + let mut cache = self.cache.write().await; + cache.insert(model.to_string(), verified); + Ok(()) + } + + async fn verify_model(&self, model: &str) -> Result { + let api_key = self.api_key.as_ref().ok_or(NearAiError::Unconfigured)?; + + let nonce_bytes = generate_nonce_bytes()?; + let nonce_hex = hex::encode(nonce_bytes); + + let report = self + .fetch_attestation_report(api_key, model, &nonce_hex) + .await?; + + // Verify gateway attestation (no GPU verification). + self.verify_single_attestation( + &report.gateway_attestation, + &nonce_bytes, + &nonce_hex, + false, + ) + .await?; + + let mut nodes = Vec::new(); + let mut total_nodes = 0usize; + let mut ok_missing_pubkey = 0usize; + let mut last_error: Option = None; + for model_att in &report.model_attestations { + total_nodes += 1; + match self + .verify_single_attestation(model_att, &nonce_bytes, &nonce_hex, true) + .await + { + Ok(Some(node)) => nodes.push(node), + Ok(None) => { + ok_missing_pubkey += 1; + } + Err(e) => { + last_error = Some(e.to_string()); + warn!( + "Near.AI model node attestation failed (model={}, signing_address={}): {}", + model, model_att.signing_address, e + ); + } + } + } + + if nodes.is_empty() { + let mut msg = format!( + "no verified model nodes available (total={}, ok_missing_pubkey={})", + total_nodes, ok_missing_pubkey + ); + if let Some(e) = last_error { + msg.push_str(&format!(", last_error={e}")); + } + return Err(NearAiError::Attestation(msg)); + } + + debug!( + "Near.AI verifier: model={} verified, {} nodes (total_attestations={}, ok_missing_pubkey={})", + model, nodes.len(), total_nodes, ok_missing_pubkey + ); + for (i, node) in nodes.iter().enumerate() { + debug!( + "Near.AI verifier: model={} node[{}] pubkey={}...", + model, + i, + &node.signing_public_key[..node.signing_public_key.len().min(16)] + ); + } + + Ok(VerifiedModel { + nodes, + verified_at: Instant::now(), + }) + } + + async fn fetch_attestation_report( + &self, + api_key: &str, + model: &str, + nonce_hex: &str, + ) -> Result { + let url = format!( + "{}/v1/attestation/report", + self.base_url.trim_end_matches('/') + ); + + for attempt in 1..=HTTP_RETRY_ATTEMPTS { + let mut req = self.http.get(&url).query(&[ + ("model", model), + ("nonce", nonce_hex), + ("signing_algo", "ecdsa"), + ]); + + // Attestation report may be public, but include auth when present. + if !api_key.trim().is_empty() { + req = req.header("Authorization", format!("Bearer {}", api_key)); + } + + let res = match req.send().await { + Ok(res) => res, + Err(e) => { + if attempt < HTTP_RETRY_ATTEMPTS && (e.is_timeout() || e.is_connect()) { + warn!( + "Near.AI attestation report request failed (attempt {}/{}): {}; retrying", + attempt, HTTP_RETRY_ATTEMPTS, e + ); + tokio::time::sleep(HTTP_RETRY_BASE_DELAY * attempt as u32).await; + continue; + } + return Err(NearAiError::Http(e)); + } + }; + + let status = res.status(); + let text = match res.text().await { + Ok(t) => t, + Err(e) => { + if attempt < HTTP_RETRY_ATTEMPTS && (e.is_timeout() || e.is_connect()) { + warn!( + "Near.AI attestation report read failed (attempt {}/{}): {}; retrying", + attempt, HTTP_RETRY_ATTEMPTS, e + ); + tokio::time::sleep(HTTP_RETRY_BASE_DELAY * attempt as u32).await; + continue; + } + return Err(NearAiError::Http(e)); + } + }; + + if status.is_server_error() && attempt < HTTP_RETRY_ATTEMPTS { + warn!( + "Near.AI attestation report returned {} (attempt {}/{}); retrying", + status, attempt, HTTP_RETRY_ATTEMPTS + ); + tokio::time::sleep(HTTP_RETRY_BASE_DELAY * attempt as u32).await; + continue; + } + + if !status.is_success() { + return Err(NearAiError::HttpStatus { + url, + status, + body: text, + }); + } + + return Ok(serde_json::from_str(&text)?); + } + + unreachable!("attestation report retry loop exhausted without returning") + } + + async fn verify_single_attestation( + &self, + att: &AttestationBaseInfo, + nonce_bytes: &[u8; 32], + nonce_hex: &str, + verify_gpu: bool, + ) -> Result, NearAiError> { + let signing_algo = att + .signing_algo + .as_deref() + .unwrap_or("ecdsa") + .to_lowercase(); + if signing_algo != "ecdsa" { + return Err(NearAiError::Attestation(format!( + "unsupported signing_algo: {}", + signing_algo + ))); + } + + let quote = retry_verify_tdx_quote(&att.intel_quote).await?; + verify_report_data_binding("e.report_data, &att.signing_address, nonce_bytes)?; + verify_compose_manifest("e.mr_config_id, &att.info)?; + + if verify_gpu { + let payload_str = att + .nvidia_payload + .as_deref() + .ok_or_else(|| NearAiError::Attestation("missing nvidia_payload".to_string()))?; + let payload_json: Value = serde_json::from_str(payload_str)?; + let jwks = self.get_or_fetch_jwks().await?; + if let Err(e) = + retry_verify_gpu_attestation(&self.http, &jwks, &payload_json, nonce_hex).await + { + match e { + NearAiError::JwtKidNotFound(_) => { + let jwks = self.refresh_jwks().await?; + retry_verify_gpu_attestation(&self.http, &jwks, &payload_json, nonce_hex) + .await?; + } + _ => return Err(e), + } + } + } + + let Some(pubkey_hex) = att.signing_public_key.as_deref() else { + // Gateway attestations do not always include a signing_public_key. + return Ok(None); + }; + + let normalized_pubkey = + verify_signing_pubkey_matches_address(pubkey_hex, &att.signing_address)?; + + Ok(Some(VerifiedModelNode { + signing_public_key: normalized_pubkey, + })) + } + + async fn get_or_fetch_jwks(&self) -> Result { + { + let cache = self.jwks_cache.read().await; + if let Some(cached) = cache.as_ref() { + if cached.fetched_at.elapsed() <= JWKS_TTL { + return Ok(cached.jwks.clone()); + } + } + } + + self.refresh_jwks().await + } + + async fn refresh_jwks(&self) -> Result { + let jwks = fetch_jwks(&self.http).await?; + + // Sanity check: NRAS issuer is as expected. + if jwks.keys.is_empty() { + return Err(NearAiError::Jwt(format!( + "JWKS from {} contained no keys", + NRAS_JWKS_URL + ))); + } + + let mut cache = self.jwks_cache.write().await; + *cache = Some(CachedJwks { + jwks: jwks.clone(), + fetched_at: Instant::now(), + }); + Ok(jwks) + } +} + +fn is_retryable_error(e: &NearAiError) -> bool { + match e { + NearAiError::Http(re) => re.is_timeout() || re.is_connect(), + NearAiError::Tdx(msg) | NearAiError::Nras(msg) => { + let m = msg.to_lowercase(); + m.contains("timeout") + || m.contains("connect") + || m.contains("error sending request") + || m.contains("connection") + } + _ => false, + } +} + +async fn retry_verify_tdx_quote( + intel_quote_hex: &str, +) -> Result { + for attempt in 1..=HTTP_RETRY_ATTEMPTS { + match verify_tdx_quote(intel_quote_hex).await { + Ok(v) => return Ok(v), + Err(e) => { + if attempt < HTTP_RETRY_ATTEMPTS && is_retryable_error(&e) { + warn!( + "TDX quote verification failed (attempt {}/{}): {}; retrying", + attempt, HTTP_RETRY_ATTEMPTS, e + ); + tokio::time::sleep(HTTP_RETRY_BASE_DELAY * attempt as u32).await; + continue; + } + return Err(e); + } + } + } + unreachable!("retry loop exhausted without returning") +} + +async fn retry_verify_gpu_attestation( + client: &reqwest::Client, + jwks: &NrasJwks, + nvidia_payload: &Value, + nonce_hex: &str, +) -> Result<(), NearAiError> { + for attempt in 1..=HTTP_RETRY_ATTEMPTS { + match verify_gpu_attestation(client, jwks, nvidia_payload, nonce_hex).await { + Ok(()) => return Ok(()), + Err(e) => { + if attempt < HTTP_RETRY_ATTEMPTS && is_retryable_error(&e) { + warn!( + "GPU attestation verification failed (attempt {}/{}): {}; retrying", + attempt, HTTP_RETRY_ATTEMPTS, e + ); + tokio::time::sleep(HTTP_RETRY_BASE_DELAY * attempt as u32).await; + continue; + } + return Err(e); + } + } + } + unreachable!("retry loop exhausted without returning") +} + +fn generate_nonce_bytes() -> Result<[u8; 32], NearAiError> { + let mut nonce = [0u8; 32]; + getrandom::getrandom(&mut nonce) + .map_err(|e| NearAiError::Crypto(format!("nonce generation failed: {e}")))?; + Ok(nonce) +} + +fn normalize_base_url(input: &str) -> String { + let trimmed = input.trim_end_matches('/'); + let trimmed = trimmed.strip_suffix("/v1").unwrap_or(trimmed); + trimmed.to_string() +} + +fn random_node_index(len: usize) -> usize { + if len <= 1 { + return 0; + } + + let mut bytes = [0u8; 8]; + if getrandom::getrandom(&mut bytes).is_ok() { + (u64::from_le_bytes(bytes) as usize) % len + } else { + // If randomness is unavailable, fall back to the first verified node. + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nearai::e2ee::{decrypt_chat_completion_json_in_place, prepare_e2ee_request}; + use serde_json::json; + + #[tokio::test] + async fn test_unconfigured_fails_fast() { + let v = NearAiVerifier::new("https://cloud-api.near.ai".to_string(), None); + let err = v + .get_verified_model_node("zai-org/GLM-5-FP8") + .await + .unwrap_err(); + match err { + NearAiError::Unconfigured => {} + other => panic!("expected Unconfigured, got {other:?}"), + } + } + + #[tokio::test] + async fn test_cached_node_selects_from_verified_nodes() { + let v = NearAiVerifier::new( + "https://cloud-api.near.ai".to_string(), + Some("test".to_string()), + ); + + { + let mut cache = v.cache.write().await; + cache.insert( + "zai-org/GLM-5-FP8".to_string(), + VerifiedModel { + nodes: vec![ + VerifiedModelNode { + signing_public_key: "pk1".to_string(), + }, + VerifiedModelNode { + signing_public_key: "pk2".to_string(), + }, + ], + verified_at: Instant::now(), + }, + ); + } + + let n1 = v + .get_verified_model_node("zai-org/GLM-5-FP8") + .await + .unwrap(); + let n2 = v + .get_verified_model_node("zai-org/GLM-5-FP8") + .await + .unwrap(); + assert!( + n1.signing_public_key == "pk1" || n1.signing_public_key == "pk2", + "unexpected node pubkey: {}", + n1.signing_public_key + ); + assert!( + n2.signing_public_key == "pk1" || n2.signing_public_key == "pk2", + "unexpected node pubkey: {}", + n2.signing_public_key + ); + } + + #[tokio::test] + async fn test_try_get_fresh_cached_node_returns_none_when_stale() { + let v = NearAiVerifier::new( + "https://cloud-api.near.ai".to_string(), + Some("test".to_string()), + ); + + { + let mut cache = v.cache.write().await; + cache.insert( + "zai-org/GLM-5-FP8".to_string(), + VerifiedModel { + nodes: vec![VerifiedModelNode { + signing_public_key: "pk1".to_string(), + }], + verified_at: Instant::now() - (VERIFICATION_TTL + Duration::from_secs(1)), + }, + ); + } + + assert!(v + .try_get_fresh_cached_node("zai-org/GLM-5-FP8") + .await + .is_none()); + } + + #[tokio::test] + async fn test_get_or_fetch_jwks_uses_cache_when_fresh() { + let v = NearAiVerifier::new( + "https://cloud-api.near.ai".to_string(), + Some("test".to_string()), + ); + + let jwks = NrasJwks { + keys: vec![crate::nearai::nras::NrasJwk { + kid: Some("kid".to_string()), + x: Some("x".to_string()), + y: Some("y".to_string()), + }], + }; + + { + let mut cache = v.jwks_cache.write().await; + *cache = Some(CachedJwks { + jwks: jwks.clone(), + fetched_at: Instant::now(), + }); + } + + let fetched = v.get_or_fetch_jwks().await.unwrap(); + assert_eq!(fetched.keys.len(), 1); + assert_eq!(fetched.keys[0].kid.as_deref(), Some("kid")); + } + + #[tokio::test] + #[ignore] + async fn live_nearai_attestation_glm5() { + let api_key = match std::env::var("NEAR_API_KEY") { + Ok(v) if !v.trim().is_empty() => v, + _ => { + eprintln!("NEAR_API_KEY not set; skipping"); + return; + } + }; + + let base_url = std::env::var("NEARAI_API_BASE") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()); + + let v = NearAiVerifier::new(base_url, Some(api_key)); + let node = v + .get_verified_model_node("zai-org/GLM-5-FP8") + .await + .expect("attestation verification should succeed"); + + assert!(!node.signing_public_key.trim().is_empty()); + } + + #[tokio::test] + #[ignore] + async fn live_nearai_chat_completion_glm5() { + let api_key = match std::env::var("NEAR_API_KEY") { + Ok(v) if !v.trim().is_empty() => v, + _ => { + eprintln!("NEAR_API_KEY not set; skipping"); + return; + } + }; + + let base_url = std::env::var("NEARAI_API_BASE") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()); + + let v = NearAiVerifier::new(base_url.clone(), Some(api_key.clone())); + let node = v + .get_verified_model_node("zai-org/GLM-5-FP8") + .await + .expect("attestation verification should succeed"); + + let mut body = json!({ + "model": "zai-org/GLM-5-FP8", + "messages": [{"role": "user", "content": "ping"}], + "temperature": 0.0, + "max_tokens": 16 + }); + + let crypto = prepare_e2ee_request(&mut body, &node.signing_public_key) + .expect("request encryption should succeed"); + + let sent = body["messages"][0]["content"].as_str().unwrap(); + assert_ne!(sent, "ping"); + + let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + let client = reqwest::Client::new(); + let res = client + .post(url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("x-signing-algo", "ecdsa") + .header("x-client-pub-key", &crypto.client_public_key_hex) + .header("x-model-pub-key", &node.signing_public_key) + .json(&body) + .send() + .await + .expect("Near.AI request should send"); + + assert!(res.status().is_success(), "status={}", res.status()); + + let mut resp: serde_json::Value = + res.json().await.expect("Near.AI response should be JSON"); + decrypt_chat_completion_json_in_place(&mut resp, &crypto) + .expect("Near.AI response should decrypt"); + + let message = &resp["choices"][0]["message"]; + + let content = message + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let reasoning_content = message + .get("reasoning_content") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let reasoning = message + .get("reasoning") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if content.trim().is_empty() + && reasoning_content.trim().is_empty() + && reasoning.trim().is_empty() + { + eprintln!("Near.AI decrypted response had no textual fields: {resp}"); + } + + assert!( + !content.trim().is_empty() + || !reasoning_content.trim().is_empty() + || !reasoning.trim().is_empty() + ); + } + + /// Test that replicates the exact app flow: serde_json::to_string serialization, + /// pre-serialized body (like hyper does), extra headers appended the same way try_provider does. + #[tokio::test] + #[ignore] + async fn live_nearai_chat_completion_hyper_flow() { + let api_key = match std::env::var("NEAR_API_KEY") { + Ok(v) if !v.trim().is_empty() => v, + _ => { + eprintln!("NEAR_API_KEY not set; skipping"); + return; + } + }; + + let base_url = std::env::var("NEARAI_API_BASE") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()); + + let v = NearAiVerifier::new(base_url.clone(), Some(api_key.clone())); + let node = v + .get_verified_model_node("zai-org/GLM-5-FP8") + .await + .expect("attestation verification should succeed"); + + // Build body exactly how the app does: start with user body, replace model name, + // add stream_options + let mut body_map = serde_json::Map::new(); + body_map.insert("model".to_string(), json!("zai-org/GLM-5-FP8")); + body_map.insert( + "messages".to_string(), + json!([{"role": "user", "content": "ping"}]), + ); + body_map.insert("max_tokens".to_string(), json!(16)); + body_map.insert("temperature".to_string(), json!(0.0)); + body_map.insert("stream".to_string(), json!(false)); + // App adds stream_options when streaming or tinfoil + body_map.insert("stream_options".to_string(), json!({"include_usage": true})); + + let mut body_value = serde_json::Value::Object(body_map); + + let crypto = prepare_e2ee_request(&mut body_value, &node.signing_public_key) + .expect("request encryption should succeed"); + + // App uses serde_json::to_string, NOT reqwest .json() + let body_json = serde_json::to_string(&body_value).unwrap(); + + eprintln!("body_json len={}", body_json.len()); + eprintln!("body_json={}", body_json); + eprintln!("x-model-pub-key={}", node.signing_public_key); + eprintln!("x-client-pub-key={}", crypto.client_public_key_hex); + + // Use reqwest but send pre-serialized body (like hyper does) + let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + let client = reqwest::Client::new(); + let res = client + .post(&url) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_key)) + .header("x-signing-algo", "ecdsa") + .header("x-client-pub-key", &crypto.client_public_key_hex) + .header("x-model-pub-key", &node.signing_public_key) + .body(body_json.clone()) + .send() + .await + .expect("request should send"); + + let status = res.status(); + let resp_text = res.text().await.unwrap(); + eprintln!("status={} body={}", status, resp_text); + + assert!(status.is_success(), "status={} body={}", status, resp_text); + + let mut resp: serde_json::Value = + serde_json::from_str(&resp_text).expect("response should be JSON"); + decrypt_chat_completion_json_in_place(&mut resp, &crypto).expect("response should decrypt"); + + let content = resp["choices"][0]["message"]["content"] + .as_str() + .unwrap_or(""); + let reasoning_content = resp["choices"][0]["message"]["reasoning_content"] + .as_str() + .unwrap_or(""); + let reasoning = resp["choices"][0]["message"]["reasoning"] + .as_str() + .unwrap_or(""); + + assert!( + !content.trim().is_empty() + || !reasoning_content.trim().is_empty() + || !reasoning.trim().is_empty(), + "decrypted response should have some text" + ); + } + + /// Test with streaming=true (how the responses API actually calls it) + #[tokio::test] + #[ignore] + async fn live_nearai_chat_completion_streaming() { + let api_key = match std::env::var("NEAR_API_KEY") { + Ok(v) if !v.trim().is_empty() => v, + _ => { + eprintln!("NEAR_API_KEY not set; skipping"); + return; + } + }; + + let base_url = std::env::var("NEARAI_API_BASE") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()); + + let v = NearAiVerifier::new(base_url.clone(), Some(api_key.clone())); + let node = v + .get_verified_model_node("zai-org/GLM-5-FP8") + .await + .expect("attestation verification should succeed"); + + let mut body_map = serde_json::Map::new(); + body_map.insert("model".to_string(), json!("zai-org/GLM-5-FP8")); + body_map.insert( + "messages".to_string(), + json!([{"role": "user", "content": "Say hello"}]), + ); + body_map.insert("max_tokens".to_string(), json!(32)); + body_map.insert("temperature".to_string(), json!(0.0)); + body_map.insert("stream".to_string(), json!(true)); + body_map.insert("stream_options".to_string(), json!({"include_usage": true})); + + let mut body_value = serde_json::Value::Object(body_map); + + let crypto = prepare_e2ee_request(&mut body_value, &node.signing_public_key) + .expect("request encryption should succeed"); + + let body_json = serde_json::to_string(&body_value).unwrap(); + eprintln!("streaming body_json len={}", body_json.len()); + + let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + let client = reqwest::Client::new(); + let res = client + .post(&url) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_key)) + .header("x-signing-algo", "ecdsa") + .header("x-client-pub-key", &crypto.client_public_key_hex) + .header("x-model-pub-key", &node.signing_public_key) + .body(body_json) + .send() + .await + .expect("request should send"); + + let status = res.status(); + let resp_text = res.text().await.unwrap(); + eprintln!("streaming status={}", status); + + if !status.is_success() { + eprintln!("streaming response body={}", resp_text); + panic!( + "streaming request failed: status={} body={}", + status, resp_text + ); + } + + // Parse SSE chunks + let mut got_content = false; + for line in resp_text.lines() { + if !line.starts_with("data: ") { + continue; + } + let data = &line["data: ".len()..]; + if data == "[DONE]" { + break; + } + let mut chunk: serde_json::Value = match serde_json::from_str(data) { + Ok(v) => v, + Err(e) => { + eprintln!("failed to parse SSE chunk: {} -- data: {}", e, data); + continue; + } + }; + + if let Err(e) = decrypt_chat_completion_json_in_place(&mut chunk, &crypto) { + eprintln!("failed to decrypt SSE chunk: {} -- data: {}", e, data); + panic!("stream chunk decryption failed"); + } + + let delta = &chunk["choices"][0]["delta"]; + let delta_content = delta.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let delta_reasoning = delta + .get("reasoning_content") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let delta_reasoning2 = delta + .get("reasoning") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !delta_content.is_empty() + || !delta_reasoning.is_empty() + || !delta_reasoning2.is_empty() + { + got_content = true; + } + } + + assert!( + got_content, + "streaming response should have produced some decrypted content" + ); + } + + /// Test with multi-turn conversation (system + user messages, like the app sends) + #[tokio::test] + #[ignore] + async fn live_nearai_chat_completion_multi_turn() { + let api_key = match std::env::var("NEAR_API_KEY") { + Ok(v) if !v.trim().is_empty() => v, + _ => { + eprintln!("NEAR_API_KEY not set; skipping"); + return; + } + }; + + let base_url = std::env::var("NEARAI_API_BASE") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()); + + let v = NearAiVerifier::new(base_url.clone(), Some(api_key.clone())); + let node = v + .get_verified_model_node("zai-org/GLM-5-FP8") + .await + .expect("attestation verification should succeed"); + + let mut body = json!({ + "model": "zai-org/GLM-5-FP8", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is 2+2?"}, + {"role": "assistant", "content": "4"}, + {"role": "user", "content": "And 3+3?"} + ], + "max_tokens": 32, + "temperature": 0.0, + "stream": false, + "stream_options": {"include_usage": true} + }); + + let crypto = prepare_e2ee_request(&mut body, &node.signing_public_key) + .expect("request encryption should succeed"); + + let body_json = serde_json::to_string(&body).unwrap(); + eprintln!("multi-turn body_json len={}", body_json.len()); + + let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + let client = reqwest::Client::new(); + let res = client + .post(&url) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_key)) + .header("x-signing-algo", "ecdsa") + .header("x-client-pub-key", &crypto.client_public_key_hex) + .header("x-model-pub-key", &node.signing_public_key) + .body(body_json) + .send() + .await + .expect("request should send"); + + let status = res.status(); + let resp_text = res.text().await.unwrap(); + eprintln!("multi-turn status={} body={}", status, resp_text); + + assert!( + status.is_success(), + "multi-turn failed: status={} body={}", + status, + resp_text + ); + + let mut resp: serde_json::Value = serde_json::from_str(&resp_text).unwrap(); + decrypt_chat_completion_json_in_place(&mut resp, &crypto) + .expect("multi-turn response should decrypt"); + } + + /// Repeated requests to catch intermittent failures + #[tokio::test] + #[ignore] + async fn live_nearai_chat_completion_repeated() { + let api_key = match std::env::var("NEAR_API_KEY") { + Ok(v) if !v.trim().is_empty() => v, + _ => { + eprintln!("NEAR_API_KEY not set; skipping"); + return; + } + }; + + let base_url = std::env::var("NEARAI_API_BASE") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()); + + let v = NearAiVerifier::new(base_url.clone(), Some(api_key.clone())); + + let client = reqwest::Client::new(); + let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + + for i in 0..10 { + // Re-fetch verified node each iteration (like the app would on separate requests) + let node = v + .get_verified_model_node("zai-org/GLM-5-FP8") + .await + .expect("attestation verification should succeed"); + + let mut body = json!({ + "model": "zai-org/GLM-5-FP8", + "messages": [{"role": "user", "content": format!("Say the number {}", i)}], + "max_tokens": 16, + "temperature": 0.0, + "stream": false, + "stream_options": {"include_usage": true} + }); + + let crypto = prepare_e2ee_request(&mut body, &node.signing_public_key) + .expect("request encryption should succeed"); + + let body_json = serde_json::to_string(&body).unwrap(); + + let res = client + .post(&url) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_key)) + .header("x-signing-algo", "ecdsa") + .header("x-client-pub-key", &crypto.client_public_key_hex) + .header("x-model-pub-key", &node.signing_public_key) + .body(body_json) + .send() + .await + .expect("request should send"); + + let status = res.status(); + let resp_text = res.text().await.unwrap(); + eprintln!( + "iteration {} status={} node_pubkey={}... body_preview={}", + i, + status, + &node.signing_public_key[..16], + &resp_text[..resp_text.len().min(120)] + ); + + assert!( + status.is_success(), + "iteration {} failed: status={} body={}", + i, + status, + resp_text + ); + + let mut resp: serde_json::Value = serde_json::from_str(&resp_text).unwrap(); + decrypt_chat_completion_json_in_place(&mut resp, &crypto) + .expect("response should decrypt"); + } + } +} diff --git a/src/proxy_config.rs b/src/proxy_config.rs index 5d8c5e00..c0dbf580 100644 --- a/src/proxy_config.rs +++ b/src/proxy_config.rs @@ -21,7 +21,7 @@ pub struct ModelRoute { pub fallbacks: Vec, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ProxyConfig { pub base_url: String, pub api_key: Option, @@ -29,6 +29,16 @@ pub struct ProxyConfig { pub provider_name: String, } +impl std::fmt::Debug for ProxyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProxyConfig") + .field("base_url", &self.base_url) + .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]")) + .field("provider_name", &self.provider_name) + .finish() + } +} + #[derive(Debug, Clone)] pub struct ProxyRouter { // Static routing table built at initialization @@ -59,6 +69,9 @@ impl ProxyRouter { ("whisper-large-v3", "tinfoil") => "whisper-large-v3-turbo".to_string(), ("whisper-large-v3-turbo", "continuum") => "whisper-large-v3".to_string(), + // Near.AI model translations + ("glm-5", "nearai") => "zai-org/GLM-5-FP8".to_string(), + // All other models use the same name on both providers _ => model.to_string(), } @@ -68,6 +81,8 @@ impl ProxyRouter { openai_base: String, openai_key: Option, tinfoil_base: Option, + nearai_base: Option, + nearai_key: Option, ) -> Self { // Default OpenAI/Continuum proxy config let default_proxy = ProxyConfig { @@ -91,8 +106,15 @@ impl ProxyRouter { provider_name: "tinfoil".to_string(), }); + // Near.AI proxy configuration + let nearai_proxy = ProxyConfig { + base_url: nearai_base.unwrap_or_else(|| "https://cloud-api.near.ai".to_string()), + api_key: nearai_key, + provider_name: "nearai".to_string(), + }; + // Build static routing table - let model_routes = Self::build_static_routes(&default_proxy, &tinfoil_proxy); + let model_routes = Self::build_static_routes(&default_proxy, &tinfoil_proxy, &nearai_proxy); // Build static models response let models_response = Self::build_models_response(&tinfoil_proxy); @@ -109,9 +131,19 @@ impl ProxyRouter { fn build_static_routes( continuum_proxy: &ProxyConfig, tinfoil_proxy: &Option, + nearai_proxy: &ProxyConfig, ) -> HashMap { let mut routes = HashMap::new(); + // Near.AI-only models + routes.insert( + "glm-5".to_string(), + ModelRoute { + primary: nearai_proxy.clone(), + fallbacks: vec![], + }, + ); + if let Some(tinfoil) = tinfoil_proxy { // Models on both providers: Tinfoil primary, Continuum fallback let both_route = ModelRoute { @@ -176,10 +208,12 @@ impl ProxyRouter { "qwen3-vl-30b", "kimi-k2-5", "nomic-embed-text", + // Near.AI-only + "glm-5", ] } else { // Without Tinfoil: only Continuum models (llama-3.3-70b not available) - vec!["gemma-3-27b", "gpt-oss-120b", "whisper-large-v3"] + vec!["gemma-3-27b", "gpt-oss-120b", "whisper-large-v3", "glm-5"] }; let model_objects: Vec = models @@ -233,6 +267,8 @@ mod tests { "http://continuum.example.com".to_string(), None, Some("http://tinfoil.example.com".to_string()), + None, + None, ); // Llama translations @@ -264,6 +300,12 @@ mod tests { router.get_model_name_for_provider("gemma-3-27b", "continuum"), "gemma-3-27b" ); + + // Near.AI translation + assert_eq!( + router.get_model_name_for_provider("glm-5", "nearai"), + "zai-org/GLM-5-FP8" + ); } #[test] @@ -273,6 +315,8 @@ mod tests { "https://api.openai.com".to_string(), Some("test-key".to_string()), None, + None, + None, ); assert_eq!(router.default_proxy.provider_name, "openai"); assert_eq!(router.default_proxy.api_key, Some("test-key".to_string())); @@ -283,6 +327,8 @@ mod tests { "http://continuum.example.com".to_string(), Some("test-key".to_string()), None, + None, + None, ); assert_eq!(router.default_proxy.provider_name, "continuum"); assert_eq!(router.default_proxy.api_key, None); // Continuum doesn't use API key @@ -292,6 +338,8 @@ mod tests { "http://continuum.example.com".to_string(), None, Some("http://tinfoil.example.com".to_string()), + None, + None, ); assert!(router.tinfoil_proxy.is_some()); let tinfoil = router.tinfoil_proxy.as_ref().unwrap(); @@ -303,7 +351,13 @@ mod tests { #[test] fn test_get_tinfoil_base_url() { // Without Tinfoil - let router = ProxyRouter::new("http://continuum.example.com".to_string(), None, None); + let router = ProxyRouter::new( + "http://continuum.example.com".to_string(), + None, + None, + None, + None, + ); assert_eq!(router.get_tinfoil_base_url(), None); // With Tinfoil @@ -311,6 +365,8 @@ mod tests { "http://continuum.example.com".to_string(), None, Some("http://tinfoil.example.com".to_string()), + None, + None, ); assert_eq!( router.get_tinfoil_base_url(), @@ -324,6 +380,8 @@ mod tests { "http://continuum.example.com".to_string(), None, Some("http://tinfoil.example.com".to_string()), + None, + None, ); // Test llama-3.3-70b is Tinfoil-only (no Continuum fallback) @@ -348,6 +406,13 @@ mod tests { // Unknown model should return None let unknown_route = router.get_model_route("gpt-4"); assert!(unknown_route.is_none()); + + // glm-5 should route to Near.AI + let glm_route = router.get_model_route("glm-5"); + assert!(glm_route.is_some()); + let route = glm_route.unwrap(); + assert_eq!(route.primary.provider_name, "nearai"); + assert!(route.fallbacks.is_empty()); } #[test] @@ -356,6 +421,8 @@ mod tests { "http://continuum.example.com".to_string(), None, None, // No Tinfoil + None, + None, ); // llama-3.3-70b should NOT be available without Tinfoil @@ -401,7 +468,6 @@ mod tests { #[test] fn test_proxy_config_debug_trait() { - // Test that ProxyConfig implements Debug properly let config = ProxyConfig { base_url: "http://test.com".to_string(), api_key: Some("secret".to_string()), @@ -411,8 +477,8 @@ mod tests { let debug_str = format!("{:?}", config); assert!(debug_str.contains("test.com")); assert!(debug_str.contains("test")); - // Should contain api_key but we're not testing the exact format - assert!(debug_str.contains("api_key")); + assert!(debug_str.contains("[REDACTED]")); + assert!(!debug_str.contains("secret")); } #[test] @@ -440,6 +506,8 @@ mod tests { "http://continuum.example.com".to_string(), None, Some("http://tinfoil.example.com".to_string()), + None, + None, ); // Should return static models list immediately @@ -460,5 +528,6 @@ mod tests { .collect(); assert!(model_ids.contains(&"llama-3.3-70b".to_string())); assert!(model_ids.contains(&"gpt-oss-120b".to_string())); + assert!(model_ids.contains(&"glm-5".to_string())); } } diff --git a/src/tokens.rs b/src/tokens.rs index cd17c383..83e7c602 100644 --- a/src/tokens.rs +++ b/src/tokens.rs @@ -28,6 +28,9 @@ pub fn model_max_ctx(model: &str) -> usize { ("deepseek-r1-0528", 128_000), // Gemma 3 27B (vision) — capped at 20k ("gemma-3-27b", 20_000), + // Near.AI + ("glm-5", 202_000), + ("zai-org/GLM-5-FP8", 202_000), ]; LIMITS @@ -72,6 +75,7 @@ mod tests { assert_eq!(model_max_ctx("llama3-3-70b"), 128_000); assert_eq!(model_max_ctx("gpt-oss-120b"), 128_000); assert_eq!(model_max_ctx("qwen3-vl-30b"), 256_000); + assert_eq!(model_max_ctx("glm-5"), 202_000); } #[test] diff --git a/src/web/openai.rs b/src/web/openai.rs index 89c1e18a..a0d3b889 100644 --- a/src/web/openai.rs +++ b/src/web/openai.rs @@ -1,5 +1,8 @@ use crate::models::token_usage::NewTokenUsage; use crate::models::users::User; +use crate::nearai::e2ee::{ + decrypt_chat_completion_json_in_place, prepare_e2ee_request, NearAiResponseCrypto, +}; use crate::proxy_config::ProxyConfig; use crate::sqs::UsageEvent; use crate::web::audio_utils::{merge_transcriptions, AudioSplitter, TINFOIL_MAX_SIZE}; @@ -28,7 +31,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::{sleep, timeout}; -use tracing::{debug, error, info, trace}; +use tracing::{debug, error, info, trace, warn}; use uuid::Uuid; // Maximum audio file size (100MB) - sanity check, CF already limits to 50MB @@ -38,6 +41,85 @@ const MAX_AUDIO_SIZE: usize = 100 * 1024 * 1024; const REQUEST_TIMEOUT_SECS: u64 = 120; // Request timeout (generous for large non-streaming responses) const STREAM_CHUNK_TIMEOUT_SECS: u64 = 120; // Per-chunk timeout for streaming reads +#[derive(Clone)] +struct PreparedChatRequest { + body_json: String, + extra_headers: Vec<(HeaderName, HeaderValue)>, + near_crypto: Option, +} + +async fn prepare_chat_request_for_provider( + state: &Arc, + proxy_config: &ProxyConfig, + mut body: Value, +) -> Result { + let mut extra_headers: Vec<(HeaderName, HeaderValue)> = Vec::new(); + let mut near_crypto: Option = None; + + if proxy_config.provider_name.to_lowercase() == "nearai" { + let model = body + .get("model") + .and_then(|v| v.as_str()) + .ok_or(ApiError::BadRequest)? + .to_string(); + + trace!("Near.AI E2EE: fetching verified node for model={}", model); + let node = state + .nearai_verifier + .get_verified_model_node(&model) + .await + .map_err(|e| { + error!("Near.AI verification failed (model={}): {}", model, e); + ApiError::ServiceUnavailable + })?; + + trace!( + "Near.AI E2EE: using model node pubkey={}... (len={})", + &node.signing_public_key[..node.signing_public_key.len().min(16)], + node.signing_public_key.len() + ); + + let crypto = prepare_e2ee_request(&mut body, &node.signing_public_key).map_err(|e| { + error!("Near.AI request encryption failed (model={}): {}", model, e); + ApiError::ServiceUnavailable + })?; + + trace!( + "Near.AI E2EE: client ephemeral pubkey={}... (len={})", + &crypto.client_public_key_hex[..crypto.client_public_key_hex.len().min(16)], + crypto.client_public_key_hex.len() + ); + + extra_headers.push(( + HeaderName::from_static("x-signing-algo"), + HeaderValue::from_static("ecdsa"), + )); + extra_headers.push(( + HeaderName::from_static("x-client-pub-key"), + HeaderValue::from_str(&crypto.client_public_key_hex) + .map_err(|_| ApiError::InternalServerError)?, + )); + extra_headers.push(( + HeaderName::from_static("x-model-pub-key"), + HeaderValue::from_str(&node.signing_public_key) + .map_err(|_| ApiError::InternalServerError)?, + )); + + near_crypto = Some(crypto); + } + + let body_json = serde_json::to_string(&body).map_err(|e| { + error!("Failed to serialize request body: {:?}", e); + ApiError::InternalServerError + })?; + + Ok(PreparedChatRequest { + body_json, + extra_headers, + near_crypto, + }) +} + /// Parameters for transcription requests struct TranscriptionParams<'a> { audio_data: &'a [u8], @@ -441,7 +523,7 @@ pub async fn get_chat_completion_response( // Prepare the request to proxies debug!("Sending request for model: {}", model_name); - let (res, successful_provider) = { + let (res, successful_provider, successful_provider_model, successful_near_crypto) = { debug!( "Using route for model {}: primary={}, fallbacks={}", model_name, @@ -461,11 +543,20 @@ pub async fn get_chat_completion_response( primary_body.insert("stream_options".to_string(), json!({"include_usage": true})); } - let primary_body_json = - serde_json::to_string(&Value::Object(primary_body)).map_err(|e| { - error!("Failed to serialize request body: {:?}", e); - ApiError::InternalServerError - })?; + let primary_provider_is_near = route.primary.provider_name.to_lowercase() == "nearai"; + let primary_body_value = Value::Object(primary_body); + let primary_prepared_static = if primary_provider_is_near { + None + } else { + Some( + prepare_chat_request_for_provider( + state, + &route.primary, + primary_body_value.clone(), + ) + .await?, + ) + }; // Prepare fallback request if available let fallback_request = if let Some(fallback) = route.fallbacks.first() { @@ -479,12 +570,24 @@ pub async fn get_chat_completion_response( fallback_body.insert("stream_options".to_string(), json!({"include_usage": true})); } - let fallback_body_json = - serde_json::to_string(&Value::Object(fallback_body)).map_err(|e| { - error!("Failed to serialize fallback request body: {:?}", e); - ApiError::InternalServerError - })?; - Some((fallback, fallback_body_json)) + let fallback_provider_is_near = fallback.provider_name.to_lowercase() == "nearai"; + let fallback_body_value = Value::Object(fallback_body); + let fallback_prepared_static = if fallback_provider_is_near { + None + } else { + Some( + prepare_chat_request_for_provider(state, fallback, fallback_body_value.clone()) + .await?, + ) + }; + + Some(( + fallback, + fallback_model_name, + fallback_provider_is_near, + fallback_body_value, + fallback_prepared_static, + )) } else { None }; @@ -494,6 +597,8 @@ pub async fn get_chat_completion_response( let mut last_error = None; let mut found_response = None; let mut successful_provider_name = String::new(); + let mut successful_provider_model_name = String::new(); + let mut successful_near_crypto: Option = None; for cycle in 0..max_cycles { if cycle > 0 { @@ -508,61 +613,149 @@ pub async fn get_chat_completion_response( cycle + 1, route.primary.provider_name ); - match try_provider(&client, &route.primary, &primary_body_json, headers).await { - Ok(response) => { - info!( - "Successfully got response from primary provider {} on cycle {}", - route.primary.provider_name, - cycle + 1 - ); - found_response = Some(response); - successful_provider_name = route.primary.provider_name.clone(); - break; - } - Err(err) => { - error!( - "Cycle {}: Primary provider {} failed: {:?}", - cycle + 1, - route.primary.provider_name, - err - ); - last_error = Some(err); + let primary_prepared = if primary_provider_is_near { + match prepare_chat_request_for_provider( + state, + &route.primary, + primary_body_value.clone(), + ) + .await + { + Ok(p) => Some(p), + Err(e) => { + error!( + "Cycle {}: Failed to prepare request for primary provider {}: {}", + cycle + 1, + route.primary.provider_name, + e + ); + last_error = Some(e.to_string()); + None + } } - } + } else { + Some( + primary_prepared_static + .clone() + .expect("non-Near.AI primary provider must be pre-prepared"), + ) + }; - // Try fallback if available - if let Some((fallback_provider, ref fallback_body_json)) = fallback_request { - debug!( - "Cycle {}: Trying fallback provider {}", - cycle + 1, - fallback_provider.provider_name - ); - match try_provider(&client, fallback_provider, fallback_body_json, headers).await { + if let Some(primary_prepared) = primary_prepared { + match try_provider( + &client, + &route.primary, + &primary_prepared.body_json, + headers, + &primary_prepared.extra_headers, + ) + .await + { Ok(response) => { info!( - "Successfully got response from fallback provider {} on cycle {}", - fallback_provider.provider_name, + "Successfully got response from primary provider {} on cycle {}", + route.primary.provider_name, cycle + 1 ); found_response = Some(response); - successful_provider_name = fallback_provider.provider_name.clone(); + successful_provider_name = route.primary.provider_name.clone(); + successful_provider_model_name = primary_model_name.clone(); + successful_near_crypto = primary_prepared.near_crypto.clone(); break; } Err(err) => { error!( - "Cycle {}: Fallback provider {} failed: {:?}", + "Cycle {}: Primary provider {} failed: {:?}", cycle + 1, - fallback_provider.provider_name, + route.primary.provider_name, err ); last_error = Some(err); } } } + + // Try fallback if available + if let Some(( + fallback_provider, + fallback_model_name, + fallback_provider_is_near, + fallback_body_value, + fallback_prepared_static, + )) = &fallback_request + { + debug!( + "Cycle {}: Trying fallback provider {}", + cycle + 1, + fallback_provider.provider_name + ); + + let fallback_prepared = if *fallback_provider_is_near { + match prepare_chat_request_for_provider( + state, + fallback_provider, + fallback_body_value.clone(), + ) + .await + { + Ok(p) => Some(p), + Err(e) => { + error!( + "Cycle {}: Failed to prepare request for fallback provider {}: {}", + cycle + 1, + fallback_provider.provider_name, + e + ); + last_error = Some(e.to_string()); + None + } + } + } else { + Some( + fallback_prepared_static + .clone() + .expect("non-Near.AI fallback provider must be pre-prepared"), + ) + }; + + if let Some(fallback_prepared) = fallback_prepared { + match try_provider( + &client, + fallback_provider, + &fallback_prepared.body_json, + headers, + &fallback_prepared.extra_headers, + ) + .await + { + Ok(response) => { + info!( + "Successfully got response from fallback provider {} on cycle {}", + fallback_provider.provider_name, + cycle + 1 + ); + found_response = Some(response); + successful_provider_name = fallback_provider.provider_name.clone(); + successful_provider_model_name = fallback_model_name.clone(); + successful_near_crypto = fallback_prepared.near_crypto.clone(); + break; + } + Err(err) => { + error!( + "Cycle {}: Fallback provider {} failed: {:?}", + cycle + 1, + fallback_provider.provider_name, + err + ); + last_error = Some(err); + } + } + } + } } match found_response { - Some(response) => (response, successful_provider_name), + Some(response) => (response, successful_provider_name, successful_provider_model_name, successful_near_crypto), None => { let error_msg = if route.fallbacks.is_empty() { format!( @@ -576,6 +769,12 @@ pub async fn get_chat_completion_response( ) }; error!("{}", error_msg); + if route.primary.provider_name.to_lowercase() == "nearai" + && route.fallbacks.is_empty() + { + return Err(ApiError::ServiceUnavailable); + } + return Err(ApiError::InternalServerError); } } @@ -595,12 +794,31 @@ pub async fn get_chat_completion_response( ApiError::InternalServerError })?; - let response_json: Value = serde_json::from_str(&String::from_utf8_lossy(&body_bytes)) + let mut response_json: Value = serde_json::from_str(&String::from_utf8_lossy(&body_bytes)) .map_err(|e| { error!("Failed to parse response JSON: {:?}", e); ApiError::InternalServerError })?; + if successful_provider.to_lowercase() == "nearai" { + let Some(crypto) = successful_near_crypto.as_ref() else { + error!("Near.AI response missing request crypto"); + return Err(ApiError::InternalServerError); + }; + + decrypt_chat_completion_json_in_place(&mut response_json, crypto).map_err(|e| { + error!("Near.AI response decryption failed: {}", e); + warn!( + "Invalidating Near.AI model cache for {} due to decryption failure", + successful_provider_model + ); + let verifier = state.nearai_verifier.clone(); + let model = successful_provider_model.clone(); + tokio::spawn(async move { verifier.invalidate_model(&model).await }); + ApiError::ServiceUnavailable + })?; + } + // ✅ Handle billing HERE, inside completions API if let Some(usage) = extract_usage(&response_json) { publish_usage_event_internal( @@ -637,11 +855,16 @@ pub async fn get_chat_completion_response( let user_clone = user.clone(); let billing_ctx = billing_context.clone(); let provider = successful_provider.clone(); + let near_crypto = successful_near_crypto.clone(); + let provider_is_near = provider.to_lowercase() == "nearai"; + let nearai_verifier = state.nearai_verifier.clone(); + let model_name_for_invalidation = successful_provider_model.clone(); tokio::spawn(async move { let mut body_stream = res.into_body().into_stream(); let mut buffer = String::new(); let mut usage_sent = false; // Track if we've already published usage for this stream + let mut stream_finished = false; // Track if we've seen finish_reason in ANY prior chunk loop { match timeout( @@ -667,15 +890,51 @@ pub async fn get_chat_completion_response( } match serde_json::from_str::(&frame) { - Ok(json) => { - // ✅ Extract and publish billing HERE - but ONLY on final chunk - // This prevents sending usage data on intermediate chunks that vLLM now includes - let has_finish = has_finish_reason(&json); + Ok(mut json) => { + if provider_is_near { + let Some(ref crypto) = near_crypto else { + error!("Near.AI stream missing request crypto"); + let _ = tx_consumer + .send(CompletionChunk::Error( + "Near.AI crypto missing".to_string(), + )) + .await; + return; + }; + + if let Err(e) = decrypt_chat_completion_json_in_place( + &mut json, crypto, + ) { + error!( + "Near.AI stream chunk decryption failed: {}", + e + ); + warn!( + "Invalidating Near.AI model cache for {} due to decryption failure", + model_name_for_invalidation + ); + nearai_verifier.invalidate_model(&model_name_for_invalidation).await; + let _ = tx_consumer + .send(CompletionChunk::Error( + "Near.AI decrypt failed".to_string(), + )) + .await; + return; + } + } + + // Track whether the stream has reached a finish_reason + // Some providers (e.g. Near.AI) send finish_reason and usage + // on separate chunks, while others (e.g. Tinfoil/vLLM) combine them. + if has_finish_reason(&json) { + stream_finished = true; + } if let Some(usage) = extract_usage(&json) { - // Only publish usage on final chunk (has finish_reason) with actual completion tokens - // Also ensure we only send usage once per stream - if has_finish + // Publish usage only after stream has finished (finish_reason seen + // on this or a prior chunk), with actual completion tokens, + // and only once per stream. + if stream_finished && usage.completion_tokens > 0 && !usage_sent { @@ -699,8 +958,8 @@ pub async fn get_chat_completion_response( } } else { trace!( - "Skipping usage publish: has_finish={}, completion_tokens={}, usage_sent={}", - has_finish, usage.completion_tokens, usage_sent + "Skipping usage publish: stream_finished={}, completion_tokens={}, usage_sent={}", + stream_finished, usage.completion_tokens, usage_sent ); } } @@ -715,17 +974,44 @@ pub async fn get_chat_completion_response( } } Err(e) => { - error!("Received non-JSON data event. Error: {:?}", e); - if tx_consumer - .send(CompletionChunk::Error( - "Invalid JSON".to_string(), - )) - .await - .is_err() - { + let trimmed = frame.trim(); + if trimmed.is_empty() { + trace!("Skipping empty SSE data frame"); + } else if trimmed.chars().all(|c| c == '\r' || c == '\n') { + trace!("Skipping whitespace-only SSE data frame"); + } else if trimmed.starts_with("error:") { + // Provider sent a plain-text error — propagate it and stop + error!( + "Provider returned a plain-text error frame: {:?}", + &trimmed[..trimmed.len().min(500)] + ); + // Invalidate cached Near.AI node so the next request + // re-verifies with a fresh key (covers key rotation). + if provider_is_near { + warn!( + "Invalidating Near.AI model cache for {} due to provider error", + model_name_for_invalidation + ); + nearai_verifier.invalidate_model(&model_name_for_invalidation).await; + } + let _ = tx_consumer + .send(CompletionChunk::Error(trimmed.to_string())) + .await; return; + } else if e.to_string().contains("expected value") { + // Likely a keepalive, comment, or unknown non-JSON line + warn!( + "Skipping non-JSON SSE data frame (first 200 chars): {:?}", + &trimmed[..trimmed.len().min(200)] + ); + } else { + error!( + "Failed to parse SSE data frame as JSON: {} — frame (first 200 chars): {:?}", + e, + &trimmed[..trimmed.len().min(200)] + ); } - break; + // Don't break the stream — skip this frame and continue } } } @@ -831,6 +1117,10 @@ fn extract_sse_frame(buffer: &mut String) -> Option { // Return data content if it's a data frame, otherwise keep looking if let Some(data) = frame.strip_prefix("data: ") { + // Skip empty data payloads (e.g. keepalive frames like "data: \n\n") + if data.trim().is_empty() { + continue; + } return Some(data.to_string()); } // Skip non-data frames (comments, etc.) and continue looking @@ -1357,7 +1647,8 @@ async fn send_transcription_request( form_data.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); // Build request - let endpoint = format!("{}/v1/audio/transcriptions", provider.base_url); + let base_url = normalize_provider_base_url(&provider.base_url); + let endpoint = format!("{}/v1/audio/transcriptions", base_url); let mut req = Request::builder().method("POST").uri(&endpoint).header( "Content-Type", format!("multipart/form-data; boundary={}", boundary), @@ -1499,7 +1790,10 @@ async fn proxy_tts( // Build request let req = Request::builder() .method("POST") - .uri(format!("{}/v1/audio/speech", proxy_config.base_url)) + .uri(format!( + "{}/v1/audio/speech", + normalize_provider_base_url(&proxy_config.base_url) + )) .header("Content-Type", "application/json") .body(HyperBody::from( serde_json::to_string(&tts_api_request).map_err(|e| { @@ -1633,7 +1927,10 @@ async fn proxy_embeddings( })?; // Build request to provider - let endpoint = format!("{}/v1/embeddings", route.primary.base_url); + let endpoint = format!( + "{}/v1/embeddings", + normalize_provider_base_url(&route.primary.base_url) + ); let mut req = Request::builder() .method("POST") .uri(&endpoint) @@ -1729,13 +2026,17 @@ async fn try_provider( proxy_config: &ProxyConfig, body_json: &str, headers: &HeaderMap, + extra_headers: &[(HeaderName, HeaderValue)], ) -> Result, String> { debug!("Making request to {}", proxy_config.provider_name); + let base_url = normalize_provider_base_url(&proxy_config.base_url); + let url = format!("{}/v1/chat/completions", base_url); + // Build request let mut req = Request::builder() .method("POST") - .uri(format!("{}/v1/chat/completions", proxy_config.base_url)) + .uri(url.clone()) .header("Content-Type", "application/json"); if let Some(api_key) = &proxy_config.api_key { @@ -1746,10 +2047,18 @@ async fn try_provider( // Forward relevant headers from the original request for (key, value) in headers.iter() { + // Prevent clients from injecting/overriding Near.AI E2EE routing/crypto headers. + // We always set these internally when calling Near.AI. + let key_str = key.as_str(); + let is_near_internal_header = key_str.eq_ignore_ascii_case("x-signing-algo") + || key_str.eq_ignore_ascii_case("x-client-pub-key") + || key_str.eq_ignore_ascii_case("x-model-pub-key"); + if key != header::HOST && key != header::AUTHORIZATION && key != header::CONTENT_LENGTH && key != header::CONTENT_TYPE + && !is_near_internal_header { if let (Ok(name), Ok(val)) = ( HeaderName::from_bytes(key.as_ref()), @@ -1760,6 +2069,24 @@ async fn try_provider( } } + // Add provider-specific headers + for (name, val) in extra_headers { + trace!( + "provider={} extra header: {}={}", + proxy_config.provider_name, + name, + val.to_str().unwrap_or("") + ); + req = req.header(name.clone(), val.clone()); + } + + trace!( + "Sending to provider={} url={} body_len={}", + proxy_config.provider_name, + url, + body_json.len() + ); + let req = req .body(HyperBody::from(body_json.to_string())) .map_err(|e| format!("Failed to create request body: {:?}", e))?; @@ -1819,3 +2146,9 @@ async fn try_provider( } } } + +fn normalize_provider_base_url(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + let trimmed = trimmed.strip_suffix("/v1").unwrap_or(trimmed); + trimmed.to_string() +}