From b8bf1ea79e17d8419b6f9b54298139a20e886e90 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 9 Sep 2026 11:18:51 +0200 Subject: [PATCH 1/3] fix: accept delegation chains issued by a non-mainnet auth provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chain from a local Internet Identity carries a canister signature whose certificate only BLS-verifies against that replica's root key. Two things made such an identity unusable: `icp identity principal`, `account-id` and `delegation sign` pass no network root key and have no flag to supply one, so the chain was always checked against mainnet and always failed. `DelegatedIdentity::new` stops at the first link it cannot verify, so the links behind the canister signature went unchecked, at load time and at link time — including in `create_identity`, which documents its session-key check as running before anything is written. A resolved network root key is now authoritative and the only key consulted; with no network resolved, mainnet is the assumption. Where that leaves a canister signature unverifiable, verify each link as far as it can be verified without a root key. For a canister signature that is everything but the certificate's own BLS signature: that the CBOR decodes, that the signing canister's certified data matches the signature tree, and that the tree carries a signature over exactly this delegation. ic-agent verifies canister signatures only as a whole, so those checks are repeated here rather than skipped with the trust check. Key the identity cache by the root key as well as the selection: the same identity validates differently against different networks, so an entry cached for one must not be handed to a load that resolved another. `canister create` and `canister settings update` now take the caller principal from the agent rather than loading the same identity a second time without a root key. Also check expiry before `icp identity link web` writes a chain, as the import and load paths already do. --- Cargo.lock | 2 + Cargo.toml | 2 + .../icp-cli/src/commands/canister/create.rs | 7 +- .../src/commands/canister/settings/update.rs | 9 +- crates/icp/Cargo.toml | 2 + crates/icp/src/identity/key.rs | 834 ++++++++++++++++-- crates/icp/src/identity/mod.rs | 39 +- 7 files changed, 828 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b783cad2a..b53f52de8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3649,6 +3649,7 @@ dependencies = [ "httptest", "hybrid-array", "ic-agent", + "ic-certification", "ic-ed25519", "ic-identity-hsm", "ic-ledger-types", @@ -3680,6 +3681,7 @@ dependencies = [ "sec1", "semver", "serde", + "serde_bytes", "serde_cbor", "serde_json", "serde_yaml", diff --git a/Cargo.toml b/Cargo.toml index e96fe579c..378ee1798 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ hmac = { version = "0.13", features = ["zeroize"] } hybrid-array = { version = "0.4.10", features = ["zeroize"] } httptest = "0.16.3" ic-agent = { version = "0.49.1" } +ic-certification = { version = "3.2.0" } ic-ed25519 = "0.6.0" ic-ledger-types = "0.16.0" ic-management-canister-types = { version = "0.9.0" } @@ -95,6 +96,7 @@ send_ctrlc = "0.6" semver = "1" serial_test = { version = "3.2.0", features = ["file_locks"] } serde = { version = "1.0", features = ["derive"] } +serde_bytes = "0.11.19" serde_cbor = "0.11.2" serde_json = "1.0" serde_yaml = "0.9.34" diff --git a/crates/icp-cli/src/commands/canister/create.rs b/crates/icp-cli/src/commands/canister/create.rs index f406718d9..341295285 100644 --- a/crates/icp-cli/src/commands/canister/create.rs +++ b/crates/icp-cli/src/commands/canister/create.rs @@ -355,13 +355,12 @@ async fn create_project_canister(ctx: &Context, args: &CreateArgs) -> Result<(), return Ok(()); } - let identity = ctx.get_identity(&selections.identity, None).await?; - let caller = identity - .sender() - .map_err(|e| anyhow!("failed to get caller principal: {e}"))?; let agent = ctx .get_agent_for_env(&selections.identity, &selections.environment) .await?; + let caller = agent + .get_principal() + .map_err(|e| anyhow!("failed to get caller principal: {e}"))?; let ids = ctx .ids_by_environment(&selections.environment) .await diff --git a/crates/icp-cli/src/commands/canister/settings/update.rs b/crates/icp-cli/src/commands/canister/settings/update.rs index 50d7d2599..0575529f2 100644 --- a/crates/icp-cli/src/commands/canister/settings/update.rs +++ b/crates/icp-cli/src/commands/canister/settings/update.rs @@ -2,7 +2,6 @@ use anyhow::bail; use candid::Nat; use clap::{ArgAction, Args}; use dialoguer::Confirm; -use ic_agent::Identity; use ic_agent::export::Principal; use ic_management_canister_types::{ CanisterIdRecord, CanisterSettings, CanisterStatusResult, EnvironmentVariable, @@ -391,11 +390,6 @@ pub(crate) struct UpdateArgs { pub(crate) async fn exec(ctx: &Context, args: &UpdateArgs) -> Result<(), anyhow::Error> { let selections = args.cmd_args.selections(); - let identity = ctx.get_identity(&selections.identity, None).await?; - let caller_principal = identity - .sender() - .map_err(|e| anyhow::anyhow!("failed to get caller principal: {e}"))?; - let agent = ctx .get_agent( &selections.identity, @@ -403,6 +397,9 @@ pub(crate) async fn exec(ctx: &Context, args: &UpdateArgs) -> Result<(), anyhow: &selections.environment, ) .await?; + let caller_principal = agent + .get_principal() + .map_err(|e| anyhow::anyhow!("failed to get caller principal: {e}"))?; let cid = ctx .get_canister_id( &selections.canister, diff --git a/crates/icp/Cargo.toml b/crates/icp/Cargo.toml index 1ea3d7a77..12b10b9b3 100644 --- a/crates/icp/Cargo.toml +++ b/crates/icp/Cargo.toml @@ -31,6 +31,7 @@ hex = { workspace = true } hmac = { workspace = true } hybrid-array = { workspace = true } ic-agent = { workspace = true } +ic-certification = { workspace = true } ic-ed25519 = { workspace = true } ic-identity-hsm = { workspace = true } ic-ledger-types = { workspace = true } @@ -61,6 +62,7 @@ scrypt = { workspace = true } semver = { workspace = true } sec1 = { workspace = true } serde = { workspace = true } +serde_bytes = { workspace = true } serde_cbor = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index 3c28075e0..eff447b20 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -6,22 +6,27 @@ use std::{ use ic_agent::{ Identity, + export::Principal, identity::{ AnonymousIdentity, BasicIdentity, DelegatedIdentity, Delegation as AgentDelegation, DelegationError, Prime256v1Identity, Secp256k1Identity, + SignedDelegation as AgentSignedDelegation, }, }; +use ic_certification::LookupResult; use ic_ed25519::PrivateKeyFormat; use ic_identity_hsm::HardwareIdentity; use keyring::Entry; use pem::Pem; use pkcs8::{ DecodePrivateKey, EncodePrivateKey, EncryptedPrivateKeyInfo, PrivateKeyInfo, SecretDocument, - pkcs5::pbes2::Parameters, + pkcs5::pbes2::Parameters, spki::SubjectPublicKeyInfoRef, }; use rand::Rng; use scrypt::Params; use sec1::{der::Decode, pem::PemLabel}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; use snafu::{OptionExt, ResultExt, Snafu, ensure}; use tracing::{debug, warn}; use url::Url; @@ -126,10 +131,10 @@ pub enum LoadIdentityError { }, #[snafu(display( - "failed to validate delegation chain loaded from `{path}`; \ - this identity may only be valid for a different network" + "the delegation chain loaded from `{path}` does not verify against the selected \ + network's root key; this identity was most likely issued for a different network" ))] - ValidateDelegationChainNetworkHint { + ValidateDelegationChainNetwork { path: PathBuf, source: DelegationError, }, @@ -667,10 +672,6 @@ fn load_webauth_identity( return DelegationExpiredSnafu { name }.fail(); } - // Convert hex-encoded wire format to ic-agent types - let (from_key, signed_delegations) = - delegation::to_agent_types(&stored_chain).context(DelegationConversionSnafu)?; - let inner: Arc = match storage { DelegationKeyStorage::Keyring | DelegationKeyStorage::Pem { @@ -681,37 +682,219 @@ fn load_webauth_identity( } => load_pbes2_identity(&doc, algorithm, password_func, &origin)?, }; - match DelegatedIdentity::new(from_key, Box::new(Arc::clone(&inner)), signed_delegations) { + build_delegated_identity(name, &chain_path, &stored_chain, inner, network_root_key) +} + +/// Assembles the delegated identity for a stored chain, verifying the chain first. +/// +/// A resolved `network_root_key` is authoritative and the only key consulted: it is the key the +/// network this command talks to verifies against, so a chain failing it belongs to another +/// network. +/// +/// `network_root_key` is `None` for callers that resolve no network at all, such as +/// `icp identity principal`. Mainnet is then the only key on hand, and a canister signature from +/// another provider — a local Internet Identity, say — cannot be checked: that provider's root key +/// is not derivable from anything the identity stores. Rather than make those commands unusable, +/// the links that are canister signatures are set aside and everything else about the chain is +/// verified; see [`verify_past_canister_signatures`]. +fn build_delegated_identity( + name: &str, + chain_path: &Path, + stored_chain: &delegation::DelegationChain, + inner: Arc, + network_root_key: Option<&[u8]>, +) -> Result, LoadIdentityError> { + let (from_key, signed_delegations) = + delegation::to_agent_types(stored_chain).context(DelegationConversionSnafu)?; + + // A resolved network root key is authoritative: it is the key the network this command talks + // to verifies against. With no network resolved, mainnet is the only assumption available. + let root_key = network_root_key.unwrap_or(IC_ROOT_KEY); + + match DelegatedIdentity::new_with_root_key( + from_key.clone(), + Box::new(Arc::clone(&inner)), + signed_delegations.clone(), + root_key, + ) { Ok(delegated) => Ok(Arc::new(delegated)), - Err(mainnet_err) => { - // Only attempt the fallback when a network root key is provided and it differs from - // the mainnet key (identical keys would produce the same failure). - let different_key = network_root_key.filter(|k| *k != IC_ROOT_KEY.as_slice()); - - if let Some(network_key) = different_key { - // re-deserialize as ::new just ate the old values (better than an up-front clone since this path should be rare) - let (from_key, signed_delegations) = delegation::to_agent_types(&stored_chain) - .expect("same conversion already succeeded"); - match DelegatedIdentity::new_with_root_key( - from_key, - Box::new(Arc::clone(&inner)), - signed_delegations, - network_key, - ) { - Ok(delegated) => return Ok(Arc::new(delegated)), - // Both root keys failed; surface the mainnet error with a network-mismatch hint. - Err(_) => { - return Err(LoadIdentityError::ValidateDelegationChainNetworkHint { - path: chain_path, - source: mainnet_err, - }); - } - } - } - Err(mainnet_err).context(ValidateDelegationChainSnafu { path: chain_path }) + // Nothing here resolved a network, so a canister signature from another provider — a local + // Internet Identity, say — cannot be checked at all: that provider's root key is not + // derivable from anything the identity stores. Rather than make callers such as + // `icp identity principal` unusable, verify what needs no root key and accept the rest. + Err(DelegationError::InvalidCanisterSignature(_)) if network_root_key.is_none() => { + verify_past_canister_signatures(&from_key, &signed_delegations, &inner) + .context(ValidateDelegationChainSnafu { path: chain_path })?; + + warn!( + "delegation chain for identity `{name}` carries a canister signature and no root \ + key was resolved to check it against; the rest of the chain verified, and only \ + the network that issued it will accept it" + ); + + Ok(Arc::new(DelegatedIdentity::new_unchecked( + from_key, + Box::new(inner), + signed_delegations, + ))) + } + + Err(e @ DelegationError::InvalidCanisterSignature(_)) => { + Err(e).context(ValidateDelegationChainNetworkSnafu { path: chain_path }) + } + Err(e) => Err(e).context(ValidateDelegationChainSnafu { path: chain_path }), + } +} + +/// Verifies every link of a chain as far as it can be verified without a root key. +/// +/// A canister signature is an IC certificate, and trusting one needs the root key of the network +/// whose canister produced it. Everything else about it is checked by +/// [`verify_canister_signature_structure`]; only the BLS trust check is skipped. +/// +/// Links are classified by the type of the key that signed them, never by the error they produced: +/// ic-agent reports corruption through the same `InvalidCanisterSignature` variant as a trust-root +/// mismatch, so an error alone cannot say whether a link is unverifiable or damaged. +/// +/// Only a leading run of canister-signed links is set aside, because ic-agent verifies a chain +/// from its root outwards and cannot resume past one further in. Every link after that run is +/// verified in full, including the last, which must hand authority to `session`. +fn verify_past_canister_signatures( + from_key: &[u8], + delegations: &[AgentSignedDelegation], + session: &Arc, +) -> Result<(), DelegationError> { + for (i, signed) in delegations.iter().enumerate() { + let signer = signer_of(from_key, delegations, i); + if is_canister_signature_key(signer) { + verify_canister_signature_structure(signer, signed)?; + } + } + + let leading = (0..delegations.len()) + .take_while(|i| is_canister_signature_key(signer_of(from_key, delegations, *i))) + .count(); + + DelegatedIdentity::new_with_root_key( + signer_of(from_key, delegations, leading).to_vec(), + Box::new(Arc::clone(session)), + delegations[leading..].to_vec(), + IC_ROOT_KEY, + ) + .map(|_| ()) +} + +/// The key that signed `delegations[i]`, which is the chain root for the first link. +fn signer_of<'a>( + from_key: &'a [u8], + delegations: &'a [AgentSignedDelegation], + i: usize, +) -> &'a [u8] { + match i.checked_sub(1) { + None => from_key, + Some(previous) => delegations[previous].delegation.pubkey.as_slice(), + } +} + +/// CBOR body of a canister signature per the IC interface spec. +/// +/// The wire encoding is `tag(55799, {"certificate": bytes, "tree": hash-tree})`; `serde_cbor` +/// strips the tag transparently. +#[derive(Deserialize)] +#[cfg_attr(test, derive(serde::Serialize))] +struct CanisterSignature { + #[serde(with = "serde_bytes")] + certificate: Vec, + tree: ic_certification::HashTree, +} + +/// Checks everything about a canister signature that does not depend on a root key. +/// +/// Of the verification the IC interface spec lays out for canister signatures, only step 3 — BLS +/// verification of the certificate against the network's root key — needs a root key. This runs +/// the others: that the signature and certificate decode, that the certified data recorded for the +/// signing canister matches the signature tree, and that the tree carries a signature over exactly +/// this delegation. What remains unchecked is whether the certificate is genuine, which the +/// network settles on ingress. +/// +/// ic-agent performs all of this together in `DelegatedIdentity::new_with_root_key` and exposes no +/// way to run the root-key-independent part alone, so it is repeated here. Tracked upstream as +/// dfinity/agent-rs#742; this function can go once that lands. +fn verify_canister_signature_structure( + signing_key: &[u8], + signed: &AgentSignedDelegation, +) -> Result<(), DelegationError> { + let invalid = |message: String| { + DelegationError::InvalidCanisterSignature(format!( + "{message} (the certificate's own signature is not covered by this check)" + )) + }; + + let (canister_id, seed) = parse_canister_signature_key(signing_key) + .ok_or_else(|| invalid("malformed canister signature public key".into()))?; + + let signature: CanisterSignature = serde_cbor::from_slice(&signed.signature) + .map_err(|e| invalid(format!("invalid canister signature CBOR: {e}")))?; + let certificate: ic_certification::Certificate = serde_cbor::from_slice(&signature.certificate) + .map_err(|e| invalid(format!("invalid certificate CBOR: {e}")))?; + + let certified_data_path: [&[u8]; 3] = [b"canister", canister_id.as_slice(), b"certified_data"]; + let certified_data = match certificate.tree.lookup_path(certified_data_path) { + LookupResult::Found(value) => value, + _ => { + return Err(invalid( + "certified_data is absent from the certificate".into(), + )); } + }; + if certified_data != signature.tree.digest().as_ref() { + return Err(invalid( + "certified_data does not match the signature tree".into(), + )); } + + let seed_hash: [u8; 32] = Sha256::digest(&seed).into(); + let payload_hash: [u8; 32] = Sha256::digest(signed.delegation.signable()).into(); + match signature + .tree + .lookup_path([&b"sig"[..], &seed_hash, &payload_hash]) + { + LookupResult::Found([]) => Ok(()), + _ => Err(invalid( + "the signature tree carries no signature over this delegation".into(), + )), + } +} + +/// Splits a canister-signature public key into the signing canister and its seed. +/// +/// The key's BIT STRING is `canister_id_length | canister_id | seed` per the IC interface spec. +fn parse_canister_signature_key(der: &[u8]) -> Option<(Principal, Vec)> { + let spki = decode_public_key(der)?; + let raw = spki.subject_public_key.raw_bytes(); + + let (&length, rest) = raw.split_first()?; + let (canister_id, seed) = rest.split_at_checked(length as usize)?; + + Some((Principal::try_from_slice(canister_id).ok()?, seed.to_vec())) +} + +/// Reports whether `der` is a canister-signature public key (OID 1.3.6.1.4.1.56387.1.2). +/// +/// Signatures under such a key are IC certificates, verifiable only against the root key of the +/// network whose canister produced them. +fn is_canister_signature_key(der: &[u8]) -> bool { + const CANISTER_SIG_OID: pkcs8::ObjectIdentifier = + pkcs8::ObjectIdentifier::new_unwrap("1.3.6.1.4.1.56387.1.2"); + + decode_public_key(der).is_some_and(|spki| spki.algorithm.oid == CANISTER_SIG_OID) +} + +/// Decodes a DER `SubjectPublicKeyInfo`, rejecting anything trailing it: a key is only what it +/// claims to be if the whole slice is that key. +fn decode_public_key(der: &[u8]) -> Option> { + SubjectPublicKeyInfoRef::from_der(der).ok() } /// Returns the DER-encoded public key for a stored web-auth session key. @@ -973,7 +1156,7 @@ pub fn create_identity( // Validate the whole chain in memory before persisting anything, so a structurally // broken chain fails here rather than on every later load. - let from_key = validate_session_delegation_chain(name, session, chain)?; + let from_key = validate_session_delegation_chain(name, &session, chain)?; // Reject a chain that has already expired (or falls within the load-time grace // window): it would import successfully but then fail on every later load with @@ -1655,15 +1838,23 @@ pub enum CreatePendingDelegationError { DlgValidateDelegationChain { source: ValidateDelegationChainError, }, + + #[snafu(display("malformed delegation chain"))] + DlgConvertChain { source: delegation::ConversionError }, + + #[snafu(display( + "delegation chain has already expired (or is about to); log in again to get a fresh one" + ))] + DlgDelegationExpired, } /// Constructs a temporary signing identity directly from an [`IdentityKey`], used to validate a /// delegation chain before storing it. -fn session_identity_for_validation(key: &IdentityKey) -> Box { +fn session_identity_for_validation(key: &IdentityKey) -> Arc { match key { - IdentityKey::Ed25519(k) => Box::new(BasicIdentity::from_raw_key(&k.serialize_raw())), - IdentityKey::Secp256k1(k) => Box::new(Secp256k1Identity::from_private_key(k.clone())), - IdentityKey::Prime256v1(k) => Box::new(Prime256v1Identity::from_private_key(k.clone())), + IdentityKey::Ed25519(k) => Arc::new(BasicIdentity::from_raw_key(&k.serialize_raw())), + IdentityKey::Secp256k1(k) => Arc::new(Secp256k1Identity::from_private_key(k.clone())), + IdentityKey::Prime256v1(k) => Arc::new(Prime256v1Identity::from_private_key(k.clone())), } } @@ -1679,26 +1870,41 @@ pub enum ValidateDelegationChainError { /// Validates that `chain` connects its root key to `session`'s public key and returns the /// DER-encoded chain root (`from_key`), from which the identity's principal is derived. /// -/// The chain is verified against the IC mainnet root key. A canister-signature mismatch is -/// downgraded to a warning (the chain most likely targets a non-mainnet network); any other -/// validation failure is an error. `session` is the temporary signing identity built from the -/// session key (see [`session_identity_for_validation`]) and is consumed by the validation. +/// The chain is verified against the IC mainnet root key. A canister signature it cannot verify is +/// downgraded to a warning — the chain most likely targets a non-mainnet network, and no root key +/// is available here to confirm that — but the chain must still hand authority to `session`. Any +/// other validation failure is an error. `session` is the temporary signing identity built from +/// the session key (see [`session_identity_for_validation`]). fn validate_session_delegation_chain( name: &str, - session: Box, + session: &Arc, chain: &delegation::DelegationChain, ) -> Result, ValidateDelegationChainError> { let (from_key, delegations) = delegation::to_agent_types(chain).context(ConvertChainSnafu)?; - match DelegatedIdentity::new(from_key.clone(), session, delegations) { - Ok(_) => {} - Err(DelegationError::InvalidCanisterSignature(_)) => { - warn!( - "delegation chain for identity `{name}` did not validate against the IC mainnet \ - root key; this identity may only be valid for a particular network" - ); - } + + match DelegatedIdentity::new( + from_key.clone(), + Box::new(Arc::clone(session)), + delegations.clone(), + ) { + Ok(_) => return Ok(from_key), + // Nothing here resolves a network, so a canister signature from a non-mainnet provider + // cannot be checked. Fall through to the checks that need no root key. + Err(DelegationError::InvalidCanisterSignature(_)) => {} Err(e) => return Err(e).context(ValidateChainSnafu), } + + // `DelegatedIdentity::new` stopped at the canister-signed link, leaving the rest of the chain + // unexamined. Nothing here resolves a network, so verify everything the root key does not + // decide — including that the chain was issued to this session key. + verify_past_canister_signatures(&from_key, &delegations, session) + .context(ValidateChainSnafu)?; + + warn!( + "delegation chain for identity `{name}` carries a canister signature that the IC mainnet \ + root key does not verify; this identity is only usable on the network that issued it" + ); + Ok(from_key) } @@ -1730,7 +1936,15 @@ pub fn link_webauth_identity( // Validate the delegation chain against the mainnet root key before storing it. let session = session_identity_for_validation(&key); - validate_session_delegation_chain(name, session, chain)?; + validate_session_delegation_chain(name, &session, chain)?; + + // Reject a chain that has already expired (or falls within the load-time grace window): it + // would link successfully but then fail on every later load. Mirrors the checks in + // `create_identity` and `load_webauth_identity`. + ensure!( + !delegation::is_expiring_soon(chain, TWO_MINUTES_NANOS).context(DlgConvertChainSnafu)?, + DlgDelegationExpiredSnafu + ); let doc = match key { IdentityKey::Secp256k1(key) => key.to_pkcs8_der().expect("infallible PKI encoding"), @@ -2168,3 +2382,515 @@ pub fn export_identity( } } } + +#[cfg(test)] +mod tests { + use super::*; + + const HOUR_FROM_NOW_NANOS: u64 = 3600 * 1_000_000_000; + + /// The root key of some other network — here, one that verifies nothing. + const OTHER_NETWORK_ROOT_KEY: &[u8] = &[0u8; 133]; + + fn new_session() -> (Arc, Vec) { + let key = ic_ed25519::PrivateKey::generate(); + let identity = BasicIdentity::from_raw_key(&key.serialize_raw()); + let public_key = identity + .public_key() + .expect("ed25519 always has a public key"); + (Arc::new(identity), public_key) + } + + fn new_signer() -> BasicIdentity { + BasicIdentity::from_raw_key(&ic_ed25519::PrivateKey::generate().serialize_raw()) + } + + fn now_plus(offset: u64) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos() as u64 + + offset + } + + /// A link handing authority to `to`, signed by `signer`. + fn signed_link(signer: &dyn Identity, to: &[u8]) -> delegation::SignedDelegation { + signed_link_expiring_at(signer, to, now_plus(HOUR_FROM_NOW_NANOS)) + } + + fn signed_link_expiring_at( + signer: &dyn Identity, + to: &[u8], + expiration: u64, + ) -> delegation::SignedDelegation { + let delegation = AgentDelegation { + pubkey: to.to_vec(), + expiration, + targets: None, + permissions: None, + }; + let signature = signer + .sign_delegation(&delegation) + .expect("signing a delegation should succeed") + .signature + .expect("a signed delegation carries a signature"); + + delegation::SignedDelegation { + signature: hex::encode(signature), + delegation: delegation::Delegation { + pubkey: hex::encode(to), + expiration: format!("{expiration:x}"), + targets: None, + }, + } + } + + /// A DER canister-signature public key (OID 1.3.6.1.4.1.56387.1.2). + /// + /// Nothing available to a unit test can verify a signature under such a key: doing so means + /// BLS-verifying an IC certificate against the root key of the network whose canister produced + /// it. That is exactly the position `icp identity principal` is in. + fn canister_sig_public_key() -> Vec { + const OID: [u8; 10] = [0x2b, 0x06, 0x01, 0x04, 0x01, 0x83, 0xb8, 0x43, 0x01, 0x02]; + let canister_id = [0x0a, 0, 0, 0, 0, 0, 0, 0, 0x07, 0x01, 0x01]; + + let mut raw = vec![canister_id.len() as u8]; + raw.extend_from_slice(&canister_id); + raw.extend_from_slice(b"seed"); + + let mut algorithm = vec![0x30, (OID.len() + 2) as u8, 0x06, OID.len() as u8]; + algorithm.extend_from_slice(&OID); + + let mut bit_string = vec![0x03, (raw.len() + 1) as u8, 0x00]; + bit_string.extend_from_slice(&raw); + + let mut spki = vec![0x30, (algorithm.len() + bit_string.len()) as u8]; + spki.extend_from_slice(&algorithm); + spki.extend_from_slice(&bit_string); + spki + } + + /// A link under a canister-signature key, as a local Internet Identity issues. + /// + /// The signature is structurally sound — its CBOR parses, the certificate records the + /// signature tree as the signing canister's certified data, and the tree carries a signature + /// over exactly this delegation — but its certificate is signed by nothing. Only a root key + /// could tell it apart from a genuine one, which is the position a caller with no network is + /// in. + fn canister_sig_link(to: &[u8]) -> delegation::SignedDelegation { + let expiration = now_plus(HOUR_FROM_NOW_NANOS); + let delegation = AgentDelegation { + pubkey: to.to_vec(), + expiration, + targets: None, + permissions: None, + }; + + let (canister_id, seed) = + parse_canister_signature_key(&canister_sig_public_key()).expect("well-formed key"); + let seed_hash: [u8; 32] = Sha256::digest(&seed).into(); + let payload_hash: [u8; 32] = Sha256::digest(delegation.signable()).into(); + + let sig_tree = ic_certification::labeled( + &b"sig"[..], + ic_certification::labeled( + &seed_hash[..], + ic_certification::labeled(&payload_hash[..], ic_certification::leaf(vec![])), + ), + ); + let certificate = ic_certification::Certificate { + tree: ic_certification::labeled( + &b"canister"[..], + ic_certification::labeled( + canister_id.as_slice(), + ic_certification::labeled( + &b"certified_data"[..], + ic_certification::leaf(sig_tree.digest().to_vec()), + ), + ), + ), + signature: vec![0; 48], + delegation: None, + }; + + let signature = encode_canister_signature(CanisterSignature { + certificate: serde_cbor::to_vec(&certificate).expect("certificate encodes"), + tree: sig_tree, + }); + + delegation::SignedDelegation { + signature: hex::encode(signature), + delegation: delegation::Delegation { + pubkey: hex::encode(to), + expiration: format!("{expiration:x}"), + targets: None, + }, + } + } + + /// Encodes a canister signature the way the wire carries one: wrapped in the self-describing + /// CBOR tag 55799, as every signature a real auth provider issues is. + fn encode_canister_signature(signature: CanisterSignature) -> Vec { + use serde::Serialize; + + let mut encoded = Vec::new(); + let mut serializer = + serde_cbor::Serializer::new(serde_cbor::ser::IoWrite::new(&mut encoded)); + serializer.self_describe().expect("tag writes"); + signature + .serialize(&mut serializer) + .expect("signature encodes"); + + assert_eq!( + &encoded[..3], + &[0xd9, 0xd9, 0xf7], + "the fixture must carry the tag a real signature does" + ); + encoded + } + + /// The same shape, but with the signature bytes replaced by rubbish. + fn corrupt_canister_sig_link(to: &[u8]) -> delegation::SignedDelegation { + let mut link = canister_sig_link(to); + link.signature = hex::encode([0xde, 0xad, 0xbe, 0xef]); + link + } + + fn chain_of( + public_key: &[u8], + delegations: Vec, + ) -> delegation::DelegationChain { + delegation::DelegationChain { + public_key: hex::encode(public_key), + delegations, + } + } + + fn tampered(mut link: delegation::SignedDelegation) -> delegation::SignedDelegation { + link.signature = hex::encode([0u8; 64]); + link + } + + /// The two-link shape a real Internet Identity issues: a canister signature to a browser + /// session key, then an ordinary signature to the key the CLI holds. + fn ii_shaped_chain( + session_key: &[u8], + tamper_second_link: bool, + ) -> delegation::DelegationChain { + let intermediate = new_signer(); + let intermediate_key = intermediate.public_key().expect("public key"); + let second = signed_link(&intermediate, session_key); + + chain_of( + &canister_sig_public_key(), + vec![ + canister_sig_link(&intermediate_key), + if tamper_second_link { + tampered(second) + } else { + second + }, + ], + ) + } + + fn load( + chain: &delegation::DelegationChain, + session: Arc, + network_root_key: Option<&[u8]>, + ) -> Result, LoadIdentityError> { + build_delegated_identity( + "test", + Path::new("chain.json"), + chain, + session, + network_root_key, + ) + } + + #[test] + fn canister_signature_keys_are_recognised_by_their_oid() { + let (_, ed25519_key) = new_session(); + assert!(is_canister_signature_key(&canister_sig_public_key())); + assert!(!is_canister_signature_key(&ed25519_key)); + assert!(!is_canister_signature_key(b"not a key")); + } + + #[test] + fn unverifiable_canister_signature_is_accepted_when_no_root_key_is_resolved() { + let (session, session_key) = new_session(); + let chain = chain_of( + &canister_sig_public_key(), + vec![canister_sig_link(&session_key)], + ); + + let identity = load(&chain, session, None).expect("accepted without a root key"); + + assert_eq!( + identity.sender().expect("sender"), + Principal::self_authenticating(canister_sig_public_key()), + ); + } + + #[test] + fn unverifiable_canister_signature_is_rejected_when_issued_to_another_session() { + let (session, _) = new_session(); + let (_, other_key) = new_session(); + let chain = chain_of( + &canister_sig_public_key(), + vec![canister_sig_link(&other_key)], + ); + + assert!(matches!( + load(&chain, session, None), + Err(LoadIdentityError::ValidateDelegationChain { .. }) + )); + } + + #[test] + fn links_behind_a_canister_signature_are_verified_when_no_root_key_is_resolved() { + let (session, session_key) = new_session(); + + load( + &ii_shaped_chain(&session_key, false), + Arc::clone(&session), + None, + ) + .expect("a sound chain behind the canister signature is accepted"); + + assert!( + matches!( + load(&ii_shaped_chain(&session_key, true), session, None), + Err(LoadIdentityError::ValidateDelegationChain { .. }) + ), + "a tampered link behind the canister signature must stay fatal" + ); + } + + #[test] + fn a_resolved_network_root_key_is_authoritative() { + let (session, session_key) = new_session(); + let chain = chain_of( + &canister_sig_public_key(), + vec![canister_sig_link(&session_key)], + ); + + // The same chain that loads unverified with no root key is rejected once a root key it + // does not verify against is on the table. + assert!(matches!( + load(&chain, session, Some(OTHER_NETWORK_ROOT_KEY)), + Err(LoadIdentityError::ValidateDelegationChainNetwork { .. }) + )); + } + + /// The root key decides canister signatures and nothing else, so a broken ordinary link must + /// not be reported as a network mismatch. + #[test] + fn a_broken_link_is_not_reported_as_a_network_mismatch() { + let (session, session_key) = new_session(); + let signer = new_signer(); + let chain = chain_of( + &signer.public_key().expect("public key"), + vec![tampered(signed_link(&signer, &session_key))], + ); + + assert!(matches!( + load(&chain, session, Some(OTHER_NETWORK_ROOT_KEY)), + Err(LoadIdentityError::ValidateDelegationChain { .. }) + )); + } + + /// A resolved root key must not over-reject: a chain with no canister signature carries + /// nothing that depends on a trust root, and verifies under any root key. + #[test] + fn a_chain_without_a_canister_signature_verifies_under_any_root_key() { + let (session, session_key) = new_session(); + let signer = new_signer(); + let chain = chain_of( + &signer.public_key().expect("public key"), + vec![signed_link(&signer, &session_key)], + ); + + load(&chain, session, Some(OTHER_NETWORK_ROOT_KEY)) + .expect("a chain with no canister signature needs no particular root key"); + } + + #[test] + fn validate_session_delegation_chain_accepts_a_mainnet_chain() { + let key = IdentityKey::Ed25519(ic_ed25519::PrivateKey::generate()); + let session = session_identity_for_validation(&key); + let session_key = session.public_key().expect("public key"); + let root = new_signer(); + let root_key = root.public_key().expect("public key"); + + let from_key = validate_session_delegation_chain( + "test", + &session, + &chain_of(&root_key, vec![signed_link(&root, &session_key)]), + ) + .expect("a chain signed by its own root validates"); + + assert_eq!(from_key, root_key); + } + + #[test] + fn validate_session_delegation_chain_accepts_an_unverifiable_canister_signature() { + let key = IdentityKey::Ed25519(ic_ed25519::PrivateKey::generate()); + let session = session_identity_for_validation(&key); + let session_key = session.public_key().expect("public key"); + + validate_session_delegation_chain("test", &session, &ii_shaped_chain(&session_key, false)) + .expect("a local-provider chain links successfully"); + } + + #[test] + fn validate_session_delegation_chain_rejects_a_broken_link_behind_a_canister_signature() { + let key = IdentityKey::Ed25519(ic_ed25519::PrivateKey::generate()); + let session = session_identity_for_validation(&key); + let session_key = session.public_key().expect("public key"); + + assert!( + validate_session_delegation_chain( + "test", + &session, + &ii_shaped_chain(&session_key, true) + ) + .is_err() + ); + } + + #[test] + fn validate_session_delegation_chain_rejects_a_chain_issued_to_another_key() { + let key = IdentityKey::Ed25519(ic_ed25519::PrivateKey::generate()); + let session = session_identity_for_validation(&key); + let (_, other_key) = new_session(); + let root = new_signer(); + + assert!( + validate_session_delegation_chain( + "test", + &session, + &chain_of( + &root.public_key().expect("public key"), + vec![signed_link(&root, &other_key)] + ) + ) + .is_err() + ); + } + + #[tokio::test] + async fn link_webauth_identity_rejects_an_expired_chain() { + let key = ic_ed25519::PrivateKey::generate(); + let identity_key = IdentityKey::Ed25519(key); + let session = session_identity_for_validation(&identity_key); + let session_key = session.public_key().expect("public key"); + + let root = new_signer(); + let chain = chain_of( + &root.public_key().expect("public key"), + vec![signed_link_expiring_at(&root, &session_key, 1)], + ); + + let tmp = camino_tempfile::tempdir().expect("tempdir"); + let dirs = IdentityPaths::new(tmp.path().to_path_buf()).expect("identity paths"); + let (result, chain_path) = dirs + .with_write(async |dirs| { + let chain_path = dirs.read().delegation_chain_path("expired"); + let result = link_webauth_identity( + dirs, + "expired", + identity_key, + &chain, + Principal::self_authenticating(root.public_key().expect("public key")), + CreateFormat::Plaintext, + Url::parse("https://id.ai").expect("url"), + None, + ); + (result, chain_path) + }) + .await + .expect("lock"); + + assert!(matches!( + result, + Err(CreatePendingDelegationError::DlgDelegationExpired) + )); + assert!( + !chain_path.exists(), + "an expired chain must not be persisted" + ); + } + + /// A canister signature cannot be trusted without a root key, but it can still be checked for + /// self-consistency, and that check must not be skipped along with the trust check. + #[test] + fn a_corrupt_canister_signature_is_rejected_even_with_no_root_key() { + let (session, session_key) = new_session(); + let chain = chain_of( + &canister_sig_public_key(), + vec![corrupt_canister_sig_link(&session_key)], + ); + + assert!(matches!( + load(&chain, session, None), + Err(LoadIdentityError::ValidateDelegationChain { .. }) + )); + } + + #[test] + fn a_canister_signature_over_another_delegation_is_rejected() { + let (session, session_key) = new_session(); + let (_, other_key) = new_session(); + + // A signature genuinely issued, but for a different delegation than the one it is attached + // to: the signature tree carries no entry for this payload. + let mut link = canister_sig_link(&other_key); + link.delegation.pubkey = hex::encode(&session_key); + let chain = chain_of(&canister_sig_public_key(), vec![link]); + + assert!(matches!( + load(&chain, session, None), + Err(LoadIdentityError::ValidateDelegationChain { .. }) + )); + } + + /// Real canister signatures arrive wrapped in the self-describing CBOR tag 55799. Decoding + /// must see through it, and every other fixture here relies on that. + #[test] + fn a_tagged_canister_signature_decodes() { + let (_, session_key) = new_session(); + let link = canister_sig_link(&session_key); + let tagged = hex::decode(&link.signature).expect("hex"); + assert_eq!(&tagged[..3], &[0xd9, 0xd9, 0xf7], "fixture carries the tag"); + + let check = |link: delegation::SignedDelegation| { + let chain = chain_of(&canister_sig_public_key(), vec![link]); + let (from_key, delegations) = + delegation::to_agent_types(&chain).expect("chain converts"); + verify_canister_signature_structure(&from_key, &delegations[0]) + }; + + check(link).expect("a tagged signature decodes"); + } + + /// A key is only a canister-signature key if the whole slice is that key. A valid prefix with + /// bytes after it is malformed, and must not be set aside as unverifiable on a partial read. + #[test] + fn a_key_with_trailing_bytes_is_not_a_canister_signature_key() { + let mut trailing = canister_sig_public_key(); + trailing.push(0); + + assert!(is_canister_signature_key(&canister_sig_public_key())); + assert!(!is_canister_signature_key(&trailing)); + assert!(parse_canister_signature_key(&trailing).is_none()); + + // And such a chain is rejected rather than accepted with a warning. + let (session, session_key) = new_session(); + let chain = chain_of(&trailing, vec![canister_sig_link(&session_key)]); + assert!(matches!( + load(&chain, session, None), + Err(LoadIdentityError::ValidateDelegationChain { .. }) + )); + } +} diff --git a/crates/icp/src/identity/mod.rs b/crates/icp/src/identity/mod.rs index 5f4cd21ae..a367af301 100644 --- a/crates/icp/src/identity/mod.rs +++ b/crates/icp/src/identity/mod.rs @@ -130,7 +130,15 @@ pub struct Loader { pem_session_duration: Option, telemetry_data: Arc, #[allow(clippy::type_complexity)] - cache: Mutex, Option)>>, + /// Keyed by the full input to a load. The root key is part of that input: the same identity + /// validates differently against different networks, so an entry cached for one must never be + /// handed to a load that resolved another. + cache: Mutex< + HashMap< + (IdentitySelection, Option>), + (Arc, Option), + >, + >, } impl Loader { @@ -157,7 +165,8 @@ impl Load for Loader { id: IdentitySelection, network_root_key: Option>, ) -> Result, LoadError> { - if let Some((cached, storage_type)) = self.cache.lock().unwrap().get(&id) { + let cache_key = (id.clone(), network_root_key.clone()); + if let Some((cached, storage_type)) = self.cache.lock().unwrap().get(&cache_key) { if let Some(t) = storage_type { self.telemetry_data.set_identity_type(*t); } @@ -229,10 +238,11 @@ impl Load for Loader { if let Some(t) = storage_type { self.telemetry_data.set_identity_type(t); } + self.cache .lock() .unwrap() - .insert(id, (Arc::clone(&identity), storage_type)); + .insert(cache_key, (Arc::clone(&identity), storage_type)); Ok(identity) } } @@ -340,5 +350,28 @@ mod tests { .await .unwrap(); assert!(Arc::ptr_eq(&i1, &i2)); + + // A different root key is a different question: the same chain validates against one + // network and not another, so the entry cached for one must not answer for the other. + let i3 = loader + .load( + IdentitySelection::Named("test".to_string()), + Some(vec![0u8; 133]), + ) + .await + .unwrap(); + assert!(!Arc::ptr_eq(&i1, &i3)); + + let i4 = loader + .load( + IdentitySelection::Named("test".to_string()), + Some(vec![0u8; 133]), + ) + .await + .unwrap(); + assert!( + Arc::ptr_eq(&i3, &i4), + "each root key still gets its own entry" + ); } } From f958af41754f428fb0a7d9c4af88a8ff8d8bdf6d Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 10 Sep 2026 09:28:17 +0200 Subject: [PATCH 2/3] test: verify a real local Internet Identity chain Every other canister-signature fixture is encoded by the same types the production code decodes with, so it is self-consistent by construction: a change that shifts encoding and decoding together would keep those tests green while rejecting every signature a real provider issues. Add a chain a local Internet Identity actually issued, with the root key of the replica that issued it, and check that it verifies against that root key, is accepted unverified when none is available, and is rejected under the mainnet key. Verification only asks the session identity for its principal, so a stub carrying it stands in and no key material is stored. --- crates/icp/src/identity/key.rs | 80 +++++++++++++++---- .../src/identity/testdata/local_ii_chain.json | 21 +++++ .../identity/testdata/local_ii_root_key.hex | 1 + 3 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 crates/icp/src/identity/testdata/local_ii_chain.json create mode 100644 crates/icp/src/identity/testdata/local_ii_root_key.hex diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index eff447b20..c619fdf08 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -749,17 +749,11 @@ fn build_delegated_identity( /// Verifies every link of a chain as far as it can be verified without a root key. /// -/// A canister signature is an IC certificate, and trusting one needs the root key of the network -/// whose canister produced it. Everything else about it is checked by -/// [`verify_canister_signature_structure`]; only the BLS trust check is skipped. -/// /// Links are classified by the type of the key that signed them, never by the error they produced: /// ic-agent reports corruption through the same `InvalidCanisterSignature` variant as a trust-root -/// mismatch, so an error alone cannot say whether a link is unverifiable or damaged. -/// -/// Only a leading run of canister-signed links is set aside, because ic-agent verifies a chain -/// from its root outwards and cannot resume past one further in. Every link after that run is -/// verified in full, including the last, which must hand authority to `session`. +/// mismatch, so an error alone cannot say whether a link is unverifiable or damaged. Only a +/// leading run of canister-signed links is set aside, since ic-agent verifies a chain from its +/// root outwards and cannot resume past one further in. fn verify_past_canister_signatures( from_key: &[u8], delegations: &[AgentSignedDelegation], @@ -871,7 +865,7 @@ fn verify_canister_signature_structure( /// /// The key's BIT STRING is `canister_id_length | canister_id | seed` per the IC interface spec. fn parse_canister_signature_key(der: &[u8]) -> Option<(Principal, Vec)> { - let spki = decode_public_key(der)?; + let spki = SubjectPublicKeyInfoRef::from_der(der).ok()?; let raw = spki.subject_public_key.raw_bytes(); let (&length, rest) = raw.split_first()?; @@ -888,13 +882,7 @@ fn is_canister_signature_key(der: &[u8]) -> bool { const CANISTER_SIG_OID: pkcs8::ObjectIdentifier = pkcs8::ObjectIdentifier::new_unwrap("1.3.6.1.4.1.56387.1.2"); - decode_public_key(der).is_some_and(|spki| spki.algorithm.oid == CANISTER_SIG_OID) -} - -/// Decodes a DER `SubjectPublicKeyInfo`, rejecting anything trailing it: a key is only what it -/// claims to be if the whole slice is that key. -fn decode_public_key(der: &[u8]) -> Option> { - SubjectPublicKeyInfoRef::from_der(der).ok() + SubjectPublicKeyInfoRef::from_der(der).is_ok_and(|spki| spki.algorithm.oid == CANISTER_SIG_OID) } /// Returns the DER-encoded public key for a stored web-auth session key. @@ -2893,4 +2881,62 @@ mod tests { Err(LoadIdentityError::ValidateDelegationChain { .. }) )); } + + /// Stands in for the session key a chain was issued to. Verification only asks the session + /// identity for its principal, so a real chain can be checked without committing its key. + struct SessionStub(Principal); + + impl Identity for SessionStub { + fn sender(&self) -> Result { + Ok(self.0) + } + + fn public_key(&self) -> Option> { + None + } + + fn sign( + &self, + _: &ic_agent::agent::EnvelopeContent, + ) -> Result { + unreachable!("verification never signs") + } + } + + /// A chain a local Internet Identity actually issued, with the root key of the replica that + /// issued it. + /// + /// Every other canister-signature fixture here is encoded by the same types the production + /// code decodes with, so it is self-consistent by construction: a change that shifts encoding + /// and decoding together — a new `ic-certification` hash-tree layout, say — would keep those + /// tests green while rejecting every real signature. Only captured bytes catch that. + #[test] + fn a_real_local_ii_chain_verifies_against_the_network_that_issued_it() { + let chain: delegation::DelegationChain = + serde_json::from_str(include_str!("testdata/local_ii_chain.json")) + .expect("fixture parses"); + let local_root_key = hex::decode(include_str!("testdata/local_ii_root_key.hex").trim()) + .expect("fixture root key is hex"); + + let (_, delegations) = delegation::to_agent_types(&chain).expect("chain converts"); + let session_key = &delegations.last().expect("a link").delegation.pubkey; + let session: Arc = + Arc::new(SessionStub(Principal::self_authenticating(session_key))); + + load(&chain, Arc::clone(&session), Some(&local_root_key)) + .expect("verifies against the root key of the network that issued it"); + + // With no network resolved, the canister signature cannot be trusted, but everything else + // about the chain still checks out. + load(&chain, Arc::clone(&session), None) + .expect("accepted unverified when no root key is available"); + + assert!( + matches!( + load(&chain, session, Some(IC_ROOT_KEY)), + Err(LoadIdentityError::ValidateDelegationChainNetwork { .. }) + ), + "mainnet's root key must reject a chain issued by a local replica" + ); + } } diff --git a/crates/icp/src/identity/testdata/local_ii_chain.json b/crates/icp/src/identity/testdata/local_ii_chain.json new file mode 100644 index 000000000..e5f0fde11 --- /dev/null +++ b/crates/icp/src/identity/testdata/local_ii_chain.json @@ -0,0 +1,21 @@ +{ + "publicKey": "303c300c060a2b0601040183b8430102032c000a00000000000000070101196a673c348fed89c62eef0e46893d2462c86b685b8f9ec213209f0904594c55", + "delegations": [ + { + "signature": "d9d9f7a26b6365727469666963617465590578d9d9f7a3647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e69737465728301830183024a00000000000000070101830183018301820458207b10c17390dcc1aa4efb74c6d120b67242cc1a0c89aa20450a2d2f446b440be383024e6365727469666965645f6461746182035820ff761bef72ae5c35b5ccf0d7bcb99dd55778215bf81e92a39f838ddd4cb7531382045820ffafd412f140d2872e5123a348206dbdce7a07d1e536fc5b3e267521853c52d982045820e4ac56ac1738fbe4a338179377f081b929a4caad786234833c9b2b20793c84f18204582018caabe82ad0a554cd4f36114c887512b60db54369cc7c85ee417165d250317c82045820f596d3e0b760f009e6eb784c2eaa918da9cad8f7d748f71c191312a92e3bb747820458200b8c3de9a4ccc27639b6cb4948ee57502ddcce934d441ddf891a46676bee77e08301820458206f84e1aec901098f09ef784a17aa1a8b41c2aa1e89d259a3080e4cfe64cd4d1c8301820458204aeba295a12367a3e09b49fc5c2b2706ba094011d64322faf97e3ee555cc21d483024474696d65820349a18eb799f69df9e918697369676e61747572655830a57949a6b8dcbbd9bbadd4bf5718af4d0be825169564bac72617d755684104184ac0dbd2da5ed23b2a9688707af79bb86a64656c65676174696f6ea2697375626e65745f6964581d43dcaf1180db82fda708ce3ac7a03a6060abde13e9546c60e8cce65d026b6365727469666963617465590337d9d9f7a26474726565830183018204582067f0bf455ff4b96600f3ab09fa36010e6d73f8d20ae3175763791fa9b3a3e84d830183024f63616e69737465725f72616e6765738301830183018302581d43dcaf1180db82fda708ce3ac7a03a6060abde13e9546c60e8cce65d0283024a0000000000000007010182035849d9d9f783824a000000000000000701014a00000000000000070101824a000000000210000001014a00000000021fffff0101824a7fffffffffd0000001014a7fffffffffdfffff0101820458208e4ca4260660a6035e32e8ab70ce1ee2378f48860dafe47c65fc8eefcbee237d82045820200d80481ce539f45723abfc55fe73939e05614ee41f54906b630fae5b9b074182045820404067c5ed82a158fc42b0d4435749b82ee3e69241aee899e50c0a7c99713678820458200f7bbd3c3776134882f8f2006bc3bc26aa6e9c8f0b42a3922eae9c236a38af3f830182045820d80c2bde10508acc00d01d04e7e1e9b086b673786bb1edc3c7cf1b12391bb64a83018302467375626e65748301830183018302581d43dcaf1180db82fda708ce3ac7a03a6060abde13e9546c60e8cce65d028301830182045820865a2de8ea6832cc7afa4714d726411924f8bb330b235fe2dad801ae9b0cf57783024a7075626c69635f6b657982035885308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c0503020103610087465072547b12a7cbd7183bd15a49f32589c4b396a9cd9ebbd70e0429a157e83c407ab13e0d31f0134e7551822605e210c2fd2c1e7968333d472c3449fe90d3085ab250ea1c4bd65bc89083596e2e6c2f0a96273b14f496343760d447abbb1e8302447479706582034673797374656d82045820e9a832ede733d0e24277ba40bb120e4626eeac9bededb85f88793917e044c73282045820f4b269cd82a09f90a9f5fda1082dd3dc7c7eb0bf3599fe00aaad43c013fd327582045820c0fc1120a7f5bf3c72088df3a83c666c82c5d99245a1804873d3b2360be2411583024474696d65820349a18eb799f69df9e918697369676e6174757265583088ff78cd54e9d596c2114fcd6d02dfda4959725781b903c27e2fb5c1cf9ddda1d6ad3ff8ed3429814879dbb04ce908da6474726565830182045820798181092293c1438a1b3e3fb43f82be0faa24464823a75f2894e84932d4f95683024373696783018204582060d4307395bf37c6b4dff956ed123f92448a2999db5b20f4514b3871dfd1015c8302582089179da630bd8cbaf5e680c260388f377ff759f1de5a355c6efcc747df891bbc83025820f3251509962e957aa0a6ed0d04635a615b389d9cea9e3743397a3ae8c33081a0820340", + "delegation": { + "pubkey": "3059301306072a8648ce3d020106082a8648ce3d030107034200048ef1ffe015adcf83a86c92d76eb88be16f94d7979227fda877e2427933a9634be0a35d2c4b1524f3129ee91ba239f93dd94ffd05ff1ef6c3f7bfb2067b1867f7", + "expiration": "18d3ff20e8f2c721", + "targets": null + } + }, + { + "signature": "aa2cc23f41027bca2c2fc81333c9dd0f59912b562b56ac439fc130ae3087219f11c8ac9d80f76dfe6d32070ddfc741af72459a50d33046a810e3def232d03604", + "delegation": { + "pubkey": "302a300506032b65700321004e5f483adc93aeb772b32e5bb3ecb76c1358fff8bb4a9446190fd0ad6dddbb53", + "expiration": "18d3ff20e8eb7400", + "targets": null + } + } + ] +} \ No newline at end of file diff --git a/crates/icp/src/identity/testdata/local_ii_root_key.hex b/crates/icp/src/identity/testdata/local_ii_root_key.hex new file mode 100644 index 000000000..670874f41 --- /dev/null +++ b/crates/icp/src/identity/testdata/local_ii_root_key.hex @@ -0,0 +1 @@ +308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100a587ac27884f235a91ddd86927eec11e09872417231b41ab8fb95605c633af9702849c0ad6929613392b39c715fc232c0a0d8eb00a1ea91985b3e440bc9d0b78a5872f96dae753f395cd6602516ad58748d698958976c6c25b86c55aa30a3af9 From 6bf58ac958703b3781fb88dcf592614af9c1da28 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 10 Sep 2026 09:40:25 +0200 Subject: [PATCH 3/3] refactor: drop a comment that restates the function's doc --- crates/icp/src/identity/key.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index c619fdf08..626b7a8fe 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -719,10 +719,7 @@ fn build_delegated_identity( ) { Ok(delegated) => Ok(Arc::new(delegated)), - // Nothing here resolved a network, so a canister signature from another provider — a local - // Internet Identity, say — cannot be checked at all: that provider's root key is not - // derivable from anything the identity stores. Rather than make callers such as - // `icp identity principal` unusable, verify what needs no root key and accept the rest. + // No root key to check the signature against, so verify what needs none and accept. Err(DelegationError::InvalidCanisterSignature(_)) if network_root_key.is_none() => { verify_past_canister_signatures(&from_key, &signed_delegations, &inner) .context(ValidateDelegationChainSnafu { path: chain_path })?;