From 0d95b3c0e2bebdf41b9cf11a0aa6f6d687bee5e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 08:25:44 +0000 Subject: [PATCH 1/4] Bump ic-agent and ic-transport-types to 0.48.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These versions add the `permissions` field to `Delegation` (with the new `DelegationPermissions` enum) for read-only delegation support. `Delegation` is not `#[non_exhaustive]`, so the struct literal built from II's `mcp_get_delegation` response now sets `permissions: None` explicitly — II issues full-access delegations for the MCP flow and does not sign a `permissions` field, and `None` hashes/verifies identically to a pre-0.48 delegation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RzjuJVat2CTiCYzeU9F6e2 --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- src/identities.rs | 4 ++++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 438322c..d71604d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1406,9 +1406,9 @@ dependencies = [ [[package]] name = "ic-agent" -version = "0.47.3" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85dbab284eac1806b77833cff143d66ae97aae74d34b0e60fb4ab4edf3d6fa12" +checksum = "af4add8891f35fa99c0ff820efd951975c9294240754df6e70a00b6491b613b7" dependencies = [ "arc-swap", "async-channel 2.5.0", @@ -1530,9 +1530,9 @@ dependencies = [ [[package]] name = "ic-transport-types" -version = "0.47.3" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2116182b3ec5a831d579db0a205b6cf9c2ca96616493bef99480532d0b3c2f5" +checksum = "2baf82406ec203c95891283e2a585b2f56bcc5bb80872445bb824dfbaf95e385" dependencies = [ "candid", "hex", diff --git a/Cargo.toml b/Cargo.toml index f4e2783..410442d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ rmcp = { version = "1.7", features = [ "transport-streamable-http-server", "auth", ] } -ic-agent = "0.47" +ic-agent = "0.48" candid = { version = "0.10", features = ["value"] } candid_parser = "0.3.2" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } diff --git a/src/identities.rs b/src/identities.rs index 6350ecf..0cfe357 100644 --- a/src/identities.rs +++ b/src/identities.rs @@ -622,6 +622,10 @@ impl IiSignedDelegation { pubkey: self.delegation.pubkey, expiration: self.delegation.expiration, targets: self.delegation.targets, + // II issues full-access delegations for the MCP flow and does not + // sign a `permissions` field, so leave it unset. `None` hashes and + // verifies identically to a pre-0.48 delegation. + permissions: None, }, signature: self.signature, }) From d027903c427ebf70f8e81e4a9c7a59506b2f46bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 08:51:36 +0000 Subject: [PATCH 2/4] Forward II's per-MCP-session delegation permission II carries a per-MCP-session permission (`opt Permissions`, `variant { queries; all }`) on the delegation returned by `mcp_get_delegation`, and that field is part of what II signs. Decode it via a local Candid mirror enum and forward it into the `ic-agent` `Delegation` (0.48's new `permissions` field) so the reconstructed delegation hashes to exactly what II signed, instead of hardcoding `None` and dropping it. An absent permission still maps to `None`, which hashes identically to a pre-0.48 delegation, so unrestricted sessions are unaffected. Adds a test locking in the queries/all decode-and-map and the absent -> None case. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RzjuJVat2CTiCYzeU9F6e2 --- src/identities.rs | 99 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/src/identities.rs b/src/identities.rs index 0cfe357..6472386 100644 --- a/src/identities.rs +++ b/src/identities.rs @@ -32,7 +32,7 @@ use std::{ use base64::Engine; use candid::{CandidType, Decode, Encode, Principal}; use ic_agent::{ - identity::{BasicIdentity, DelegatedIdentity, Delegation, SignedDelegation}, + identity::{BasicIdentity, DelegatedIdentity, Delegation, DelegationPermissions, SignedDelegation}, Agent, Identity, }; use serde::Deserialize; @@ -601,6 +601,32 @@ struct IiDelegation { pubkey: Vec, expiration: u64, targets: Option>, + /// Which request kinds this delegation authorizes — II's per-MCP-session + /// access, `opt Permissions` (`variant { queries; all }`). Absent/`None` + /// means unrestricted. Part of what II signs, so it must be forwarded + /// verbatim into the `ic-agent` delegation for the hash to match. + permissions: Option, +} + +/// II's `Permissions` variant on a delegation (`variant { queries; all }`), +/// mirrored for Candid decoding since `ic-agent`'s `DelegationPermissions` +/// isn't `CandidType`. Maps 1:1 into it, preserving the on-wire representation +/// (`queries`/`all`) II hashed over. +#[derive(CandidType, Deserialize)] +enum IiPermissions { + #[serde(rename = "queries")] + Queries, + #[serde(rename = "all")] + All, +} + +impl From for DelegationPermissions { + fn from(p: IiPermissions) -> Self { + match p { + IiPermissions::Queries => DelegationPermissions::Queries, + IiPermissions::All => DelegationPermissions::All, + } + } } /// `SignedDelegation` as returned by II's `mcp_get_delegation`. @@ -622,10 +648,11 @@ impl IiSignedDelegation { pubkey: self.delegation.pubkey, expiration: self.delegation.expiration, targets: self.delegation.targets, - // II issues full-access delegations for the MCP flow and does not - // sign a `permissions` field, so leave it unset. `None` hashes and - // verifies identically to a pre-0.48 delegation. - permissions: None, + // Forward II's per-MCP-session permission verbatim so the + // reconstructed delegation hashes to exactly what II signed. + // An absent permission (`None`) hashes identically to a + // pre-0.48 delegation, so unrestricted sessions are unaffected. + permissions: self.delegation.permissions.map(Into::into), }, signature: self.signature, }) @@ -774,4 +801,66 @@ mod tests { assert_eq!(decoded[1].name.as_deref(), Some("savings")); assert_eq!(decoded[1].last_used, Some(123)); } + + // Lock in the mcp_get_delegation permission contract: II's `Delegation` + // carries `permissions: opt variant { queries; all }`, and it must round-trip + // through `into_agent` onto the `ic-agent` delegation so the reconstructed + // hash matches what II signed. An absent permission stays `None`. + #[test] + fn signed_delegation_forwards_ii_permissions() { + #[derive(CandidType, Deserialize)] + enum WirePermissions { + #[serde(rename = "queries")] + Queries, + #[serde(rename = "all")] + All, + } + #[derive(CandidType)] + struct WireDelegation { + pubkey: Vec, + expiration: u64, + targets: Option>, + permissions: Option, + } + #[derive(CandidType)] + struct WireSignedDelegation { + delegation: WireDelegation, + signature: Vec, + } + + let app_key = vec![1u8, 2, 3, 4]; + // Decode a `queries`-scoped delegation and confirm it maps to Queries. + let make = |permissions| WireSignedDelegation { + delegation: WireDelegation { + pubkey: app_key.clone(), + expiration: 42, + targets: None, + permissions, + }, + signature: vec![9, 9, 9], + }; + + let bytes = Encode!(&make(Some(WirePermissions::Queries))).expect("encode"); + let agent = Decode!(&bytes, IiSignedDelegation) + .expect("decode") + .into_agent(&app_key) + .expect("into_agent"); + assert_eq!(agent.delegation.permissions, Some(DelegationPermissions::Queries)); + + let bytes = Encode!(&make(Some(WirePermissions::All))).expect("encode"); + let agent = Decode!(&bytes, IiSignedDelegation) + .expect("decode") + .into_agent(&app_key) + .expect("into_agent"); + assert_eq!(agent.delegation.permissions, Some(DelegationPermissions::All)); + + // An unrestricted (absent) permission decodes and forwards as None, + // hashing identically to a pre-0.48 delegation. + let bytes = Encode!(&make(None)).expect("encode"); + let agent = Decode!(&bytes, IiSignedDelegation) + .expect("decode") + .into_agent(&app_key) + .expect("into_agent"); + assert_eq!(agent.delegation.permissions, None); + } } From 61cf1abcf3e5db5c2dece5c470f53e6778619a12 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 10:25:14 +0000 Subject: [PATCH 3/4] Add read-only delegation e2e test and run cargo tests in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an end-to-end test that drives the full server path for a read-only (`queries`) delegation — II reply -> `Decode!` -> `IiSignedDelegation::into_agent` -> `build_identity` -> `Identity::sign` — with a genuinely anchor-signed chain (no network). It asserts that: - the read-only scope survives decoding into the `ic-agent` delegation, - every request the resulting identity signs (an update AND a read) carries the `Queries` permission, so the replica scopes both to queries/read_state, and - the scope is bound into the delegation signature: stripping it to an unrestricted delegation changes the signed bytes and invalidates the chain, so a client cannot silently widen a read-only delegation to full access. The shared II wire types (`WirePermissions` / `WireDelegation` / `WireSignedDelegation`) are lifted to the test module so both permission tests reuse them. Also adds a CI workflow that runs `cargo build`/`cargo test --locked` on pull requests and pushes to main — previously CI only ran the status-dashboard's npm tests, so no Rust tests (including this one) ran in CI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RzjuJVat2CTiCYzeU9F6e2 --- .github/workflows/ci.yml | 42 ++++++++++ src/identities.rs | 169 ++++++++++++++++++++++++++++++++++----- 2 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f212f8e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +# Continuous integration: build and test the Rust crate on every pull request +# and every push to main, so regressions are caught before merge. This is where +# the read-only-delegation e2e test +# (`identities::tests::read_only_delegation_scopes_signed_requests_end_to_end`) +# and the rest of the `cargo test` suite run in CI — the deploy workflow only +# runs the status-dashboard's npm tests. +name: CI + +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs on the same ref to save CI minutes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-24.04 + steps: + # Third-party actions are pinned to a commit SHA (supply-chain hardening); + # the trailing comment records the human-readable version. + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + # Use the runner's pre-installed rustup to pull a current stable toolchain, + # avoiding an extra (unpinned) third-party action. + - name: Install stable Rust toolchain + run: | + rustup update --no-self-update stable + rustup default stable + rustc --version && cargo --version + + # `--locked` fails if Cargo.lock is out of date, so CI also guards the + # dependency pins (e.g. ic-agent / ic-transport-types) from silent drift. + - name: Build + run: cargo build --locked --all-targets + + - name: Test + run: cargo test --locked --all-targets diff --git a/src/identities.rs b/src/identities.rs index 6472386..607c2e0 100644 --- a/src/identities.rs +++ b/src/identities.rs @@ -802,32 +802,35 @@ mod tests { assert_eq!(decoded[1].last_used, Some(123)); } + // II's `SignedDelegation` / `Delegation` / `Permissions` Candid contract, + // mirrored so tests can encode a reply exactly as II would and then drive the + // real `Decode!` -> `into_agent` path over it. + #[derive(CandidType, Deserialize)] + enum WirePermissions { + #[serde(rename = "queries")] + Queries, + #[serde(rename = "all")] + All, + } + #[derive(CandidType)] + struct WireDelegation { + pubkey: Vec, + expiration: u64, + targets: Option>, + permissions: Option, + } + #[derive(CandidType)] + struct WireSignedDelegation { + delegation: WireDelegation, + signature: Vec, + } + // Lock in the mcp_get_delegation permission contract: II's `Delegation` // carries `permissions: opt variant { queries; all }`, and it must round-trip // through `into_agent` onto the `ic-agent` delegation so the reconstructed // hash matches what II signed. An absent permission stays `None`. #[test] fn signed_delegation_forwards_ii_permissions() { - #[derive(CandidType, Deserialize)] - enum WirePermissions { - #[serde(rename = "queries")] - Queries, - #[serde(rename = "all")] - All, - } - #[derive(CandidType)] - struct WireDelegation { - pubkey: Vec, - expiration: u64, - targets: Option>, - permissions: Option, - } - #[derive(CandidType)] - struct WireSignedDelegation { - delegation: WireDelegation, - signature: Vec, - } - let app_key = vec![1u8, 2, 3, 4]; // Decode a `queries`-scoped delegation and confirm it maps to Queries. let make = |permissions| WireSignedDelegation { @@ -863,4 +866,130 @@ mod tests { .expect("into_agent"); assert_eq!(agent.delegation.permissions, None); } + + // End-to-end: a read-only (`queries`) delegation from II must survive our full + // decode -> DelegatedIdentity path and scope EVERY request the resulting + // identity signs, so the replica allows reads but refuses update calls. This + // drives the real path — `IiSignedDelegation::into_agent` -> `build_identity` + // -> `Identity::sign` — with a genuinely anchor-signed chain, no network: the + // replica enforces the scope because it is bound into the delegation signature + // (proven by the tamper check at the end). + #[test] + fn read_only_delegation_scopes_signed_requests_end_to_end() { + use ic_agent::agent::EnvelopeContent; + + // The anchor (II user) key that signs the delegation, and the per-app key + // it is issued to (what the server ultimately signs canister calls as). + let (user_seed, user_der) = fresh_ed25519(); + let (app_seed, app_der) = fresh_ed25519(); + let anchor = BasicIdentity::from_raw_key(&user_seed); + let expiration = now_ns() + 3_600_000_000_000; + + // The exact `queries`-scoped delegation II would sign, signed as the + // anchor. `signable()` covers `permissions`, so this signature is valid + // ONLY for the read-only delegation. + let delegation = Delegation { + pubkey: app_der.clone(), + expiration, + targets: None, + permissions: Some(DelegationPermissions::Queries), + }; + let signature = anchor + .sign_delegation(&delegation) + .expect("anchor signs delegation") + .signature + .expect("ed25519 signature present"); + + // Feed it back through the real II-reply -> decode -> into_agent path. + let wire = WireSignedDelegation { + delegation: WireDelegation { + pubkey: app_der.clone(), + expiration, + targets: None, + permissions: Some(WirePermissions::Queries), + }, + signature, + }; + let bytes = Encode!(&wire).expect("encode II reply"); + let signed = Decode!(&bytes, IiSignedDelegation) + .expect("decode") + .into_agent(&app_der) + .expect("into_agent"); + assert_eq!( + signed.delegation.permissions, + Some(DelegationPermissions::Queries), + "read-only scope survives decoding" + ); + + // Build the delegated identity exactly as the server does; the chain + // validates because the anchor's signature covers this read-only delegation. + let app = AppDelegation { + user_key: user_der.clone(), + chain: vec![signed.clone()], + expiration_ns: expiration, + app_key_seed: app_seed, + }; + let identity = build_identity(&app).expect("valid read-only chain builds an identity"); + let sender = identity.sender().expect("sender"); + + // Every request this identity signs — an update AND a read — carries the + // read-only delegation, so the replica scopes both to queries/read_state. + let update = EnvelopeContent::Call { + nonce: None, + ingress_expiry: expiration, + sender, + canister_id: Principal::management_canister(), + method_name: "some_update".to_string(), + arg: vec![], + sender_info: None, + }; + let read = EnvelopeContent::Query { + ingress_expiry: expiration, + sender, + canister_id: Principal::management_canister(), + method_name: "some_query".to_string(), + arg: vec![], + nonce: None, + sender_info: None, + }; + for content in [&update, &read] { + let chain = identity + .sign(content) + .expect("sign request") + .delegations + .expect("delegation chain attached to the request"); + assert_eq!(chain.len(), 1); + assert_eq!( + chain[0].delegation.permissions, + Some(DelegationPermissions::Queries), + "the signed request carries the read-only scope the replica enforces" + ); + } + + // The restriction is bound into the signature, not cosmetic: widening it to + // an unrestricted delegation changes the signed bytes, so the anchor's + // signature no longer verifies and the identity refuses to build. A client + // cannot silently promote a read-only delegation to full access. + let unrestricted = Delegation { + permissions: None, + ..delegation.clone() + }; + assert_ne!( + delegation.signable(), + unrestricted.signable(), + "the permission must change what is signed" + ); + let mut tampered = signed; + tampered.delegation.permissions = None; + let tampered_app = AppDelegation { + user_key: user_der, + chain: vec![tampered], + expiration_ns: expiration, + app_key_seed: app_seed, + }; + assert!( + build_identity(&tampered_app).is_err(), + "stripping the read-only scope must invalidate the delegation signature" + ); + } } From 3f1939bac9451284bbdff58122a67a5196524f3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 12:11:21 +0000 Subject: [PATCH 4/4] Test decoding a pre-0.48 delegation with no permissions field The existing "absent permission" case encoded `permissions: None`, which Candid still serializes as an `opt`-null field. A genuine pre-0.48 II reply omits the `permissions` field from the `Delegation` record entirely, which is a distinct wire type. Add a field-less legacy fixture and assert it decodes to `None`, locking in backward compatibility with old II replies (Candid subtyping decodes the absent optional as None). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RzjuJVat2CTiCYzeU9F6e2 --- src/identities.rs | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/identities.rs b/src/identities.rs index 607c2e0..59089b9 100644 --- a/src/identities.rs +++ b/src/identities.rs @@ -825,6 +825,21 @@ mod tests { signature: Vec, } + // A pre-0.48 II reply whose `Delegation` record has NO `permissions` field at + // all — the older wire type, distinct from an `opt`-null field. Used to prove + // Candid decodes the absent optional as `None`, so old replies keep working. + #[derive(CandidType)] + struct WireLegacyDelegation { + pubkey: Vec, + expiration: u64, + targets: Option>, + } + #[derive(CandidType)] + struct WireLegacySignedDelegation { + delegation: WireLegacyDelegation, + signature: Vec, + } + // Lock in the mcp_get_delegation permission contract: II's `Delegation` // carries `permissions: opt variant { queries; all }`, and it must round-trip // through `into_agent` onto the `ic-agent` delegation so the reconstructed @@ -857,14 +872,32 @@ mod tests { .expect("into_agent"); assert_eq!(agent.delegation.permissions, Some(DelegationPermissions::All)); - // An unrestricted (absent) permission decodes and forwards as None, - // hashing identically to a pre-0.48 delegation. + // An unrestricted (present-but-null) permission decodes and forwards as + // None, hashing identically to a pre-0.48 delegation. let bytes = Encode!(&make(None)).expect("encode"); let agent = Decode!(&bytes, IiSignedDelegation) .expect("decode") .into_agent(&app_key) .expect("into_agent"); assert_eq!(agent.delegation.permissions, None); + + // Backward compatibility: a pre-0.48 reply omits the `permissions` field + // from the record entirely (not `opt`-null as above). Candid's subtyping + // decodes the absent optional as None, so old II replies keep working. + let legacy = WireLegacySignedDelegation { + delegation: WireLegacyDelegation { + pubkey: app_key.clone(), + expiration: 42, + targets: None, + }, + signature: vec![9, 9, 9], + }; + let bytes = Encode!(&legacy).expect("encode legacy reply"); + let agent = Decode!(&bytes, IiSignedDelegation) + .expect("decode legacy reply") + .into_agent(&app_key) + .expect("into_agent"); + assert_eq!(agent.delegation.permissions, None); } // End-to-end: a read-only (`queries`) delegation from II must survive our full