diff --git a/core/Cargo.lock b/core/Cargo.lock index 0581aa1..3be4e88 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -2,18 +2,181 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -23,6 +186,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -32,6 +251,108 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "serde" version = "1.0.229" @@ -59,7 +380,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -75,6 +396,45 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.3" @@ -86,20 +446,254 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.51.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e608df10a8a5f3c45a2ad4801f93083620b4f3a816551e5e407cdfcd6690f56" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "versevdi-core" version = "0.1.0" dependencies = [ + "base64", + "getrandom 0.4.3", + "quinn", + "rustls", + "rustls-pemfile", "serde", "serde_json", + "tokio", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.23" diff --git a/core/Cargo.toml b/core/Cargo.toml index a587af8..31065f5 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -9,8 +9,14 @@ publish = false crate-type = ["staticlib", "rlib"] [dependencies] +base64 = "=0.22.1" +getrandom = "=0.4.3" +quinn = { version = "=0.11.11", default-features = false, features = ["runtime-tokio", "rustls-ring"] } +rustls = { version = "=0.23.43", default-features = false, features = ["std", "ring"] } +rustls-pemfile = "=2.2.0" serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" +tokio = { version = "=1.51.4", features = ["rt-multi-thread", "sync", "time", "net"] } [[test]] name = "protocol_fixtures" diff --git a/core/src/error.rs b/core/src/error.rs index d6948fd..77b2168 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -5,6 +5,9 @@ use std::fmt; pub enum CoreError { InvalidArgument, AuthorityRejected, + Tls, + Transport, + Protocol, Expired, QueueFull, Cancelled, @@ -33,6 +36,9 @@ impl CoreError { match self { Self::InvalidArgument => "invalid_argument", Self::AuthorityRejected => "authority_rejected", + Self::Tls => "tls", + Self::Transport => "transport", + Self::Protocol => "protocol", Self::Expired => "expired", Self::QueueFull => "queue_full", Self::Cancelled => "cancelled", diff --git a/core/src/lib.rs b/core/src/lib.rs index 73f991a..3adcf13 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -16,7 +16,10 @@ pub mod error; pub mod input; pub mod media; pub mod session; +pub mod transport; pub mod wire; +mod tls; + #[allow(unsafe_code)] mod abi; diff --git a/core/src/tls.rs b/core/src/tls.rs new file mode 100644 index 0000000..b147b50 --- /dev/null +++ b/core/src/tls.rs @@ -0,0 +1,114 @@ +use std::fmt; +use std::io::Cursor; +use std::sync::Arc; + +use rustls::client::ResolvesClientCert; +use rustls::pki_types::CertificateDer; +use rustls::sign::{CertifiedKey, Signer, SigningKey}; +use rustls::{ClientConfig, RootCertStore, SignatureAlgorithm, SignatureScheme}; + +use crate::error::{CoreError, Result}; +use crate::wire::NativeTunnelCredential; + +pub(crate) type SignCallback = dyn Fn(&[u8]) -> Result<[u8; 64]> + Send + Sync; + +pub(crate) struct CallbackSigningKey { + callback: Arc, +} + +impl fmt::Debug for CallbackSigningKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CallbackSigningKey") + } +} + +impl CallbackSigningKey { + pub(crate) fn new(callback: Arc) -> Self { + Self { callback } + } +} + +impl SigningKey for CallbackSigningKey { + fn choose_scheme(&self, offered: &[SignatureScheme]) -> Option> { + offered + .contains(&SignatureScheme::ED25519) + .then(|| Box::new(CallbackSigner(Arc::clone(&self.callback))) as Box) + } + + fn algorithm(&self) -> SignatureAlgorithm { + SignatureAlgorithm::ED25519 + } +} + +struct CallbackSigner(Arc); + +impl fmt::Debug for CallbackSigner { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CallbackSigner") + } +} + +impl Signer for CallbackSigner { + fn sign(&self, message: &[u8]) -> std::result::Result, rustls::Error> { + (self.0)(message) + .map(|signature| signature.to_vec()) + .map_err(|_| rustls::Error::General("client signing failed".to_owned())) + } + + fn scheme(&self) -> SignatureScheme { + SignatureScheme::ED25519 + } +} + +#[derive(Debug)] +struct ClientIdentity(Arc); + +impl ResolvesClientCert for ClientIdentity { + fn resolve( + &self, + _root_hint_subjects: &[&[u8]], + sigschemes: &[SignatureScheme], + ) -> Option> { + sigschemes + .contains(&SignatureScheme::ED25519) + .then(|| Arc::clone(&self.0)) + } + + fn has_certs(&self) -> bool { + true + } +} + +pub(crate) fn client_config( + credential: &NativeTunnelCredential, + callback: Arc, +) -> Result { + let certificate_chain = rustls_pemfile::certs(&mut Cursor::new( + credential.certificate_chain_pem().as_bytes(), + )) + .collect::>, _>>() + .map_err(|_| CoreError::Tls)?; + if certificate_chain.is_empty() { + return Err(CoreError::Tls); + } + let mut roots = RootCertStore::empty(); + let trust_bundle = + rustls_pemfile::certs(&mut Cursor::new(credential.trust_bundle_pem().as_bytes())) + .collect::>, _>>() + .map_err(|_| CoreError::Tls)?; + if trust_bundle.is_empty() || roots.add_parsable_certificates(trust_bundle).1 != 0 { + return Err(CoreError::Tls); + } + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut config = ClientConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .map_err(|_| CoreError::Tls)? + .with_root_certificates(roots) + .with_client_cert_resolver(Arc::new(ClientIdentity(Arc::new(CertifiedKey::new( + certificate_chain, + Arc::new(CallbackSigningKey::new(callback)), + ))))); + config.alpn_protocols = vec![b"versevdi-gateway-v1".to_vec()]; + config.enable_early_data = false; + Ok(config) +} diff --git a/core/src/transport.rs b/core/src/transport.rs new file mode 100644 index 0000000..ab5d33f --- /dev/null +++ b/core/src/transport.rs @@ -0,0 +1,422 @@ +use std::fmt; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use quinn::crypto::rustls::QuicClientConfig; +use quinn::{Endpoint, TransportConfig, VarInt}; + +use crate::error::{CoreError, Result}; +use crate::tls::{client_config, SignCallback}; +use crate::wire::{ + decode_stable_error, CapabilityProfile, ClientSessionAuthority, ConnectionManifest, + NativeTunnelCredential, +}; + +const HELLO_LIMIT: usize = 16 * 1024; +const APPLICATION_ERROR: VarInt = VarInt::from_u32(0x100); + +pub struct AdmissionSigner(Arc); + +impl AdmissionSigner { + pub fn new(callback: impl Fn(&[u8]) -> Result<[u8; 64]> + Send + Sync + 'static) -> Self { + Self(Arc::new(callback)) + } +} + +pub struct TlsEd25519Signer(Arc); + +impl TlsEd25519Signer { + pub fn new(callback: impl Fn(&[u8]) -> Result<[u8; 64]> + Send + Sync + 'static) -> Self { + Self(Arc::new(callback)) + } +} + +pub struct Signers { + admission: AdmissionSigner, + tls: TlsEd25519Signer, +} + +#[derive(Clone, Default)] +pub struct Cancellation(Arc); + +impl Cancellation { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + + fn check(&self) -> Result<()> { + if self.0.load(Ordering::Acquire) { + Err(CoreError::Cancelled) + } else { + Ok(()) + } + } +} + +impl Signers { + #[must_use] + pub fn new(admission: AdmissionSigner, tls: TlsEd25519Signer) -> Self { + Self { admission, tls } + } +} + +pub struct TransportSession { + endpoint: Endpoint, + connection: quinn::Connection, + authority: ClientSessionAuthority, +} + +impl TransportSession { + #[must_use] + pub const fn authority(&self) -> &ClientSessionAuthority { + &self.authority + } + + pub async fn close(self) { + self.connection.close(APPLICATION_ERROR, b"cancelled"); + self.connection.closed().await; + self.endpoint.wait_idle().await; + } +} + +impl fmt::Debug for TransportSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("TransportSession") + } +} + +/// Opens one TLS 1.3 QUIC admission session using only manifest addresses and private trust. +/// +/// # Errors +/// +/// Returns a provider-free validation, TLS, transport, protocol, expiry, or cancellation error. +pub async fn connect( + manifest: &ConnectionManifest, + credential: &NativeTunnelCredential, + signers: Signers, + now_utc: &str, + deadline: Duration, +) -> Result { + connect_with_cancellation( + manifest, + credential, + signers, + now_utc, + deadline, + &Cancellation::new(), + ) + .await +} + +/// Opens one cancellable TLS 1.3 QUIC admission session. +/// +/// # Errors +/// +/// Returns `cancelled` when the signal fires or the overall deadline expires. +pub async fn connect_with_cancellation( + manifest: &ConnectionManifest, + credential: &NativeTunnelCredential, + signers: Signers, + now_utc: &str, + deadline: Duration, + cancellation: &Cancellation, +) -> Result { + let started = Instant::now(); + cancellation.check()?; + manifest.validate_at(now_utc)?; + credential.validate_at(now_utc)?; + let capabilities = CapabilityProfile::new( + "quic-tls13", + "datagram-v2", + "encoded", + "encoded", + "server", + vec!["h264-opus".to_owned()], + )?; + let admission_signer = signers.admission.0; + let tls_signer = signers.tls.0; + let tls_cancellation = cancellation.clone(); + let checked_tls_signer: Arc = Arc::new(move |message| { + tls_cancellation.check()?; + if started.elapsed() >= deadline { + return Err(CoreError::Cancelled); + } + let signature = tls_signer(message)?; + tls_cancellation.check()?; + if started.elapsed() >= deadline { + return Err(CoreError::Cancelled); + } + Ok(signature) + }); + let rustls = client_config(credential, checked_tls_signer)?; + let crypto = QuicClientConfig::try_from(rustls).map_err(|_| CoreError::Tls)?; + let mut quinn_config = quinn::ClientConfig::new(Arc::new(crypto)); + let mut transport = TransportConfig::default(); + transport.max_concurrent_bidi_streams(VarInt::from_u32(1)); + transport.max_concurrent_uni_streams(VarInt::from_u32(0)); + transport.datagram_receive_buffer_size(Some(64 * 1024)); + quinn_config.transport_config(Arc::new(transport)); + + let remaining = deadline + .checked_sub(started.elapsed()) + .ok_or(CoreError::Cancelled)?; + let result = tokio::time::timeout(remaining, async move { + let address_count = manifest.addresses().len(); + for (address_index, address) in manifest.addresses().iter().enumerate() { + cancellation.check()?; + let Ok(resolved) = tokio::net::lookup_host(address).await else { + continue; + }; + let resolved = resolved.take(8).collect::>(); + let address_budget = deadline.saturating_sub(started.elapsed()) + / u32::try_from(address_count.saturating_sub(address_index).max(1)).unwrap_or(1); + let address_deadline = Instant::now() + address_budget; + let remote_count = resolved.len(); + for (remote_index, remote) in resolved.into_iter().enumerate() { + cancellation.check()?; + let remaining_remotes = remote_count.saturating_sub(remote_index).max(1); + let attempt_budget = address_deadline.saturating_duration_since(Instant::now()) + / u32::try_from(remaining_remotes).unwrap_or(1); + match cancellable_timeout( + attempt_budget, + cancellation, + dial( + remote, + quinn_config.clone(), + &AttemptContext { + manifest, + offered: &capabilities, + admission_signer: admission_signer.as_ref(), + cancellation, + now_utc, + started, + deadline, + }, + ), + ) + .await + { + Ok(Ok(session)) => return Ok(session), + Ok(Err(AttemptError::Retry)) | Err(CoreError::Transport) => {} + Ok(Err(AttemptError::Terminal(error))) | Err(error) => return Err(error), + } + } + } + Err(CoreError::Transport) + }) + .await + .map_err(|_| CoreError::Cancelled)?; + if matches!(result, Err(CoreError::Transport)) && started.elapsed() >= deadline { + Err(CoreError::Cancelled) + } else { + result + } +} + +struct AttemptContext<'a> { + manifest: &'a ConnectionManifest, + offered: &'a CapabilityProfile, + admission_signer: &'a SignCallback, + cancellation: &'a Cancellation, + now_utc: &'a str, + started: Instant, + deadline: Duration, +} + +async fn cancellable_timeout( + duration: Duration, + cancellation: &Cancellation, + future: impl std::future::Future, +) -> Result { + let started = Instant::now(); + let mut future = std::pin::pin!(future); + loop { + cancellation.check()?; + let remaining = duration + .checked_sub(started.elapsed()) + .ok_or(CoreError::Transport)?; + let slice = remaining.min(Duration::from_millis(5)); + match tokio::time::timeout(slice, &mut future).await { + Ok(result) => return Ok(result), + Err(_) if started.elapsed() < duration => {} + Err(_) => return Err(CoreError::Transport), + } + } +} + +async fn dial( + remote: SocketAddr, + config: quinn::ClientConfig, + context: &AttemptContext<'_>, +) -> std::result::Result { + let bind = match remote.ip() { + IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + }; + let mut endpoint = Endpoint::client(bind).map_err(|_| AttemptError::Retry)?; + endpoint.set_default_client_config(config); + let mut connecting = endpoint + .connect(remote, context.manifest.public_identity()) + .map_err(|_| AttemptError::Retry)?; + let negotiated = connecting + .handshake_data() + .await + .map_err(|_| AttemptError::Terminal(CoreError::Tls))? + .downcast::() + .ok() + .and_then(|data| data.protocol.clone()); + if negotiated.as_deref() != Some(b"versevdi-gateway-v1") { + return Err(AttemptError::Terminal(CoreError::Tls)); + } + let connection = connecting + .await + .map_err(|_| AttemptError::Terminal(CoreError::Tls))?; + let payload = admission_payload( + context.manifest, + context.offered, + context.admission_signer, + context.cancellation, + context.started, + context.deadline, + )?; + let (mut send, mut receive) = connection + .open_bi() + .await + .map_err(|_| AttemptError::Terminal(connection_error(&connection)))?; + write_frame(&mut send, &payload) + .await + .map_err(|_| AttemptError::Terminal(connection_error(&connection)))?; + send.finish() + .map_err(|_| AttemptError::Terminal(connection_error(&connection)))?; + let response = read_frame(&mut receive) + .await + .map_err(|_| AttemptError::Terminal(connection_error(&connection)))?; + let Ok(authority) = ClientSessionAuthority::decode(&response) else { + let stable = decode_stable_error(&response)?; + return if stable.retryable { + Err(AttemptError::Retry) + } else { + Err(AttemptError::Terminal(stable.error)) + }; + }; + authority + .validate_binding(context.manifest, context.offered, context.now_utc) + .map_err(AttemptError::Terminal)?; + Ok(TransportSession { + endpoint, + connection, + authority, + }) +} + +fn admission_payload( + manifest: &ConnectionManifest, + offered: &CapabilityProfile, + signer: &SignCallback, + cancellation: &Cancellation, + started: Instant, + deadline: Duration, +) -> std::result::Result, AttemptError> { + cancellation.check()?; + let mut nonce = [0_u8; 24]; + getrandom::fill(&mut nonce).map_err(|_| CoreError::Transport)?; + let nonce = URL_SAFE_NO_PAD.encode(nonce); + let unsigned = manifest.admission(nonce.clone(), "A".repeat(86), offered.clone())?; + cancellation.check()?; + let signature = signer(&unsigned.admission_transcript())?; + cancellation.check()?; + if started.elapsed() >= deadline { + return Err(CoreError::Cancelled.into()); + } + let admission = + manifest.admission(nonce, URL_SAFE_NO_PAD.encode(signature), offered.clone())?; + let payload = serde_json::to_vec(&admission).map_err(|_| CoreError::Protocol)?; + hello_length(payload.len())?; + Ok(payload) +} + +fn connection_error(connection: &quinn::Connection) -> CoreError { + match connection.close_reason() { + Some(quinn::ConnectionError::TransportError(error)) + if (0x100..0x200).contains(&u64::from(error.code)) => + { + CoreError::Tls + } + Some(quinn::ConnectionError::ConnectionClosed(frame)) + if (0x100..0x200).contains(&u64::from(frame.error_code)) => + { + CoreError::Tls + } + _ => CoreError::Transport, + } +} + +enum AttemptError { + Retry, + Terminal(CoreError), +} + +impl From for AttemptError { + fn from(error: CoreError) -> Self { + Self::Terminal(error) + } +} + +async fn write_frame(stream: &mut quinn::SendStream, payload: &[u8]) -> Result<()> { + let length = hello_length(payload.len())?; + stream + .write_all(&length.to_be_bytes()) + .await + .map_err(|_| CoreError::Transport)?; + stream + .write_all(payload) + .await + .map_err(|_| CoreError::Transport) +} + +async fn read_frame(stream: &mut quinn::RecvStream) -> Result> { + let mut header = [0_u8; 4]; + stream + .read_exact(&mut header) + .await + .map_err(|_| CoreError::Transport)?; + let length = usize::try_from(hello_length( + usize::try_from(u32::from_be_bytes(header)).map_err(|_| CoreError::Protocol)?, + )?) + .map_err(|_| CoreError::Protocol)?; + let mut payload = vec![0; length]; + stream + .read_exact(&mut payload) + .await + .map_err(|_| CoreError::Transport)?; + Ok(payload) +} + +fn hello_length(length: usize) -> Result { + if length == 0 || length > HELLO_LIMIT { + return Err(CoreError::Protocol); + } + u32::try_from(length).map_err(|_| CoreError::Protocol) +} + +#[cfg(test)] +mod tests { + use super::{hello_length, HELLO_LIMIT}; + use crate::error::CoreError; + + #[test] + fn hello_length_bound_accepts_exact_limit_and_rejects_larger() { + assert_eq!(hello_length(HELLO_LIMIT), Ok(16_384)); + assert_eq!(hello_length(0), Err(CoreError::Protocol)); + assert_eq!(hello_length(HELLO_LIMIT + 1), Err(CoreError::Protocol)); + } +} diff --git a/core/src/wire.rs b/core/src/wire.rs index bbff678..bbb8059 100644 --- a/core/src/wire.rs +++ b/core/src/wire.rs @@ -1,5 +1,5 @@ use serde::de::DeserializeOwned; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::error::{CoreError, Result}; @@ -203,6 +203,8 @@ fn timestamp(value: &str, exact_seconds: bool) -> Option { fn valid_dns_name(value: &str) -> bool { bounded(value, 1, 253) + && value.parse::().is_err() + && !uuid_shaped(value) && value.split('.').all(|label| { bounded(label, 1, 63) && !label.starts_with('-') @@ -213,7 +215,15 @@ fn valid_dns_name(value: &str) -> bool { }) } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +fn uuid_shaped(value: &str) -> bool { + value.len() == 36 + && value.bytes().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => byte == b'-', + _ => byte.is_ascii_hexdigit(), + }) +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct CapabilityProfile { transport: String, @@ -432,6 +442,35 @@ impl ConnectionManifest { } Ok(()) } + + pub(crate) fn addresses(&self) -> &[String] { + &self.gateway.addresses + } + + pub(crate) fn public_identity(&self) -> &str { + &self.gateway.public_identity + } + + pub(crate) fn admission( + &self, + client_nonce: String, + device_signature: String, + capabilities: CapabilityProfile, + ) -> Result { + let request = TunnelAdmissionRequest { + version: "1".to_owned(), + session_id: self.session_id.clone(), + gateway_id: self.gateway.id.clone(), + audience: self.grant.audience.clone(), + grant: self.grant.opaque_value.clone(), + reconnect_sequence: self.reconnect_sequence, + client_nonce, + device_signature, + capabilities, + }; + request.validate()?; + Ok(request) + } } #[derive(Clone, Debug, Deserialize)] @@ -477,9 +516,17 @@ impl NativeTunnelCredential { } Ok(()) } + + pub(crate) fn certificate_chain_pem(&self) -> &str { + &self.certificate_chain_pem + } + + pub(crate) fn trust_bundle_pem(&self) -> &str { + &self.trust_bundle_pem + } } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct TunnelAdmissionRequest { version: String, @@ -501,23 +548,25 @@ impl TunnelAdmissionRequest { /// Returns `invalid_argument` for malformed, unknown, or out-of-bound data. pub fn decode(bytes: &[u8]) -> Result { let request: Self = decode_strict(bytes, MAX_ADMISSION_JSON_BYTES)?; - if request.version != "1" - || !bounded(&request.session_id, 1, 128) - || !bounded(&request.gateway_id, 1, 128) - || !bounded(&request.audience, 1, 256) - || !bounded(&request.grant, 43, 256) - || !bounded(&request.client_nonce, 16, 128) - || request.device_signature.len() != 86 - || !matches!( - raw_base64url_decoded_len(&request.client_nonce), - Some(12..=96) - ) - || raw_base64url_decoded_len(&request.device_signature) != Some(64) + request.validate()?; + Ok(request) + } + + fn validate(&self) -> Result<()> { + if self.version != "1" + || !bounded(&self.session_id, 1, 128) + || !bounded(&self.gateway_id, 1, 128) + || !bounded(&self.audience, 1, 256) + || !bounded(&self.grant, 43, 256) + || !bounded(&self.client_nonce, 16, 128) + || self.device_signature.len() != 86 + || !matches!(raw_base64url_decoded_len(&self.client_nonce), Some(12..=96)) + || raw_base64url_decoded_len(&self.device_signature) != Some(64) { return Err(CoreError::InvalidArgument); } - request.capabilities.validate()?; - Ok(request) + self.capabilities.validate()?; + Ok(()) } #[must_use] @@ -561,6 +610,20 @@ pub struct ClientSessionAuthority { capabilities: CapabilityProfile, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct StableError { + version: String, + code: String, + message: String, + retryable: bool, +} + +pub(crate) struct DecodedStableError { + pub(crate) error: CoreError, + pub(crate) retryable: bool, +} + impl ClientSessionAuthority { /// Strictly decodes and validates a provider-free RC5 client authority. /// @@ -608,4 +671,71 @@ impl ClientSessionAuthority { } Ok(()) } + + #[must_use] + pub fn session_id(&self) -> &str { + &self.session_id + } +} + +pub(crate) fn decode_stable_error(bytes: &[u8]) -> Result { + let stable: StableError = decode_strict(bytes, MAX_ADMISSION_JSON_BYTES)?; + if stable.version != "1" || !bounded(&stable.code, 1, 128) || !bounded(&stable.message, 1, 512) + { + return Err(CoreError::Protocol); + } + let error = match stable.code.as_str() { + "expired_grant" => CoreError::Expired, + "admission_rejected" + | "gateway_draining" + | "invalid_authority" + | "no_capability_overlap" + | "wrong_gateway" + | "provider_work_unavailable" + | "clipboard_audit_unavailable" => CoreError::AuthorityRejected, + "provider_identity_rejected" + | "provider_malformed" + | "provider_timeout" + | "provider_unavailable" + | "provider_state_unavailable" => CoreError::Transport, + "invalid_hello" => CoreError::Protocol, + _ => return Err(CoreError::Protocol), + }; + Ok(DecodedStableError { + error, + retryable: stable.retryable, + }) +} + +#[cfg(test)] +mod stable_error_tests { + use super::decode_stable_error; + use crate::error::CoreError; + + #[test] + fn stable_error_uses_exact_rc5_bounds_and_preserves_retryability() { + let message = "m".repeat(512); + let bytes = serde_json::to_vec(&serde_json::json!({ + "version": "1", + "code": "gateway_draining", + "message": message, + "retryable": true, + })) + .expect("encode stable error"); + let decoded = decode_stable_error(&bytes).expect("RC5 stable error"); + assert_eq!(decoded.error, CoreError::AuthorityRejected); + assert!(decoded.retryable); + + for invalid in [ + serde_json::json!({"version":"1","code":"gateway_draining","message":"","retryable":true}), + serde_json::json!({"version":"1","code":"c".repeat(129),"message":"m","retryable":true}), + serde_json::json!({"version":"1","code":"gateway_draining","message":"m".repeat(513),"retryable":true}), + serde_json::json!({"version":"1","code":"unknown","message":"m","retryable":true}), + ] { + assert_eq!( + decode_stable_error(&serde_json::to_vec(&invalid).expect("encode invalid")).err(), + Some(CoreError::Protocol) + ); + } + } } diff --git a/core/tests/gateway_oracle.rs b/core/tests/gateway_oracle.rs new file mode 100644 index 0000000..4f80666 --- /dev/null +++ b/core/tests/gateway_oracle.rs @@ -0,0 +1,806 @@ +use std::fs; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; +use rustls::sign::SigningKey; +use rustls::SignatureScheme; +use serde::Deserialize; +use versevdi_core::error::CoreError; +use versevdi_core::transport::{ + connect, connect_with_cancellation, AdmissionSigner, Cancellation, Signers, TlsEd25519Signer, +}; +use versevdi_core::wire::{ConnectionManifest, NativeTunnelCredential}; + +const ORACLE_SOURCE: &str = r#" +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "math/big" + "os" + "sync" + "time" + + gateway "git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/gateway" + protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol" +) + +type admission struct { + mu sync.Mutex + lastTranscript []byte + reusable bool + failWorkOnce bool + authority protocol.SessionAuthority + work protocol.ProviderSessionWork + public ed25519.PublicKey +} + +func (a *admission) Admit(_ context.Context, request protocol.TunnelAdmissionRequest) (protocol.SessionAuthority, error) { + signature, err := base64.RawURLEncoding.DecodeString(request.DeviceSignature) + transcript := request.DeviceAdmissionTranscript() + if err != nil || !ed25519.Verify(a.public, transcript, signature) { + return protocol.SessionAuthority{}, gateway.ErrAdmissionRejected + } + a.mu.Lock(); defer a.mu.Unlock() + if !a.reusable && bytes.Equal(a.lastTranscript, transcript) { return protocol.SessionAuthority{}, gateway.ErrAdmissionRejected } + a.lastTranscript = append(a.lastTranscript[:0], transcript...) + return a.authority, nil +} +func (a *admission) ProviderWork(context.Context, protocol.SessionAuthority) (protocol.ProviderSessionWork, error) { + a.mu.Lock(); defer a.mu.Unlock() + if a.failWorkOnce { a.failWorkOnce = false; return protocol.ProviderSessionWork{}, context.DeadlineExceeded } + return a.work, nil +} +func (a *admission) Release(context.Context, protocol.SessionAuthority) error { return nil } + +type provider struct{} +type session struct { state protocol.ProviderState; video chan gateway.ProviderMedia; audio chan gateway.ProviderMedia; events chan gateway.ProviderEvent } +func (provider) Start(_ context.Context, request gateway.LaunchRequest) (gateway.ProviderSession, error) { + return &session{state: protocol.ProviderState{Version:"1", SessionID:request.SessionID, State:gateway.ProviderStateReady, Channels:[]string{"video","audio","input","feedback"}}, video:make(chan gateway.ProviderMedia), audio:make(chan gateway.ProviderMedia), events:make(chan gateway.ProviderEvent)}, nil +} +func (s *session) Ready(context.Context) error { return nil } +func (s *session) Video() <-chan gateway.ProviderMedia { return s.video } +func (s *session) Audio() <-chan gateway.ProviderMedia { return s.audio } +func (s *session) Events() <-chan gateway.ProviderEvent { return s.events } +func (s *session) Input(context.Context, gateway.InputEvent) error { return nil } +func (s *session) Feedback(context.Context, gateway.Feedback) error { return nil } +func (s *session) ReadClipboard(context.Context) (string, error) { return "", errors.New("disabled") } +func (s *session) WriteClipboard(context.Context, string) error { return errors.New("disabled") } +func (s *session) Telemetry() gateway.ProviderTelemetry { return gateway.ProviderTelemetry{State:s.state.State} } +func (s *session) ReleaseAll(context.Context) error { return nil } +func (s *session) Terminate(context.Context) error { return nil } +func (s *session) State() protocol.ProviderState { return s.state } + +func makeCertificate(parent *x509.Certificate, parentKey ed25519.PrivateKey, serial int64, dns string, usage x509.ExtKeyUsage, isCA bool) ([]byte, ed25519.PrivateKey) { + public, private, err := ed25519.GenerateKey(rand.Reader); if err != nil { panic(err) } + template := &x509.Certificate{SerialNumber:big.NewInt(serial), Subject:pkix.Name{CommonName:dns}, DNSNames:[]string{dns}, NotBefore:time.Now().Add(-time.Hour), NotAfter:time.Now().Add(time.Hour), IsCA:isCA, BasicConstraintsValid:true, KeyUsage:x509.KeyUsageDigitalSignature} + if isCA { template.KeyUsage |= x509.KeyUsageCertSign } else { template.ExtKeyUsage = []x509.ExtKeyUsage{usage} } + if parent == nil { parent = template; parentKey = private } + der, err := x509.CreateCertificate(rand.Reader, template, parent, public, parentKey); if err != nil { panic(err) } + return der, private +} +func pemCert(ders ...[]byte) string { var out []byte; for _, der := range ders { out = append(out, pem.EncodeToMemory(&pem.Block{Type:"CERTIFICATE",Bytes:der})...) }; return string(out) } + +func main() { + readyPath, stopPath := os.Args[1], os.Args[2] + mode := os.Getenv("VERSEVDI_RUST_ORACLE_MODE") + caDER, caKey := makeCertificate(nil, nil, 1, "Verse Rust Oracle CA", 0, true) + ca, err := x509.ParseCertificate(caDER); if err != nil { panic(err) } + serverDER, serverKey := makeCertificate(ca, caKey, 2, "gateway.test", x509.ExtKeyUsageServerAuth, false) + clientDER, clientKey := makeCertificate(ca, caKey, 3, "client.test", x509.ExtKeyUsageClientAuth, false) + _, admissionKey := makeCertificate(ca, caKey, 6, "admission.test", x509.ExtKeyUsageClientAuth, false) + badCADER, _ := makeCertificate(nil, nil, 4, "Wrong CA", 0, true) + _, wrongClientKey := makeCertificate(ca, caKey, 5, "wrong-client.test", x509.ExtKeyUsageClientAuth, false) + pool := x509.NewCertPool(); pool.AddCert(ca) + server := &tls.Config{MinVersion:tls.VersionTLS13, MaxVersion:tls.VersionTLS13, Certificates:[]tls.Certificate{{Certificate:[][]byte{serverDER,caDER},PrivateKey:serverKey}}, ClientAuth:tls.RequireAndVerifyClientCert, ClientCAs:pool} + expiry := time.Now().Add(5*time.Minute).UTC().Truncate(time.Second).Format(time.RFC3339) + authority := protocol.SessionAuthority{Version:"1",SessionID:"session-1",GatewayID:"gateway-1",Audience:"versevdi-gateway",ExpiresAt:expiry,Capabilities:gateway.DefaultCapabilities(),ProviderProfile:gateway.ProviderProfileApollo,ProviderIdentity:"oracle#sha256:fixture"} + if mode == "session" { authority.SessionID = "other-session" } + if mode == "gateway" { authority.GatewayID = "other-gateway" } + if mode == "audience" { authority.Audience = "other-audience" } + if mode == "reconnect" { authority.ReconnectSequence = 1 } + if mode == "expiry" { authority.ExpiresAt = time.Now().Add(-time.Minute).UTC().Truncate(time.Second).Format(time.RFC3339) } + if mode == "capability" { authority.Capabilities.ClientDecode = []string{"hevc-opus"} } + if mode == "alpn" { server.NextProtos = []string{"wrong-alpn"} } + work := protocol.ProviderSessionWork{Version:"1",SessionID:authority.SessionID,GatewayID:authority.GatewayID,ExpiresAt:expiry,ProviderProfile:gateway.ProviderProfileApollo,ProviderIdentity:authority.ProviderIdentity,PolicyVersionID:"policy-1",StreamPolicy:protocol.ProviderStreamPolicy{ResolutionWidth:1920,ResolutionHeight:1080,Fps:60,Codec:"H264",BitrateKbps:8000,AudioEnabled:true},ApplicationID:"1",ClientID:"client",ManagementHost:"provider.invalid",ManagementPort:47990,StreamHost:"provider.invalid",StreamPort:47984,ClientCertificatePem:"certificate",ClientPrivateKeyPem:"private-key",ServerCertificatePem:"certificate",ClipboardPolicy:protocol.ClipboardPolicy{MaxTextBytes:65536,MaxUpdatesPerMinute:30}} + replayGuard := &admission{authority:authority,work:work,public:admissionKey.Public().(ed25519.PublicKey)} + replayRequest := protocol.TunnelAdmissionRequest{Version:"1",SessionID:"session-1",GatewayID:"gateway-1",Audience:"versevdi-gateway",Grant:"ggggggggggggggggggggggggggggggggggggggggggg",ClientNonce:"bm9uY2UtZm9yLXJlcGxheQ",Capabilities:gateway.DefaultCapabilities()} + replayRequest.DeviceSignature = base64.RawURLEncoding.EncodeToString(ed25519.Sign(admissionKey,replayRequest.DeviceAdmissionTranscript())) + _, firstReplayErr := replayGuard.Admit(context.Background(),replayRequest) + _, secondReplayErr := replayGuard.Admit(context.Background(),replayRequest) + replayRejected := firstReplayErr == nil && errors.Is(secondReplayErr,gateway.ErrAdmissionRejected) + admissionService := &admission{authority:authority,work:work,public:admissionKey.Public().(ed25519.PublicKey),reusable:mode == "reusable",failWorkOnce:mode == "post-retryable"} + newService := func() *gateway.Server { + service, err := gateway.NewServer(gateway.ServerConfig{ListenAddress:"127.0.0.1:0",TLSConfig:server,GatewayID:authority.GatewayID,Admission:admissionService,Provider:provider{}}); if err != nil { panic(err) } + return service + } + service := newService() + services := []*gateway.Server{service} + addresses := []string{service.Addr().String()} + if mode == "retryable" { + draining := newService() + draining.BeginDrain() + services = []*gateway.Server{draining, service} + addresses = []string{draining.Addr().String(), service.Addr().String()} + } + if mode == "post-retryable" { + second := newService() + services = []*gateway.Server{service, second} + addresses = []string{service.Addr().String(), second.Addr().String()} + } + ctx, cancel := context.WithCancel(context.Background()); defer cancel() + done := make(chan error,len(services)); for _, current := range services { go func(server *gateway.Server){done <- server.Serve(ctx)}(current) } + keyDER, err := x509.MarshalPKCS8PrivateKey(clientKey); if err != nil { panic(err) } + admissionKeyDER, err := x509.MarshalPKCS8PrivateKey(admissionKey); if err != nil { panic(err) } + manifest := map[string]any{"version":"1","purpose":"launch","session_id":"session-1","reconnect_sequence":0,"gateway":map[string]any{"id":"gateway-1","addresses":addresses,"public_identity":"gateway.test"},"tunnel":map[string]any{"versions":[]string{"verse-gateway-v1/1"},"features":[]string{"control.v1"}},"profile":map[string]any{"id":"standard","bounds":map[string]any{"minimum_kbps":1000,"target_kbps":5000,"maximum_kbps":10000},"display_mode":map[string]any{"resolution_width":1920,"resolution_height":1080,"fps":60}},"grant":map[string]any{"opaque_value":"ggggggggggggggggggggggggggggggggggggggggggg","expires_at":"2099-01-01T00:00:00Z","audience":"versevdi-gateway"},"correlation_id":"oracle"} + credential := map[string]any{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":pemCert(clientDER,caDER),"trust_bundle_pem":pemCert(caDER),"expires_at":"2099-01-01T00:00:00Z"} + manifestJSON,_ := json.Marshal(manifest); credentialJSON,_ := json.Marshal(credential) + wrongKeyDER, err := x509.MarshalPKCS8PrivateKey(wrongClientKey); if err != nil { panic(err) } + serverKeyDER, err := x509.MarshalPKCS8PrivateKey(serverKey); if err != nil { panic(err) } + ready,_ := json.Marshal(map[string]any{"manifest":string(manifestJSON),"credential":string(credentialJSON),"admission_key":base64.StdEncoding.EncodeToString(admissionKeyDER),"client_key":base64.StdEncoding.EncodeToString(keyDER),"wrong_client_key":base64.StdEncoding.EncodeToString(wrongKeyDER),"server_key":base64.StdEncoding.EncodeToString(serverKeyDER),"server_chain":pemCert(serverDER,caDER),"bad_trust":pemCert(badCADER),"replay_rejected":replayRejected}) + if err := os.WriteFile(readyPath,ready,0600); err != nil { panic(err) } + for { if _, err := os.Stat(stopPath); err == nil { break }; time.Sleep(10*time.Millisecond) } + cancel(); for _, current := range services { _ = current.Close() }; for range services { <-done } +} +"#; + +#[derive(Deserialize)] +struct Ready { + manifest: String, + credential: String, + admission_key: String, + client_key: String, + wrong_client_key: String, + server_key: String, + server_chain: String, + bad_trust: String, + replay_rejected: bool, +} + +struct Oracle { + _serial: std::sync::MutexGuard<'static, ()>, + child: Child, + directory: std::path::PathBuf, + ready: Ready, +} + +impl Oracle { + fn start(mode: &str) -> Self { + static SERIAL: Mutex<()> = Mutex::new(()); + static NEXT: AtomicUsize = AtomicUsize::new(1); + let serial = SERIAL + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let directory = std::env::temp_dir().join(format!( + "versevdi-rust-gateway-oracle-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&directory).expect("create oracle directory"); + let source = directory.join("main.go"); + let ready_path = directory.join("ready.json"); + let stop_path = directory.join("stop"); + fs::write(&source, ORACLE_SOURCE).expect("write oracle source"); + let child = Command::new("go") + .args(["run", source.to_str().expect("source path is UTF-8")]) + .arg(&ready_path) + .arg(&stop_path) + .env("VERSEVDI_RUST_ORACLE_MODE", mode) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start Go gateway oracle"); + let deadline = Instant::now() + Duration::from_secs(20); + while Instant::now() < deadline { + if let Ok(bytes) = fs::read(&ready_path) { + if let Ok(ready) = serde_json::from_slice(&bytes) { + return Self { + _serial: serial, + child, + directory, + ready, + }; + } + } + thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().expect("collect Go oracle output"); + panic!( + "Go gateway oracle did not become ready: status={} stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +impl Drop for Oracle { + fn drop(&mut self) { + let _ = fs::write(self.directory.join("stop"), []); + let _ = self.child.wait(); + let _ = fs::remove_dir_all(&self.directory); + } +} + +fn test_key(encoded: &str) -> Arc { + let der = STANDARD.decode(encoded).expect("decode test key"); + rustls::crypto::ring::default_provider() + .key_provider + .load_private_key(PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(der))) + .expect("load ephemeral Ed25519 test key") +} + +#[test] +fn callback_ed25519_signer_completes_tls13_quic_admission_without_private_key_input() { + let oracle = Oracle::start(""); + let manifest = ConnectionManifest::decode(oracle.ready.manifest.as_bytes()).expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let admission_key = test_key(&oracle.ready.admission_key); + let tls_key = test_key(&oracle.ready.client_key); + let admission_inputs = Arc::new(Mutex::new(Vec::new())); + let tls_inputs = Arc::new(Mutex::new(Vec::new())); + let admission_calls = Arc::new(AtomicUsize::new(0)); + let tls_calls = Arc::new(AtomicUsize::new(0)); + + let sign = |key: Arc, input: &[u8]| -> Result<[u8; 64], CoreError> { + let signer = key + .choose_scheme(&[SignatureScheme::ED25519]) + .ok_or(CoreError::Tls)?; + signer + .sign(input) + .map_err(|_| CoreError::Tls)? + .try_into() + .map_err(|_| CoreError::Tls) + }; + let admission = { + let key = Arc::clone(&admission_key); + let inputs = Arc::clone(&admission_inputs); + let calls = Arc::clone(&admission_calls); + AdmissionSigner::new(move |input| { + calls.fetch_add(1, Ordering::SeqCst); + *inputs.lock().expect("admission inputs") = input.to_vec(); + sign(Arc::clone(&key), input) + }) + }; + let tls = { + let key = Arc::clone(&tls_key); + let inputs = Arc::clone(&tls_inputs); + let calls = Arc::clone(&tls_calls); + TlsEd25519Signer::new(move |input| { + calls.fetch_add(1, Ordering::SeqCst); + *inputs.lock().expect("TLS inputs") = input.to_vec(); + sign(Arc::clone(&key), input) + }) + }; + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let session = runtime + .block_on(connect( + &manifest, + &credential, + Signers::new(admission, tls), + "2026-08-12T00:00:00Z", + Duration::from_secs(10), + )) + .expect("production Go gateway admission"); + + assert_eq!(session.authority().session_id(), "session-1"); + assert_eq!(admission_calls.load(Ordering::SeqCst), 1); + assert!(tls_calls.load(Ordering::SeqCst) >= 1); + assert!(admission_inputs + .lock() + .expect("admission transcript") + .starts_with(b"versevdi/tunnel-admission/v1")); + assert_ne!( + *admission_inputs.lock().expect("admission transcript"), + *tls_inputs.lock().expect("TLS transcript") + ); + runtime.block_on(session.close()); +} + +fn signer(key: Arc) -> impl Fn(&[u8]) -> Result<[u8; 64], CoreError> { + move |input| { + key.choose_scheme(&[SignatureScheme::ED25519]) + .ok_or(CoreError::Tls)? + .sign(input) + .map_err(|_| CoreError::Tls)? + .try_into() + .map_err(|_| CoreError::Tls) + } +} + +fn connect_oracle( + oracle: &Oracle, + admission_key: &str, + tls_key: &str, + mutate_manifest: impl FnOnce(&mut serde_json::Value), + mutate_credential: impl FnOnce(&mut serde_json::Value), +) -> Result { + let mut manifest: serde_json::Value = + serde_json::from_str(&oracle.ready.manifest).expect("manifest JSON"); + let mut credential: serde_json::Value = + serde_json::from_str(&oracle.ready.credential).expect("credential JSON"); + mutate_manifest(&mut manifest); + mutate_credential(&mut credential); + let manifest = + ConnectionManifest::decode(&serde_json::to_vec(&manifest).expect("encode manifest"))?; + let credential = NativeTunnelCredential::decode( + &serde_json::to_vec(&credential).expect("encode credential"), + )?; + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + runtime.block_on(connect( + &manifest, + &credential, + Signers::new( + AdmissionSigner::new(signer(test_key(admission_key))), + TlsEd25519Signer::new(signer(test_key(tls_key))), + ), + "2026-08-12T00:00:00Z", + Duration::from_secs(5), + )) +} + +#[test] +fn production_gateway_rejects_tls_and_admission_identity_mismatches() { + for case in [ + "wrong-key", + "wrong-signature", + "wrong-leaf", + "wrong-sni", + "wrong-root", + "wrong-alpn", + ] { + let oracle = Oracle::start(""); + let admission_key = &oracle.ready.admission_key; + let tls_key = &oracle.ready.client_key; + let wrong = &oracle.ready.wrong_client_key; + let result = match case { + "wrong-key" => connect_oracle(&oracle, admission_key, wrong, |_| {}, |_| {}), + "wrong-signature" => connect_oracle(&oracle, wrong, tls_key, |_| {}, |_| {}), + "wrong-leaf" => connect_oracle( + &oracle, + admission_key, + &oracle.ready.server_key, + |_| {}, + |credential| { + credential["certificate_chain_pem"] = oracle.ready.server_chain.clone().into(); + }, + ), + "wrong-sni" => connect_oracle( + &oracle, + admission_key, + tls_key, + |manifest| manifest["gateway"]["public_identity"] = "other.test".into(), + |_| {}, + ), + "wrong-root" => connect_oracle( + &oracle, + admission_key, + tls_key, + |_| {}, + |credential| credential["trust_bundle_pem"] = oracle.ready.bad_trust.clone().into(), + ), + "wrong-alpn" => { + drop(oracle); + let oracle = Oracle::start("alpn"); + connect_oracle( + &oracle, + &oracle.ready.admission_key, + &oracle.ready.client_key, + |_| {}, + |_| {}, + ) + } + _ => unreachable!(), + }; + let expected = if case == "wrong-signature" { + CoreError::AuthorityRejected + } else { + CoreError::Tls + }; + assert_eq!(result.err(), Some(expected), "wrong error for {case}"); + } +} + +#[test] +fn production_gateway_and_rust_reject_authority_binding_and_capability_mismatches() { + for mode in [ + "session", + "gateway", + "audience", + "reconnect", + "capability", + "expiry", + ] { + let oracle = Oracle::start(mode); + let result = connect_oracle( + &oracle, + &oracle.ready.admission_key, + &oracle.ready.client_key, + |_| {}, + |_| {}, + ); + assert!(result.is_err(), "{mode} mismatch unexpectedly connected"); + } +} + +#[test] +fn signer_purposes_are_not_interchangeable() { + let oracle = Oracle::start(""); + let admission_key = test_key(&oracle.ready.admission_key); + let tls_key = test_key(&oracle.ready.client_key); + let manifest = ConnectionManifest::decode(oracle.ready.manifest.as_bytes()).expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let admission = AdmissionSigner::new(move |message| { + if !message.starts_with(b"versevdi/tunnel-admission/v1") { + return Err(CoreError::AuthorityRejected); + } + signer(Arc::clone(&admission_key))(message) + }); + let tls = TlsEd25519Signer::new(move |message| { + if message.starts_with(b"versevdi/tunnel-admission/v1") { + return Err(CoreError::Tls); + } + signer(Arc::clone(&tls_key))(message) + }); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let session = runtime + .block_on(connect( + &manifest, + &credential, + Signers::new(admission, tls), + "2026-08-12T00:00:00Z", + Duration::from_secs(5), + )) + .expect("purpose-separated signers"); + runtime.block_on(session.close()); +} + +#[test] +fn swapping_admission_and_tls_signer_purposes_is_rejected() { + let oracle = Oracle::start(""); + let result = connect_oracle( + &oracle, + &oracle.ready.client_key, + &oracle.ready.admission_key, + |_| {}, + |_| {}, + ); + assert!( + result.is_err(), + "one interchangeable key was accepted for both signer purposes" + ); +} + +fn recording_signers( + admission_key: Arc, + tls_key: Arc, + transcripts: Arc>>>, +) -> Signers { + Signers::new( + AdmissionSigner::new(move |message| { + transcripts + .lock() + .expect("admission transcripts") + .push(message.to_vec()); + signer(Arc::clone(&admission_key))(message) + }), + TlsEd25519Signer::new(signer(tls_key)), + ) +} + +#[test] +fn fresh_nonce_changes_transcript_and_oracle_rejects_identical_replay() { + let transcripts = Arc::new(Mutex::new(Vec::new())); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + for _ in 0..2 { + let oracle = Oracle::start(""); + let manifest = + ConnectionManifest::decode(oracle.ready.manifest.as_bytes()).expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let session = runtime + .block_on(connect( + &manifest, + &credential, + recording_signers( + test_key(&oracle.ready.admission_key), + test_key(&oracle.ready.client_key), + Arc::clone(&transcripts), + ), + "2026-08-12T00:00:00Z", + Duration::from_secs(5), + )) + .expect("fresh nonce admission"); + runtime.block_on(session.close()); + assert!( + oracle.ready.replay_rejected, + "oracle accepted identical replay" + ); + } + let transcripts = transcripts.lock().expect("transcripts"); + assert_eq!(transcripts.len(), 2); + assert_ne!(transcripts[0], transcripts[1], "nonce was reused"); +} + +#[test] +fn overall_deadline_cancels_an_unresponsive_manifest_address() { + let oracle = Oracle::start(""); + let socket = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind blackhole"); + let mut manifest: serde_json::Value = + serde_json::from_str(&oracle.ready.manifest).expect("manifest JSON"); + manifest["gateway"]["addresses"] = + serde_json::json!([socket.local_addr().expect("blackhole address").to_string()]); + let manifest = + ConnectionManifest::decode(&serde_json::to_vec(&manifest).expect("encode manifest")) + .expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let started = Instant::now(); + let result = runtime.block_on(connect( + &manifest, + &credential, + recording_signers( + test_key(&oracle.ready.admission_key), + test_key(&oracle.ready.client_key), + Arc::new(Mutex::new(Vec::new())), + ), + "2026-08-12T00:00:00Z", + Duration::from_millis(150), + )); + assert_eq!(result.err(), Some(CoreError::Cancelled)); + assert!(started.elapsed() < Duration::from_secs(2)); +} + +#[test] +fn overall_deadline_includes_synchronous_admission_signing() { + let oracle = Oracle::start(""); + let manifest = ConnectionManifest::decode(oracle.ready.manifest.as_bytes()).expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let admission_key = test_key(&oracle.ready.admission_key); + let tls_key = test_key(&oracle.ready.client_key); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let started = Instant::now(); + let result = runtime.block_on(connect( + &manifest, + &credential, + Signers::new( + AdmissionSigner::new(move |message| { + thread::sleep(Duration::from_millis(100)); + signer(Arc::clone(&admission_key))(message) + }), + TlsEd25519Signer::new(signer(tls_key)), + ), + "2026-08-12T00:00:00Z", + Duration::from_millis(25), + )); + assert_eq!(result.err(), Some(CoreError::Cancelled)); + assert!(started.elapsed() < Duration::from_secs(2)); +} + +#[test] +fn explicit_cancellation_interrupts_network_wait() { + let oracle = Oracle::start(""); + let socket = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind blackhole"); + let mut manifest: serde_json::Value = + serde_json::from_str(&oracle.ready.manifest).expect("manifest JSON"); + manifest["gateway"]["addresses"] = + serde_json::json!([socket.local_addr().expect("blackhole address").to_string()]); + let manifest = + ConnectionManifest::decode(&serde_json::to_vec(&manifest).expect("encode manifest")) + .expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let cancellation = Cancellation::new(); + let trigger = cancellation.clone(); + let canceller = thread::spawn(move || { + thread::sleep(Duration::from_millis(25)); + trigger.cancel(); + }); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let started = Instant::now(); + let result = runtime.block_on(connect_with_cancellation( + &manifest, + &credential, + recording_signers( + test_key(&oracle.ready.admission_key), + test_key(&oracle.ready.client_key), + Arc::new(Mutex::new(Vec::new())), + ), + "2026-08-12T00:00:00Z", + Duration::from_secs(5), + &cancellation, + )); + canceller.join().expect("canceller"); + assert_eq!(result.err(), Some(CoreError::Cancelled)); + assert!(started.elapsed() < Duration::from_secs(2)); +} + +#[test] +fn blackholed_first_manifest_address_does_not_starve_live_second_address() { + let oracle = Oracle::start(""); + let socket = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind blackhole"); + let mut manifest: serde_json::Value = + serde_json::from_str(&oracle.ready.manifest).expect("manifest JSON"); + let live = manifest["gateway"]["addresses"][0].clone(); + manifest["gateway"]["addresses"] = serde_json::json!([ + socket.local_addr().expect("blackhole address").to_string(), + live, + ]); + let manifest = + ConnectionManifest::decode(&serde_json::to_vec(&manifest).expect("encode manifest")) + .expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let session = runtime + .block_on(connect( + &manifest, + &credential, + recording_signers( + test_key(&oracle.ready.admission_key), + test_key(&oracle.ready.client_key), + Arc::new(Mutex::new(Vec::new())), + ), + "2026-08-12T00:00:00Z", + Duration::from_secs(5), + )) + .expect("live second manifest address"); + runtime.block_on(session.close()); +} + +#[test] +fn multi_record_first_manifest_address_does_not_starve_live_second_address() { + let oracle = Oracle::start(""); + let socket = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind blackhole"); + let port = socket.local_addr().expect("blackhole address").port(); + let mut manifest: serde_json::Value = + serde_json::from_str(&oracle.ready.manifest).expect("manifest JSON"); + let live = manifest["gateway"]["addresses"][0].clone(); + manifest["gateway"]["addresses"] = serde_json::json!([format!("localhost:{port}"), live]); + let manifest = + ConnectionManifest::decode(&serde_json::to_vec(&manifest).expect("encode manifest")) + .expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let session = runtime + .block_on(connect( + &manifest, + &credential, + recording_signers( + test_key(&oracle.ready.admission_key), + test_key(&oracle.ready.client_key), + Arc::new(Mutex::new(Vec::new())), + ), + "2026-08-12T00:00:00Z", + Duration::from_secs(5), + )) + .expect("live second address after multi-record first address"); + runtime.block_on(session.close()); +} + +#[test] +fn invalid_first_manifest_address_does_not_block_live_second_address() { + let oracle = Oracle::start(""); + let mut manifest: serde_json::Value = + serde_json::from_str(&oracle.ready.manifest).expect("manifest JSON"); + let live = manifest["gateway"]["addresses"][0].clone(); + manifest["gateway"]["addresses"] = serde_json::json!(["127.0.0.1:0", live]); + let manifest = + ConnectionManifest::decode(&serde_json::to_vec(&manifest).expect("encode manifest")) + .expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let session = runtime + .block_on(connect( + &manifest, + &credential, + recording_signers( + test_key(&oracle.ready.admission_key), + test_key(&oracle.ready.client_key), + Arc::new(Mutex::new(Vec::new())), + ), + "2026-08-12T00:00:00Z", + Duration::from_secs(5), + )) + .expect("live address after resolution failure"); + runtime.block_on(session.close()); +} + +#[test] +fn retryable_stable_error_advances_to_live_manifest_address() { + let oracle = Oracle::start("retryable"); + let manifest = ConnectionManifest::decode(oracle.ready.manifest.as_bytes()).expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let session = runtime + .block_on(connect( + &manifest, + &credential, + recording_signers( + test_key(&oracle.ready.admission_key), + test_key(&oracle.ready.client_key), + Arc::new(Mutex::new(Vec::new())), + ), + "2026-08-12T00:00:00Z", + Duration::from_secs(5), + )) + .expect("retryable draining response advanced to live address"); + runtime.block_on(session.close()); +} + +#[test] +fn post_admission_retry_uses_a_fresh_signed_nonce() { + let oracle = Oracle::start("post-retryable"); + let manifest = ConnectionManifest::decode(oracle.ready.manifest.as_bytes()).expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let transcripts = Arc::new(Mutex::new(Vec::new())); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let session = runtime + .block_on(connect( + &manifest, + &credential, + recording_signers( + test_key(&oracle.ready.admission_key), + test_key(&oracle.ready.client_key), + Arc::clone(&transcripts), + ), + "2026-08-12T00:00:00Z", + Duration::from_secs(5), + )) + .expect("post-admission retry used a fresh request"); + runtime.block_on(session.close()); + let transcripts = transcripts.lock().expect("transcripts"); + assert_eq!(transcripts.len(), 2); + assert_ne!(transcripts[0], transcripts[1]); +} + +#[test] +fn repeated_production_gateway_admissions_remain_bounded() { + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + for index in 0..10 { + let oracle = Oracle::start("reusable"); + let manifest = + ConnectionManifest::decode(oracle.ready.manifest.as_bytes()).expect("manifest"); + let credential = + NativeTunnelCredential::decode(oracle.ready.credential.as_bytes()).expect("credential"); + let session = runtime + .block_on(connect( + &manifest, + &credential, + recording_signers( + test_key(&oracle.ready.admission_key), + test_key(&oracle.ready.client_key), + Arc::new(Mutex::new(Vec::new())), + ), + "2026-08-12T00:00:00Z", + Duration::from_secs(5), + )) + .unwrap_or_else(|error| panic!("stress admission {index}: {error:?}")); + runtime.block_on(session.close()); + } +} diff --git a/core/tests/protocol_fixtures.rs b/core/tests/protocol_fixtures.rs index 4939f84..223cdbc 100644 --- a/core/tests/protocol_fixtures.rs +++ b/core/tests/protocol_fixtures.rs @@ -134,6 +134,19 @@ fn strict_rc5_dtos_reject_duplicate_trailing_unknown_and_provider_fields() { assert!(TunnelAdmissionRequest::decode(admission_provider.as_bytes()).is_err()); } +#[test] +fn manifest_public_identity_requires_dns_sni_not_ip_or_uuid() { + for invalid_identity in ["127.0.0.1", "::1", "550e8400-e29b-41d4-a716-446655440000"] { + let manifest = String::from_utf8(valid_manifest().to_vec()) + .expect("fixture is UTF-8") + .replace("gateway.test\"}", &format!("{invalid_identity}\"}}")); + assert!( + ConnectionManifest::decode(manifest.as_bytes()).is_err(), + "non-DNS SNI accepted: {invalid_identity}" + ); + } +} + #[test] fn rc5_manifest_credential_and_authority_enforce_bounds_and_bindings() { let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest"); @@ -164,6 +177,70 @@ fn rc5_manifest_credential_and_authority_enforce_bounds_and_bindings() { let provider_authority = br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]},"provider_profile":"apollo"}"#; assert!(ClientSessionAuthority::decode(provider_authority).is_err()); + let provider_route = br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]},"stream_host":"provider.invalid"}"#; + assert!(ClientSessionAuthority::decode(provider_route).is_err()); +} + +#[test] +fn client_authority_directly_rejects_every_manifest_binding_mismatch() { + let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest"); + let offered = CapabilityProfile::new( + "quic-tls13", + "datagram-v2", + "encoded", + "encoded", + "server", + vec!["h264-opus".to_owned()], + ) + .expect("offered capabilities"); + let valid = serde_json::json!({ + "version": "1", + "session_id": "session", + "gateway_id": "gateway", + "audience": "audience", + "reconnect_sequence": 0, + "expires_at": "2098-01-01T00:00:00Z", + "capabilities": { + "transport": "quic-tls13", + "framing": "datagram-v2", + "media": "encoded", + "audio": "encoded", + "source_rate_control": "server", + "client_decode": ["h264-opus"], + }, + }); + for case in [ + "session", + "gateway", + "audience", + "reconnect", + "expired", + "beyond-grant", + "capability", + ] { + let mut value = valid.clone(); + match case { + "session" => value["session_id"] = "other".into(), + "gateway" => value["gateway_id"] = "other".into(), + "audience" => value["audience"] = "other".into(), + "reconnect" => value["reconnect_sequence"] = 1.into(), + "expired" => value["expires_at"] = "2026-08-12T00:00:00Z".into(), + "beyond-grant" => value["expires_at"] = "2100-01-01T00:00:00Z".into(), + "capability" => { + value["capabilities"]["client_decode"] = + serde_json::json!(["h264-opus", "hevc-opus"]); + } + _ => unreachable!(), + } + let authority = + ClientSessionAuthority::decode(&serde_json::to_vec(&value).expect("encode authority")) + .expect("structurally valid authority"); + assert_eq!( + authority.validate_binding(&manifest, &offered, "2026-08-12T00:00:00Z"), + Err(versevdi_core::error::CoreError::AuthorityRejected), + "binding mismatch accepted: {case}" + ); + } } #[test] diff --git a/gateway/gateway_test.go b/gateway/gateway_test.go index f95866f..771ab00 100644 --- a/gateway/gateway_test.go +++ b/gateway/gateway_test.go @@ -14,6 +14,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "io" "math/big" "net" @@ -476,6 +477,83 @@ func TestAdmissionQUICMTLSRelayAndCleanup(t *testing.T) { } } +func TestStableErrorFramePrecedesConnectionTeardown(t *testing.T) { + for _, retryable := range []bool{false, true} { + t.Run(fmt.Sprintf("retryable=%t", retryable), func(t *testing.T) { + serverTLS, clientTLS := testTLS(t) + clientTLS.NextProtos = []string{"versevdi-gateway-v1"} + fake := NewFakeApollo(FakeApolloConfig{Now: time.Now()}) + authority := protocol.SessionAuthority{ + Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway", + ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Capabilities: DefaultCapabilities(), + ProviderProfile: ProviderProfileApollo, ProviderIdentity: fake.config.Identity.Key(), + } + server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: authority.GatewayID, Admission: &oneTimeAdmission{authority: authority, released: make(chan struct{})}, Provider: fake}) + if err != nil { + t.Fatal(err) + } + if retryable { + server.BeginDrain() + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = server.Serve(ctx) }() + connection, err := quic.DialAddr(context.Background(), server.Addr().String(), clientTLS, &quic.Config{EnableDatagrams: true}) + if err != nil { + t.Fatal(err) + } + stream, err := connection.OpenStreamSync(context.Background()) + if err != nil { + t.Fatal(err) + } + gatewayID := authority.GatewayID + if !retryable { + gatewayID = "wrong-gateway" + } + request := protocol.TunnelAdmissionRequest{Version: "1", SessionID: authority.SessionID, GatewayID: gatewayID, Audience: authority.Audience, Grant: strings.Repeat("g", 64), ClientNonce: "nonce-0000000001", DeviceSignature: strings.Repeat("s", 86), Capabilities: DefaultCapabilities()} + payload, err := protocol.EncodeTunnelAdmissionRequest(request) + if err != nil { + t.Fatal(err) + } + if err := writeWire(stream, payload, defaultHelloLimit); err != nil { + t.Fatal(err) + } + response, err := readWire(stream, defaultHelloLimit) + if err != nil { + t.Fatalf("stable response lost before connection teardown: %v", err) + } + stable, err := protocol.DecodeStableError(response) + if err != nil || stable.Retryable != retryable { + t.Fatalf("stable response = %#v, %v", stable, err) + } + select { + case <-connection.Context().Done(): + case <-time.After(2 * time.Second): + t.Fatal("server retained rejected connection") + } + _ = server.Close() + }) + } +} + +func TestStableErrorNeverLeaksInternalProviderDetails(t *testing.T) { + var wire bytes.Buffer + if err := writeStableError(&wire, "provider_unavailable", errors.New("https://provider.invalid/launch?rikey=secret-sentinel"), true); err != nil { + t.Fatal(err) + } + response, err := readWire(&wire, defaultHelloLimit) + if err != nil { + t.Fatal(err) + } + stable, err := protocol.DecodeStableError(response) + if err != nil { + t.Fatal(err) + } + if strings.Contains(stable.Message, "provider.invalid") || strings.Contains(stable.Message, "secret-sentinel") { + t.Fatalf("stable error leaked internal details: %q", stable.Message) + } +} + func TestGatewayTelemetrySeparatesQueueProcessingAndPacing(t *testing.T) { serverTLS, clientTLS := testTLS(t) session := &fakeSession{ diff --git a/gateway/transport.go b/gateway/transport.go index 6cb1a1e..7ca8c19 100644 --- a/gateway/transport.go +++ b/gateway/transport.go @@ -206,74 +206,84 @@ func (s *Server) handleConnection(parent context.Context, connection *quic.Conn) if err != nil { return } + writeError := func(code string, err error, retryable bool) { + if writeStableError(stream, code, err, retryable) == nil && stream.Close() == nil { + responseCtx, responseCancel := context.WithTimeout(ctx, time.Second) + defer responseCancel() + select { + case <-connection.Context().Done(): + case <-responseCtx.Done(): + } + } + } requestBytes, err := readWire(stream, defaultHelloLimit) if err != nil { - _ = writeStableError(stream, "invalid_hello", err, false) + writeError("invalid_hello", err, false) return } request, err := protocol.DecodeTunnelAdmissionRequest(requestBytes) if err != nil { - _ = writeStableError(stream, "invalid_hello", err, false) + writeError("invalid_hello", err, false) return } if s.Draining() { - _ = writeStableError(stream, "gateway_draining", ErrGatewayDraining, true) + writeError("gateway_draining", ErrGatewayDraining, true) return } if request.GatewayID != s.config.GatewayID { - _ = writeStableError(stream, "wrong_gateway", ErrAdmissionRejected, false) + writeError("wrong_gateway", ErrAdmissionRejected, false) return } authority, err := s.config.Admission.Admit(ctx, request) if err != nil { s.metrics.AdmissionRejects.Add(1) - _ = writeStableError(stream, stableAdmissionCode(err), err, errors.Is(err, context.DeadlineExceeded)) + writeError(stableAdmissionCode(err), err, errors.Is(err, context.DeadlineExceeded)) return } if s.Draining() { _ = s.config.Admission.Release(context.Background(), authority) - _ = writeStableError(stream, "gateway_draining", ErrGatewayDraining, true) + writeError("gateway_draining", ErrGatewayDraining, true) return } if err := s.validateAuthority(authority, request); err != nil { _ = s.config.Admission.Release(context.Background(), authority) - _ = writeStableError(stream, "invalid_authority", err, false) + writeError("invalid_authority", err, false) return } work, err := s.config.Admission.ProviderWork(ctx, authority) if err != nil || s.validateProviderWork(work, authority) != nil { _ = s.config.Admission.Release(context.Background(), authority) - _ = writeStableError(stream, "provider_work_unavailable", ErrAdmissionRejected, err != nil) + writeError("provider_work_unavailable", ErrAdmissionRejected, err != nil) return } selected, err := IntersectCapabilities(s.config.Capabilities, s.config.ProviderCapabilities, request.Capabilities, authority.Capabilities) if err != nil { _ = s.config.Admission.Release(context.Background(), authority) s.metrics.AdmissionRejects.Add(1) - _ = writeStableError(stream, "no_capability_overlap", err, false) + writeError("no_capability_overlap", err, false) return } selected, err = selectApolloPolicyCapabilities(work.StreamPolicy, selected) if err != nil { _ = s.config.Admission.Release(context.Background(), authority) s.metrics.AdmissionRejects.Add(1) - _ = writeStableError(stream, "no_capability_overlap", err, false) + writeError("no_capability_overlap", err, false) return } clipboard, err := newClipboardGate(work.ClipboardPolicy, time.Now) if err != nil { _ = s.config.Admission.Release(context.Background(), authority) - _ = writeStableError(stream, "provider_work_unavailable", ErrAdmissionRejected, false) + writeError("provider_work_unavailable", ErrAdmissionRejected, false) return } if (work.ClipboardPolicy.ClientToProviderEnabled || work.ClipboardPolicy.ProviderToClientEnabled) && s.config.ClipboardAuditReporter == nil { _ = s.config.Admission.Release(context.Background(), authority) - _ = writeStableError(stream, "clipboard_audit_unavailable", ErrAdmissionRejected, true) + writeError("clipboard_audit_unavailable", ErrAdmissionRejected, true) return } if err := s.reportProviderState(ctx, protocol.ProviderState{Version: "1", SessionID: request.SessionID, State: ProviderStateStarting, CleanupPending: false, Channels: []string{"video", "audio", "input", "feedback"}}); err != nil { _ = s.config.Admission.Release(context.Background(), authority) - _ = writeStableError(stream, "provider_state_unavailable", err, true) + writeError("provider_state_unavailable", err, true) return } providerSession, err := s.config.Provider.Start(ctx, LaunchRequest{SessionID: request.SessionID, Capabilities: selected, ProviderProfile: authority.ProviderProfile, ProviderIdentity: work.ProviderIdentity, ProviderWork: work}) @@ -281,14 +291,14 @@ func (s *Server) handleConnection(parent context.Context, connection *quic.Conn) s.metrics.ProviderErrors.Add(1) _ = s.reportProviderState(context.Background(), protocol.ProviderState{Version: "1", SessionID: request.SessionID, State: ProviderStateFailed, CleanupPending: false, Channels: []string{"video", "audio", "input", "feedback"}}) _ = s.config.Admission.Release(context.Background(), authority) - _ = writeStableError(stream, stableProviderCode(err), err, errors.Is(err, context.DeadlineExceeded)) + writeError(stableProviderCode(err), err, errors.Is(err, context.DeadlineExceeded)) return } if err := s.reportProviderState(ctx, providerSession.State()); err != nil { _ = providerSession.ReleaseAll(context.Background()) _ = providerSession.Terminate(context.Background()) _ = s.config.Admission.Release(context.Background(), authority) - _ = writeStableError(stream, "provider_state_unavailable", err, true) + writeError("provider_state_unavailable", err, true) return } clientAuthority := protocol.ClientSessionAuthority{ @@ -943,11 +953,8 @@ func (s *gatewaySession) cleanup() { }) } -func writeStableError(writer io.Writer, code string, err error, retryable bool) error { - message := err.Error() - if len(message) > 256 { - message = message[:256] - } +func writeStableError(writer io.Writer, code string, _ error, retryable bool) error { + message := stableErrorMessage(code) payload, encodeErr := protocol.EncodeStableError(protocol.StableError{Version: "1", Code: code, Message: message, Retryable: retryable}) if encodeErr != nil { return encodeErr @@ -955,6 +962,29 @@ func writeStableError(writer io.Writer, code string, err error, retryable bool) return writeWire(writer, payload, defaultHelloLimit) } +func stableErrorMessage(code string) string { + switch code { + case "invalid_hello": + return "invalid client hello" + case "gateway_draining": + return "gateway is draining" + case "wrong_gateway": + return "gateway does not match admission request" + case "admission_rejected", "expired_grant": + return "admission rejected" + case "invalid_authority": + return "invalid session authority" + case "no_capability_overlap": + return "no compatible capability" + case "clipboard_audit_unavailable": + return "clipboard audit unavailable" + case "provider_work_unavailable", "provider_identity_rejected", "provider_malformed", "provider_timeout", "provider_unavailable", "provider_state_unavailable": + return "provider unavailable" + default: + return "request failed" + } +} + func stableAdmissionCode(err error) string { if errors.Is(err, ErrGatewayDraining) { return "gateway_draining"