diff --git a/Cargo.lock b/Cargo.lock index 0d183f6..2ca429f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -240,6 +246,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -568,6 +580,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -949,6 +970,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +dependencies = [ + "serde", +] + [[package]] name = "errno" version = "0.3.14" @@ -956,7 +986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -986,6 +1016,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" @@ -1014,6 +1050,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1192,6 +1238,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "1.8.3" @@ -1242,6 +1307,9 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hex-literal" @@ -1331,6 +1399,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1352,9 +1421,11 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -1363,7 +1434,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1429,7 +1500,7 @@ dependencies = [ "http-body-util", "ic-certification", "ic-ed25519", - "ic-transport-types", + "ic-transport-types 0.49.0", "ic-verify-bls-signature", "ic_principal", "k256", @@ -1439,7 +1510,7 @@ dependencies = [ "pkcs8", "rand 0.10.1", "rangemap", - "reqwest", + "reqwest 0.13.4", "sec1", "serde", "serde_bytes", @@ -1502,6 +1573,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ic-management-canister-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3149217e24186df3f13dc45eee14cdb3e5cad07d0b2b67bd53555c1c55462957" +dependencies = [ + "candid", + "serde", + "serde_bytes", +] + [[package]] name = "ic-representation-independent-hash" version = "3.2.0" @@ -1528,6 +1610,24 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "ic-transport-types" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a775244756a5d97ff19b08071a946a4b4896904e35deb036bf215e80f2e703d" +dependencies = [ + "candid", + "hex", + "ic-certification", + "leb128", + "serde", + "serde_bytes", + "serde_cbor", + "serde_repr", + "sha2 0.10.9", + "thiserror 2.0.18", +] + [[package]] name = "ic-transport-types" version = "0.49.0" @@ -1716,7 +1816,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", - "base64", + "base64 0.22.1", "candid", "candid_parser", "crc32fast", @@ -1728,8 +1828,9 @@ dependencies = [ "ic-representation-independent-hash", "ic-signature-verification", "p256", + "pocket-ic", "regex", - "reqwest", + "reqwest 0.13.4", "rmcp", "schemars 0.8.22", "serde", @@ -1945,6 +2046,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2044,6 +2151,26 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -2067,7 +2194,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2126,7 +2253,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http", @@ -2234,7 +2361,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -2337,6 +2464,39 @@ dependencies = [ "spki", ] +[[package]] +name = "pocket-ic" +version = "15.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe796a5fbeb08244338f5eb30f2de91fa12152ea2b5176f9da20fecd20b1ced" +dependencies = [ + "backoff", + "base64 0.13.1", + "candid", + "flate2", + "hex", + "ic-certification", + "ic-management-canister-types", + "ic-transport-types 0.45.0", + "reqwest 0.12.28", + "schemars 0.8.22", + "semver", + "serde", + "serde_cbor", + "serde_json", + "sha2 0.10.9", + "slog", + "strum", + "strum_macros", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", + "wslpath", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2644,13 +2804,58 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -2681,7 +2886,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -2716,7 +2921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "futures", @@ -2727,7 +2932,7 @@ dependencies = [ "pastey", "pin-project-lite", "rand 0.10.1", - "reqwest", + "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", "serde", @@ -2771,6 +2976,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -2779,6 +2997,7 @@ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2825,7 +3044,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2974,6 +3193,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -3162,6 +3385,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -3190,6 +3419,18 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slog" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b3b8565691b22d2bdfc066426ed48f837fc0c5f2c8cad8d9718f7f99d6995c1" +dependencies = [ + "anyhow", + "erased-serde", + "rustversion", + "serde_core", +] + [[package]] name = "smallvec" version = "1.15.2" @@ -3203,7 +3444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3245,7 +3486,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3278,6 +3519,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3290,6 +3553,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -3332,6 +3601,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "term" version = "0.7.0" @@ -3412,6 +3694,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3420,6 +3703,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -3463,6 +3756,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -3622,6 +3916,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -3654,6 +3961,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -3664,12 +3981,16 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", + "time", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -3696,6 +4017,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3900,6 +4227,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -3954,6 +4294,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3976,7 +4325,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4309,6 +4658,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wslpath" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04a2ecdf2cc4d33a6a93d71bcfbc00bb1f635cdb8029a2cc0709204a045ec7a3" + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index af28a11..4704397 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,17 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls"] } getrandom = "0.3.4" urlencoding = "2.1.3" url = "2" +# Only for the PocketIC-backed end-to-end test (src/e2e_handshake.rs), behind +# the `e2e` feature so the default build never compiles it. Client library for a +# local Internet Identity canister; version tracks the `pocket-ic` server binary. +pocket-ic = { version = "15", optional = true } + +[features] +# Compiles + enables the real II<>MCP handshake test against a live Internet +# Identity canister in PocketIC (needs II_WASM + POCKET_IC_BIN at runtime; see +# src/e2e_handshake.rs). Off by default: the standard `cargo test` neither +# builds `pocket-ic` nor touches the network. +e2e = ["dep:pocket-ic"] [dev-dependencies] # Router-level contract tests (tests/routers.rs) drive the composed app with diff --git a/src/auth.rs b/src/auth.rs index c1089bc..b7b60c9 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -126,7 +126,7 @@ const GRANT_TTL_SECS: u64 = 3600; /// Name of the browser-session cookie that binds `/oauth/authorize` to /// `/oauth/connect/redeem`: only the browser that started the flow can complete it. -const CONNECT_COOKIE: &str = "mcp_connect"; +pub(crate) const CONNECT_COOKIE: &str = "mcp_connect"; /// A registered OAuth client (RFC 7591): the redirect URIs it declared. The /// auth-code flow only redirects a code to one of these (exact match), so the diff --git a/src/e2e_handshake.rs b/src/e2e_handshake.rs new file mode 100644 index 0000000..1cfd155 --- /dev/null +++ b/src/e2e_handshake.rs @@ -0,0 +1,565 @@ +//! Real Internet-Identity ↔ MCP connect-handshake test, end to end against a +//! **live Internet Identity canister** running in PocketIC. Nothing is mocked: +//! II runs its production wasm, issues a genuine canister-signed registration +//! delegation, and the real [`crate::McpServer`] redeems it (`mcp_register_v2`) +//! through its own `ic-agent`, verifying the canister signature against +//! PocketIC's fetched root key — exactly the production redeem path. +//! +//! ## What it exercises for real +//! Register an II anchor → trust the MCP server (`mcp_set_config`) → the II +//! registration-delegation ceremony (`prepare_mcp_registration_delegation` + +//! `get_mcp_registration_delegation`, playing the browser/frontend) → feed the +//! **real** delegation to the server's `/oauth/connect/redeem` → the server +//! signs `mcp_register_v2` on the live II → `/oauth/token` → the minted bearer +//! token authenticates (`session_for_token`). Only the human's browser clicks +//! are stood in for; every canister interaction and every signature is genuine. +//! +//! ## Gating (default `cargo test` is untouched and offline) +//! Behind the `e2e` cargo feature (so `pocket-ic` is neither compiled nor +//! downloaded by the default build) AND a runtime guard that skips unless both +//! artifacts — which cargo does not fetch — are provided: +//! +//! ```text +//! II_WASM=/abs/internet_identity_backend.wasm.gz \ # release asset, gz as-is +//! POCKET_IC_BIN=/abs/pocket-ic \ # pocket-ic v15 server +//! cargo test --features e2e -- --nocapture +//! ``` +//! +//! The candid types below are hand-rolled from `internet_identity.did` at tag +//! `release-2026-07-17` (rather than a git dependency) so the crate stays +//! self-contained. `InternetIdentityInit` is declared minimally — all its fields +//! are `opt`, so a record carrying only `captcha_config` is a valid candid +//! subtype and II fills the rest with `null`. + +// The hand-rolled candid types mirror II's `.did` in full for wire fidelity, so +// some variants/fields are declared but never constructed on our send path. +#![allow(dead_code)] + +use candid::{CandidType, Decode, Encode, Principal}; +use http_body_util::BodyExt; +use serde::Deserialize; +use std::time::SystemTime; +use tower::ServiceExt; + +use axum::{ + body::Body, + http::{header, Request, StatusCode}, +}; + +// ---- II candid interface (subset), from internet_identity.did @ release-2026-07-17 ---- + +/// `type Permissions = variant { queries; all }`. +#[derive(CandidType, Deserialize, Clone, Copy)] +enum Permissions { + #[serde(rename = "queries")] + Queries, + #[serde(rename = "all")] + All, +} + +/// `type McpConfig = record { enabled : bool; url : opt text }`. +#[derive(CandidType)] +struct McpConfig { + enabled: bool, + url: Option, +} + +/// `type Delegation = record { pubkey; expiration; targets : opt vec principal; +/// permissions : opt text }`. The delegation record's `permissions` is `opt +/// text` ("queries" = read-only, absent = unrestricted) — deliberately NOT the +/// named `Permissions` variant above (that variant is only the `prepare_…` +/// argument / `mcp_register_v2` reply). Mixing them up is the II #40 outage +/// class; the registration delegation returns this field absent (`None`). +#[derive(CandidType, Deserialize)] +struct Delegation { + pubkey: Vec, + expiration: u64, + targets: Option>, + permissions: Option, +} + +/// `type SignedDelegation = record { delegation; signature }`. +#[derive(CandidType, Deserialize)] +struct SignedDelegation { + delegation: Delegation, + signature: Vec, +} + +/// `Ok` payload of `prepare_mcp_registration_delegation`. +#[derive(CandidType, Deserialize)] +struct PrepareMcpRegistrationDelegation { + user_key: Vec, + expiration: u64, +} + +/// `type Challenge = record { png_base64 : text; challenge_key : text }`. +#[derive(CandidType, Deserialize)] +struct Challenge { + #[allow(dead_code)] + png_base64: String, + challenge_key: String, +} + +/// `type ChallengeResult = record { key : text; chars : text }`. +#[derive(CandidType)] +struct ChallengeResult { + key: String, + chars: String, +} + +#[derive(CandidType, Deserialize)] +enum Purpose { + #[serde(rename = "recovery")] + Recovery, + #[serde(rename = "authentication")] + Authentication, +} + +#[derive(CandidType, Deserialize)] +enum KeyType { + #[serde(rename = "unknown")] + Unknown, + #[serde(rename = "platform")] + Platform, + #[serde(rename = "cross_platform")] + CrossPlatform, + #[serde(rename = "seed_phrase")] + SeedPhrase, + #[serde(rename = "browser_storage_key")] + BrowserStorageKey, +} + +#[derive(CandidType, Deserialize)] +enum DeviceProtection { + #[serde(rename = "protected")] + Protected, + #[serde(rename = "unprotected")] + Unprotected, +} + +/// `type MetadataMap = vec record { text; variant { map; string; bytes } }` — +/// recursive; only needed so `DeviceData.metadata` has a type (we send `null`). +#[derive(CandidType, Deserialize)] +enum MetadataVal { + #[serde(rename = "map")] + Map(Vec<(String, MetadataVal)>), + #[serde(rename = "string")] + String(String), + #[serde(rename = "bytes")] + Bytes(Vec), +} + +/// `type DeviceData = record { ... }`. +#[derive(CandidType)] +struct DeviceData { + pubkey: Vec, + alias: String, + credential_id: Option>, + aaguid: Option>, + purpose: Purpose, + key_type: KeyType, + protection: DeviceProtection, + origin: Option, + metadata: Option>, +} + +/// `type RegisterResponse = variant { registered : record { user_number }; canister_full; bad_challenge }`. +#[derive(CandidType, Deserialize)] +enum RegisterResponse { + #[serde(rename = "registered")] + Registered { user_number: u64 }, + #[serde(rename = "canister_full")] + CanisterFull, + #[serde(rename = "bad_challenge")] + BadChallenge, +} + +// -- Install init arg (captcha disabled). All II init fields are `opt`, so a +// one-field record is a valid subtype. -- + +#[derive(CandidType)] +enum StaticCaptchaTrigger { + CaptchaEnabled, + CaptchaDisabled, +} + +#[derive(CandidType)] +enum CaptchaTrigger { + Dynamic { + threshold_pct: u16, + current_rate_sampling_interval_s: u64, + reference_rate_sampling_interval_s: u64, + }, + Static(StaticCaptchaTrigger), +} + +#[derive(CandidType)] +struct CaptchaConfig { + max_unsolved_captchas: u64, + captcha_trigger: CaptchaTrigger, +} + +#[derive(CandidType)] +struct InternetIdentityInit { + captcha_config: Option, +} + +// ---- II test-anchor helpers (mirroring canister_tests::framework) ---- + +/// The device pubkey II's test helpers use; not a real DER key, accepted only +/// because the registration captcha is disabled by the install arg. +const DEVICE_PUBKEY: &[u8] = b"test"; + +/// `principal_1()` = the device principal that authorizes anchor operations. +fn principal_1() -> Principal { + Principal::self_authenticating(DEVICE_PUBKEY) +} + +fn device_data_1() -> DeviceData { + DeviceData { + pubkey: DEVICE_PUBKEY.to_vec(), + alias: "e2e device".to_string(), + credential_id: None, + aaguid: None, + purpose: Purpose::Authentication, + key_type: KeyType::CrossPlatform, + protection: DeviceProtection::Unprotected, + origin: None, + metadata: None, + } +} + +/// Register a fresh anchor (captcha disabled ⇒ the accepted solution is "a"). +async fn register_anchor(pic: &pocket_ic::nonblocking::PocketIc, ii: Principal) -> u64 { + let bytes = pic + .update_call(ii, Principal::anonymous(), "create_challenge", Encode!().unwrap()) + .await + .expect("create_challenge"); + let challenge = Decode!(&bytes, Challenge).unwrap(); + let attempt = ChallengeResult { + key: challenge.challenge_key, + chars: "a".to_string(), + }; + let bytes = pic + .update_call( + ii, + principal_1(), + "register", + Encode!(&device_data_1(), &attempt, &Option::::None).unwrap(), + ) + .await + .expect("register"); + match Decode!(&bytes, RegisterResponse).unwrap() { + RegisterResponse::Registered { user_number } => user_number, + RegisterResponse::CanisterFull => panic!("register: canister full"), + RegisterResponse::BadChallenge => panic!("register: bad challenge (captcha not disabled?)"), + } +} + +// ---- HTTP helpers (drive the real router, like tests/routers.rs) ---- + +async fn body_json(resp: axum::response::Response) -> serde_json::Value { + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} + +/// Pull `key=value` out of a `&`-separated fragment/query (values are base64url +/// or plain text here — none contain `&`). +fn field<'a>(blob: &'a str, key: &str) -> Option<&'a str> { + blob.split('&') + .filter_map(|kv| kv.split_once('=')) + .find(|(k, _)| *k == key) + .map(|(_, v)| v) +} + +/// Restores (or clears) the process-global `OAUTH_CLIENTS_FILE` on drop — even on +/// a panic-unwind — so this test's override can't leak into other test threads +/// running in parallel, and removes the temp file it pointed at. +struct ClientsFileEnvGuard { + prev: Option, + path: std::path::PathBuf, +} + +impl Drop for ClientsFileEnvGuard { + fn drop(&mut self) { + match self.prev.take() { + Some(v) => std::env::set_var("OAUTH_CLIENTS_FILE", v), + None => std::env::remove_var("OAUTH_CLIENTS_FILE"), + } + let _ = std::fs::remove_file(&self.path); + } +} + +#[tokio::test] +async fn registration_delegation_end_to_end() { + // Runtime guard: skip cleanly unless the un-fetchable artifacts are provided. + let (Ok(ii_wasm_path), Ok(_)) = + (std::env::var("II_WASM"), std::env::var("POCKET_IC_BIN")) + else { + eprintln!( + "skipping registration_delegation_end_to_end: set II_WASM (internet_identity \ + release .wasm.gz) and POCKET_IC_BIN (pocket-ic v15 server) to run it" + ); + return; + }; + let ii_wasm = std::fs::read(&ii_wasm_path).expect("read II_WASM (gz bytes; PocketIC gunzips)"); + + // Isolate the OAuth client store: a UNIQUE temp file (so concurrent runs + // never collide on a fixed name) plus a guard that restores/clears the + // process-global `OAUTH_CLIENTS_FILE` on drop — even on a panic — so the + // override can't leak into other test threads. + let nanos = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let mut clients_file = std::env::temp_dir(); + clients_file.push(format!( + "imcp2-e2e-oauth-clients-{}-{nanos}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&clients_file); + let _clients_env = ClientsFileEnvGuard { + prev: std::env::var_os("OAUTH_CLIENTS_FILE"), + path: clients_file.clone(), + }; + std::env::set_var("OAUTH_CLIENTS_FILE", &clients_file); + + // --- PocketIC + live II --- + let mut pic = pocket_ic::PocketIcBuilder::new() + .with_nns_subnet() + .with_application_subnet() + .build_async() + .await; + // Fresh instances boot at a 2021 mock clock; align to now BEFORE minting the + // (~5-min) registration delegation, or it is already expired. + pic.set_time(SystemTime::now().into()).await; + pic.tick().await; + + let ii = pic.create_canister().await; + pic.add_cycles(ii, 2_000_000_000_000).await; + let init = InternetIdentityInit { + captcha_config: Some(CaptchaConfig { + max_unsolved_captchas: 500, + captcha_trigger: CaptchaTrigger::Static(StaticCaptchaTrigger::CaptchaDisabled), + }), + }; + pic.install_canister(ii, ii_wasm, Encode!(&Some(init)).unwrap(), None) + .await; + + // Expose the gateway and point the server's OWN injected agent at it. + let gateway = pic.make_live(None).await; + let agent = crate::Agent::builder() + .with_url(gateway.as_str()) + .build() + .expect("build agent"); + agent.fetch_root_key().await.expect("fetch PocketIC root key"); + + // --- The real MCP server, injected with the PocketIC-backed agent --- + let public_url = "http://localhost:8000"; // http ⇒ the sid cookie is not `Secure` + let server = crate::McpServer::new(crate::McpConfig { + agent, + instance: crate::IiInstance { + name: "e2e", + ii_url: gateway.to_string(), + ii_canister: ii, + }, + public_url: public_url.into(), + mcp_path: "/mcp".into(), + clients: crate::SharedClients::load(), + }); + let app = axum::Router::new() + .nest_service(server.mcp_path(), server.mcp_router()) + .merge(server.well_known_router()); + + // --- 1. Register a loopback OAuth client (loopback is always permitted) --- + let redirect_uri = "http://127.0.0.1:6112/cb"; + let resp = app + .clone() + .oneshot( + Request::post("/mcp/oauth/register") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ "redirect_uris": [redirect_uri] }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert!(resp.status().is_success(), "register status: {}", resp.status()); + let client_id = body_json(resp).await["client_id"].as_str().unwrap().to_string(); + + // --- 2. Authorize: mints X for this connect; returns state + registration_key + cookie --- + // RFC 7636 test vector (challenge = S256(verifier)); avoids hashing here. + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + let authorize_url = format!( + "/mcp/oauth/authorize?response_type=code&client_id={client_id}\ + &redirect_uri={redirect_uri}&code_challenge={challenge}&code_challenge_method=S256" + ); + let resp = app + .clone() + .oneshot(Request::get(&authorize_url).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FOUND, "authorize should 302 to II"); + let location = resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(); + let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap(); + // Everything the server put in the II connect link rides the fragment. + let fragment = location.split_once('#').expect("connect link fragment").1; + let state = field(fragment, "state").expect("state").to_string(); + let reg_key_b64 = field(fragment, "registration_key").expect("registration_key"); + // pub(X) as II expects it (DER). base64url no-pad, per registration_pubkey_b64. + let reg_key_x = base64::Engine::decode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + reg_key_b64, + ) + .expect("decode registration_key"); + // The initiator cookie `mcp_connect=` (not Secure over http). + let cookie_val = set_cookie + .split(';') + .next() + .and_then(|kv| kv.trim().strip_prefix(&format!("{}=", crate::auth::CONNECT_COOKIE))) + .expect("mcp_connect cookie") + .to_string(); + + // --- 3. The registration-delegation ceremony (browser/frontend role) --- + let anchor = register_anchor(&pic, ii).await; + // Trust this MCP server for the anchor (precondition for `prepare`). + let set_cfg = pic + .update_call( + ii, + principal_1(), + "mcp_set_config", + Encode!( + &anchor, + &McpConfig { + enabled: true, + url: Some(format!("{public_url}/mcp")), + } + ) + .unwrap(), + ) + .await + .expect("mcp_set_config call"); + Decode!(&set_cfg, Result<(), String>).unwrap().expect("mcp_set_config Ok"); + + // prepare: consent (full access, 24h grant) is recorded server-side, keyed + // by the P_reg the canister returns as `user_key`. + const GRANT_TTL_NS: u64 = 24 * 60 * 60 * 1_000_000_000; + let prep_bytes = pic + .update_call( + ii, + principal_1(), + "prepare_mcp_registration_delegation", + Encode!( + &anchor, + ®_key_x, + &Some(Permissions::All), + &Some(GRANT_TTL_NS) + ) + .unwrap(), + ) + .await + .expect("prepare call"); + let prepared = Decode!(&prep_bytes, Result) + .unwrap() + .expect("prepare Ok"); + + // get: the single canister-signed hop P_reg -> X. + let get_bytes = pic + .query_call( + ii, + principal_1(), + "get_mcp_registration_delegation", + Encode!(&anchor, ®_key_x, &prepared.user_key, &prepared.expiration).unwrap(), + ) + .await + .expect("get call"); + let signed = Decode!(&get_bytes, Result) + .unwrap() + .expect("get Ok"); + assert_eq!(signed.delegation.pubkey, reg_key_x, "delegation targets our X"); + + // Serialize into the agent-js DelegationChain JSON the server parses: hex + // byte fields, HEX-string expiration, publicKey = der(P_reg). No permissions + // field (parser is deny_unknown_fields); targets only if II set them. + let mut delegation = serde_json::json!({ + "pubkey": hex::encode(&signed.delegation.pubkey), + "expiration": format!("{:x}", signed.delegation.expiration), + }); + if let Some(targets) = &signed.delegation.targets { + delegation["targets"] = + serde_json::Value::from(targets.iter().map(|p| p.to_text()).collect::>()); + } + let chain_json = serde_json::json!({ + "publicKey": hex::encode(&prepared.user_key), + "delegations": [{ "delegation": delegation, "signature": hex::encode(&signed.signature) }], + }) + .to_string(); + + // --- 4. Redeem: the REAL server signs mcp_register_v2 on the live II --- + let resp = app + .clone() + .oneshot( + Request::post("/mcp/oauth/connect/redeem") + .header(header::CONTENT_TYPE, "application/json") + .header(header::COOKIE, format!("{}={cookie_val}", crate::auth::CONNECT_COOKIE)) + .body(Body::from( + serde_json::json!({ "state": state, "delegation": chain_json }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "redeem should succeed against live II"); + let redirect = body_json(resp).await["redirect"].as_str().unwrap().to_string(); + assert!(redirect.starts_with(redirect_uri), "redirect to the client: {redirect}"); + let query = redirect.split_once('?').expect("redirect query").1; + let code = field(query, "code").expect("authorization code").to_string(); + + // --- 5. Token: exchange the code (PKCE S256) for a bearer access token --- + let resp = app + .clone() + .oneshot( + Request::post("/mcp/oauth/token") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!( + "grant_type=authorization_code&code={code}&client_id={client_id}&code_verifier={verifier}" + ))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "token exchange should succeed"); + let tok = body_json(resp).await; + let access_token = tok["access_token"].as_str().expect("access_token").to_string(); + assert_eq!(tok["token_type"], "Bearer"); + // TTL tracks the II grant (never outlives it): positive, ~24h. + let expires_in = tok["expires_in"].as_u64().expect("expires_in"); + assert!(expires_in > 0 && expires_in <= GRANT_TTL_NS / 1_000_000_000, "TTL tracks grant: {expires_in}"); + + // --- 6. The minted token authenticates and resolves to this connect --- + let (principal, session_id) = server + .store + .session_for_token(&access_token) + .await + .expect("the minted token must authenticate"); + assert_eq!(session_id, state); + assert_eq!( + Some(principal), + server.identities.session_principal(&state).await, + "token principal == self_authenticating(session key S)" + ); + + // Bonus: a Bearer request clears the require_token gate (not 401). + let resp = app + .oneshot( + Request::post("/mcp") + .header(header::AUTHORIZATION, format!("Bearer {access_token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(resp.status(), StatusCode::UNAUTHORIZED, "valid token must pass require_token"); +} diff --git a/src/identities.rs b/src/identities.rs index 40ba017..8052ae3 100644 --- a/src/identities.rs +++ b/src/identities.rs @@ -825,6 +825,7 @@ impl Identities { chain: Vec, ) -> Result { self.ensure_session(session_id).await; + // priv(X) to sign the ingress as, and pub(S) to register. let (reg_seed, reg_der, session_der) = { let sessions = self.sessions.read().await; @@ -842,7 +843,13 @@ impl Identities { // Sign `mcp_register_v2` AS X, presenting the `P_reg -> Y -> X` chain. // `reg_user_key` is `der(P_reg)`: the chain root II recovers `caller() == // P_reg` from. - let identity = registration_identity(reg_user_key, reg_seed, ®_der, chain)?; + // Verify the delivered chain against the injected agent's root key (the + // network we are about to make the `mcp_register_v2` call to), not a + // hard-coded mainnet key — so a host pointed at a non-mainnet IC verifies + // and signs against the same trust anchor. In production the agent is + // mainnet, so this is the mainnet root. + let identity = + registration_identity(reg_user_key, reg_seed, ®_der, chain, &self.agent.read_root_key())?; let agent = self.agent_as(identity); // mcp_register_v2(session_key) -> variant { Ok : McpRegisterV2Ok; Err : text } @@ -1060,14 +1067,23 @@ fn build_identity(app: &AppDelegation) -> Result { /// the chain root. Guards locally that the chain is non-empty and its FINAL hop /// delegates to this connect's `X` — the only key we hold the private half of, /// so a chain toward any other key is rejected before signing anything. Length -/// is otherwise not constrained here: the replica authoritatively verifies -/// every hop (the canister signature on `P_reg -> Y`, the `Y` signature on -/// `Y -> X`) at redemption. +/// is otherwise not constrained here. +/// +/// The chain is verified at construction by +/// [`DelegatedIdentity::new_with_root_key`] against `root_key` — the **injected +/// agent's** root key (`Agent::read_root_key`), which is the IC mainnet key in +/// production and whatever the agent targets otherwise (e.g. a local replica's +/// key after `fetch_root_key`). Using the agent's own trust anchor — rather than +/// hard-coding mainnet via [`DelegatedIdentity::new`] — keeps chain verification +/// consistent with the network the `mcp_register_v2` call itself goes to: the +/// canister signature on `P_reg -> Y` is BLS-verified against that root, and the +/// `Y -> X` hop against `Y`. The replica re-verifies every hop at redemption. fn registration_identity( reg_user_key: Vec, reg_seed: [u8; 32], reg_der: &[u8], chain: Vec, + root_key: &[u8], ) -> Result { match chain.last() { Some(last) if last.delegation.pubkey == reg_der => {} @@ -1078,8 +1094,13 @@ fn registration_identity( } None => return Err("registration delegation chain is empty".to_string()), } - DelegatedIdentity::new(reg_user_key, Box::new(BasicIdentity::from_raw_key(®_seed)), chain) - .map_err(|e| format!("invalid registration delegation chain: {e}")) + DelegatedIdentity::new_with_root_key( + reg_user_key, + Box::new(BasicIdentity::from_raw_key(®_seed)), + chain, + root_key, + ) + .map_err(|e| format!("invalid registration delegation chain: {e}")) } /// Render an `AccountDelegationError` as an actionable message. Any `Unauthorized` @@ -2011,7 +2032,15 @@ mod tests { }; let chain = vec![hop(&preg_seed, &y_der), hop(&y_seed, &x_der)]; - let identity = registration_identity(preg_der.clone(), x_seed, &x_der, chain) + // Verified against the agent's root key (mainnet default here); the + // Ed25519 stand-in hops don't consult it — only a real canister-signed + // hop would — so any root key builds this all-Ed25519 chain. + let root = crate::Agent::builder() + .with_url("https://ii.test") + .build() + .expect("agent") + .read_root_key(); + let identity = registration_identity(preg_der.clone(), x_seed, &x_der, chain, &root) .expect("a two-hop chain ending at X must build"); let sender = identity.sender().expect("sender"); let content = EnvelopeContent::Call { @@ -2033,7 +2062,7 @@ mod tests { // signing — we don't hold that key's private half. let (_, other_der) = fresh_ed25519(); let bad = vec![hop(&preg_seed, &y_der), hop(&y_seed, &other_der)]; - assert!(registration_identity(preg_der, x_seed, &x_der, bad).is_err()); + assert!(registration_identity(preg_der, x_seed, &x_der, bad, &root).is_err()); } // Redemption with no registration key minted, or an empty chain, fails diff --git a/src/lib.rs b/src/lib.rs index 65961a0..7a0ff2f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -75,6 +75,13 @@ mod management; mod skills; mod tools; +// Real II<>MCP handshake test against a live Internet Identity canister in +// PocketIC. In-crate (not tests/) so it can use the feature-gated optional +// `pocket-ic` dependency and the crate internals; gated so the default build +// compiles neither it nor `pocket-ic`. See the module docs to run it. +#[cfg(all(test, feature = "e2e"))] +mod e2e_handshake; + use axum::{ middleware, routing::{get, post},