Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
257 changes: 256 additions & 1 deletion src/identities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -601,6 +601,32 @@ struct IiDelegation {
pubkey: Vec<u8>,
expiration: u64,
targets: Option<Vec<Principal>>,
/// 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<IiPermissions>,
}

/// 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<IiPermissions> 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`.
Expand All @@ -622,6 +648,11 @@ impl IiSignedDelegation {
pubkey: self.delegation.pubkey,
expiration: self.delegation.expiration,
targets: self.delegation.targets,
// 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),
Comment thread
aterga marked this conversation as resolved.
Resolved
},
signature: self.signature,
})
Expand Down Expand Up @@ -770,4 +801,228 @@ mod tests {
assert_eq!(decoded[1].name.as_deref(), Some("savings"));
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<u8>,
expiration: u64,
targets: Option<Vec<Principal>>,
permissions: Option<WirePermissions>,
}
#[derive(CandidType)]
struct WireSignedDelegation {
delegation: WireDelegation,
signature: Vec<u8>,
}

// 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<u8>,
expiration: u64,
targets: Option<Vec<Principal>>,
}
#[derive(CandidType)]
struct WireLegacySignedDelegation {
delegation: WireLegacyDelegation,
signature: Vec<u8>,
}

// 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() {
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 (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
// 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"
);
}
}