From 8ba4a6dae20d0406963194ebd36e0500abe6444d Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 23 Jun 2026 08:33:13 -0700 Subject: [PATCH 1/7] Implement delegation imports --- .../icp-cli/src/commands/identity/import.rs | 45 ++++- crates/icp-cli/src/commands/identity/new.rs | 1 + .../icp-cli/tests/assets/session_p256.pub.pem | 4 + crates/icp-cli/tests/identity_tests.rs | 83 +++++++++ crates/icp/src/identity/key.rs | 167 ++++++++++++++---- crates/icp/src/identity/mod.rs | 1 + docs/reference/cli.md | 3 + 7 files changed, 266 insertions(+), 38 deletions(-) create mode 100644 crates/icp-cli/tests/assets/session_p256.pub.pem diff --git a/crates/icp-cli/src/commands/identity/import.rs b/crates/icp-cli/src/commands/identity/import.rs index 628863fb1..9c44b4fff 100644 --- a/crates/icp-cli/src/commands/identity/import.rs +++ b/crates/icp-cli/src/commands/identity/import.rs @@ -3,11 +3,15 @@ use clap::{ArgGroup, Args}; use dialoguer::Password; use elliptic_curve::zeroize::Zeroizing; use icp::identity::{ + delegation::DelegationChain, key::{CreateFormat, CreateIdentityError, IdentityKey, create_identity}, manifest::IdentityKeyAlgorithm, seed::derive_key_from_seed_slip10, }; -use icp::{fs::read_to_string, prelude::*}; +use icp::{ + fs::{json, read_to_string}, + prelude::*, +}; use itertools::Itertools; use k256::Secp256k1; use p256::NistP256; @@ -65,6 +69,12 @@ pub(crate) struct ImportArgs { /// Curve for SLIP-0010 key derivation from a seed phrase #[arg(long, value_enum, default_value_t = IdentityKeyAlgorithm::Secp256k1, requires = "seed")] seed_curve: IdentityKeyAlgorithm, + + /// Attach a signed delegation chain (JSON with keys `publicKey` and `delegations`) + /// + /// For security, in most cases it is better to use `icp identity delegation` instead. + #[arg(long, value_name = "FILE")] + delegation: Option, } pub(crate) async fn exec(ctx: &Context, args: &ImportArgs) -> Result<(), anyhow::Error> { @@ -89,6 +99,12 @@ pub(crate) async fn exec(ctx: &Context, args: &ImportArgs) -> Result<(), anyhow: } } }; + let chain = args + .delegation + .as_deref() + .map(json::load::) + .transpose()?; + if let Some(from_pem) = &args.from_pem { import_from_pem( ctx, @@ -97,18 +113,35 @@ pub(crate) async fn exec(ctx: &Context, args: &ImportArgs) -> Result<(), anyhow: args.decryption_password_from_file.as_deref(), args.assert_key_type.clone(), format, + chain.as_ref(), ) .await?; } else if let Some(path) = &args.from_seed_file { let phrase = read_to_string(path).context(ReadSeedFileSnafu)?; - import_from_seed_phrase(ctx, &args.name, &phrase, args.seed_curve.clone(), format).await?; + import_from_seed_phrase( + ctx, + &args.name, + &phrase, + args.seed_curve.clone(), + format, + chain.as_ref(), + ) + .await?; } else if args.read_seed_phrase { let phrase = Password::new() .with_prompt("Enter seed phrase") .with_confirmation("Re-enter seed phrase", "Seed phrases do not match") .interact() .context(ReadSeedPhraseFromTerminalSnafu)?; - import_from_seed_phrase(ctx, &args.name, &phrase, args.seed_curve.clone(), format).await?; + import_from_seed_phrase( + ctx, + &args.name, + &phrase, + args.seed_curve.clone(), + format, + chain.as_ref(), + ) + .await?; } else { unreachable!(); } @@ -131,6 +164,7 @@ async fn import_from_pem( decryption_password_file: Option<&Path>, known_key_type: Option, format: CreateFormat, + delegation: Option<&DelegationChain>, ) -> Result<(), LoadKeyError> { // the pem file may be in SEC1 format or PKCS#8 format // - if SEC1, the key algorithm can be embedded, separate, or missing @@ -183,7 +217,7 @@ async fn import_from_pem( ctx.dirs .identity()? - .with_write(async move |dirs| create_identity(dirs, name, key, format)) + .with_write(async move |dirs| create_identity(dirs, name, key, format, delegation)) .await??; Ok(()) @@ -380,12 +414,13 @@ async fn import_from_seed_phrase( phrase: &str, algorithm: IdentityKeyAlgorithm, format: CreateFormat, + delegation: Option<&DelegationChain>, ) -> Result<(), DeriveKeyError> { let mnemonic = Mnemonic::from_phrase(phrase, Language::English).context(ParseMnemonicSnafu)?; let key = derive_key_from_seed_slip10(&mnemonic, &algorithm); ctx.dirs .identity()? - .with_write(async move |dirs| create_identity(dirs, name, key, format)) + .with_write(async move |dirs| create_identity(dirs, name, key, format, delegation)) .await??; Ok(()) } diff --git a/crates/icp-cli/src/commands/identity/new.rs b/crates/icp-cli/src/commands/identity/new.rs index 855489a83..edd878d9d 100644 --- a/crates/icp-cli/src/commands/identity/new.rs +++ b/crates/icp-cli/src/commands/identity/new.rs @@ -97,6 +97,7 @@ pub(crate) async fn exec(ctx: &Context, args: &NewArgs) -> Result<(), anyhow::Er &args.name, derive_key_from_seed_slip10(&mnemonic, &IdentityKeyAlgorithm::Secp256k1), format, + None, ) }) .await??; diff --git a/crates/icp-cli/tests/assets/session_p256.pub.pem b/crates/icp-cli/tests/assets/session_p256.pub.pem new file mode 100644 index 000000000..9691f6275 --- /dev/null +++ b/crates/icp-cli/tests/assets/session_p256.pub.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE8ml7yIPIr2DlV38RXoats0N55Rxs +yzkLwFOQHqVgQ0lFXdi/XxNgWgrJX3zL8m3BTNS2SwaqIUtllkRLrgcqnQ== +-----END PUBLIC KEY----- diff --git a/crates/icp-cli/tests/identity_tests.rs b/crates/icp-cli/tests/identity_tests.rs index 3f27abe8b..0cc3c9938 100644 --- a/crates/icp-cli/tests/identity_tests.rs +++ b/crates/icp-cli/tests/identity_tests.rs @@ -1249,6 +1249,89 @@ fn identity_link_hsm_rename() { assert_eq!(principal_before_str, principal_after_str); } +#[test] +fn identity_import_delegation() { + let ctx = TestContext::new(); + + // Import the root identity that signs the delegation. + ctx.icp() + .args(["identity", "import", "root-identity", "--from-pem"]) + .arg(ctx.make_asset("decrypted_sec1_k256.pem")) + .assert() + .success(); + + // Sign a delegation from root-identity to the session key whose private half lives in + // `decrypted_sec1_p256.pem` (its SPKI public key is `session_p256.pub.pem`). + let sign_output = ctx + .icp() + .args([ + "identity", + "delegation", + "sign", + "--identity", + "root-identity", + "--key-pem", + ]) + .arg(ctx.make_asset("session_p256.pub.pem")) + .args(["--duration", "1d"]) + .assert() + .success(); + let chain_json_file = ctx.home_path().join("delegation-chain.json"); + std::fs::write(&chain_json_file, &sign_output.get_output().stdout).unwrap(); + + // Import the session key and attach the chain in a single step. + ctx.icp() + .args([ + "identity", + "import", + "delegated-identity", + "--storage", + "plaintext", + "--from-pem", + ]) + .arg(ctx.make_asset("decrypted_sec1_p256.pem")) + .arg("--delegation") + .arg(&chain_json_file) + .assert() + .success(); + + // The delegated identity presents the root's principal (chains are rooted at the signer), + // and loads successfully (validating the attached chain and session key). + let root_principal = str::from_utf8( + &ctx.icp() + .args(["identity", "principal", "--identity", "root-identity"]) + .assert() + .success() + .get_output() + .stdout, + ) + .unwrap() + .trim() + .to_string(); + ctx.icp() + .args(["identity", "principal", "--identity", "delegated-identity"]) + .assert() + .success() + .stdout(eq(root_principal).trim()); + + // Importing a key that is not the chain's session key must be rejected. + ctx.icp() + .args([ + "identity", + "import", + "wrong-key", + "--storage", + "plaintext", + "--from-pem", + ]) + .arg(ctx.make_asset("decrypted_sec1_k256.pem")) + .arg("--delegation") + .arg(&chain_json_file) + .assert() + .failure() + .stderr(contains("does not match the session key")); +} + #[cfg(unix)] // moc #[tokio::test] async fn identity_delegation_whoami() { diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index bf176b570..d3dbd7145 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -892,41 +892,109 @@ pub enum CreateIdentityError { #[snafu(display("identity `{name}` already exists"))] IdentityAlreadyExists { name: String }, + + #[snafu(display("delegation chain contains no delegations"))] + CreateIdentityEmptyDelegation, + + #[snafu(display("invalid session public key in delegation chain"))] + CreateIdentityDecodeLeafKey { source: hex::FromHexError }, + + #[snafu(display("invalid root public key in delegation chain"))] + CreateIdentityDecodeRootKey { source: hex::FromHexError }, + + #[snafu(display( + "the imported key does not match the session key the delegation chain was issued to" + ))] + CreateIdentityKeyMismatch, + + #[snafu(display("failed to create delegation directory"))] + CreateIdentityDelegationDir { source: crate::fs::IoError }, + + #[snafu(display("failed to save delegation chain to `{path}`"))] + CreateIdentitySaveDelegation { + path: PathBuf, + source: delegation::SaveError, + }, } +/// Creates a new identity from `key`, stored according to `format`. +/// +/// If `delegation` is supplied, the identity is registered as a delegation-based identity +/// (as if created via `icp identity delegation use`): `key` is stored as the chain's +/// session key, the chain is saved to disk, and the identity's principal is derived from +/// the chain's root key. `key` must be the session key the chain's leaf delegation was +/// issued to, which is verified before anything is written. pub fn create_identity( dirs: LWrite<&IdentityPaths>, name: &str, key: IdentityKey, format: CreateFormat, + delegation: Option<&delegation::DelegationChain>, ) -> Result<(), CreateIdentityError> { let mut identity_list = IdentityList::load_from(dirs.read())?; ensure!( !identity_list.identities.contains_key(name), IdentityAlreadyExistsSnafu { name } ); - let principal = match &key { - IdentityKey::Secp256k1(secret_key) => { - Secp256k1Identity::from_private_key(secret_key.clone()) - .sender() - .expect("infallible method") - } - IdentityKey::Prime256v1(secret_key) => { - Prime256v1Identity::from_private_key(secret_key.clone()) - .sender() - .expect("infallible method") - } - IdentityKey::Ed25519(secret_key) => { - BasicIdentity::from_raw_key(&secret_key.serialize_raw()) - .sender() - .expect("infallible method") - } - }; - let algorithm = match key { + let algorithm = match &key { IdentityKey::Secp256k1(_) => IdentityKeyAlgorithm::Secp256k1, IdentityKey::Prime256v1(_) => IdentityKeyAlgorithm::Prime256v1, IdentityKey::Ed25519(_) => IdentityKeyAlgorithm::Ed25519, }; + + // For a plain identity the principal is the key's own; for a delegation identity it + // comes from the chain's root key, and the imported key is verified to be the session + // key the chain delegates to (catching the wrong key here, not as a load-time failure). + let principal = if let Some(chain) = delegation { + let session_public_key = match &key { + IdentityKey::Secp256k1(secret_key) => { + Secp256k1Identity::from_private_key(secret_key.clone()) + .public_key() + .expect("secp256k1 always has a public key") + } + IdentityKey::Prime256v1(secret_key) => { + Prime256v1Identity::from_private_key(secret_key.clone()) + .public_key() + .expect("p256 always has a public key") + } + IdentityKey::Ed25519(secret_key) => { + BasicIdentity::from_raw_key(&secret_key.serialize_raw()) + .public_key() + .expect("ed25519 always has a public key") + } + }; + let leaf = chain + .delegations + .last() + .context(CreateIdentityEmptyDelegationSnafu)?; + let leaf_public_key = + hex::decode(&leaf.delegation.pubkey).context(CreateIdentityDecodeLeafKeySnafu)?; + ensure!( + leaf_public_key == session_public_key, + CreateIdentityKeyMismatchSnafu + ); + let from_key = hex::decode(&chain.public_key).context(CreateIdentityDecodeRootKeySnafu)?; + ic_agent::export::Principal::self_authenticating(&from_key) + } else { + match &key { + IdentityKey::Secp256k1(secret_key) => { + Secp256k1Identity::from_private_key(secret_key.clone()) + .sender() + .expect("infallible method") + } + IdentityKey::Prime256v1(secret_key) => { + Prime256v1Identity::from_private_key(secret_key.clone()) + .sender() + .expect("infallible method") + } + IdentityKey::Ed25519(secret_key) => { + BasicIdentity::from_raw_key(&secret_key.serialize_raw()) + .sender() + .expect("infallible method") + } + } + }; + let doc = match key { IdentityKey::Secp256k1(key) => key.to_pkcs8_der().expect("infallible PKI encoding"), IdentityKey::Prime256v1(key) => key.to_pkcs8_der().expect("infallible PKI encoding"), @@ -935,7 +1003,8 @@ pub fn create_identity( .try_into() .expect("infallible PKI encoding"), }; - // store key material + // store key material. Delegation session keys stored in the keyring use the + // `delegation:` prefix so `load_webauth_session_pem` can find them. match &format { CreateFormat::Plaintext => { let pem = doc @@ -951,7 +1020,12 @@ pub fn create_identity( let pem = doc .to_pem(PrivateKeyInfo::PEM_LABEL, Default::default()) .expect("infallible PKI encoding"); - let entry = Entry::new(SERVICE_NAME, name).context(CreateEntrySnafu)?; + let username = if delegation.is_some() { + dlg_keyring_key(name) + } else { + name.to_string() + }; + let entry = Entry::new(SERVICE_NAME, &username).context(CreateEntrySnafu)?; let res = entry.set_password(&pem); #[cfg(target_os = "linux")] if let Err(keyring::Error::NoStorageAccess(err)) = &res @@ -962,21 +1036,48 @@ pub fn create_identity( res.context(SetEntryPasswordSnafu)?; } } - let spec = match format { - CreateFormat::Plaintext => IdentitySpec::Pem { - format: PemFormat::Plaintext, - algorithm, - principal, - }, - CreateFormat::Pbes2 { .. } => IdentitySpec::Pem { - format: PemFormat::Pbes2, + + if let Some(chain) = delegation { + let delegation_path = dirs + .ensure_delegation_chain_path(name) + .context(CreateIdentityDelegationDirSnafu)?; + delegation::save(&delegation_path, chain).context(CreateIdentitySaveDelegationSnafu { + path: &delegation_path, + })?; + } + + let spec = if delegation.is_some() { + let storage = match format { + CreateFormat::Plaintext => DelegationKeyStorage::Pem { + format: PemFormat::Plaintext, + }, + CreateFormat::Pbes2 { .. } => DelegationKeyStorage::Pem { + format: PemFormat::Pbes2, + }, + CreateFormat::Keyring => DelegationKeyStorage::Keyring, + }; + IdentitySpec::Delegation { algorithm, principal, - }, - CreateFormat::Keyring => IdentitySpec::Keyring { - principal, - algorithm, - }, + storage, + } + } else { + match format { + CreateFormat::Plaintext => IdentitySpec::Pem { + format: PemFormat::Plaintext, + algorithm, + principal, + }, + CreateFormat::Pbes2 { .. } => IdentitySpec::Pem { + format: PemFormat::Pbes2, + algorithm, + principal, + }, + CreateFormat::Keyring => IdentitySpec::Keyring { + principal, + algorithm, + }, + } }; identity_list.identities.insert(name.to_string(), spec); identity_list.write_to(dirs)?; diff --git a/crates/icp/src/identity/mod.rs b/crates/icp/src/identity/mod.rs index 90e6f4f0e..5f4cd21ae 100644 --- a/crates/icp/src/identity/mod.rs +++ b/crates/icp/src/identity/mod.rs @@ -319,6 +319,7 @@ mod tests { "test", IdentityKey::Secp256k1(SecretKey::from_bytes(&k.into()).unwrap()), CreateFormat::Plaintext, + None, ) .unwrap(); }) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 3bcec30de..f51f224f9 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1126,6 +1126,9 @@ Import a new identity Possible values: `secp256k1`, `prime256v1`, `ed25519` +* `--delegation ` — Attach a signed delegation chain (JSON with keys `publicKey` and `delegations`) + + For security, in most cases it is better to use `icp identity delegation` instead. From a86ac8e9578ee26af7d3b9e6a4c17fdf05dca4e1 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 23 Jun 2026 09:03:42 -0700 Subject: [PATCH 2/7] bot feedback --- crates/icp-cli/tests/identity_tests.rs | 35 ++++++++++ crates/icp/src/identity/key.rs | 89 +++++++++++++++++--------- 2 files changed, 93 insertions(+), 31 deletions(-) diff --git a/crates/icp-cli/tests/identity_tests.rs b/crates/icp-cli/tests/identity_tests.rs index 0cc3c9938..3f3d3b53e 100644 --- a/crates/icp-cli/tests/identity_tests.rs +++ b/crates/icp-cli/tests/identity_tests.rs @@ -1330,6 +1330,41 @@ fn identity_import_delegation() { .assert() .failure() .stderr(contains("does not match the session key")); + + // A chain whose signature does not verify must be rejected at import, not persisted to + // fail on every later load. Tamper with the (correctly-keyed) chain's signature. + let mut chain: serde_json::Value = + serde_json::from_slice(&sign_output.get_output().stdout).unwrap(); + let sig = chain["delegations"][0]["signature"].as_str().unwrap(); + let tampered_sig: String = sig + .chars() + .enumerate() + .map(|(i, c)| { + if i == 0 { + if c == 'a' { 'b' } else { 'a' } + } else { + c + } + }) + .collect(); + chain["delegations"][0]["signature"] = serde_json::Value::String(tampered_sig); + let tampered_chain_file = ctx.home_path().join("tampered-chain.json"); + std::fs::write(&tampered_chain_file, serde_json::to_vec(&chain).unwrap()).unwrap(); + + ctx.icp() + .args([ + "identity", + "import", + "tampered", + "--storage", + "plaintext", + "--from-pem", + ]) + .arg(ctx.make_asset("decrypted_sec1_p256.pem")) + .arg("--delegation") + .arg(&tampered_chain_file) + .assert() + .failure(); } #[cfg(unix)] // moc diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index d3dbd7145..308fe7fa6 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -899,14 +899,17 @@ pub enum CreateIdentityError { #[snafu(display("invalid session public key in delegation chain"))] CreateIdentityDecodeLeafKey { source: hex::FromHexError }, - #[snafu(display("invalid root public key in delegation chain"))] - CreateIdentityDecodeRootKey { source: hex::FromHexError }, - #[snafu(display( "the imported key does not match the session key the delegation chain was issued to" ))] CreateIdentityKeyMismatch, + #[snafu(display("malformed delegation chain"))] + CreateIdentityConvertChain { source: delegation::ConversionError }, + + #[snafu(display("delegation chain failed validation"))] + CreateIdentityValidateChain { source: DelegationError }, + #[snafu(display("failed to create delegation directory"))] CreateIdentityDelegationDir { source: crate::fs::IoError }, @@ -946,23 +949,11 @@ pub fn create_identity( // comes from the chain's root key, and the imported key is verified to be the session // key the chain delegates to (catching the wrong key here, not as a load-time failure). let principal = if let Some(chain) = delegation { - let session_public_key = match &key { - IdentityKey::Secp256k1(secret_key) => { - Secp256k1Identity::from_private_key(secret_key.clone()) - .public_key() - .expect("secp256k1 always has a public key") - } - IdentityKey::Prime256v1(secret_key) => { - Prime256v1Identity::from_private_key(secret_key.clone()) - .public_key() - .expect("p256 always has a public key") - } - IdentityKey::Ed25519(secret_key) => { - BasicIdentity::from_raw_key(&secret_key.serialize_raw()) - .public_key() - .expect("ed25519 always has a public key") - } - }; + // The imported key must be the session key the chain's leaf delegation was issued to. + // Check that explicitly first, for a clearer error than the full validation below gives. + let session_public_key = session_identity_for_validation(&key) + .public_key() + .expect("non-anonymous identity always has a public key"); let leaf = chain .delegations .last() @@ -973,7 +964,27 @@ pub fn create_identity( leaf_public_key == session_public_key, CreateIdentityKeyMismatchSnafu ); - let from_key = hex::decode(&chain.public_key).context(CreateIdentityDecodeRootKeySnafu)?; + + // Validate the whole chain in memory before persisting anything, so a structurally + // broken chain fails here rather than on every later load. A canister-signature + // mismatch only means the chain targets a non-mainnet network, so warn and continue + // (mirrors `link_webauth_identity`). + let (from_key, delegations) = + delegation::to_agent_types(chain).context(CreateIdentityConvertChainSnafu)?; + match DelegatedIdentity::new( + from_key.clone(), + session_identity_for_validation(&key), + 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" + ); + } + Err(e) => return Err(e).context(CreateIdentityValidateChainSnafu), + } ic_agent::export::Principal::self_authenticating(&from_key) } else { match &key { @@ -1037,14 +1048,10 @@ pub fn create_identity( } } - if let Some(chain) = delegation { - let delegation_path = dirs - .ensure_delegation_chain_path(name) - .context(CreateIdentityDelegationDirSnafu)?; - delegation::save(&delegation_path, chain).context(CreateIdentitySaveDelegationSnafu { - path: &delegation_path, - })?; - } + // Whether a session key was just stored in the keyring that must be rolled back if the + // writes below fail: the manifest would never be written, so `identity remove` could not + // later find the orphaned `delegation:` credential. + let rollback_keyring = delegation.is_some() && matches!(format, CreateFormat::Keyring); let spec = if delegation.is_some() { let storage = match format { @@ -1079,8 +1086,28 @@ pub fn create_identity( }, } }; - identity_list.identities.insert(name.to_string(), spec); - identity_list.write_to(dirs)?; + + let persist = || -> Result<(), CreateIdentityError> { + if let Some(chain) = delegation { + let delegation_path = dirs + .ensure_delegation_chain_path(name) + .context(CreateIdentityDelegationDirSnafu)?; + delegation::save(&delegation_path, chain).context( + CreateIdentitySaveDelegationSnafu { + path: &delegation_path, + }, + )?; + } + identity_list.identities.insert(name.to_string(), spec); + identity_list.write_to(dirs)?; + Ok(()) + }; + if let Err(e) = persist() { + if rollback_keyring && let Ok(entry) = Entry::new(SERVICE_NAME, &dlg_keyring_key(name)) { + let _ = entry.delete_credential(); + } + return Err(e); + } Ok(()) } From 36eeac31ae377275f5882a842420060cc4b3df73 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 23 Jun 2026 09:18:52 -0700 Subject: [PATCH 3/7] docs --- CHANGELOG.md | 2 ++ crates/icp-cli/src/commands/identity/import.rs | 2 +- docs/reference/cli.md | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e4d3cd4..f81fd663c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ bump. Currently experimental: sync plugins. # Unreleased +* feat: `icp identity import` can now be used with a `--delegation` flag to import a delegated identity. This is most useful for containers or other internal-only delegations; for anything involving a network, `icp identity delegation request` remains the recommended way to work with delegtions. + # v1.0.0 * feat: The default gateway domain is now `icp.net`, not `icp0.io`. diff --git a/crates/icp-cli/src/commands/identity/import.rs b/crates/icp-cli/src/commands/identity/import.rs index 9c44b4fff..458ab5d2e 100644 --- a/crates/icp-cli/src/commands/identity/import.rs +++ b/crates/icp-cli/src/commands/identity/import.rs @@ -70,7 +70,7 @@ pub(crate) struct ImportArgs { #[arg(long, value_enum, default_value_t = IdentityKeyAlgorithm::Secp256k1, requires = "seed")] seed_curve: IdentityKeyAlgorithm, - /// Attach a signed delegation chain (JSON with keys `publicKey` and `delegations`) + /// Attach a signed delegation chain JSON (same format as `icp identity delegation sign`). /// /// For security, in most cases it is better to use `icp identity delegation` instead. #[arg(long, value_name = "FILE")] diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f51f224f9..9a40a459b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1126,7 +1126,7 @@ Import a new identity Possible values: `secp256k1`, `prime256v1`, `ed25519` -* `--delegation ` — Attach a signed delegation chain (JSON with keys `publicKey` and `delegations`) +* `--delegation ` — Attach a signed delegation chain JSON (same format as `icp identity delegation sign`). For security, in most cases it is better to use `icp identity delegation` instead. From 5e2108158a61f31d2deac6caa173cff65982a9cc Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Tue, 23 Jun 2026 13:50:08 -0400 Subject: [PATCH 4/7] docs(changelog): fix typo "delegtions" -> "delegations" Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f81fd663c..a2d69020c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ bump. Currently experimental: sync plugins. # Unreleased -* feat: `icp identity import` can now be used with a `--delegation` flag to import a delegated identity. This is most useful for containers or other internal-only delegations; for anything involving a network, `icp identity delegation request` remains the recommended way to work with delegtions. +* feat: `icp identity import` can now be used with a `--delegation` flag to import a delegated identity. This is most useful for containers or other internal-only delegations; for anything involving a network, `icp identity delegation request` remains the recommended way to work with delegations. # v1.0.0 From daeffcdf247ac0d9626b81dae15eea540a9300ee Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Tue, 23 Jun 2026 13:51:38 -0400 Subject: [PATCH 5/7] refactor(identity): build delegation session identity once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create_identity` constructed the temporary validation identity twice in the delegation branch — once to read the session public key for the key-mismatch check, and again to hand to `DelegatedIdentity::new`. Build it once, read its public key, then move it into the validation call. Each construction clones the secret key and boxes a new identity, so this drops a redundant key clone on every delegation import. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/icp/src/identity/key.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index 308fe7fa6..81cd248f1 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -951,7 +951,8 @@ pub fn create_identity( let principal = if let Some(chain) = delegation { // The imported key must be the session key the chain's leaf delegation was issued to. // Check that explicitly first, for a clearer error than the full validation below gives. - let session_public_key = session_identity_for_validation(&key) + let session = session_identity_for_validation(&key); + let session_public_key = session .public_key() .expect("non-anonymous identity always has a public key"); let leaf = chain @@ -971,11 +972,7 @@ pub fn create_identity( // (mirrors `link_webauth_identity`). let (from_key, delegations) = delegation::to_agent_types(chain).context(CreateIdentityConvertChainSnafu)?; - match DelegatedIdentity::new( - from_key.clone(), - session_identity_for_validation(&key), - delegations, - ) { + match DelegatedIdentity::new(from_key.clone(), session, delegations) { Ok(_) => {} Err(DelegationError::InvalidCanisterSignature(_)) => { warn!( From 4d78f7da0ad6813be37bad674abb5e16b3c63a8b Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Tue, 23 Jun 2026 13:52:17 -0400 Subject: [PATCH 6/7] fix(identity): reject expired delegation chains at import `create_identity` validated a delegation chain's structure and signatures before persisting it, but never checked expiry. An already-expired (or about-to-expire) chain therefore imported "successfully" and then failed on every later load with `DelegationExpired`, since the load path (`load_webauth_identity`) checks `is_expiring_soon`. Reject such chains at import using the same 2-minute grace as the load path, so anything that would fail to load fails up front with a clear error. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/icp/src/identity/key.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index 81cd248f1..128882d90 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -910,6 +910,11 @@ pub enum CreateIdentityError { #[snafu(display("delegation chain failed validation"))] CreateIdentityValidateChain { source: DelegationError }, + #[snafu(display( + "delegation chain has already expired (or is about to); import a freshly signed chain" + ))] + CreateIdentityDelegationExpired, + #[snafu(display("failed to create delegation directory"))] CreateIdentityDelegationDir { source: crate::fs::IoError }, @@ -982,6 +987,16 @@ pub fn create_identity( } Err(e) => return Err(e).context(CreateIdentityValidateChainSnafu), } + + // 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 + // `DelegationExpired`. Mirrors the expiry check in `load_webauth_identity`. + if delegation::is_expiring_soon(chain, TWO_MINUTES_NANOS) + .context(CreateIdentityConvertChainSnafu)? + { + return CreateIdentityDelegationExpiredSnafu.fail(); + } + ic_agent::export::Principal::self_authenticating(&from_key) } else { match &key { From 897b0ab259506a6e49e2c49f4c29039e829a084a Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Tue, 23 Jun 2026 13:56:57 -0400 Subject: [PATCH 7/7] refactor(identity): share delegation-chain validation helper `create_identity` (delegation import) and `link_webauth_identity` contained a near-verbatim copy of the same chain-validation logic (`to_agent_types` + `DelegatedIdentity::new` + warn-and-continue on a mainnet-root-key mismatch), plus parallel error variants (`CreateIdentity{Convert,Validate}Chain` vs `Dlg{Convert,Validate}Chain`). Extract `validate_session_delegation_chain` and a shared `ValidateDelegationChainError`, and route both callers through it via a transparent error variant. A future fix to chain validation now lives in one place. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/icp/src/identity/key.rs | 91 ++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index 128882d90..a51c24ee8 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -904,11 +904,10 @@ pub enum CreateIdentityError { ))] CreateIdentityKeyMismatch, - #[snafu(display("malformed delegation chain"))] - CreateIdentityConvertChain { source: delegation::ConversionError }, - - #[snafu(display("delegation chain failed validation"))] - CreateIdentityValidateChain { source: DelegationError }, + #[snafu(transparent)] + CreateIdentityValidateDelegationChain { + source: ValidateDelegationChainError, + }, #[snafu(display( "delegation chain has already expired (or is about to); import a freshly signed chain" @@ -972,28 +971,13 @@ 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. A canister-signature - // mismatch only means the chain targets a non-mainnet network, so warn and continue - // (mirrors `link_webauth_identity`). - let (from_key, delegations) = - delegation::to_agent_types(chain).context(CreateIdentityConvertChainSnafu)?; - 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" - ); - } - Err(e) => return Err(e).context(CreateIdentityValidateChainSnafu), - } + // broken chain fails here rather than on every later load. + 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 // `DelegationExpired`. Mirrors the expiry check in `load_webauth_identity`. - if delegation::is_expiring_soon(chain, TWO_MINUTES_NANOS) - .context(CreateIdentityConvertChainSnafu)? - { + if delegation::is_expiring_soon(chain, TWO_MINUTES_NANOS).context(ConvertChainSnafu)? { return CreateIdentityDelegationExpiredSnafu.fail(); } @@ -1666,11 +1650,10 @@ pub enum CreatePendingDelegationError { source: delegation::SaveError, }, - #[snafu(display("failed to decode delegation chain fields"))] - DlgConvertChain { source: delegation::ConversionError }, - - #[snafu(display("delegation chain is structurally invalid"))] - DlgValidateChain { source: DelegationError }, + #[snafu(transparent)] + DlgValidateDelegationChain { + source: ValidateDelegationChainError, + }, } /// Constructs a temporary signing identity directly from an [`IdentityKey`], used to validate a @@ -1683,6 +1666,41 @@ fn session_identity_for_validation(key: &IdentityKey) -> Box { } } +#[derive(Debug, Snafu)] +pub enum ValidateDelegationChainError { + #[snafu(display("malformed delegation chain"))] + ConvertChain { source: delegation::ConversionError }, + + #[snafu(display("delegation chain failed validation"))] + ValidateChain { source: DelegationError }, +} + +/// 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. +fn validate_session_delegation_chain( + name: &str, + session: Box, + 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" + ); + } + Err(e) => return Err(e).context(ValidateChainSnafu), + } + Ok(from_key) +} + /// Links a web-auth identity to a new named identity. /// /// Stores the session keypair according to `storage` and the delegation chain @@ -1710,21 +1728,8 @@ pub fn link_webauth_identity( }; // Validate the delegation chain against the mainnet root key before storing it. - // An InvalidCanisterSignature error means the chain was likely issued by a canister on a - // different network; warn and continue. Any other error means the chain is structurally broken. - let (from_key, delegations) = - delegation::to_agent_types(chain).context(DlgConvertChainSnafu)?; - let inner = session_identity_for_validation(&key); - match DelegatedIdentity::new(from_key, inner, 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" - ); - } - Err(e) => return Err(CreatePendingDelegationError::DlgValidateChain { source: e }), - } + let session = session_identity_for_validation(&key); + validate_session_delegation_chain(name, session, chain)?; let doc = match key { IdentityKey::Secp256k1(key) => key.to_pkcs8_der().expect("infallible PKI encoding"),