From 2501dec81afff18b7f5f946789d22ad7cf8e91e6 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Feb 2026 15:13:58 -0600 Subject: [PATCH 01/19] feat: integrate Near.AI provider (glm-5) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- Cargo.lock | 1077 ++++++++++++++++++++++++++++++++----- Cargo.toml | 3 + entrypoint.sh | 36 ++ src/main.rs | 84 +++ src/nearai/attestation.rs | 179 ++++++ src/nearai/e2ee.rs | 230 ++++++++ src/nearai/error.rs | 71 +++ src/nearai/mod.rs | 6 + src/nearai/models.rs | 33 ++ src/nearai/nras.rs | 161 ++++++ src/nearai/verifier.rs | 333 ++++++++++++ src/proxy_config.rs | 66 ++- src/tokens.rs | 4 + src/web/openai.rs | 173 +++++- 14 files changed, 2311 insertions(+), 145 deletions(-) create mode 100644 src/nearai/attestation.rs create mode 100644 src/nearai/e2ee.rs create mode 100644 src/nearai/error.rs create mode 100644 src/nearai/mod.rs create mode 100644 src/nearai/models.rs create mode 100644 src/nearai/nras.rs create mode 100644 src/nearai/verifier.rs diff --git a/Cargo.lock b/Cargo.lock index c8e7775b..3337e9b4 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", ] @@ -684,7 +684,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 +828,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 +867,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 +907,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 +988,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 +1049,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 +1109,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" @@ -1053,7 +1152,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 +1254,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 +1327,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 +1347,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 +1452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1299,6 +1512,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" @@ -1337,6 +1562,12 @@ 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 +1591,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" @@ -1485,6 +1722,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,7 +1767,7 @@ dependencies = [ "parking_lot", "portable-atomic", "quanta", - "rand", + "rand 0.8.5", "smallvec", "spinning_top", ] @@ -1613,6 +1864,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,10 +2052,27 @@ 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-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]] @@ -1954,19 +2277,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 +2296,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 +2347,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 +2383,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 +2418,7 @@ dependencies = [ "ciborium", "hmac", "lazy_static", - "rand_core", + "rand_core 0.6.4", "secp256k1", "serde", "serde_json", @@ -2106,6 +2442,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,13 +2482,19 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "matchers" -version = "0.1.0" +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" dependencies = [ @@ -2230,6 +2575,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 +2694,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 +2725,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 +2743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2364,15 +2754,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 +2786,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 +2825,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", @@ -2447,7 +2843,7 @@ dependencies = [ "oauth2", "once_cell", "password-auth", - "rand_core", + "rand_core 0.6.4", "rcgen", "regex", "reqwest 0.11.27", @@ -2458,8 +2854,9 @@ dependencies = [ "serde_cbor", "serde_json", "sha2", + "sha3", "subtle", - "thiserror", + "thiserror 1.0.63", "tiktoken-rs", "tokio", "tokio-stream", @@ -2531,6 +2928,34 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[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 +2986,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 +2998,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 +3012,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 +3059,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 +3139,15 @@ dependencies = [ "vcpkg", ] +[[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 +3194,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 +3258,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 +3275,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 +3288,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 +3309,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 +3328,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 +3436,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 +3446,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", + "rustls 0.21.12", "rustls-pemfile", "serde", "serde_json", @@ -2895,36 +3455,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 +3499,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,12 +3519,18 @@ 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 = "ring" version = "0.17.8" @@ -2964,13 +3539,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 +3579,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 +3624,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 +3665,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 +3683,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 +3706,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" @@ -3116,7 +3772,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 +3825,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 +3877,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 +3950,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 +3968,7 @@ checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 1.0.63", "time", ] @@ -3344,6 +4022,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 +4131,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 +4162,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 +4185,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 +4218,7 @@ dependencies = [ "fancy-regex", "lazy_static", "parking_lot", - "rustc-hash", + "rustc-hash 1.1.0", ] [[package]] @@ -3604,7 +4324,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 +4362,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 +4441,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 +4543,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 +4558,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 +4588,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 +4623,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 +4633,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 +4707,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 +4744,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 +4754,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 = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +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 = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -4033,6 +4800,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 +4846,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 +5000,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 +5019,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 +5037,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 +5053,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 +5082,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror", + "thiserror 1.0.63", "time", ] @@ -4385,3 +5208,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..759c96d4 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,4 @@ 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"] } diff --git a/entrypoint.sh b/entrypoint.sh index f4e56aaa..67382a49 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -361,6 +361,12 @@ 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 +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 +467,15 @@ 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 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 +668,27 @@ 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..87552ce2 --- /dev/null +++ b/src/nearai/attestation.rs @@ -0,0 +1,179 @@ +use crate::nearai::error::NearAiError; +use crate::nearai::models::AttestationInfo; +use dcap_qvl::collateral::get_collateral; +use dcap_qvl::verify::ring::verify as verify_tdx; +use secp256k1::PublicKey; +use sha2::{Digest, Sha256}; +use sha3::Keccak256; +use std::time::{SystemTime, UNIX_EPOCH}; + +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], +} + +pub async fn verify_tdx_quote(intel_quote_hex: &str) -> Result { + let quote_bytes = hex::decode(intel_quote_hex.trim_start_matches("0x"))?; + + let collateral = get_collateral(INTEL_PCCS_URL, "e_bytes) + .await + .map_err(|e| NearAiError::Tdx(e.to_string()))?; + + 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()))?; + + // Be strict: dcap-qvl may return non-fatal statuses. We require UpToDate. + if verified.status != "UpToDate" { + return Err(NearAiError::Tdx(format!( + "quote verification status is {}", + verified.status + ))); + } + + let td10 = verified + .report + .as_td10() + .ok_or_else(|| NearAiError::Tdx("expected TD10 report".to_string()))?; + + Ok(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 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") +} diff --git a/src/nearai/e2ee.rs b/src/nearai/e2ee.rs new file mode 100644 index 00000000..812e4ca8 --- /dev/null +++ b/src/nearai/e2ee.rs @@ -0,0 +1,230 @@ +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::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 { + 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 + + // 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())); + }; + + for msg in messages { + let Some(obj) = msg.as_object_mut() else { + continue; + }; + let Some(content_val) = obj.get_mut("content") else { + continue; + }; + + match content_val { + Value::String(s) => { + if !s.is_empty() { + let plaintext = s.clone(); + let encrypted_hex = encrypt_ecies_hex(plaintext.as_bytes(), &model_pubkey)?; + *content_val = Value::String(encrypted_hex); + } + } + Value::Null => {} + _ => { + // v1 limitation: multimodal content will be visible to the Near gateway. + warn!("Near.AI E2EE: leaving non-string messages[].content in plaintext (v1 limitation)"); + } + } + } + + 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 { + return Ok(()); + }; + + for choice in choices { + let Some(choice_obj) = choice.as_object_mut() else { + continue; + }; + + if let Some(msg) = choice_obj.get_mut("message") { + decrypt_message_like_in_place(msg, crypto)?; + } + + if let Some(delta) = choice_obj.get_mut("delta") { + 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) { + return Err(NearAiError::Crypto( + "Near.AI response field did not look like hex ciphertext".to_string(), + )); + } + let plaintext = crypto.decrypt_field(s)?; + *val = Value::String(plaintext); + Ok(()) + } + _ => Err(NearAiError::Crypto( + "Near.AI response field had unexpected non-string type".to_string(), + )), + } +} + +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); + } + } +} 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..c1ad60a2 --- /dev/null +++ b/src/nearai/mod.rs @@ -0,0 +1,6 @@ +mod attestation; +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..ffa89218 --- /dev/null +++ b/src/nearai/nras.rs @@ -0,0 +1,161 @@ +use crate::nearai::error::NearAiError; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; +use serde::Deserialize; +use serde_json::Value; + +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: &str = "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); + validation.set_issuer(&[NRAS_ISSUER]); + validation.validate_aud = false; + validation.leeway = 60; + + let token = decode::(jwt, &key, &validation)?; + let claims = token.claims; + + let verdict = claims + .get("x-nvidia-overall-att-result") + .and_then(|v| v.as_bool()) + .unwrap_or(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(()) +} diff --git a/src/nearai/verifier.rs b/src/nearai/verifier.rs new file mode 100644 index 00000000..b37c02c8 --- /dev/null +++ b/src/nearai/verifier.rs @@ -0,0 +1,333 @@ +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_ISSUER}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock}; +use tracing::{info, 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); + +#[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>, + rr_counter: AtomicUsize, +} + +impl NearAiVerifier { + pub fn new(base_url: String, api_key: Option) -> Self { + let http = reqwest::Client::builder() + .pool_idle_timeout(Duration::from_secs(30)) + .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), + rr_counter: AtomicUsize::new(0), + } + } + + 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"); + for model in &models { + if let Err(e) = self.verify_and_cache_model(model).await { + warn!( + "Near.AI preflight verification failed for model {}: {}", + model, e + ); + } else { + info!( + "Near.AI preflight verification succeeded for model {}", + model + ); + } + } + + loop { + tokio::time::sleep(PERIODIC_REVERIFY_INTERVAL).await; + for model in &models { + if let Err(e) = self.verify_and_cache_model(model).await { + warn!( + "Near.AI periodic re-verification failed for model {}: {}", + model, e + ); + } + } + } + }); + } + + 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 { + return Ok(node); + } + + // 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 { + return Ok(node); + } + + 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)?; + if verified.verified_at.elapsed() > VERIFICATION_TTL { + return None; + } + + if verified.nodes.is_empty() { + return None; + } + + let idx = self.rr_counter.fetch_add(1, Ordering::Relaxed) % verified.nodes.len(); + Some(verified.nodes[idx].clone()) + } + + 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(); + for model_att in &report.model_attestations { + match self + .verify_single_attestation(model_att, &nonce_bytes, &nonce_hex, true) + .await + { + Ok(Some(node)) => nodes.push(node), + Ok(None) => {} + Err(e) => { + warn!( + "Near.AI model node attestation failed (model={}, signing_address={}): {}", + model, model_att.signing_address, e + ); + } + } + } + + if nodes.is_empty() { + return Err(NearAiError::Attestation( + "no verified model nodes available".to_string(), + )); + } + + 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('/') + ); + + 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 = req.send().await?; + let status = res.status(); + let text = res.text().await?; + if !status.is_success() { + return Err(NearAiError::HttpStatus { + url, + status, + body: text, + }); + } + + Ok(serde_json::from_str(&text)?) + } + + 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 = 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) = + verify_gpu_attestation(&self.http, &jwks, &payload_json, nonce_hex).await + { + match e { + NearAiError::JwtKidNotFound(_) => { + let jwks = self.refresh_jwks().await?; + 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_ISSUER + ))); + } + + let mut cache = self.jwks_cache.write().await; + *cache = Some(CachedJwks { + jwks: jwks.clone(), + fetched_at: Instant::now(), + }); + Ok(jwks) + } +} + +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) +} diff --git a/src/proxy_config.rs b/src/proxy_config.rs index 5d8c5e00..08f2878c 100644 --- a/src/proxy_config.rs +++ b/src/proxy_config.rs @@ -59,6 +59,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 +71,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 +96,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 +121,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 +198,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 +257,8 @@ mod tests { "http://continuum.example.com".to_string(), None, Some("http://tinfoil.example.com".to_string()), + None, + None, ); // Llama translations @@ -264,6 +290,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 +305,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 +317,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 +328,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 +341,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 +355,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 +370,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 +396,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 +411,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 @@ -440,6 +497,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 +519,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..f3c49092 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}; @@ -38,6 +41,72 @@ 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(); + + let node = state + .nearai_verifier + .get_verified_model_node(&model) + .await + .map_err(|e| { + error!("Near.AI verification failed (model={}): {}", model, e); + ApiError::ServiceUnavailable + })?; + + 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 + })?; + + 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 +510,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_near_crypto) = { debug!( "Using route for model {}: primary={}, fallbacks={}", model_name, @@ -461,11 +530,9 @@ 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_prepared = + prepare_chat_request_for_provider(state, &route.primary, Value::Object(primary_body)) + .await?; // Prepare fallback request if available let fallback_request = if let Some(fallback) = route.fallbacks.first() { @@ -479,12 +546,10 @@ 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 prepared = + prepare_chat_request_for_provider(state, fallback, Value::Object(fallback_body)) + .await?; + Some((fallback, prepared)) } else { None }; @@ -494,6 +559,7 @@ 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_near_crypto: Option = None; for cycle in 0..max_cycles { if cycle > 0 { @@ -508,7 +574,15 @@ pub async fn get_chat_completion_response( cycle + 1, route.primary.provider_name ); - match try_provider(&client, &route.primary, &primary_body_json, headers).await { + match try_provider( + &client, + &route.primary, + &primary_prepared.body_json, + headers, + &primary_prepared.extra_headers, + ) + .await + { Ok(response) => { info!( "Successfully got response from primary provider {} on cycle {}", @@ -517,6 +591,7 @@ pub async fn get_chat_completion_response( ); found_response = Some(response); successful_provider_name = route.primary.provider_name.clone(); + successful_near_crypto = primary_prepared.near_crypto.clone(); break; } Err(err) => { @@ -531,13 +606,21 @@ pub async fn get_chat_completion_response( } // Try fallback if available - if let Some((fallback_provider, ref fallback_body_json)) = fallback_request { + if let Some((fallback_provider, fallback_prepared)) = &fallback_request { debug!( "Cycle {}: Trying fallback provider {}", cycle + 1, fallback_provider.provider_name ); - match try_provider(&client, fallback_provider, fallback_body_json, headers).await { + 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 {}", @@ -546,6 +629,7 @@ pub async fn get_chat_completion_response( ); found_response = Some(response); successful_provider_name = fallback_provider.provider_name.clone(); + successful_near_crypto = fallback_prepared.near_crypto.clone(); break; } Err(err) => { @@ -562,7 +646,7 @@ pub async fn get_chat_completion_response( } match found_response { - Some(response) => (response, successful_provider_name), + Some(response) => (response, successful_provider_name, successful_near_crypto), None => { let error_msg = if route.fallbacks.is_empty() { format!( @@ -576,6 +660,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 +685,24 @@ 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); + ApiError::ServiceUnavailable + })?; + } + // ✅ Handle billing HERE, inside completions API if let Some(usage) = extract_usage(&response_json) { publish_usage_event_internal( @@ -637,6 +739,8 @@ 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"; tokio::spawn(async move { let mut body_stream = res.into_body().into_stream(); @@ -667,7 +771,34 @@ pub async fn get_chat_completion_response( } match serde_json::from_str::(&frame) { - Ok(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 + ); + let _ = tx_consumer + .send(CompletionChunk::Error( + "Near.AI decrypt failed".to_string(), + )) + .await; + return; + } + } + // ✅ 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); @@ -1729,6 +1860,7 @@ 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); @@ -1760,6 +1892,11 @@ async fn try_provider( } } + // Add provider-specific headers + for (name, val) in extra_headers { + req = req.header(name.clone(), val.clone()); + } + let req = req .body(HyperBody::from(body_json.to_string())) .map_err(|e| format!("Failed to create request body: {:?}", e))?; From be95341d8f71dd0dcad2bd15e5b1f741032b8043 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Feb 2026 15:22:09 -0600 Subject: [PATCH 02/19] test: add Near.AI verification and E2EE tests Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- Cargo.lock | 121 +++++++++++++++++++++++ Cargo.toml | 3 + src/nearai/attestation.rs | 88 +++++++++++++++++ src/nearai/e2ee.rs | 122 ++++++++++++++++++++++++ src/nearai/nras.rs | 147 ++++++++++++++++++++++++++++ src/nearai/verifier.rs | 196 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 677 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 3337e9b4..99831f7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -1145,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" @@ -1488,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" @@ -1503,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" @@ -1556,6 +1609,16 @@ 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" @@ -1707,6 +1770,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1772,6 +1836,17 @@ dependencies = [ "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" @@ -2842,6 +2917,7 @@ dependencies = [ "lazy_static", "oauth2", "once_cell", + "p384", "password-auth", "rand_core 0.6.4", "rcgen", @@ -2928,6 +3004,18 @@ 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" @@ -3139,6 +3227,15 @@ 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" @@ -3531,6 +3628,16 @@ 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]] name = "ring" version = "0.17.8" @@ -3765,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" diff --git a/Cargo.toml b/Cargo.toml index 759c96d4..b6281b07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,3 +78,6 @@ 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/src/nearai/attestation.rs b/src/nearai/attestation.rs index 87552ce2..47fff0a5 100644 --- a/src/nearai/attestation.rs +++ b/src/nearai/attestation.rs @@ -177,3 +177,91 @@ fn ethereum_address_from_uncompressed_pubkey_64(pubkey_64: &[u8]) -> [u8; 20] { let hash = Keccak256::digest(pubkey_64); hash[12..32].try_into().expect("slice length is 20 bytes") } + +#[cfg(test)] +mod tests { + use super::*; + use secp256k1::{PublicKey, Secp256k1, SecretKey}; + use serde_json::json; + + #[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 app_compose = "version: '3'\nservices:\n app:\n image: example"; + 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/e2ee.rs b/src/nearai/e2ee.rs index 812e4ca8..8068237e 100644 --- a/src/nearai/e2ee.rs +++ b/src/nearai/e2ee.rs @@ -228,3 +228,125 @@ fn generate_secp256k1_secret_key() -> Result { } } } + +#[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_only() { + 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 multimodal = json!([ + {"type": "input_text", "text": "this is plaintext"}, + {"type": "input_image", "image_url": "https://example.com/foo.png"} + ]); + + let mut body = json!({ + "model": "zai-org/GLM-5-FP8", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "user", "content": multimodal.clone()}, + {"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)); + + assert_eq!(body["messages"][1]["content"], multimodal); + assert_eq!(body["messages"][2]["content"], ""); + + 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_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/nras.rs b/src/nearai/nras.rs index ffa89218..41d62905 100644 --- a/src/nearai/nras.rs +++ b/src/nearai/nras.rs @@ -159,3 +159,150 @@ pub fn verify_nras_jwt( 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, "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, + "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, + "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, + "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, + "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()); + } +} diff --git a/src/nearai/verifier.rs b/src/nearai/verifier.rs index b37c02c8..32703ce7 100644 --- a/src/nearai/verifier.rs +++ b/src/nearai/verifier.rs @@ -331,3 +331,199 @@ fn generate_nonce_bytes() -> Result<[u8; 32], NearAiError> { .map_err(|e| NearAiError::Crypto(format!("nonce generation failed: {e}")))?; Ok(nonce) } + +#[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_round_robins_when_fresh() { + 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_ne!(n1.signing_public_key, 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 content = resp["choices"][0]["message"]["content"] + .as_str() + .unwrap_or(""); + assert!(!content.trim().is_empty()); + } +} From 4ba1129979f038bc2378f7ed107e0cbac1dd1472 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Feb 2026 15:39:27 -0600 Subject: [PATCH 03/19] fix: improve Near.AI live verification Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/nearai/attestation.rs | 9 ++----- src/nearai/nras.rs | 15 +++++------ src/nearai/verifier.rs | 53 ++++++++++++++++++++++++++++++++------- 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/nearai/attestation.rs b/src/nearai/attestation.rs index 47fff0a5..597144d3 100644 --- a/src/nearai/attestation.rs +++ b/src/nearai/attestation.rs @@ -29,13 +29,8 @@ pub async fn verify_tdx_quote(intel_quote_hex: &str) -> Result = 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(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 @@ -189,9 +196,14 @@ impl NearAiVerifier { } if nodes.is_empty() { - return Err(NearAiError::Attestation( - "no verified model nodes available".to_string(), - )); + 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)); } Ok(VerifiedModel { @@ -312,7 +324,7 @@ impl NearAiVerifier { if jwks.keys.is_empty() { return Err(NearAiError::Jwt(format!( "JWKS from {} contained no keys", - NRAS_ISSUER + NRAS_JWKS_URL ))); } @@ -521,9 +533,32 @@ mod tests { decrypt_chat_completion_json_in_place(&mut resp, &crypto) .expect("Near.AI response should decrypt"); - let content = resp["choices"][0]["message"]["content"] - .as_str() + 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(""); - assert!(!content.trim().is_empty()); + + 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() + ); } } From e74392bbda3be4d86f01e03756eb2dd05ba68de2 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Feb 2026 19:23:54 -0600 Subject: [PATCH 04/19] fix: flatten text-array content for Near.AI E2EE encryption The responses API wraps user text in OpenAI multimodal array format ([{"type":"text","text":"..."}]) but Near.AI E2EE only encrypted string content, leaving array content as plaintext. Near.AI's model node then failed to decrypt with 'Failed to decrypt field'. Added try_flatten_text_content_array() to detect text-only arrays and flatten them to plain strings before encryption. Genuinely multimodal content (with images) is still left as-is. Also added debug logging across the E2EE pipeline and new live integration tests (hyper-flow, streaming, multi-turn, repeated). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/nearai/e2ee.rs | 188 +++++++++++++++++- src/nearai/verifier.rs | 422 ++++++++++++++++++++++++++++++++++++++++- src/web/openai.rs | 29 +++ 3 files changed, 628 insertions(+), 11 deletions(-) diff --git a/src/nearai/e2ee.rs b/src/nearai/e2ee.rs index 8068237e..495982dd 100644 --- a/src/nearai/e2ee.rs +++ b/src/nearai/e2ee.rs @@ -7,7 +7,7 @@ use secp256k1::ecdh::shared_secret_point; use secp256k1::{PublicKey, Secp256k1, SecretKey}; use serde_json::Value; use sha2::Sha256; -use tracing::warn; +use tracing::{debug, trace, warn}; const HKDF_INFO: &[u8] = b"ecdsa_encryption"; const MIN_CIPHERTEXT_BYTES: usize = 65 + 12 + 16; @@ -30,6 +30,11 @@ 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(); @@ -38,35 +43,80 @@ pub fn prepare_e2ee_request( 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())); }; - for msg in messages { + 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) => { if !s.is_empty() { + 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; + } else { + skipped_count += 1; } } - Value::Null => {} + Value::Null => { + skipped_count += 1; + } _ => { - // v1 limitation: multimodal content will be visible to the Near gateway. - warn!("Near.AI E2EE: leaving non-string messages[].content in plaintext (v1 limitation)"); + // Genuinely multimodal content (images, etc.) -- cannot flatten to string. + warn!( + "Near.AI E2EE: leaving non-string messages[{}].content (role={}) in plaintext (v1 limitation)", + i, role + ); + skipped_count += 1; } } } + 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() @@ -87,19 +137,24 @@ pub fn decrypt_chat_completion_json_in_place( 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(()); }; - for choice in choices { + 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)?; } } @@ -136,20 +191,61 @@ fn decrypt_string_field_in_place( } // 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(()) } - _ => Err(NearAiError::Crypto( - "Near.AI response field had unexpected non-string type".to_string(), - )), + _ => { + 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; @@ -262,6 +358,7 @@ mod tests { let model_pk_uncompressed = model_pk.serialize_uncompressed(); let model_pubkey_hex = hex::encode(&model_pk_uncompressed[1..]); + // Genuinely multimodal: has an image part, so cannot be flattened let multimodal = json!([ {"type": "input_text", "text": "this is plaintext"}, {"type": "input_image", "image_url": "https://example.com/foo.png"} @@ -283,6 +380,7 @@ mod tests { assert_ne!(c0, "hello"); assert!(looks_like_hex_ciphertext(c0)); + // Genuinely multimodal content stays as array (can't flatten) assert_eq!(body["messages"][1]["content"], multimodal); assert_eq!(body["messages"][2]["content"], ""); @@ -294,6 +392,78 @@ mod tests { .all(|b| b.is_ascii_hexdigit())); } + #[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(); diff --git a/src/nearai/verifier.rs b/src/nearai/verifier.rs index 4c9c79f1..ffdb8dc7 100644 --- a/src/nearai/verifier.rs +++ b/src/nearai/verifier.rs @@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, RwLock}; -use tracing::{info, warn}; +use tracing::{debug, info, trace, warn}; const VERIFICATION_TTL: Duration = Duration::from_secs(10 * 60); const PERIODIC_REVERIFY_INTERVAL: Duration = Duration::from_secs(10 * 60); @@ -112,17 +112,33 @@ impl NearAiVerifier { // 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(|| { @@ -133,18 +149,40 @@ impl NearAiVerifier { async fn try_get_fresh_cached_node(&self, model: &str) -> Option { let cache = self.cache.read().await; let verified = cache.get(model)?; - if verified.verified_at.elapsed() > VERIFICATION_TTL { + 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 = self.rr_counter.fetch_add(1, Ordering::Relaxed) % 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; @@ -206,6 +244,19 @@ impl NearAiVerifier { return Err(NearAiError::Attestation(msg)); } + info!( + "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(), @@ -561,4 +612,371 @@ mod tests { || !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/web/openai.rs b/src/web/openai.rs index f3c49092..a17cce05 100644 --- a/src/web/openai.rs +++ b/src/web/openai.rs @@ -63,6 +63,10 @@ async fn prepare_chat_request_for_provider( .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) @@ -72,11 +76,23 @@ async fn prepare_chat_request_for_provider( 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"), @@ -1894,9 +1910,22 @@ 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={}/v1/chat/completions body_len={}", + proxy_config.provider_name, + proxy_config.base_url, + body_json.len() + ); + let req = req .body(HyperBody::from(body_json.to_string())) .map_err(|e| format!("Failed to create request body: {:?}", e))?; From 21049fa4853c3b6cce5cdca5dee5a5c0338475c5 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 09:34:05 -0600 Subject: [PATCH 05/19] fix: harden Near.AI node selection and retries Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/nearai/verifier.rs | 160 ++++++++++++++++++++++-------- src/web/openai.rs | 219 ++++++++++++++++++++++++++++++----------- 2 files changed, 280 insertions(+), 99 deletions(-) diff --git a/src/nearai/verifier.rs b/src/nearai/verifier.rs index ffdb8dc7..f0e40d01 100644 --- a/src/nearai/verifier.rs +++ b/src/nearai/verifier.rs @@ -7,15 +7,16 @@ 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::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, RwLock}; -use tracing::{debug, info, trace, warn}; +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(30); +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug, Clone)] pub struct VerifiedModelNode { @@ -41,13 +42,17 @@ pub struct NearAiVerifier { cache: RwLock>, verify_lock: Mutex<()>, jwks_cache: RwLock>, - rr_counter: AtomicUsize, } 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"); @@ -58,7 +63,6 @@ impl NearAiVerifier { cache: RwLock::new(HashMap::new()), verify_lock: Mutex::new(()), jwks_cache: RwLock::new(None), - rr_counter: AtomicUsize::new(0), } } @@ -74,28 +78,78 @@ impl NearAiVerifier { tokio::spawn(async move { info!("Near.AI preflight verification starting"); + + let mut last_state: HashMap = HashMap::new(); + for model in &models { - if let Err(e) = self.verify_and_cache_model(model).await { - warn!( - "Near.AI preflight verification failed for model {}: {}", - model, e - ); - } else { - info!( - "Near.AI preflight verification succeeded for model {}", - model - ); + 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 { - if let Err(e) = self.verify_and_cache_model(model).await { - warn!( - "Near.AI periodic re-verification failed for model {}: {}", - model, e - ); + 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 + ); + } + } } } } @@ -165,7 +219,7 @@ impl NearAiVerifier { return None; } - let idx = self.rr_counter.fetch_add(1, Ordering::Relaxed) % verified.nodes.len(); + 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", @@ -178,6 +232,7 @@ impl NearAiVerifier { Some(verified.nodes[idx].clone()) } + #[allow(dead_code)] pub async fn invalidate_model(&self, model: &str) { let mut cache = self.cache.write().await; cache.remove(model); @@ -244,7 +299,7 @@ impl NearAiVerifier { return Err(NearAiError::Attestation(msg)); } - info!( + debug!( "Near.AI verifier: model={} verified, {} nodes (total_attestations={}, ok_missing_pubkey={})", model, nodes.len(), total_nodes, ok_missing_pubkey ); @@ -395,6 +450,26 @@ fn generate_nonce_bytes() -> Result<[u8; 32], NearAiError> { 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::*; @@ -415,7 +490,7 @@ mod tests { } #[tokio::test] - async fn test_cached_node_round_robins_when_fresh() { + async fn test_cached_node_selects_from_verified_nodes() { let v = NearAiVerifier::new( "https://cloud-api.near.ai".to_string(), Some("test".to_string()), @@ -447,7 +522,16 @@ mod tests { .get_verified_model_node("zai-org/GLM-5-FP8") .await .unwrap(); - assert_ne!(n1.signing_public_key, n2.signing_public_key); + 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] @@ -649,10 +733,7 @@ mod tests { 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}), - ); + body_map.insert("stream_options".to_string(), json!({"include_usage": true})); let mut body_value = serde_json::Value::Object(body_map); @@ -686,17 +767,11 @@ mod tests { let resp_text = res.text().await.unwrap(); eprintln!("status={} body={}", status, resp_text); - assert!( - status.is_success(), - "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"); + decrypt_chat_completion_json_in_place(&mut resp, &crypto).expect("response should decrypt"); let content = resp["choices"][0]["message"]["content"] .as_str() @@ -748,10 +823,7 @@ mod tests { 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}), - ); + body_map.insert("stream_options".to_string(), json!({"include_usage": true})); let mut body_value = serde_json::Value::Object(body_map); @@ -781,7 +853,10 @@ mod tests { if !status.is_success() { eprintln!("streaming response body={}", resp_text); - panic!("streaming request failed: status={} body={}", status, resp_text); + panic!( + "streaming request failed: status={} body={}", + status, resp_text + ); } // Parse SSE chunks @@ -825,7 +900,10 @@ mod tests { } } - assert!(got_content, "streaming response should have produced some decrypted content"); + assert!( + got_content, + "streaming response should have produced some decrypted content" + ); } /// Test with multi-turn conversation (system + user messages, like the app sends) diff --git a/src/web/openai.rs b/src/web/openai.rs index a17cce05..da5632c2 100644 --- a/src/web/openai.rs +++ b/src/web/openai.rs @@ -546,9 +546,16 @@ pub async fn get_chat_completion_response( primary_body.insert("stream_options".to_string(), json!({"include_usage": true})); } - let primary_prepared = - prepare_chat_request_for_provider(state, &route.primary, Value::Object(primary_body)) - .await?; + 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() { @@ -562,10 +569,23 @@ pub async fn get_chat_completion_response( fallback_body.insert("stream_options".to_string(), json!({"include_usage": true})); } - let prepared = - prepare_chat_request_for_provider(state, fallback, Value::Object(fallback_body)) - .await?; - Some((fallback, prepared)) + 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_provider_is_near, + fallback_body_value, + fallback_prepared_static, + )) } else { None }; @@ -590,75 +610,142 @@ pub async fn get_chat_completion_response( cycle + 1, route.primary.provider_name ); - match try_provider( - &client, - &route.primary, - &primary_prepared.body_json, - headers, - &primary_prepared.extra_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(); - successful_near_crypto = primary_prepared.near_crypto.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, fallback_prepared)) = &fallback_request { - debug!( - "Cycle {}: Trying fallback provider {}", - cycle + 1, - fallback_provider.provider_name - ); + if let Some(primary_prepared) = primary_prepared { match try_provider( &client, - fallback_provider, - &fallback_prepared.body_json, + &route.primary, + &primary_prepared.body_json, headers, - &fallback_prepared.extra_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_near_crypto = fallback_prepared.near_crypto.clone(); + successful_provider_name = route.primary.provider_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_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_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 { @@ -1504,7 +1591,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), @@ -1646,7 +1734,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| { @@ -1780,7 +1871,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) @@ -1880,10 +1974,13 @@ async fn try_provider( ) -> 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 { @@ -1920,9 +2017,9 @@ async fn try_provider( } trace!( - "Sending to provider={} url={}/v1/chat/completions body_len={}", + "Sending to provider={} url={} body_len={}", proxy_config.provider_name, - proxy_config.base_url, + url, body_json.len() ); @@ -1985,3 +2082,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() +} From 7c0f738a9a87380e5e58e2791faf52afcd7d5c3c Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 09:48:51 -0600 Subject: [PATCH 06/19] fix: handle streaming billing when finish_reason and usage arrive on separate chunks Near.AI sends finish_reason and usage on separate SSE chunks, unlike Tinfoil/vLLM which combine them. Track stream_finished state across chunks so billing fires when usage arrives after finish_reason. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/web/openai.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/web/openai.rs b/src/web/openai.rs index da5632c2..b6bcaed6 100644 --- a/src/web/openai.rs +++ b/src/web/openai.rs @@ -849,6 +849,7 @@ pub async fn get_chat_completion_response( 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( @@ -902,14 +903,18 @@ pub async fn get_chat_completion_response( } } - // ✅ 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); + // 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 { @@ -933,8 +938,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 ); } } From 876f8edcf92e0eaf1642653dd85638083fc1fc96 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 09:50:10 -0600 Subject: [PATCH 07/19] style: cargo fmt Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/nearai/e2ee.rs | 22 +++++++++++++++------- src/web/openai.rs | 13 +++++++------ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/nearai/e2ee.rs b/src/nearai/e2ee.rs index 495982dd..633ecb5c 100644 --- a/src/nearai/e2ee.rs +++ b/src/nearai/e2ee.rs @@ -75,7 +75,8 @@ pub fn prepare_e2ee_request( 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 + i, + role ); *content_val = Value::String(flattened); } @@ -213,9 +214,7 @@ fn decrypt_string_field_in_place( Ok(()) } _ => { - trace!( - "Near.AI E2EE decrypt: unexpected non-string field type" - ); + trace!("Near.AI E2EE decrypt: unexpected non-string field type"); Err(NearAiError::Crypto( "Near.AI response field had unexpected non-string type".to_string(), )) @@ -431,18 +430,27 @@ mod tests { 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())); + 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())); + 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())); + assert_eq!( + try_flatten_text_content_array(&val), + Some("hey".to_string()) + ); // Mixed with image -- not flattenable let val = json!([ diff --git a/src/web/openai.rs b/src/web/openai.rs index b6bcaed6..b1862d3c 100644 --- a/src/web/openai.rs +++ b/src/web/openai.rs @@ -63,10 +63,7 @@ async fn prepare_chat_request_for_provider( .ok_or(ApiError::BadRequest)? .to_string(); - trace!( - "Near.AI E2EE: fetching verified node for model={}", - model - ); + trace!("Near.AI E2EE: fetching verified node for model={}", model); let node = state .nearai_verifier .get_verified_model_node(&model) @@ -552,8 +549,12 @@ pub async fn get_chat_completion_response( None } else { Some( - prepare_chat_request_for_provider(state, &route.primary, primary_body_value.clone()) - .await?, + prepare_chat_request_for_provider( + state, + &route.primary, + primary_body_value.clone(), + ) + .await?, ) }; From ad118ef71394262ffd80ee52244d15117c9f4b80 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 11:09:40 -0600 Subject: [PATCH 08/19] fix: support TD15 TDX quotes in Near.AI attestation Avoid verification failures if Near upgrades to TD15 quotes. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/nearai/attestation.rs | 61 +++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/src/nearai/attestation.rs b/src/nearai/attestation.rs index 597144d3..5dcd6976 100644 --- a/src/nearai/attestation.rs +++ b/src/nearai/attestation.rs @@ -1,6 +1,7 @@ use crate::nearai::error::NearAiError; use crate::nearai::models::AttestationInfo; use dcap_qvl::collateral::get_collateral; +use dcap_qvl::quote::{Report, TDReport10}; use dcap_qvl::verify::ring::verify as verify_tdx; use secp256k1::PublicKey; use sha2::{Digest, Sha256}; @@ -32,12 +33,19 @@ pub async fn verify_tdx_quote(intel_quote_hex: &str) -> Result 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, + }; - Ok(VerifiedTdxQuote { + Some(VerifiedTdxQuote { report_data: td10.report_data, mr_config_id: td10.mr_config_id, }) @@ -176,9 +184,52 @@ fn ethereum_address_from_uncompressed_pubkey_64(pubkey_64: &[u8]) -> [u8; 20] { #[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]; From 2f7148c6f21d816c01db92a133649ad727390654 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 11:29:39 -0600 Subject: [PATCH 09/19] fix: accept string verdict in NRAS JWT x-nvidia-overall-att-result NVIDIA NRAS may return the attestation verdict as either a boolean or a string. Previously only the boolean form was handled, which would silently reject a string "true" and fail model node verification. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/nearai/nras.rs | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/nearai/nras.rs b/src/nearai/nras.rs index b68eb3a3..4b3eb345 100644 --- a/src/nearai/nras.rs +++ b/src/nearai/nras.rs @@ -133,10 +133,11 @@ pub fn verify_nras_jwt( let token = decode::(jwt, &key, &validation)?; let claims = token.claims; - let verdict = claims - .get("x-nvidia-overall-att-result") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + 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(), @@ -306,4 +307,36 @@ mod tests { 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()); + } } From b99a904f8664f05286a0d5811f0b6982a0980857 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 12:03:09 -0600 Subject: [PATCH 10/19] fix: harden Near.AI verification strictness and retries Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/nearai/attestation.rs | 18 ++++++++- src/nearai/e2ee.rs | 29 ++++++------- src/nearai/nras.rs | 61 ++++++++++++++++++++++++++++ src/nearai/verifier.rs | 85 +++++++++++++++++++++++++++++---------- 4 files changed, 157 insertions(+), 36 deletions(-) diff --git a/src/nearai/attestation.rs b/src/nearai/attestation.rs index 5dcd6976..6dc48e6f 100644 --- a/src/nearai/attestation.rs +++ b/src/nearai/attestation.rs @@ -149,6 +149,21 @@ pub fn verify_compose_manifest( .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); @@ -288,7 +303,8 @@ mod tests { #[test] fn test_verify_compose_manifest_accepts_object_and_string() { - let app_compose = "version: '3'\nservices:\n app:\n image: example"; + 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 { diff --git a/src/nearai/e2ee.rs b/src/nearai/e2ee.rs index 633ecb5c..d0f1f832 100644 --- a/src/nearai/e2ee.rs +++ b/src/nearai/e2ee.rs @@ -83,19 +83,18 @@ pub fn prepare_e2ee_request( match content_val { Value::String(s) => { - if !s.is_empty() { - 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; - } else { - skipped_count += 1; - } + 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; @@ -381,7 +380,9 @@ mod tests { // Genuinely multimodal content stays as array (can't flatten) assert_eq!(body["messages"][1]["content"], multimodal); - assert_eq!(body["messages"][2]["content"], ""); + let c2 = body["messages"][2]["content"].as_str().unwrap(); + assert_ne!(c2, ""); + assert!(looks_like_hex_ciphertext(c2)); assert_eq!(crypto.client_public_key_hex.len(), 128); assert!(crypto diff --git a/src/nearai/nras.rs b/src/nearai/nras.rs index 4b3eb345..202eca56 100644 --- a/src/nearai/nras.rs +++ b/src/nearai/nras.rs @@ -2,6 +2,7 @@ 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"; @@ -126,13 +127,36 @@ pub fn verify_nras_jwt( 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"), @@ -339,4 +363,41 @@ mod tests { 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 index f0e40d01..9d0936d9 100644 --- a/src/nearai/verifier.rs +++ b/src/nearai/verifier.rs @@ -15,8 +15,10 @@ 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(30); -const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +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 { @@ -329,29 +331,70 @@ impl NearAiVerifier { self.base_url.trim_end_matches('/') ); - let mut req = self.http.get(&url).query(&[ - ("model", model), - ("nonce", nonce_hex), - ("signing_algo", "ecdsa"), - ]); + 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)); - } + // 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 = req.send().await?; - let status = res.status(); - let text = res.text().await?; - if !status.is_success() { - return Err(NearAiError::HttpStatus { - url, - status, - body: text, - }); + 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)?); } - Ok(serde_json::from_str(&text)?) + unreachable!("attestation report retry loop exhausted without returning") } async fn verify_single_attestation( From fed7b848f7ba2b37c659b2378b13e17dfd30998e Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 13:19:33 -0600 Subject: [PATCH 11/19] fix: redact api_key in ProxyConfig Debug output to prevent credential leaks in logs Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/proxy_config.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/proxy_config.rs b/src/proxy_config.rs index 08f2878c..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 @@ -458,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()), @@ -468,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] From 246398558056c44a18db65e200e065ac50fe442f Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 14:55:59 -0600 Subject: [PATCH 12/19] fix: harden Near.AI header handling and TDX policy Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> fix: require UpToDate TDX status by default Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> fix: require UpToDate TDX status Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/nearai/attestation.rs | 20 ++++++++++++++++++-- src/web/openai.rs | 8 ++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/nearai/attestation.rs b/src/nearai/attestation.rs index 6dc48e6f..eeb5bbff 100644 --- a/src/nearai/attestation.rs +++ b/src/nearai/attestation.rs @@ -7,6 +7,7 @@ use secp256k1::PublicKey; use sha2::{Digest, Sha256}; use sha3::Keccak256; use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::debug; pub const INTEL_PCCS_URL: &str = "https://api.trustedservices.intel.com/tdx/certification/v4"; @@ -30,8 +31,23 @@ pub async fn verify_tdx_quote(intel_quote_hex: &str) -> Result Date: Fri, 20 Feb 2026 15:24:50 -0600 Subject: [PATCH 13/19] fix: reject multimodal content in Near.AI E2EE instead of silently passing plaintext Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/nearai/e2ee.rs | 53 ++++++++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/src/nearai/e2ee.rs b/src/nearai/e2ee.rs index d0f1f832..6e4a7aa3 100644 --- a/src/nearai/e2ee.rs +++ b/src/nearai/e2ee.rs @@ -100,12 +100,11 @@ pub fn prepare_e2ee_request( skipped_count += 1; } _ => { - // Genuinely multimodal content (images, etc.) -- cannot flatten to string. - warn!( - "Near.AI E2EE: leaving non-string messages[{}].content (role={}) in plaintext (v1 limitation)", + return Err(NearAiError::Crypto(format!( + "messages[{}].content (role={}) contains non-text content (e.g. images). \ + Near.AI E2EE only supports text messages.", i, role - ); - skipped_count += 1; + ))); } } } @@ -349,24 +348,17 @@ mod tests { } #[test] - fn test_prepare_e2ee_request_encrypts_string_content_only() { + 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..]); - // Genuinely multimodal: has an image part, so cannot be flattened - let multimodal = json!([ - {"type": "input_text", "text": "this is plaintext"}, - {"type": "input_image", "image_url": "https://example.com/foo.png"} - ]); - let mut body = json!({ "model": "zai-org/GLM-5-FP8", "messages": [ {"role": "user", "content": "hello"}, - {"role": "user", "content": multimodal.clone()}, {"role": "user", "content": ""} ], "tools": [{"type": "function", "function": {"name": "foo", "parameters": {}}}] @@ -378,11 +370,9 @@ mod tests { assert_ne!(c0, "hello"); assert!(looks_like_hex_ciphertext(c0)); - // Genuinely multimodal content stays as array (can't flatten) - assert_eq!(body["messages"][1]["content"], multimodal); - let c2 = body["messages"][2]["content"].as_str().unwrap(); - assert_ne!(c2, ""); - assert!(looks_like_hex_ciphertext(c2)); + 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 @@ -392,6 +382,33 @@ mod tests { .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(); From 641c7bba5169c688ab1f98764d9f4335134623f9 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 16:21:41 -0600 Subject: [PATCH 14/19] docs: add Near.AI vsock proxy setup and API key provisioning to nitro-deploy Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/nitro-deploy.md | 124 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/docs/nitro-deploy.md b/docs/nitro-deploy.md index 8426a8ba..5c11b057 100644 --- a/docs/nitro-deploy.md +++ b/docs/nitro-deploy.md @@ -1328,6 +1328,109 @@ 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} +``` + +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 +``` + +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 +``` + +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 +``` + ## 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 +1802,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 From c2de54824d2149aae47010de84f06843844816f7 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 16:57:46 -0600 Subject: [PATCH 15/19] fix: add retry logic to Intel TDX and NVIDIA GPU attestation verification Intel get_collateral and NVIDIA verify_gpu_attestation calls had no retry handling, unlike fetch_attestation_report which already retried 3 times. Transient network failures (e.g. vsock proxy not ready at boot) caused immediate verification failure with a 10-minute wait for recovery. Also use alternate Display format for get_collateral errors to log the full error chain instead of just the top-level message. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/nearai/attestation.rs | 2 +- src/nearai/verifier.rs | 68 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/nearai/attestation.rs b/src/nearai/attestation.rs index eeb5bbff..6d043b4d 100644 --- a/src/nearai/attestation.rs +++ b/src/nearai/attestation.rs @@ -21,7 +21,7 @@ pub async fn verify_tdx_quote(intel_quote_hex: &str) -> Result { let jwks = self.refresh_jwks().await?; - verify_gpu_attestation(&self.http, &jwks, &payload_json, nonce_hex).await?; + retry_verify_gpu_attestation(&self.http, &jwks, &payload_json, nonce_hex) + .await?; } _ => return Err(e), } @@ -486,6 +487,67 @@ impl NearAiVerifier { } } +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) From 0a14c594686ab3fd6d5458c39f002b661ab0397b Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 17:37:55 -0600 Subject: [PATCH 16/19] fix: replace dcap-qvl collateral fetcher to avoid hickory-dns in enclaves dcap-qvl's get_collateral builds its own reqwest client with hickory-dns enabled, which bypasses /etc/hosts and requires a real DNS server. Nitro enclaves have no DNS -- all hosts resolve via /etc/hosts to local vsock traffic forwarders. Add nearai/collateral.rs that replicates the HTTP fetching logic from dcap-qvl 0.3.12 using a plain reqwest client. All cryptographic verification still uses dcap_qvl::verify::ring::verify unchanged. Also add certificates.trustedservices.intel.com vsock proxy for the Intel root CA CRL distribution point used by Intel PCS. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/nitro-deploy.md | 25 ++++ entrypoint.sh | 11 ++ src/nearai/attestation.rs | 19 ++- src/nearai/collateral.rs | 259 ++++++++++++++++++++++++++++++++++++++ src/nearai/mod.rs | 1 + 5 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 src/nearai/collateral.rs diff --git a/docs/nitro-deploy.md b/docs/nitro-deploy.md index 5c11b057..ea2c80dd 100644 --- a/docs/nitro-deploy.md +++ b/docs/nitro-deploy.md @@ -1342,6 +1342,7 @@ 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: @@ -1409,6 +1410,26 @@ Restart=always 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 @@ -1422,6 +1443,9 @@ 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: @@ -1429,6 +1453,7 @@ If you need to restart these services: 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 diff --git a/entrypoint.sh b/entrypoint.sh index 67382a49..443b5aed 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -365,6 +365,7 @@ log "Added Tinfoil proxy domains 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 @@ -476,6 +477,9 @@ 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 & @@ -689,6 +693,13 @@ else log "Intel PCS connection failed" fi +log "Testing connection to Intel Certificates:" +if timeout 5 bash -c ' 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 collateral = get_collateral(INTEL_PCCS_URL, "e_bytes) - .await - .map_err(|e| NearAiError::Tdx(format!("{e:#}")))?; + 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) 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/mod.rs b/src/nearai/mod.rs index c1ad60a2..b23c3c49 100644 --- a/src/nearai/mod.rs +++ b/src/nearai/mod.rs @@ -1,4 +1,5 @@ mod attestation; +mod collateral; pub mod e2ee; pub mod error; mod models; From 0486b4d6b0ca2fc883e96eefddfebb14a7e5214c Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Feb 2026 18:28:23 -0600 Subject: [PATCH 17/19] fix: accept OutOfDate TDX TCB status with warning Most production TDX deployments lag behind Intel's latest firmware updates, resulting in OutOfDate or SWHardeningNeeded TCB status. Accept these statuses (the quote is still cryptographically valid) and log a warning with the advisory IDs. Only Revoked is rejected. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/nearai/attestation.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/nearai/attestation.rs b/src/nearai/attestation.rs index 468d7d0f..ebc7faca 100644 --- a/src/nearai/attestation.rs +++ b/src/nearai/attestation.rs @@ -6,7 +6,7 @@ use secp256k1::PublicKey; use sha2::{Digest, Sha256}; use sha3::Keccak256; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tracing::debug; +use tracing::{debug, warn}; pub const INTEL_PCCS_URL: &str = "https://api.trustedservices.intel.com/tdx/certification/v4"; @@ -48,9 +48,21 @@ pub async fn verify_tdx_quote(intel_quote_hex: &str) -> Result Result Date: Fri, 20 Feb 2026 18:45:17 -0600 Subject: [PATCH 18/19] Update pcr --- pcrDev.json | 4 ++-- pcrDevHistory.json | 7 +++++++ pcrProd.json | 4 ++-- pcrProdHistory.json | 7 +++++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/pcrDev.json b/pcrDev.json index e5a39ff2..4cfda111 100644 --- a/pcrDev.json +++ b/pcrDev.json @@ -1,6 +1,6 @@ { "HashAlgorithm": "Sha384 { ... }", - "PCR0": "a8ac79f4e3e57813727cc0c424ecdd7a50f6774e4e79e162f8a96f294293c9f7db4d160dab36e7f5fb642f7fa12cf6f3", + "PCR0": "07331a85d922e9d33b8bea190427e962a77e6f65425ff99e8545e4ce7ca1f4a637cf10b8f84eb0701bdebb7ae6ff181c", "PCR1": "f004075c672258b499f8e88d59701031a3b451f65c7de60c81d09da2b0799272675481ec390527594dd7069cb7de59d7", - "PCR2": "bb1579a2089477c38f152701cd7c3e398d8c37f98aebc6d60d8d2c2ed3671e7cededae1bdf61c211d37dd3094f4ba5f3" + "PCR2": "af52809711e969c13b2d93ae5bed02008b9af021d35419be7047196d6731dff9d6dbdaf3609e55770a5da450dffead28" } diff --git a/pcrDevHistory.json b/pcrDevHistory.json index 0d20bd7e..15d72a4c 100644 --- a/pcrDevHistory.json +++ b/pcrDevHistory.json @@ -544,5 +544,12 @@ "PCR2": "bb1579a2089477c38f152701cd7c3e398d8c37f98aebc6d60d8d2c2ed3671e7cededae1bdf61c211d37dd3094f4ba5f3", "timestamp": 1770852354, "signature": "AfsiYvvNU6CsvfCtyCCLTRiTemej5pFqsCcIFhfSOUtAU0wV5n3za7jwU3p9lK5F3UsbNzS98dZwuw7PM4u9Ne83qltekPt7xXdXQ84epVhG+4hHxfa8g7GX60iVBZSb" + }, + { + "PCR0": "07331a85d922e9d33b8bea190427e962a77e6f65425ff99e8545e4ce7ca1f4a637cf10b8f84eb0701bdebb7ae6ff181c", + "PCR1": "f004075c672258b499f8e88d59701031a3b451f65c7de60c81d09da2b0799272675481ec390527594dd7069cb7de59d7", + "PCR2": "af52809711e969c13b2d93ae5bed02008b9af021d35419be7047196d6731dff9d6dbdaf3609e55770a5da450dffead28", + "timestamp": 1771634556, + "signature": "hTLAVNQdFhdDlWMAmjsE+Wl7gRFTdY1TfA5C5I8PWPjzgot5oyJ1jK4RsLrRlBmZ5E5qE/xsP5hVy4daldY3aL973uGN8W/kiw3JdtWoih8YEoXyHniMS2q86FHCAEe/" } ] diff --git a/pcrProd.json b/pcrProd.json index ec6cf6bd..df187141 100644 --- a/pcrProd.json +++ b/pcrProd.json @@ -1,6 +1,6 @@ { "HashAlgorithm": "Sha384 { ... }", - "PCR0": "4403544d28e867b9e2e4ae7ee881db9f9d75a2cbdd64ab31d413dfbc9ca36b0e6e7416fb4abfb852b0051ad2d3fd2734", + "PCR0": "fffbfb0cbe63a5a3f54a8bb38b727e8e93bfe58dd3d82d0c4d8b80022f960d2c5c688be01f7d989032e1f439166c77dc", "PCR1": "f004075c672258b499f8e88d59701031a3b451f65c7de60c81d09da2b0799272675481ec390527594dd7069cb7de59d7", - "PCR2": "5fac6ea109cda1791a579bbd5f973b51d40bf53af409afd098dd8aa88ebafcd5d53b1bbb5c3a8a0855e26c9b1278c32c" + "PCR2": "f6b83a784d35cc136851ead7f54bb4524af81b28d0cae7397bf6b4568937570d941242685787b7f0dea5c2d4f75b7627" } diff --git a/pcrProdHistory.json b/pcrProdHistory.json index 5218e5fa..e489d94b 100644 --- a/pcrProdHistory.json +++ b/pcrProdHistory.json @@ -544,5 +544,12 @@ "PCR2": "5fac6ea109cda1791a579bbd5f973b51d40bf53af409afd098dd8aa88ebafcd5d53b1bbb5c3a8a0855e26c9b1278c32c", "timestamp": 1770852382, "signature": "dWP0Bk9xQPHjSYKfMQz0rIwBGMhxUpOQE/VsZiTLDlusSWG/QMH0TGPzfTFeFoPUuWAnmfWb4aoqt8OdYV1nXZyEgw7hfw9hlaZw2DUoHSbSyGvRtyDne82oQ7FyPeL0" + }, + { + "PCR0": "fffbfb0cbe63a5a3f54a8bb38b727e8e93bfe58dd3d82d0c4d8b80022f960d2c5c688be01f7d989032e1f439166c77dc", + "PCR1": "f004075c672258b499f8e88d59701031a3b451f65c7de60c81d09da2b0799272675481ec390527594dd7069cb7de59d7", + "PCR2": "f6b83a784d35cc136851ead7f54bb4524af81b28d0cae7397bf6b4568937570d941242685787b7f0dea5c2d4f75b7627", + "timestamp": 1771634598, + "signature": "7uvD22Q9c32ui9m/y5ybTR1YokJYBsGFma+67gfvoTaPhfvGZiGTBnBfGKKp6eFKBeg0NCiS8lOsBKBQ+aXLNcEBID8CE4L+ma/AHslW66DvPlLZZ1NL88zgS0ah4K0H" } ] From 78013ddeceafbb7ec55cccb6c5a81d0fc59c8b6a Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 23 Feb 2026 12:04:16 -0600 Subject: [PATCH 19/19] Attempt to fix parsing issues --- src/nearai/verifier.rs | 1 - src/web/openai.rs | 74 +++++++++++++++++++++++++++++++++++------- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/nearai/verifier.rs b/src/nearai/verifier.rs index 79e59f23..ce0012e3 100644 --- a/src/nearai/verifier.rs +++ b/src/nearai/verifier.rs @@ -234,7 +234,6 @@ impl NearAiVerifier { Some(verified.nodes[idx].clone()) } - #[allow(dead_code)] pub async fn invalidate_model(&self, model: &str) { let mut cache = self.cache.write().await; cache.remove(model); diff --git a/src/web/openai.rs b/src/web/openai.rs index fde46f6c..a0d3b889 100644 --- a/src/web/openai.rs +++ b/src/web/openai.rs @@ -31,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 @@ -523,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, successful_near_crypto) = { + let (res, successful_provider, successful_provider_model, successful_near_crypto) = { debug!( "Using route for model {}: primary={}, fallbacks={}", model_name, @@ -583,6 +583,7 @@ pub async fn get_chat_completion_response( Some(( fallback, + fallback_model_name, fallback_provider_is_near, fallback_body_value, fallback_prepared_static, @@ -596,6 +597,7 @@ 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 { @@ -657,6 +659,7 @@ pub async fn get_chat_completion_response( ); found_response = Some(response); 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; } @@ -675,6 +678,7 @@ pub async fn get_chat_completion_response( // Try fallback if available if let Some(( fallback_provider, + fallback_model_name, fallback_provider_is_near, fallback_body_value, fallback_prepared_static, @@ -732,6 +736,7 @@ pub async fn get_chat_completion_response( ); 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; } @@ -750,7 +755,7 @@ pub async fn get_chat_completion_response( } match found_response { - Some(response) => (response, successful_provider_name, successful_near_crypto), + Some(response) => (response, successful_provider_name, successful_provider_model_name, successful_near_crypto), None => { let error_msg = if route.fallbacks.is_empty() { format!( @@ -803,6 +808,13 @@ pub async fn get_chat_completion_response( 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 })?; } @@ -845,6 +857,8 @@ pub async fn get_chat_completion_response( 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(); @@ -895,6 +909,11 @@ pub async fn get_chat_completion_response( "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(), @@ -955,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 } } } @@ -1071,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