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..626b7a8fe 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,39 +682,206 @@ 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 }) + // 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 })?; + + 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. +/// +/// 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, 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], + 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 = SubjectPublicKeyInfoRef::from_der(der).ok()?; + 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"); + + 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. /// /// Used during re-authentication to obtain the session public key without @@ -973,7 +1141,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 +1823,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 +1855,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 +1921,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 +2367,573 @@ 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 { .. }) + )); + } + + /// 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/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" + ); } } 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