use std::{
collections::HashMap,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
time::{SystemTime, UNIX_EPOCH},
};
use base64::Engine;
use candid::{CandidType, Decode, Encode, Principal};
use ic_agent::{
identity::{BasicIdentity, DelegatedIdentity, Delegation, DelegationPermissions, SignedDelegation},
Agent, Identity,
};
use rmcp::schemars;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
const REDERIVE_MARGIN_NS: u64 = 30 * 1_000_000_000;
pub(crate) const MAX_PENDING_CONNECTS: usize = 1_024;
const MAX_SESSIONS: usize = 20_000;
const PENDING_CONNECT_TTL_NS: u64 = 15 * 60 * 1_000_000_000;
const MAX_APP_DELEGATIONS: usize = 64;
const AT_CAPACITY_MSG: &str = "This server is at capacity for Internet Identity sessions right now. \
Wait a few minutes and start the sign-in again.";
const II_URL_DEFAULT: &str = "https://beta.id.ai";
const II_CANISTER_ID_DEFAULT: &str = "fgte5-ciaaa-aaaad-aaatq-cai";
const II_URL_PROD_DEFAULT: &str = "https://id.ai";
const II_CANISTER_ID_PROD_DEFAULT: &str = "rdmx6-jaaaa-aaaaa-aaadq-cai";
const RECONNECT_MSG: &str = "Your Internet Identity session is over (the grant expired, was revoked, \
or was replaced by a newer connection). Reconnect with Internet Identity to continue — do not retry.";
const READ_ONLY_MSG: &str = "This Internet Identity session was authorized for \"Questions only\", so it \
can't make changes. Creating, installing, starting, stopping, or deleting canisters — and even \
reading canister status — are update calls a Questions-only session can't make. Reconnect with \
Internet Identity and choose \"Actions & questions\" on the consent screen, then try again.";
#[derive(Clone, Debug)]
pub struct IiInstance {
pub name: &'static str,
pub ii_url: String,
pub ii_canister: Principal,
}
impl IiInstance {
pub fn beta() -> Result<Self, String> {
Ok(Self {
name: "beta",
ii_url: env_origin("II_URL", II_URL_DEFAULT),
ii_canister: env_principal("II_CANISTER_ID", II_CANISTER_ID_DEFAULT)?,
})
}
pub fn prod() -> Result<Self, String> {
Ok(Self {
name: "prod",
ii_url: env_origin("II_URL_PROD", II_URL_PROD_DEFAULT),
ii_canister: env_principal("II_CANISTER_ID_PROD", II_CANISTER_ID_PROD_DEFAULT)?,
})
}
}
fn env_origin(var: &str, default: &str) -> String {
std::env::var(var)
.unwrap_or_else(|_| default.to_string())
.trim_end_matches('/')
.to_string()
}
fn env_principal(var: &str, default: &str) -> Result<Principal, String> {
let raw = std::env::var(var).unwrap_or_else(|_| default.to_string());
Principal::from_text(&raw).map_err(|e| format!("invalid {var} '{raw}': {e}"))
}
fn now_ns() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64
}
const ACTIVE_SESSION_WINDOW_NS: u64 = 15 * 60 * 1_000_000_000;
#[derive(Debug, Clone, Copy)]
pub struct SessionGauges {
pub live: usize,
pub active: usize,
}
pub(crate) fn target_origin(domain: &str) -> String {
let host = domain
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://");
let host = host.split(['/', '?', '#']).next().unwrap_or(host);
let host = host.strip_suffix(":443").unwrap_or(host);
for gateway in [".icp0.io", ".icp.net"] {
if let Some(label) = host.strip_suffix(gateway) {
return format!("https://{label}.ic0.app");
}
}
format!("https://{host}")
}
struct Session {
key_seed: [u8; 32],
pubkey_der: Vec<u8>,
grant_expiration_ns: Option<u64>,
created_ns: u64,
last_seen_ns: AtomicU64,
reg_key_seed: Option<[u8; 32]>,
reg_pubkey_der: Option<Vec<u8>>,
read_only: Option<bool>,
app_delegations: HashMap<(String, Option<u64>), AppDelegation>,
}
struct AppDelegation {
user_key: Vec<u8>,
chain: Vec<SignedDelegation>,
expiration_ns: u64,
app_key_seed: [u8; 32],
}
impl AppDelegation {
fn fresh(&self) -> bool {
self.expiration_ns > now_ns().saturating_add(REDERIVE_MARGIN_NS)
}
}
pub struct AccountInfo {
pub account_number: Option<u64>,
pub name: Option<String>,
pub last_used: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GetPrincipalArgs {
#[serde(alias = "domain")]
pub derivation_origin: String,
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct PrincipalOutput {
pub derived_for_origin: String,
pub requested: String,
pub derivation_origin_source: String,
pub account: Option<String>,
pub principal: String,
pub read_only: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ListAccountsArgs {
#[serde(alias = "domain")]
pub derivation_origin: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct AccountEntry {
pub name: Option<String>,
pub account_number: Option<u64>,
pub last_used: Option<u64>,
}
impl From<&AccountInfo> for AccountEntry {
fn from(a: &AccountInfo) -> Self {
Self {
name: a.name.clone(),
account_number: a.account_number,
last_used: a.last_used,
}
}
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct AccountsOutput {
pub derived_for_origin: String,
pub requested: String,
pub derivation_origin_source: String,
pub accounts: Vec<AccountEntry>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ResolveAppArgs {
pub app_url: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct ResolveAppOutput {
pub application_origin: String,
pub derivation_origin: String,
pub derivation_origin_source: String,
pub alternative_origins: Vec<String>,
pub application_is_ic: Option<bool>,
pub note: Option<String>,
}
#[derive(Clone)]
pub struct Identities {
instance: IiInstance,
public_url: String,
agent: Agent,
sessions: Arc<RwLock<HashMap<String, Session>>>,
}
impl Identities {
pub fn new(instance: IiInstance, public_url: String, agent: Agent) -> Self {
Self {
instance,
public_url,
agent,
sessions: Arc::default(),
}
}
pub(crate) fn agent_as<I: ic_agent::Identity + 'static>(&self, identity: I) -> Agent {
let mut agent = self.agent.clone();
agent.set_identity(identity);
agent
}
pub fn instance(&self) -> &IiInstance {
&self.instance
}
async fn ensure_session(&self, session_id: &str) -> Result<(), String> {
let now = now_ns();
let closed = {
let mut sessions = self.sessions.write().await;
if sessions.contains_key(session_id) {
return Ok(());
}
let closed = make_room(&mut sessions, now)?;
let (key_seed, pubkey_der) = fresh_ed25519();
sessions.insert(
session_id.to_string(),
Session {
key_seed,
pubkey_der,
grant_expiration_ns: None,
created_ns: now,
last_seen_ns: AtomicU64::new(now),
reg_key_seed: None,
reg_pubkey_der: None,
read_only: None,
app_delegations: HashMap::new(),
},
);
closed
};
for sid in &closed {
tracing::info!(instance = self.instance.name, session_id = %sid, "session closed");
}
Ok(())
}
async fn session_key(&self, session_id: &str) -> Option<([u8; 32], Vec<u8>)> {
let sessions = self.sessions.read().await;
let s = sessions.get(session_id)?;
Some((s.key_seed, s.pubkey_der.clone()))
}
pub(crate) async fn registration_pubkey_b64(&self, session_id: &str) -> Result<String, String> {
self.ensure_session(session_id).await?;
let mut sessions = self.sessions.write().await;
let s = sessions.get_mut(session_id).ok_or("no such session")?;
if s.reg_key_seed.is_none() {
let (seed, der) = fresh_ed25519();
s.reg_key_seed = Some(seed);
s.reg_pubkey_der = Some(der);
}
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(s.reg_pubkey_der.as_ref().expect("just set")))
}
pub async fn session_principal(&self, session_id: &str) -> Option<String> {
let (_, der) = self.session_key(session_id).await?;
Some(Principal::self_authenticating(&der).to_text())
}
pub async fn set_grant_expiration(&self, session_id: &str, expiration_ns: u64) {
if let Err(e) = self.ensure_session(session_id).await {
tracing::warn!(session_id = %session_id, "could not record the grant expiration: {e}");
return;
}
let now = now_ns();
let opened = {
let mut sessions = self.sessions.write().await;
match sessions.get_mut(session_id) {
Some(s) => {
let was_live = s.grant_expiration_ns.is_some_and(|e| e > now);
s.grant_expiration_ns = Some(expiration_ns);
s.last_seen_ns.store(now, Ordering::Relaxed);
!was_live && expiration_ns > now
}
None => false,
}
};
if opened {
tracing::info!(
instance = self.instance.name,
session_id = %session_id,
expiration_ns,
"session opened"
);
}
}
pub async fn set_permissions(&self, session_id: &str, permissions: &str) {
let level = match permissions.trim().to_ascii_lowercase().as_str() {
"queries" => Some(true),
"all" => Some(false),
other => {
tracing::warn!("mcp_register_v2 reply had unrecognized permissions {other:?}; access level left unknown");
return;
}
};
if let Err(e) = self.ensure_session(session_id).await {
tracing::warn!(session_id = %session_id, "could not record the access level: {e}");
return;
}
let mut sessions = self.sessions.write().await;
if let Some(s) = sessions.get_mut(session_id) {
s.read_only = level;
}
}
pub async fn grant_expiration_ns(&self, session_id: &str) -> Option<u64> {
self.sessions.read().await.get(session_id).and_then(|s| s.grant_expiration_ns)
}
pub async fn touch_session(&self, session_id: &str) {
let now = now_ns();
let sessions = self.sessions.read().await;
if let Some(s) = sessions.get(session_id) {
s.last_seen_ns.store(now, Ordering::Relaxed);
}
}
pub async fn session_gauges(&self) -> SessionGauges {
let now = now_ns();
let sessions = self.sessions.read().await;
let mut g = SessionGauges { live: 0, active: 0 };
for s in sessions.values() {
if s.grant_expiration_ns.is_some_and(|e| e > now) {
g.live += 1;
if now.saturating_sub(s.last_seen_ns.load(Ordering::Relaxed))
<= ACTIVE_SESSION_WINDOW_NS
{
g.active += 1;
}
}
}
g
}
#[cfg(test)]
pub async fn live_session_count(&self) -> usize {
self.session_gauges().await.live
}
#[cfg(test)]
pub async fn active_session_count(&self) -> usize {
self.session_gauges().await.active
}
pub async fn reap_expired_sessions(&self) -> usize {
let now = now_ns();
let (closed, abandoned) = {
let mut sessions = self.sessions.write().await;
prune_stale(&mut sessions, now)
};
for sid in &closed {
tracing::info!(instance = self.instance.name, session_id = %sid, "session closed");
}
if !abandoned.is_empty() {
tracing::info!(
instance = self.instance.name,
count = abandoned.len(),
"abandoned connects reaped"
);
}
closed.len() + abandoned.len()
}
pub async fn is_read_only(&self, session_id: &str) -> Option<bool> {
let sessions = self.sessions.read().await;
sessions.get(session_id).and_then(|s| s.read_only)
}
pub async fn require_write(&self, session_id: &str) -> Result<(), String> {
if self.is_read_only(session_id).await == Some(true) {
return Err(READ_ONLY_MSG.to_string());
}
Ok(())
}
async fn session_signer(&self, session_id: &str) -> Result<BasicIdentity, String> {
self.ensure_session(session_id).await?;
let sessions = self.sessions.read().await;
let s = sessions.get(session_id).ok_or("no such session")?;
if let Some(exp) = s.grant_expiration_ns {
if exp <= now_ns() {
return Err(RECONNECT_MSG.to_string());
}
}
Ok(BasicIdentity::from_raw_key(&s.key_seed))
}
async fn session_agent(&self, session_id: &str) -> Result<Agent, String> {
let signer = self.session_signer(session_id).await?;
Ok(self.agent_as(signer))
}
pub async fn management_identity(&self, session_id: &str) -> Result<DelegatedIdentity, String> {
let origin = self.public_url.clone();
self.delegated_identity(session_id, &origin, None).await
}
pub async fn list_accounts(
&self,
session_id: &str,
domain: &str,
) -> Result<Vec<AccountInfo>, String> {
let agent = self.session_agent(session_id).await?;
let canister = self.instance.ii_canister;
let origin = target_origin(domain);
let arg = Encode!(&origin).map_err(|e| format!("could not encode mcp_get_accounts args: {e}"))?;
let reply = agent
.query(&canister, "mcp_get_accounts")
.with_arg(arg)
.call()
.await
.map_err(|e| format!("mcp_get_accounts failed: {e}"))?;
let accounts = Decode!(&reply, McpGetAccountsReply)
.map_err(|e| format!("could not decode mcp_get_accounts reply: {e}"))?
.map_err(map_delegation_error)?;
Ok(accounts
.into_iter()
.map(|a| AccountInfo {
account_number: a.account_number,
name: a.name,
last_used: a.last_used,
})
.collect())
}
pub(crate) async fn redeem_registration_delegation(
&self,
session_id: &str,
reg_user_key: Vec<u8>,
chain: Vec<SignedDelegation>,
) -> Result<RegistrationOutcome, String> {
self.ensure_session(session_id).await?;
let (reg_seed, reg_der, session_der) = {
let sessions = self.sessions.read().await;
let s = sessions.get(session_id).ok_or("no such session")?;
let reg_seed = s
.reg_key_seed
.ok_or("no registration key was minted for this connect")?;
let reg_der = s
.reg_pubkey_der
.clone()
.ok_or("no registration key was minted for this connect")?;
(reg_seed, reg_der, s.pubkey_der.clone())
};
let identity =
registration_identity(reg_user_key, reg_seed, ®_der, chain, &self.agent.read_root_key())?;
let agent = self.agent_as(identity);
let arg = Encode!(&session_der)
.map_err(|e| format!("could not encode mcp_register_v2 args: {e}"))?;
let reply = agent
.update(&self.instance.ii_canister, "mcp_register_v2")
.with_arg(arg)
.call_and_wait()
.await
.map_err(|e| format!("mcp_register_v2 failed: {e}"))?;
let outcome = Decode!(&reply, McpRegisterV2Reply)
.map_err(|e| format!("could not decode mcp_register_v2 reply: {e}"))?
.map_err(|e| format!("Internet Identity rejected registration: {e}"))?;
let permissions = outcome.permissions.as_text();
self.set_grant_expiration(session_id, outcome.expiration).await;
self.set_permissions(session_id, permissions).await;
Ok(RegistrationOutcome {
expiration_ns: outcome.expiration,
permissions,
})
}
async fn resolve_account(
&self,
session_id: &str,
domain: &str,
name: Option<&str>,
) -> Result<Option<u64>, String> {
let Some(name) = name else {
return Ok(None); };
let accounts = self.list_accounts(session_id, domain).await?;
let mut matching = accounts.iter().filter(|a| a.name.as_deref() == Some(name));
match (matching.next(), matching.next()) {
(None, _) => Err(format!(
"no account named \"{name}\" at {domain} — call `list_app_accounts` (with this app's \
`derivation_origin` or `app_url`) to see your accounts there, or omit `account` to \
use the default one"
)),
(Some(a), None) => Ok(a.account_number),
(Some(_), Some(_)) => Err(format!(
"more than one account named \"{name}\" at {domain}; cannot disambiguate"
)),
}
}
pub async fn delegated_identity_for(
&self,
session_id: &str,
domain: &str,
account: Option<&str>,
) -> Result<DelegatedIdentity, String> {
let account_number = self.resolve_account(session_id, domain, account).await?;
self.delegated_identity(session_id, domain, account_number).await
}
pub async fn delegated_identity(
&self,
session_id: &str,
domain: &str,
account_number: Option<u64>,
) -> Result<DelegatedIdentity, String> {
self.ensure_session(session_id).await?;
if let Some(app) = self.cached_fresh(session_id, domain, account_number).await {
return build_identity(&app);
}
let app = self.derive_app_delegation(session_id, domain, account_number).await?;
let identity = build_identity(&app)?;
self.store(session_id, domain, account_number, app).await;
Ok(identity)
}
async fn cached_fresh(
&self,
session_id: &str,
domain: &str,
account_number: Option<u64>,
) -> Option<AppDelegation> {
let sessions = self.sessions.read().await;
let app = sessions
.get(session_id)?
.app_delegations
.get(&(domain.to_string(), account_number))?;
if !app.fresh() {
return None;
}
Some(AppDelegation {
user_key: app.user_key.clone(),
chain: app.chain.clone(),
expiration_ns: app.expiration_ns,
app_key_seed: app.app_key_seed,
})
}
async fn store(&self, session_id: &str, domain: &str, account_number: Option<u64>, app: AppDelegation) {
let mut sessions = self.sessions.write().await;
if let Some(s) = sessions.get_mut(session_id) {
let key = (domain.to_string(), account_number);
if !s.app_delegations.contains_key(&key) {
bound_app_delegations(&mut s.app_delegations);
}
s.app_delegations.insert(key, app);
}
}
async fn derive_app_delegation(
&self,
session_id: &str,
domain: &str,
account_number: Option<u64>,
) -> Result<AppDelegation, String> {
let origin = target_origin(domain);
let canister = self.instance.ii_canister;
let (app_key_seed, app_key_der) = fresh_ed25519();
let agent = self.session_agent(session_id).await?;
let prepare_arg = Encode!(&origin, &account_number, &app_key_der, &None::<u64>)
.map_err(|e| format!("could not encode prepare args: {e}"))?;
let prepared = agent
.update(&canister, "mcp_prepare_delegation")
.with_arg(prepare_arg)
.call_and_wait()
.await
.map_err(|e| format!("mcp_prepare_delegation failed: {e}"))?;
let prepared = Decode!(&prepared, PrepareReply)
.map_err(|e| format!("could not decode prepare reply: {e}"))?
.map_err(map_delegation_error)?;
let get_arg = Encode!(&origin, &prepared.account_number, &app_key_der, &prepared.expiration)
.map_err(|e| format!("could not encode get args: {e}"))?;
let got = agent
.query(&canister, "mcp_get_delegation")
.with_arg(get_arg)
.call()
.await
.map_err(|e| format!("mcp_get_delegation failed: {e}"))?;
let signed = Decode!(&got, GetReply)
.map_err(|e| format!("could not decode get reply: {e}"))?
.map_err(map_delegation_error)?;
let chain = vec![signed.into_agent(&app_key_der)?];
Ok(AppDelegation {
user_key: prepared.user_key,
chain,
expiration_ns: prepared.expiration,
app_key_seed,
})
}
}
fn prune_stale(sessions: &mut HashMap<String, Session>, now: u64) -> (Vec<String>, Vec<String>) {
let (mut closed, mut abandoned) = (Vec::new(), Vec::new());
sessions.retain(|sid, s| match s.grant_expiration_ns {
Some(exp) if exp <= now => {
closed.push(sid.clone());
false
}
None if now.saturating_sub(s.created_ns) >= PENDING_CONNECT_TTL_NS => {
abandoned.push(sid.clone());
false
}
_ => true,
});
(closed, abandoned)
}
fn make_room(sessions: &mut HashMap<String, Session>, now: u64) -> Result<Vec<String>, String> {
if sessions.len() < MAX_PENDING_CONNECTS {
return Ok(Vec::new());
}
let (closed, _abandoned) = prune_stale(sessions, now);
let mut pending: Vec<(u64, String)> = sessions
.iter()
.filter(|(_, s)| s.grant_expiration_ns.is_none())
.map(|(sid, s)| (s.created_ns, sid.clone()))
.collect();
if pending.len() >= MAX_PENDING_CONNECTS {
pending.sort_unstable(); let excess = pending.len() + 1 - MAX_PENDING_CONNECTS;
for (_, sid) in pending.into_iter().take(excess) {
sessions.remove(&sid);
}
}
if sessions.len() >= MAX_SESSIONS {
return Err(AT_CAPACITY_MSG.to_string());
}
Ok(closed)
}
fn bound_app_delegations(cache: &mut HashMap<(String, Option<u64>), AppDelegation>) {
if cache.len() < MAX_APP_DELEGATIONS {
return;
}
cache.retain(|_, a| a.fresh());
while cache.len() >= MAX_APP_DELEGATIONS {
let Some(victim) = cache
.iter()
.min_by_key(|(_, a)| a.expiration_ns)
.map(|(k, _)| k.clone())
else {
break;
};
cache.remove(&victim);
}
}
fn fresh_ed25519() -> ([u8; 32], Vec<u8>) {
let mut seed = [0u8; 32];
getrandom::fill(&mut seed).expect("getrandom");
let pubkey_der = BasicIdentity::from_raw_key(&seed)
.public_key()
.expect("ed25519 public key");
(seed, pubkey_der)
}
fn build_identity(app: &AppDelegation) -> Result<DelegatedIdentity, String> {
let key = BasicIdentity::from_raw_key(&app.app_key_seed);
DelegatedIdentity::new(app.user_key.clone(), Box::new(key), app.chain.clone())
.map_err(|e| format!("invalid delegation chain: {e}"))
}
fn registration_identity(
reg_user_key: Vec<u8>,
reg_seed: [u8; 32],
reg_der: &[u8],
chain: Vec<SignedDelegation>,
root_key: &[u8],
) -> Result<DelegatedIdentity, String> {
match chain.last() {
Some(last) if last.delegation.pubkey == reg_der => {}
Some(_) => {
return Err("registration delegation does not delegate to this connect's \
registration key"
.to_string())
}
None => return Err("registration delegation chain is empty".to_string()),
}
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}"))
}
fn map_delegation_error(e: AccountDelegationError) -> String {
match e {
AccountDelegationError::Unauthorized(_) => RECONNECT_MSG.to_string(),
AccountDelegationError::NoSuchDelegation => {
"Internet Identity returned NoSuchDelegation — the prepared account/expiration were not \
threaded through. Retry the request."
.to_string()
}
AccountDelegationError::InternalCanisterError(t) => {
format!("Internet Identity internal error: {t}")
}
}
}
#[derive(Debug)]
pub(crate) struct RegistrationOutcome {
pub(crate) expiration_ns: u64,
pub(crate) permissions: &'static str,
}
#[derive(CandidType, Deserialize)]
struct PreparedDelegation {
user_key: Vec<u8>,
account_number: Option<u64>,
expiration: u64,
}
#[derive(CandidType, Deserialize, Debug)]
enum AccountDelegationError {
InternalCanisterError(String),
Unauthorized(Principal),
NoSuchDelegation,
}
type PrepareReply = std::result::Result<PreparedDelegation, AccountDelegationError>;
type GetReply = std::result::Result<IiSignedDelegation, AccountDelegationError>;
type McpGetAccountsReply = std::result::Result<Vec<IiAccountInfo>, AccountDelegationError>;
#[derive(CandidType, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IiPermissions {
#[serde(rename = "queries")]
Queries,
#[serde(rename = "all")]
All,
}
impl IiPermissions {
fn as_text(&self) -> &'static str {
match self {
IiPermissions::Queries => "queries",
IiPermissions::All => "all",
}
}
}
#[derive(CandidType, Deserialize)]
struct McpRegisterV2Ok {
expiration: u64,
permissions: IiPermissions,
}
type McpRegisterV2Reply = std::result::Result<McpRegisterV2Ok, String>;
#[derive(CandidType, Deserialize)]
struct IiAccountInfo {
account_number: Option<u64>,
last_used: Option<u64>,
name: Option<String>,
}
#[derive(CandidType, Deserialize)]
struct IiDelegation {
pubkey: Vec<u8>,
expiration: u64,
targets: Option<Vec<Principal>>,
permissions: Option<String>,
}
pub(crate) fn permissions_from_text(permissions: Option<&str>) -> Result<Option<DelegationPermissions>, String> {
match permissions {
None => Ok(None),
Some("queries") => Ok(Some(DelegationPermissions::Queries)),
Some("all") => Ok(Some(DelegationPermissions::All)),
Some(other) => Err(format!(
"Internet Identity issued a delegation with an unrecognized permission {other:?}; \
this server can't represent it faithfully, so the delegation's signature would not \
verify. The server needs updating to handle this permission."
)),
}
}
#[derive(CandidType, Deserialize)]
struct IiSignedDelegation {
delegation: IiDelegation,
signature: Vec<u8>,
}
impl IiSignedDelegation {
fn into_agent(self, app_key_der: &[u8]) -> Result<SignedDelegation, String> {
if self.delegation.pubkey != app_key_der {
return Err("II delegation does not delegate to this app's per-app key".to_string());
}
Ok(SignedDelegation {
delegation: Delegation {
pubkey: self.delegation.pubkey,
expiration: self.delegation.expiration,
targets: self.delegation.targets,
permissions: permissions_from_text(self.delegation.permissions.as_deref())?,
},
signature: self.signature,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instance_defaults_are_valid() {
IiInstance::beta().expect("beta defaults");
IiInstance::prod().expect("prod defaults");
}
#[test]
fn remaps_gateway_domains_to_ic0_app() {
assert_eq!(
target_origin("rdmx6-jaaaa-aaaaa-aaadq-cai.icp0.io"),
"https://rdmx6-jaaaa-aaaaa-aaadq-cai.ic0.app"
);
assert_eq!(target_origin("foo.icp.net"), "https://foo.ic0.app");
}
#[test]
fn passes_through_custom_domains() {
assert_eq!(target_origin("oisy.com"), "https://oisy.com");
assert_eq!(target_origin("https://oisy.com/app"), "https://oisy.com");
assert_eq!(target_origin("http://oisy.com"), "https://oisy.com");
}
#[test]
fn target_origin_normalizes_port_path_and_slash() {
assert_eq!(target_origin("https://oisy.com:443"), "https://oisy.com");
assert_eq!(target_origin("https://oisy.com/"), "https://oisy.com");
assert_eq!(target_origin("https://oisy.com/app/x"), "https://oisy.com");
assert_eq!(target_origin("https://oisy.com?a=1"), "https://oisy.com");
assert_eq!(target_origin("https://oisy.com#frag"), "https://oisy.com");
assert_eq!(target_origin("https://oisy.com:443/app/x"), "https://oisy.com");
assert_eq!(target_origin("foo.icp0.io:443"), "https://foo.ic0.app");
assert_eq!(target_origin("https://foo.icp0.io/app"), "https://foo.ic0.app");
assert_eq!(target_origin("https://localhost:8080"), "https://localhost:8080");
}
#[tokio::test]
async fn permissions_set_read_only_and_gate_writes() {
let ids = test_ids();
ids.ensure_session("sess").await.expect("room for a session");
assert_eq!(ids.is_read_only("sess").await, None);
assert!(ids.require_write("sess").await.is_ok());
ids.set_permissions("sess", "queries").await;
assert_eq!(ids.is_read_only("sess").await, Some(true));
assert!(ids.require_write("sess").await.is_err());
ids.set_permissions("sess", "ALL").await;
assert_eq!(ids.is_read_only("sess").await, Some(false));
assert!(ids.require_write("sess").await.is_ok());
ids.ensure_session("sess2").await.expect("room for a session");
ids.set_permissions("sess2", "something-new").await;
assert_eq!(ids.is_read_only("sess2").await, None);
assert!(ids.require_write("sess2").await.is_ok());
}
fn test_ids() -> Identities {
let agent = Agent::builder()
.with_url("https://ii.test")
.build()
.expect("test agent");
Identities::new(
IiInstance {
name: "test",
ii_url: "https://ii.test".into(),
ii_canister: Principal::anonymous(),
},
"https://mcp.test".into(),
agent,
)
}
async fn seed_live(ids: &Identities, session_id: &str) {
ids.ensure_session(session_id).await.expect("room for a session");
let mut sessions = ids.sessions.write().await;
let s = sessions.get_mut(session_id).expect("ensured session");
s.grant_expiration_ns = Some(u64::MAX);
}
async fn seed_app(ids: &Identities, session_id: &str, domain: &str, account: Option<u64>, exp: u64) {
let mut sessions = ids.sessions.write().await;
let s = sessions.get_mut(session_id).expect("session");
s.app_delegations.insert(
(domain.to_string(), account),
AppDelegation {
user_key: vec![account.unwrap_or(0) as u8],
chain: vec![],
expiration_ns: exp,
app_key_seed: [account.unwrap_or(0) as u8; 32],
},
);
}
#[tokio::test]
async fn resolve_account_defaults_to_none_without_network() {
let ids = test_ids();
seed_live(&ids, "sess").await;
assert_eq!(ids.resolve_account("sess", "oisy.com", None).await.unwrap(), None);
}
#[tokio::test]
async fn cached_delegations_are_keyed_by_account_number() {
let ids = test_ids();
seed_live(&ids, "sess").await;
let future = now_ns() + REDERIVE_MARGIN_NS + 60 * 1_000_000_000;
seed_app(&ids, "sess", "oisy.com", None, future).await;
seed_app(&ids, "sess", "oisy.com", Some(7), future).await;
assert!(ids.cached_fresh("sess", "oisy.com", None).await.is_some());
assert!(ids.cached_fresh("sess", "oisy.com", Some(7)).await.is_some());
assert!(ids.cached_fresh("sess", "oisy.com", Some(9)).await.is_none());
assert!(ids.cached_fresh("sess", "nns.ic0.app", None).await.is_none());
}
#[tokio::test]
async fn cached_delegation_near_expiry_is_a_miss() {
let ids = test_ids();
seed_live(&ids, "sess").await;
seed_app(&ids, "sess", "oisy.com", None, now_ns() + 1).await;
assert!(ids.cached_fresh("sess", "oisy.com", None).await.is_none());
}
#[tokio::test]
async fn expired_grant_blocks_signing() {
let ids = test_ids();
ids.ensure_session("sess").await.expect("room for a session");
ids.set_grant_expiration("sess", now_ns().saturating_sub(1)).await;
assert!(ids.session_signer("sess").await.is_err());
}
#[tokio::test]
async fn live_session_count_counts_only_valid_grants() {
let ids = test_ids();
assert_eq!(ids.live_session_count().await, 0);
let future = now_ns() + 3_600_000_000_000;
ids.set_grant_expiration("live1", future).await;
ids.set_grant_expiration("live2", future).await;
ids.set_grant_expiration("expired", now_ns().saturating_sub(1)).await;
ids.ensure_session("pending").await.expect("room for a session");
assert_eq!(ids.live_session_count().await, 2);
}
#[tokio::test]
async fn reap_expired_sessions_evicts_expired_only() {
let ids = test_ids();
let future = now_ns() + 3_600_000_000_000;
ids.set_grant_expiration("live", future).await;
ids.set_grant_expiration("expired", now_ns().saturating_sub(1)).await;
ids.ensure_session("pending").await.expect("room for a session");
assert_eq!(ids.reap_expired_sessions().await, 1);
let sessions = ids.sessions.read().await;
assert!(sessions.contains_key("live"));
assert!(sessions.contains_key("pending"));
assert!(!sessions.contains_key("expired"));
drop(sessions);
assert_eq!(ids.reap_expired_sessions().await, 0);
assert_eq!(ids.live_session_count().await, 1);
}
fn session_at(created_ns: u64, grant_expiration_ns: Option<u64>) -> Session {
Session {
key_seed: [0u8; 32],
pubkey_der: Vec::new(),
grant_expiration_ns,
created_ns,
last_seen_ns: AtomicU64::new(created_ns),
reg_key_seed: None,
reg_pubkey_der: None,
read_only: None,
app_delegations: HashMap::new(),
}
}
fn app_delegation(expiration_ns: u64) -> AppDelegation {
AppDelegation {
user_key: Vec::new(),
chain: Vec::new(),
expiration_ns,
app_key_seed: [0u8; 32],
}
}
#[test]
fn make_room_bounds_pending_connects_and_spares_grants() {
let now = 1_700_000_000 * 1_000_000_000;
let mut sessions = HashMap::new();
sessions.insert("live".to_string(), session_at(now, Some(now + 3_600_000_000_000)));
for i in 0..MAX_PENDING_CONNECTS as u64 {
sessions.insert(format!("pending-{i}"), session_at(now - i * 1_000_000, None));
}
make_room(&mut sessions, now).expect("pending pressure must not refuse a connect");
assert!(sessions.contains_key("live"), "a live grant is never evicted");
let pending = sessions.values().filter(|s| s.grant_expiration_ns.is_none()).count();
assert_eq!(pending, MAX_PENDING_CONNECTS - 1, "room made for exactly one more connect");
assert!(
!sessions.contains_key(&format!("pending-{}", MAX_PENDING_CONNECTS - 1)),
"the oldest pending connect goes first"
);
assert!(sessions.contains_key("pending-0"), "recent connects in flight are kept");
}
#[test]
fn make_room_reclaims_stale_entries_first() {
let now = 1_700_000_000 * 1_000_000_000;
let mut sessions = HashMap::new();
sessions.insert("expired".to_string(), session_at(now, Some(now - 1)));
sessions.insert(
"abandoned".to_string(),
session_at(now.saturating_sub(PENDING_CONNECT_TTL_NS + 1), None),
);
for i in 0..(MAX_PENDING_CONNECTS as u64 - 2) {
sessions.insert(format!("pending-{i}"), session_at(now - i * 1_000_000, None));
}
let closed = make_room(&mut sessions, now).expect("room is available");
assert_eq!(closed, vec!["expired".to_string()], "an expired grant is reported for logging");
assert!(!sessions.contains_key("expired"));
assert!(!sessions.contains_key("abandoned"));
assert!(sessions.contains_key("pending-0"));
assert_eq!(sessions.len(), MAX_PENDING_CONNECTS - 2);
}
#[test]
fn make_room_refuses_rather_than_evicting_live_grants() {
let now = 1_700_000_000 * 1_000_000_000;
let mut sessions: HashMap<String, Session> = (0..MAX_SESSIONS)
.map(|i| (format!("live-{i}"), session_at(now, Some(now + 3_600_000_000_000))))
.collect();
let err = make_room(&mut sessions, now).expect_err("a full map must refuse the connect");
assert!(err.contains("at capacity"), "got: {err}");
assert_eq!(sessions.len(), MAX_SESSIONS, "no live grant was evicted to make room");
}
#[tokio::test]
async fn reap_drops_abandoned_connects_and_keeps_fresh_ones() {
let ids = test_ids();
ids.ensure_session("in-flight").await.expect("room for a session");
ids.ensure_session("abandoned").await.expect("room for a session");
{
let mut sessions = ids.sessions.write().await;
sessions.get_mut("abandoned").expect("session").created_ns =
now_ns().saturating_sub(PENDING_CONNECT_TTL_NS + 1);
}
assert_eq!(ids.reap_expired_sessions().await, 1);
let sessions = ids.sessions.read().await;
assert!(sessions.contains_key("in-flight"), "a connect in flight survives the sweep");
assert!(!sessions.contains_key("abandoned"), "an abandoned connect is reaped");
}
#[tokio::test]
async fn app_delegation_cache_is_capped_per_session() {
let ids = test_ids();
seed_live(&ids, "sess").await;
let base = now_ns() + REDERIVE_MARGIN_NS + 60 * 1_000_000_000;
for i in 0..MAX_APP_DELEGATIONS as u64 {
ids.store("sess", &format!("app{i}.example"), None, app_delegation(base + i * 1_000_000_000))
.await;
}
assert_eq!(
ids.sessions.read().await.get("sess").expect("session").app_delegations.len(),
MAX_APP_DELEGATIONS
);
ids.store("sess", "one-more.example", None, app_delegation(base + 9_999_000_000_000)).await;
{
let sessions = ids.sessions.read().await;
let cache = &sessions.get("sess").expect("session").app_delegations;
assert_eq!(cache.len(), MAX_APP_DELEGATIONS, "the cache stays at its cap");
assert!(
!cache.contains_key(&("app0.example".to_string(), None)),
"the entry nearest expiry is evicted"
);
assert!(
cache.contains_key(&("one-more.example".to_string(), None)),
"the new entry is cached"
);
}
ids.store("sess", "app1.example", None, app_delegation(base + 9_999_000_000_000)).await;
assert_eq!(
ids.sessions.read().await.get("sess").expect("session").app_delegations.len(),
MAX_APP_DELEGATIONS
);
}
#[tokio::test]
async fn live_session_count_keeps_idle_sessions_until_grant_expiry() {
let ids = test_ids();
let future = now_ns() + 3_600_000_000_000;
ids.set_grant_expiration("idle", future).await;
{
let stale = now_ns().saturating_sub(ACTIVE_SESSION_WINDOW_NS + 3_600_000_000_000);
let sessions = ids.sessions.read().await;
sessions.get("idle").unwrap().last_seen_ns.store(stale, Ordering::Relaxed);
}
assert_eq!(ids.live_session_count().await, 1);
assert_eq!(ids.active_session_count().await, 0);
ids.set_grant_expiration("expired", now_ns().saturating_sub(1)).await;
assert_eq!(ids.live_session_count().await, 1);
assert!(ids.sessions.read().await.contains_key("expired"));
assert_eq!(ids.reap_expired_sessions().await, 1);
assert_eq!(ids.live_session_count().await, 1);
}
#[tokio::test]
async fn active_session_count_tracks_the_activity_window() {
let ids = test_ids();
let future = now_ns() + 3_600_000_000_000;
ids.set_grant_expiration("active", future).await;
ids.set_grant_expiration("quiet", future).await;
assert_eq!(ids.active_session_count().await, 2);
assert_eq!(ids.live_session_count().await, 2);
{
let stale = now_ns().saturating_sub(ACTIVE_SESSION_WINDOW_NS + 1_000_000_000);
let sessions = ids.sessions.read().await;
sessions.get("quiet").unwrap().last_seen_ns.store(stale, Ordering::Relaxed);
}
assert_eq!(ids.active_session_count().await, 1);
assert_eq!(ids.live_session_count().await, 2);
let g = ids.session_gauges().await;
assert_eq!((g.live, g.active), (2, 1));
ids.touch_session("quiet").await;
assert_eq!(ids.active_session_count().await, 2);
ids.set_grant_expiration("expired", now_ns().saturating_sub(1)).await;
ids.touch_session("expired").await;
assert_eq!(ids.active_session_count().await, 2);
assert_eq!(ids.live_session_count().await, 2);
}
#[test]
fn mcp_get_accounts_reply_decodes_account_records() {
#[derive(CandidType)]
struct WireAccount {
account_number: Option<u64>,
origin: String,
last_used: Option<u64>,
name: Option<String>,
}
let wire: std::result::Result<Vec<WireAccount>, AccountDelegationError> = Ok(vec![
WireAccount { account_number: None, origin: "https://oisy.com".into(), last_used: None, name: None },
WireAccount {
account_number: Some(7),
origin: "https://oisy.com".into(),
last_used: Some(123),
name: Some("savings".into()),
},
]);
let bytes = Encode!(&wire).expect("encode");
let decoded = Decode!(&bytes, McpGetAccountsReply).expect("decode").expect("Ok arm");
assert_eq!(decoded.len(), 2);
assert_eq!(decoded[0].account_number, None);
assert_eq!(decoded[0].name, None);
assert_eq!(decoded[1].account_number, Some(7));
assert_eq!(decoded[1].name.as_deref(), Some("savings"));
assert_eq!(decoded[1].last_used, Some(123));
}
#[derive(CandidType)]
struct WireDelegation {
pubkey: Vec<u8>,
expiration: u64,
targets: Option<Vec<Principal>>,
permissions: Option<String>,
}
#[derive(CandidType)]
struct WireSignedDelegation {
delegation: WireDelegation,
signature: Vec<u8>,
}
#[derive(CandidType)]
struct WireLegacyDelegation {
pubkey: Vec<u8>,
expiration: u64,
targets: Option<Vec<Principal>>,
}
#[derive(CandidType)]
struct WireLegacySignedDelegation {
delegation: WireLegacyDelegation,
signature: Vec<u8>,
}
#[test]
fn signed_delegation_forwards_ii_permissions() {
let app_key = vec![1u8, 2, 3, 4];
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("queries".to_string()))).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("all".to_string()))).expect("encode");
let agent = Decode!(&bytes, IiSignedDelegation)
.expect("decode")
.into_agent(&app_key)
.expect("into_agent");
assert_eq!(agent.delegation.permissions, Some(DelegationPermissions::All));
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);
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);
}
#[test]
fn ii_opt_text_permissions_is_dropped_by_variant_decode_but_kept_by_text() {
#[derive(CandidType, Deserialize)]
enum VariantPermissions {
#[serde(rename = "queries")]
Queries,
#[serde(rename = "all")]
All,
}
#[derive(CandidType, Deserialize)]
struct VariantDelegation {
pubkey: Vec<u8>,
expiration: u64,
targets: Option<Vec<Principal>>,
permissions: Option<VariantPermissions>,
}
#[derive(CandidType, Deserialize)]
struct VariantSigned {
delegation: VariantDelegation,
signature: Vec<u8>,
}
let app_key = vec![1u8, 2, 3, 4];
let bytes = Encode!(&WireSignedDelegation {
delegation: WireDelegation {
pubkey: app_key.clone(),
expiration: 42,
targets: None,
permissions: Some("queries".to_string()),
},
signature: vec![9, 9, 9],
})
.expect("encode II opt-text reply");
let dropped = Decode!(&bytes, VariantSigned).expect("decode as variant");
assert!(
dropped.delegation.permissions.is_none(),
"opt text must NOT survive an opt-variant decode — it silently drops to None"
);
let kept = Decode!(&bytes, IiSignedDelegation)
.expect("decode as text")
.into_agent(&app_key)
.expect("into_agent");
assert_eq!(kept.delegation.permissions, Some(DelegationPermissions::Queries));
}
#[test]
fn unknown_permission_fails_fast_rather_than_silently_dropping() {
let app_key = vec![1u8, 2, 3, 4];
let bytes = Encode!(&WireSignedDelegation {
delegation: WireDelegation {
pubkey: app_key.clone(),
expiration: 42,
targets: None,
permissions: Some("write-only".to_string()), },
signature: vec![9, 9, 9],
})
.expect("encode");
let err = Decode!(&bytes, IiSignedDelegation)
.expect("decode")
.into_agent(&app_key)
.expect_err("an unrecognized permission must error, not silently drop");
assert!(err.contains("unrecognized permission"), "got: {err}");
}
#[test]
fn read_only_delegation_scopes_signed_requests_end_to_end() {
use ic_agent::agent::EnvelopeContent;
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;
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");
let wire = WireSignedDelegation {
delegation: WireDelegation {
pubkey: app_der.clone(),
expiration,
targets: None,
permissions: Some("queries".to_string()),
},
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"
);
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");
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"
);
}
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"
);
}
#[tokio::test]
async fn registration_key_is_minted_once_and_distinct_from_session_key() {
let ids = test_ids();
let x1 = ids.registration_pubkey_b64("sess").await.expect("mint X");
let x2 = ids.registration_pubkey_b64("sess").await.expect("mint X");
assert_eq!(x1, x2, "the registration key is stable across a connect");
let (s_der, x_der) = {
let sessions = ids.sessions.read().await;
let s = sessions.get("sess").expect("session");
(s.pubkey_der.clone(), s.reg_pubkey_der.clone().expect("reg key minted"))
};
assert_ne!(x_der, s_der, "X (registration key) must differ from S (session key)");
}
#[test]
fn mcp_register_v2_arg_encodes_session_key_only() {
use candid::types::value::IDLArgs;
let session_der = vec![1u8, 2, 3];
let bytes = Encode!(&session_der).expect("encode");
let k = Decode!(&bytes, Vec<u8>).expect("decode");
assert_eq!(k, session_der);
let args = IDLArgs::from_bytes(&bytes).expect("typeless-decode args").args;
assert_eq!(args.len(), 1, "mcp_register_v2 takes exactly one arg (session_key)");
}
#[test]
fn ii_permissions_variant_labels_match_ii() {
use candid::types::value::IDLArgs;
let literal = |v: &str| {
let b = candid_parser::parse_idl_args(&format!("(variant {{ {v} }})"))
.expect("parse literal")
.to_bytes()
.expect("encode literal");
IDLArgs::from_bytes(&b).expect("typeless-decode literal").args[0].to_string()
};
let wire = |p: IiPermissions| {
let b = Encode!(&p).expect("encode perm");
IDLArgs::from_bytes(&b).expect("typeless-decode perm").args[0].to_string()
};
assert_eq!(wire(IiPermissions::Queries), literal("queries"), "on-wire label must be `queries`");
assert_eq!(wire(IiPermissions::All), literal("all"), "on-wire label must be `all`");
assert_ne!(wire(IiPermissions::Queries), literal("all"));
}
#[tokio::test]
async fn redeem_rejects_delegation_not_targeting_registration_key() {
let ids = test_ids();
ids.registration_pubkey_b64("sess").await.expect("mint X");
let wrong_target = SignedDelegation {
delegation: Delegation {
pubkey: vec![0xaa; 32], expiration: now_ns() + 60 * 1_000_000_000,
targets: None,
permissions: None,
},
signature: vec![],
};
let err = ids
.redeem_registration_delegation("sess", vec![1, 2, 3], vec![wrong_target])
.await
.expect_err("a delegation to the wrong key must be rejected");
assert!(err.contains("does not delegate"), "got: {err}");
}
#[test]
fn two_hop_registration_chain_builds_a_signing_identity() {
use ic_agent::agent::EnvelopeContent;
let (preg_seed, preg_der) = fresh_ed25519(); let (y_seed, y_der) = fresh_ed25519(); let (x_seed, x_der) = fresh_ed25519(); let exp = now_ns() + 300 * 1_000_000_000;
let hop = |signer_seed: &[u8; 32], to_der: &[u8]| {
let delegation = Delegation {
pubkey: to_der.to_vec(),
expiration: exp,
targets: None,
permissions: None,
};
let signature = BasicIdentity::from_raw_key(signer_seed)
.sign_delegation(&delegation)
.expect("sign delegation")
.signature
.expect("signature present");
SignedDelegation { delegation, signature }
};
let chain = vec![hop(&preg_seed, &y_der), hop(&y_seed, &x_der)];
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 {
nonce: None,
ingress_expiry: exp,
sender,
canister_id: Principal::management_canister(),
method_name: "mcp_register_v2".to_string(),
arg: vec![],
sender_info: None,
};
let signed = identity.sign(&content).expect("sign");
let attached = signed.delegations.expect("chain attached to the request");
assert_eq!(attached.len(), 2, "both hops must ride the request");
assert_eq!(attached[0].delegation.pubkey, y_der, "hop 1 targets Y");
assert_eq!(attached[1].delegation.pubkey, x_der, "hop 2 targets X");
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, &root).is_err());
}
#[tokio::test]
async fn redeem_guards_missing_key_and_empty_chain() {
let ids = test_ids();
let err = ids
.redeem_registration_delegation("sess", vec![1], vec![])
.await
.expect_err("no registration key => error");
assert!(err.contains("no registration key"), "got: {err}");
ids.registration_pubkey_b64("sess").await.expect("mint X");
let err = ids
.redeem_registration_delegation("sess", vec![1], vec![])
.await
.expect_err("empty chain => error");
assert!(err.contains("chain is empty"), "got: {err}");
}
}