diff --git a/crates/icp-cli/src/commands/identity/delegation/mod.rs b/crates/icp-cli/src/commands/identity/delegation/mod.rs new file mode 100644 index 00000000..fab304bc --- /dev/null +++ b/crates/icp-cli/src/commands/identity/delegation/mod.rs @@ -0,0 +1,13 @@ +use clap::Subcommand; + +pub(crate) mod request; +pub(crate) mod sign; +pub(crate) mod r#use; + +/// Manage delegations for identities +#[derive(Debug, Subcommand)] +pub(crate) enum Command { + Request(request::RequestArgs), + Sign(sign::SignArgs), + Use(r#use::UseArgs), +} diff --git a/crates/icp-cli/src/commands/identity/delegation/request.rs b/crates/icp-cli/src/commands/identity/delegation/request.rs new file mode 100644 index 00000000..2b9c7862 --- /dev/null +++ b/crates/icp-cli/src/commands/identity/delegation/request.rs @@ -0,0 +1,87 @@ +use clap::Args; +use dialoguer::Password; +use elliptic_curve::zeroize::Zeroizing; +use icp::{context::Context, fs::read_to_string, identity::key, prelude::*}; +use pem::Pem; +use snafu::{ResultExt, Snafu}; +use tracing::warn; + +use crate::commands::identity::StorageMode; + +/// Create a pending delegation identity with a new P256 session key +/// +/// Prints the session public key as a PEM-encoded SPKI to stdout. Pass this to +/// `icp identity delegation sign --key-pem` on another machine to obtain a +/// delegation chain, then complete the identity with `icp identity delegation use`. +#[derive(Debug, Args)] +pub(crate) struct RequestArgs { + /// Name for the new identity + name: String, + + /// Where to store the session private key + #[arg(long, value_enum, default_value_t)] + storage: StorageMode, + + /// Read the storage password from a file instead of prompting (for --storage password) + #[arg(long, value_name = "FILE")] + storage_password_file: Option, +} + +pub(crate) async fn exec(ctx: &Context, args: &RequestArgs) -> Result<(), RequestError> { + let create_format = match args.storage { + StorageMode::Plaintext => key::CreateFormat::Plaintext, + StorageMode::Keyring => key::CreateFormat::Keyring, + StorageMode::Password => { + let password = if let Some(path) = &args.storage_password_file { + read_to_string(path) + .context(ReadStoragePasswordFileSnafu)? + .trim() + .to_string() + } else { + Password::new() + .with_prompt("Enter password to encrypt identity") + .with_confirmation("Confirm password", "Passwords do not match") + .interact() + .context(StoragePasswordTermReadSnafu)? + }; + key::CreateFormat::Pbes2 { + password: Zeroizing::new(password), + } + } + }; + + let der_public_key = ctx + .dirs + .identity()? + .with_write(async |dirs| key::create_pending_delegation(dirs, &args.name, create_format)) + .await? + .context(CreateSnafu)?; + + let pem = pem::encode(&Pem::new("PUBLIC KEY", der_public_key)); + print!("{pem}"); + + if matches!(args.storage, StorageMode::Plaintext) { + warn!( + "This identity is stored in plaintext and is not secure. Do not use it for anything of significant value." + ); + } + + Ok(()) +} + +#[derive(Debug, Snafu)] +pub(crate) enum RequestError { + #[snafu(display("failed to read storage password file"))] + ReadStoragePasswordFile { source: icp::fs::IoError }, + + #[snafu(display("failed to read storage password from terminal"))] + StoragePasswordTermRead { source: dialoguer::Error }, + + #[snafu(transparent)] + LockIdentityDir { source: icp::fs::lock::LockError }, + + #[snafu(display("failed to create pending delegation identity"))] + Create { + source: key::CreatePendingDelegationError, + }, +} diff --git a/crates/icp-cli/src/commands/identity/delegation/sign.rs b/crates/icp-cli/src/commands/identity/delegation/sign.rs new file mode 100644 index 00000000..6c3e8da1 --- /dev/null +++ b/crates/icp-cli/src/commands/identity/delegation/sign.rs @@ -0,0 +1,192 @@ +use std::{ + str::FromStr, + time::{SystemTime, UNIX_EPOCH}, +}; + +use clap::Args; +use ic_agent::{Identity as _, export::Principal, identity::Delegation as AgentDelegation}; +use icp::{ + context::{Context, GetIdentityError}, + fs::read_to_string, + identity::delegation::{ + Delegation as WireDelegation, DelegationChain, SignedDelegation as WireSignedDelegation, + }, + prelude::*, +}; +use pem::Pem; +use snafu::{OptionExt, ResultExt, Snafu}; + +use crate::options::IdentityOpt; + +/// Sign a delegation from the selected identity to a target key +#[derive(Debug, Args)] +pub(crate) struct SignArgs { + /// Public key PEM file of the key to delegate to + #[arg(long, value_name = "FILE")] + key_pem: PathBuf, + + /// Delegation validity duration (e.g. "30d", "24h", "3600s", or plain seconds) + #[arg(long)] + duration: DurationArg, + + /// Canister principals to restrict the delegation to (comma-separated) + #[arg(long, value_delimiter = ',')] + canisters: Option>, + + #[command(flatten)] + identity: IdentityOpt, +} + +pub(crate) async fn exec(ctx: &Context, args: &SignArgs) -> Result<(), SignError> { + let identity = ctx + .get_identity(&args.identity.clone().into()) + .await + .context(GetIdentitySnafu)?; + + let signer_pubkey = identity.public_key().context(AnonymousIdentitySnafu)?; + + let target_pubkey = der_pubkey_from_pem(&args.key_pem)?; + + let now_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos() as u64; + let expiration = now_nanos.saturating_add(args.duration.as_nanos()); + + let delegation = AgentDelegation { + pubkey: target_pubkey.clone(), + expiration, + targets: args.canisters.clone(), + }; + + let sig = identity + .sign_delegation(&delegation) + .map_err(|message| SignError::SignDelegation { message })?; + + let signature_bytes = sig.signature.context(AnonymousIdentitySnafu)?; + + // For a DelegatedIdentity (e.g. Internet Identity), sig.delegations holds the existing + // chain linking the root key to the signing session key. These must be included before + // the new delegation so the verifier can walk the full chain. + let mut wire_delegations: Vec = sig + .delegations + .unwrap_or_default() + .into_iter() + .map(|sd| WireSignedDelegation { + signature: hex::encode(&sd.signature), + delegation: WireDelegation { + pubkey: hex::encode(&sd.delegation.pubkey), + expiration: format!("{:x}", sd.delegation.expiration), + targets: sd + .delegation + .targets + .as_ref() + .map(|ts| ts.iter().map(|p| hex::encode(p.as_slice())).collect()), + }, + }) + .collect(); + + wire_delegations.push(WireSignedDelegation { + signature: hex::encode(&signature_bytes), + delegation: WireDelegation { + pubkey: hex::encode(&target_pubkey), + expiration: format!("{expiration:x}"), + targets: args + .canisters + .as_ref() + .map(|ts| ts.iter().map(|p| hex::encode(p.as_slice())).collect()), + }, + }); + + let chain = DelegationChain { + public_key: hex::encode(&signer_pubkey), + delegations: wire_delegations, + }; + + let json = serde_json::to_string_pretty(&chain).context(SerializeSnafu)?; + println!("{json}"); + + Ok(()) +} + +/// Extract the DER-encoded SubjectPublicKeyInfo bytes from a `PUBLIC KEY` PEM file. +fn der_pubkey_from_pem(path: &Path) -> Result, SignError> { + let pem_str = read_to_string(path).context(ReadKeyPemSnafu)?; + let pem = pem_str.parse::().context(ParseKeyPemSnafu { path })?; + if pem.tag() != "PUBLIC KEY" { + return UnexpectedPemTagSnafu { + path, + found: pem.tag().to_string(), + } + .fail(); + } + Ok(pem.contents().to_vec()) +} + +/// A duration expressed as a plain number of seconds or with a unit suffix. +/// +/// Accepted suffixes: `s` (seconds), `m` (minutes), `h` (hours), `d` (days). +/// A bare integer is interpreted as seconds. +#[derive(Debug, Clone)] +pub(crate) struct DurationArg(u64); + +impl DurationArg { + /// Duration in nanoseconds. + pub(crate) fn as_nanos(&self) -> u64 { + self.0.saturating_mul(1_000_000_000) + } +} + +impl FromStr for DurationArg { + type Err = String; + + fn from_str(s: &str) -> Result { + let (digits, multiplier) = if let Some(d) = s.strip_suffix('d') { + (d, 86400u64) + } else if let Some(h) = s.strip_suffix('h') { + (h, 3600u64) + } else if let Some(m) = s.strip_suffix('m') { + (m, 60u64) + } else if let Some(s2) = s.strip_suffix('s') { + (s2, 1u64) + } else { + (s, 1u64) + }; + + let n: u64 = digits.parse().map_err(|_| { + format!("invalid duration `{s}`: expected a number with optional suffix (s/m/h/d)") + })?; + + Ok(DurationArg(n.saturating_mul(multiplier))) + } +} + +#[derive(Debug, Snafu)] +pub(crate) enum SignError { + #[snafu(display("failed to load identity"))] + GetIdentity { + #[snafu(source(from(GetIdentityError, Box::new)))] + source: Box, + }, + + #[snafu(display("anonymous identity cannot sign delegations"))] + AnonymousIdentity, + + #[snafu(display("failed to read key PEM file"))] + ReadKeyPem { source: icp::fs::IoError }, + + #[snafu(display("corrupted PEM file `{path}`"))] + ParseKeyPem { + path: PathBuf, + source: pem::PemError, + }, + + #[snafu(display("expected a PUBLIC KEY PEM in `{path}`, found `{found}`"))] + UnexpectedPemTag { path: PathBuf, found: String }, + + #[snafu(display("failed to sign delegation: {message}"))] + SignDelegation { message: String }, + + #[snafu(display("failed to serialize delegation chain"))] + Serialize { source: serde_json::Error }, +} diff --git a/crates/icp-cli/src/commands/identity/delegation/use.rs b/crates/icp-cli/src/commands/identity/delegation/use.rs new file mode 100644 index 00000000..99d5fa0e --- /dev/null +++ b/crates/icp-cli/src/commands/identity/delegation/use.rs @@ -0,0 +1,67 @@ +use clap::Args; +use icp::{ + context::Context, + fs::json, + identity::{ + delegation::DelegationChain, + key, + manifest::{DelegationKeyStorage, PemFormat}, + }, + prelude::*, +}; +use snafu::{ResultExt, Snafu}; +use tracing::{info, warn}; + +/// Complete a pending delegation identity by providing a signed delegation chain +/// +/// Reads the JSON output of `icp identity delegation sign` from a file and attaches +/// it to the named identity, making it usable for signing. +#[derive(Debug, Args)] +pub(crate) struct UseArgs { + /// Name of the pending delegation identity to complete + name: String, + + /// Path to the delegation chain JSON file (output of `icp identity delegation sign`) + #[arg(long, value_name = "FILE")] + from_json: PathBuf, +} + +pub(crate) async fn exec(ctx: &Context, args: &UseArgs) -> Result<(), UseError> { + let chain: DelegationChain = json::load(&args.from_json)?; + + let storage = ctx + .dirs + .identity()? + .with_write(async |dirs| key::complete_delegation(dirs, &args.name, &chain)) + .await? + .context(CompleteSnafu)?; + + info!("Identity `{}` delegation complete", args.name); + + if matches!( + storage, + DelegationKeyStorage::Pem { + format: PemFormat::Plaintext + } + ) { + warn!( + "This identity is stored in plaintext and is not secure. Do not use it for anything of significant value." + ); + } + + Ok(()) +} + +#[derive(Debug, Snafu)] +pub(crate) enum UseError { + #[snafu(transparent)] + LoadDelegationChain { source: json::Error }, + + #[snafu(transparent)] + LockIdentityDir { source: icp::fs::lock::LockError }, + + #[snafu(display("failed to complete delegation identity"))] + Complete { + source: key::CompleteDelegationError, + }, +} diff --git a/crates/icp-cli/src/commands/identity/link/ii.rs b/crates/icp-cli/src/commands/identity/link/ii.rs index b78d5961..e9689199 100644 --- a/crates/icp-cli/src/commands/identity/link/ii.rs +++ b/crates/icp-cli/src/commands/identity/link/ii.rs @@ -127,7 +127,9 @@ pub(crate) enum IiError { LockIdentityDir { source: icp::fs::lock::LockError }, #[snafu(display("failed to link II identity"))] - Link { source: key::LinkIiIdentityError }, + Link { + source: key::CreatePendingDelegationError, + }, } /// Fallback host. Dummy value until we get a real domain. A staging instance can be found at ut7yr-7iaaa-aaaag-ak7ca-caia.ic0.app diff --git a/crates/icp-cli/src/commands/identity/list.rs b/crates/icp-cli/src/commands/identity/list.rs index 723b1a1a..a7ecaa11 100644 --- a/crates/icp-cli/src/commands/identity/list.rs +++ b/crates/icp-cli/src/commands/identity/list.rs @@ -65,7 +65,10 @@ pub(crate) async fn exec(ctx: &Context, args: &ListArgs) -> Result<(), anyhow::E .unwrap_or(0); for (name, id) in sorted_identities.iter() { - let principal = id.principal(); + let principal = id + .principal() + .map(|p| p.to_string()) + .unwrap_or_else(|| "(pending delegation)".to_string()); let padded_name = format!("{name: , } diff --git a/crates/icp-cli/src/commands/identity/mod.rs b/crates/icp-cli/src/commands/identity/mod.rs index de089bd4..79534c53 100644 --- a/crates/icp-cli/src/commands/identity/mod.rs +++ b/crates/icp-cli/src/commands/identity/mod.rs @@ -2,6 +2,7 @@ use clap::{Subcommand, ValueEnum}; pub(crate) mod account_id; pub(crate) mod default; +pub(crate) mod delegation; pub(crate) mod delete; pub(crate) mod export; pub(crate) mod import; @@ -17,6 +18,8 @@ pub(crate) mod rename; pub(crate) enum Command { AccountId(account_id::AccountIdArgs), Default(default::DefaultArgs), + #[command(subcommand)] + Delegation(delegation::Command), Delete(delete::DeleteArgs), Export(export::ExportArgs), Import(import::ImportArgs), diff --git a/crates/icp-cli/src/commands/network/start.rs b/crates/icp-cli/src/commands/network/start.rs index 55c8e6d0..109dcea2 100644 --- a/crates/icp-cli/src/commands/network/start.rs +++ b/crates/icp-cli/src/commands/network/start.rs @@ -116,12 +116,16 @@ pub(crate) async fn exec(ctx: &Context, args: &StartArgs) -> Result<(), anyhow:: }) .await??; - let all_identities: Vec = ids.identities.values().map(|id| id.principal()).collect(); + let all_identities: Vec = ids + .identities + .values() + .filter_map(|id| id.principal()) + .collect(); let default_identity = ids .identities .get(&defaults.default) - .map(|id| id.principal()); + .and_then(|id| id.principal()); debug!("Project root: {pdir}"); debug!("Network root: {}", nd.network_root); diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index 573a7ae1..9d580708 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -317,6 +317,18 @@ async fn dispatch(ctx: &icp::context::Context, command: Command) -> Result<(), E commands::identity::default::exec(ctx, &args).await? } + commands::identity::Command::Delegation(cmd) => match cmd { + commands::identity::delegation::Command::Request(args) => { + commands::identity::delegation::request::exec(ctx, &args).await? + } + commands::identity::delegation::Command::Sign(args) => { + commands::identity::delegation::sign::exec(ctx, &args).await? + } + commands::identity::delegation::Command::Use(args) => { + commands::identity::delegation::r#use::exec(ctx, &args).await? + } + }, + commands::identity::Command::Delete(args) => { commands::identity::delete::exec(ctx, &args).await? } diff --git a/crates/icp-cli/tests/assets/whoami_canister/main.mo b/crates/icp-cli/tests/assets/whoami_canister/main.mo new file mode 100644 index 00000000..726b92e0 --- /dev/null +++ b/crates/icp-cli/tests/assets/whoami_canister/main.mo @@ -0,0 +1,5 @@ +persistent actor { + public shared query ({ caller }) func whoami() : async Principal { + return caller; + }; +}; diff --git a/crates/icp-cli/tests/assets/whoami_canister/mops.toml b/crates/icp-cli/tests/assets/whoami_canister/mops.toml new file mode 100644 index 00000000..9f5b72d1 --- /dev/null +++ b/crates/icp-cli/tests/assets/whoami_canister/mops.toml @@ -0,0 +1,2 @@ +[toolchain] +moc = "0.16.3" diff --git a/crates/icp-cli/tests/identity_tests.rs b/crates/icp-cli/tests/identity_tests.rs index 448c53b6..3e1ef070 100644 --- a/crates/icp-cli/tests/identity_tests.rs +++ b/crates/icp-cli/tests/identity_tests.rs @@ -1225,6 +1225,176 @@ fn identity_link_hsm_rename() { assert_eq!(principal_before_str, principal_after_str); } +#[tokio::test] +async fn identity_delegation_whoami() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + + // Import a root identity to sign the delegation + ctx.icp() + .args(["identity", "import", "root-identity", "--from-pem"]) + .arg(ctx.make_asset("decrypted_sec1_k256.pem")) + .assert() + .success(); + + // Create a pending delegation identity, capturing the session public key PEM + let request_output = ctx + .icp() + .args([ + "identity", + "delegation", + "request", + "delegated-identity", + "--storage", + "plaintext", + ]) + .assert() + .success(); + let pem_str = str::from_utf8(&request_output.get_output().stdout).unwrap(); + + // Write the session public key PEM to a temp file for the sign step + let key_pem_file = ctx.home_path().join("session-key.pem"); + std::fs::write(&key_pem_file, pem_str).unwrap(); + + // Sign a delegation from root-identity to the session key + let sign_output = ctx + .icp() + .args([ + "identity", + "delegation", + "sign", + "--identity", + "root-identity", + "--key-pem", + ]) + .arg(&key_pem_file) + .args(["--duration", "1d"]) + .assert() + .success(); + let chain_json = str::from_utf8(&sign_output.get_output().stdout).unwrap(); + + // Write the delegation chain JSON to a temp file for the use step + let chain_json_file = ctx.home_path().join("delegation-chain.json"); + std::fs::write(&chain_json_file, chain_json).unwrap(); + + // Complete the delegation identity with the signed chain + ctx.icp() + .args([ + "identity", + "delegation", + "use", + "delegated-identity", + "--from-json", + ]) + .arg(&chain_json_file) + .assert() + .success(); + + // Both identities should present the same principal: the root's principal + // (delegation chains are rooted at the signing key) + let root_principal = str::from_utf8( + &ctx.icp() + .args(["identity", "principal", "--identity", "root-identity"]) + .assert() + .success() + .get_output() + .stdout, + ) + .unwrap() + .trim() + .to_string(); + let delegated_principal = str::from_utf8( + &ctx.icp() + .args(["identity", "principal", "--identity", "delegated-identity"]) + .assert() + .success() + .get_output() + .stdout, + ) + .unwrap() + .trim() + .to_string(); + assert_eq!(root_principal, delegated_principal); + + // Set up project manifest with whoami canister built via Motoko recipe + ctx.copy_asset_dir("whoami_canister", &project_dir); + let pm = formatdoc! {r#" + canisters: + - name: whoami + recipe: + type: "@dfinity/motoko@v4.0.0" + configuration: + main: main.mo + args: "" + + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}; + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + + let _g = ctx.start_network_in(&project_dir, "random-network").await; + ctx.ping_until_healthy(&project_dir, "random-network"); + + clients::icp(&ctx, &project_dir, Some("random-environment".to_string())) + .mint_cycles(10 * TRILLION); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--subnet", + common::SUBNET_ID, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Call whoami with root-identity + let root_whoami = ctx + .icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + "--identity", + "root-identity", + "whoami", + "whoami", + "()", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + // Call whoami with delegated-identity — the canister sees the root's principal + let delegated_whoami = ctx + .icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + "--identity", + "delegated-identity", + "whoami", + "whoami", + "()", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + assert_eq!(root_whoami, delegated_whoami); +} + #[test] fn identity_link_hsm_delete() { let ctx = TestContext::new(); diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index c9d7efb4..50cf7a1d 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -33,8 +33,8 @@ use crate::{ identity::{ IdentityPaths, delegation, manifest::{ - IdentityDefaults, IdentityKeyAlgorithm, IdentityList, IdentitySpec, IiKeyStorage, - LoadIdentityManifestError, PemFormat, WriteIdentityManifestError, + DelegationKeyStorage, IdentityDefaults, IdentityKeyAlgorithm, IdentityList, + IdentitySpec, LoadIdentityManifestError, PemFormat, WriteIdentityManifestError, }, }, prelude::*, @@ -129,6 +129,12 @@ pub enum LoadIdentityError { #[snafu(display("failed to convert delegation chain"))] DelegationConversion { source: delegation::ConversionError }, + + #[snafu(display( + "identity `{name}` has no delegation yet; \ + run `icp identity delegation use {name}` to complete it" + ))] + DelegationNotYetProvided { name: String }, } pub fn load_identity( @@ -157,6 +163,10 @@ pub fn load_identity( IdentitySpec::InternetIdentity { algorithm, storage, .. } => load_ii_identity(dirs, name, algorithm, storage, password_func), + IdentitySpec::PendingDelegation { .. } => DelegationNotYetProvidedSnafu { name }.fail(), + IdentitySpec::Delegation { + algorithm, storage, .. + } => load_ii_identity(dirs, name, algorithm, storage, password_func), } } @@ -329,7 +339,7 @@ fn load_ii_identity( dirs: LRead<&IdentityPaths>, name: &str, algorithm: &IdentityKeyAlgorithm, - storage: &IiKeyStorage, + storage: &DelegationKeyStorage, password_func: impl FnOnce() -> Result, ) -> Result, LoadIdentityError> { let (doc, origin) = load_ii_session_pem(dirs, name, storage)?; @@ -351,11 +361,11 @@ fn load_ii_identity( delegation::to_agent_types(&stored_chain).context(DelegationConversionSnafu)?; let pem_format = match storage { - IiKeyStorage::Keyring - | IiKeyStorage::Pem { + DelegationKeyStorage::Keyring + | DelegationKeyStorage::Pem { format: PemFormat::Plaintext, } => PemFormat::Plaintext, - IiKeyStorage::Pem { + DelegationKeyStorage::Pem { format: PemFormat::Pbes2, } => PemFormat::Pbes2, }; @@ -420,17 +430,17 @@ pub fn load_ii_session_public_key( dirs: LRead<&IdentityPaths>, name: &str, algorithm: &IdentityKeyAlgorithm, - storage: &IiKeyStorage, + storage: &DelegationKeyStorage, password_func: impl FnOnce() -> Result, ) -> Result, LoadIdentityError> { let (doc, origin) = load_ii_session_pem(dirs, name, storage)?; match storage { - IiKeyStorage::Keyring - | IiKeyStorage::Pem { + DelegationKeyStorage::Keyring + | DelegationKeyStorage::Pem { format: PemFormat::Plaintext, } => load_ii_public_key_plaintext(&doc, algorithm, &origin), - IiKeyStorage::Pem { + DelegationKeyStorage::Pem { format: PemFormat::Pbes2, } => { let pw = password_func() @@ -443,10 +453,10 @@ pub fn load_ii_session_public_key( fn load_ii_session_pem( dirs: LRead<&IdentityPaths>, name: &str, - storage: &IiKeyStorage, + storage: &DelegationKeyStorage, ) -> Result<(Pem, PemOrigin), LoadIdentityError> { match storage { - IiKeyStorage::Keyring => { + DelegationKeyStorage::Keyring => { let username = ii_keyring_key(name); let entry = Entry::new(SERVICE_NAME, &username).context(LoadEntrySnafu)?; let pem_str = entry.get_password().context(LoadPasswordFromEntrySnafu)?; @@ -459,7 +469,7 @@ fn load_ii_session_pem( .context(ParsePemSnafu { origin: &origin })?; Ok((doc, origin)) } - IiKeyStorage::Pem { .. } => { + DelegationKeyStorage::Pem { .. } => { let pem_path = dirs.key_pem_path(name); let origin = PemOrigin::File { path: pem_path.clone(), @@ -803,6 +813,8 @@ pub fn rename_identity( enum OldKeyMaterial { Pem(PathBuf), Keyring(Entry), + DelegationKeyring(Entry), + DelegationPem(PathBuf), IiKeyringAndDelegation(Entry, PathBuf), IiPemAndDelegation(PathBuf, PathBuf), None, @@ -842,7 +854,7 @@ pub fn rename_identity( fs::write(&new_delegation, &delegation_contents).context(CopyKeyFileSnafu)?; match storage { - IiKeyStorage::Keyring => { + DelegationKeyStorage::Keyring => { let old_entry = Entry::new(SERVICE_NAME, &ii_keyring_key(old_name)) .context(LoadKeyringEntrySnafu { name: old_name })?; let password = old_entry @@ -855,7 +867,7 @@ pub fn rename_identity( .context(SetKeyringEntryPasswordSnafu { new_name })?; OldKeyMaterial::IiKeyringAndDelegation(old_entry, old_delegation) } - IiKeyStorage::Pem { .. } => { + DelegationKeyStorage::Pem { .. } => { let old_pem = dirs.key_pem_path(old_name); let new_pem = dirs.key_pem_path(new_name); let contents = fs::read(&old_pem).context(CopyKeyFileSnafu)?; @@ -871,6 +883,59 @@ pub fn rename_identity( IdentitySpec::Anonymous => { unreachable!("anonymous identity should have been rejected above") } + IdentitySpec::PendingDelegation { storage, .. } => match storage { + DelegationKeyStorage::Keyring => { + let old_entry = Entry::new(SERVICE_NAME, &ii_keyring_key(old_name)) + .context(LoadKeyringEntrySnafu { name: old_name })?; + let password = old_entry + .get_password() + .context(ReadKeyringEntrySnafu { name: old_name })?; + let new_entry = Entry::new(SERVICE_NAME, &ii_keyring_key(new_name)) + .context(CreateKeyringEntrySnafu { new_name })?; + new_entry + .set_password(&password) + .context(SetKeyringEntryPasswordSnafu { new_name })?; + OldKeyMaterial::DelegationKeyring(old_entry) + } + DelegationKeyStorage::Pem { .. } => { + let old_pem = dirs.key_pem_path(old_name); + let new_pem = dirs.key_pem_path(new_name); + let contents = fs::read(&old_pem).context(CopyKeyFileSnafu)?; + fs::write(&new_pem, &contents).context(CopyKeyFileSnafu)?; + OldKeyMaterial::DelegationPem(old_pem) + } + }, + IdentitySpec::Delegation { storage, .. } => { + let old_delegation = dirs.delegation_chain_path(old_name); + let new_delegation = dirs + .ensure_delegation_chain_path(new_name) + .context(CopyKeyFileSnafu)?; + let delegation_contents = fs::read(&old_delegation).context(CopyKeyFileSnafu)?; + fs::write(&new_delegation, &delegation_contents).context(CopyKeyFileSnafu)?; + + match storage { + DelegationKeyStorage::Keyring => { + let old_entry = Entry::new(SERVICE_NAME, &ii_keyring_key(old_name)) + .context(LoadKeyringEntrySnafu { name: old_name })?; + let password = old_entry + .get_password() + .context(ReadKeyringEntrySnafu { name: old_name })?; + let new_entry = Entry::new(SERVICE_NAME, &ii_keyring_key(new_name)) + .context(CreateKeyringEntrySnafu { new_name })?; + new_entry + .set_password(&password) + .context(SetKeyringEntryPasswordSnafu { new_name })?; + OldKeyMaterial::IiKeyringAndDelegation(old_entry, old_delegation) + } + DelegationKeyStorage::Pem { .. } => { + let old_pem = dirs.key_pem_path(old_name); + let new_pem = dirs.key_pem_path(new_name); + let contents = fs::read(&old_pem).context(CopyKeyFileSnafu)?; + fs::write(&new_pem, &contents).context(CopyKeyFileSnafu)?; + OldKeyMaterial::IiPemAndDelegation(old_pem, old_delegation) + } + } + } }; // Update the identity list with the new name @@ -894,6 +959,14 @@ pub fn rename_identity( .delete_credential() .context(DeleteKeyringEntrySnafu { old_name })?; } + OldKeyMaterial::DelegationKeyring(old_entry) => { + old_entry + .delete_credential() + .context(DeleteKeyringEntrySnafu { old_name })?; + } + OldKeyMaterial::DelegationPem(old_pem) => { + fs::remove_file(&old_pem).context(DeleteOldKeyFileSnafu)?; + } OldKeyMaterial::IiKeyringAndDelegation(old_entry, old_delegation) => { old_entry .delete_credential() @@ -990,14 +1063,14 @@ pub fn delete_identity( } IdentitySpec::InternetIdentity { storage, .. } => { match storage { - IiKeyStorage::Keyring => { + DelegationKeyStorage::Keyring => { let entry = Entry::new(SERVICE_NAME, &ii_keyring_key(name)) .context(LoadKeyringEntryForDeleteSnafu { name })?; entry .delete_credential() .context(DeleteKeyringEntryForDeleteSnafu { name })?; } - IiKeyStorage::Pem { .. } => { + DelegationKeyStorage::Pem { .. } => { let pem_path = dirs.key_pem_path(name); fs::remove_file(&pem_path)?; } @@ -1011,6 +1084,36 @@ pub fn delete_identity( IdentitySpec::Anonymous => { unreachable!("anonymous identity should have been rejected above") } + IdentitySpec::PendingDelegation { storage, .. } => match storage { + DelegationKeyStorage::Keyring => { + let entry = Entry::new(SERVICE_NAME, &ii_keyring_key(name)) + .context(LoadKeyringEntryForDeleteSnafu { name })?; + entry + .delete_credential() + .context(DeleteKeyringEntryForDeleteSnafu { name })?; + } + DelegationKeyStorage::Pem { .. } => { + let pem_path = dirs.key_pem_path(name); + fs::remove_file(&pem_path)?; + } + }, + IdentitySpec::Delegation { storage, .. } => { + match storage { + DelegationKeyStorage::Keyring => { + let entry = Entry::new(SERVICE_NAME, &ii_keyring_key(name)) + .context(LoadKeyringEntryForDeleteSnafu { name })?; + entry + .delete_credential() + .context(DeleteKeyringEntryForDeleteSnafu { name })?; + } + DelegationKeyStorage::Pem { .. } => { + let pem_path = dirs.key_pem_path(name); + fs::remove_file(&pem_path)?; + } + } + let delegation_path = dirs.delegation_chain_path(name); + fs::remove_file(&delegation_path)?; + } } Ok(()) @@ -1069,7 +1172,7 @@ pub fn link_hsm_identity( } #[derive(Debug, Snafu)] -pub enum LinkIiIdentityError { +pub enum CreatePendingDelegationError { #[snafu(transparent)] LoadIdentityManifest { source: LoadIdentityManifestError }, @@ -1077,31 +1180,31 @@ pub enum LinkIiIdentityError { WriteIdentityManifest { source: WriteIdentityManifestError }, #[snafu(display("identity `{name}` already exists"))] - IiNameTaken { name: String }, + DlgNameTaken { name: String }, - #[snafu(display("failed to create II session key keyring entry"))] - CreateIiKeyringEntry { source: keyring::Error }, + #[snafu(display("failed to create session key keyring entry"))] + DlgCreateKeyringEntry { source: keyring::Error }, - #[snafu(display("failed to store II session key in keyring"))] - SetIiKeyringEntryPassword { source: keyring::Error }, + #[snafu(display("failed to store session key in keyring"))] + DlgSetKeyringEntryPassword { source: keyring::Error }, #[cfg(target_os = "linux")] #[snafu(display( "no keyring available - have you set it up? gnome-keyring must be installed and configured with a default keyring." ))] - NoIiKeyring, + DlgNoKeyring, - #[snafu(display("failed to write II session key PEM file for `{name}`"))] - WriteIiPemFile { + #[snafu(display("failed to write session key PEM file for `{name}`"))] + DlgWritePemFile { name: String, source: crate::fs::IoError, }, #[snafu(display("failed to create delegation directory"))] - CreateIiDelegationDir { source: crate::fs::IoError }, + DlgCreateDelegationDir { source: crate::fs::IoError }, #[snafu(display("failed to save delegation chain to `{path}`"))] - SaveIiDelegation { + DlgSaveDelegation { path: PathBuf, source: delegation::SaveError, }, @@ -1119,11 +1222,11 @@ pub fn link_ii_identity( principal: ic_agent::export::Principal, create_format: CreateFormat, host: Url, -) -> Result<(), LinkIiIdentityError> { +) -> Result<(), CreatePendingDelegationError> { let mut identity_list = IdentityList::load_from(dirs.read())?; ensure!( !identity_list.identities.contains_key(name), - IiNameTakenSnafu { name } + DlgNameTakenSnafu { name } ); let algorithm = match &key { @@ -1147,16 +1250,16 @@ pub fn link_ii_identity( .to_pem(PrivateKeyInfo::PEM_LABEL, Default::default()) .expect("infallible PKI encoding"); let entry = Entry::new(SERVICE_NAME, &ii_keyring_key(name)) - .context(CreateIiKeyringEntrySnafu)?; + .context(DlgCreateKeyringEntrySnafu)?; let res = entry.set_password(&pem); #[cfg(target_os = "linux")] if let Err(keyring::Error::NoStorageAccess(err)) = &res && err.to_string().contains("no result found") { - return NoIiKeyringSnafu.fail()?; + return DlgNoKeyringSnafu.fail()?; } - res.context(SetIiKeyringEntryPasswordSnafu)?; - IiKeyStorage::Keyring + res.context(DlgSetKeyringEntryPasswordSnafu)?; + DelegationKeyStorage::Keyring } CreateFormat::Plaintext => { let pem = doc @@ -1164,9 +1267,9 @@ pub fn link_ii_identity( .expect("infallible PKI encoding"); let pem_path = dirs .ensure_key_pem_path(name) - .context(WriteIiPemFileSnafu { name })?; - fs::write_string(&pem_path, &pem).context(WriteIiPemFileSnafu { name })?; - IiKeyStorage::Pem { + .context(DlgWritePemFileSnafu { name })?; + fs::write_string(&pem_path, &pem).context(DlgWritePemFileSnafu { name })?; + DelegationKeyStorage::Pem { format: PemFormat::Plaintext, } } @@ -1174,9 +1277,9 @@ pub fn link_ii_identity( let pem = make_pkcs5_encrypted_pem(&doc, password.as_str()); let pem_path = dirs .ensure_key_pem_path(name) - .context(WriteIiPemFileSnafu { name })?; - fs::write_string(&pem_path, &pem).context(WriteIiPemFileSnafu { name })?; - IiKeyStorage::Pem { + .context(DlgWritePemFileSnafu { name })?; + fs::write_string(&pem_path, &pem).context(DlgWritePemFileSnafu { name })?; + DelegationKeyStorage::Pem { format: PemFormat::Pbes2, } } @@ -1184,8 +1287,8 @@ pub fn link_ii_identity( let delegation_path = dirs .ensure_delegation_chain_path(name) - .context(CreateIiDelegationDirSnafu)?; - delegation::save(&delegation_path, chain).context(SaveIiDelegationSnafu { + .context(DlgCreateDelegationDirSnafu)?; + delegation::save(&delegation_path, chain).context(DlgSaveDelegationSnafu { path: &delegation_path, })?; @@ -1249,6 +1352,151 @@ pub fn update_ii_delegation( Ok(()) } +/// Creates a new pending delegation identity with a fresh P256 session key. +/// +/// Stores the session key according to `create_format` and registers the identity +/// as `PendingDelegation`. Returns the DER-encoded SPKI public key to hand to a +/// signer via `icp identity delegation sign --key-pem`. +pub fn create_pending_delegation( + dirs: LWrite<&IdentityPaths>, + name: &str, + create_format: CreateFormat, +) -> Result, CreatePendingDelegationError> { + let mut identity_list = IdentityList::load_from(dirs.read())?; + ensure!( + !identity_list.identities.contains_key(name), + DlgNameTakenSnafu { name } + ); + + let mut key_bytes = Zeroizing::new([0u8; 32]); + rand::rng().fill_bytes(key_bytes.as_mut()); + let key = p256::SecretKey::from_slice(&key_bytes[..]) + .expect("random 32 bytes are a valid p256 scalar"); + let identity = Prime256v1Identity::from_private_key(key.clone()); + let der_public_key = identity.public_key().expect("p256 always has a public key"); + + let doc = key.to_pkcs8_der().expect("infallible PKI encoding"); + + let storage = match &create_format { + CreateFormat::Keyring => { + let pem = doc + .to_pem(PrivateKeyInfo::PEM_LABEL, Default::default()) + .expect("infallible PKI encoding"); + let entry = Entry::new(SERVICE_NAME, &ii_keyring_key(name)) + .context(DlgCreateKeyringEntrySnafu)?; + let res = entry.set_password(&pem); + #[cfg(target_os = "linux")] + if let Err(keyring::Error::NoStorageAccess(err)) = &res + && err.to_string().contains("no result found") + { + return DlgNoKeyringSnafu.fail()?; + } + res.context(DlgSetKeyringEntryPasswordSnafu)?; + DelegationKeyStorage::Keyring + } + CreateFormat::Plaintext => { + let pem = doc + .to_pem(PrivateKeyInfo::PEM_LABEL, Default::default()) + .expect("infallible PKI encoding"); + let pem_path = dirs + .ensure_key_pem_path(name) + .context(DlgWritePemFileSnafu { name })?; + fs::write_string(&pem_path, &pem).context(DlgWritePemFileSnafu { name })?; + DelegationKeyStorage::Pem { + format: PemFormat::Plaintext, + } + } + CreateFormat::Pbes2 { password } => { + let pem = make_pkcs5_encrypted_pem(&doc, password.as_str()); + let pem_path = dirs + .ensure_key_pem_path(name) + .context(DlgWritePemFileSnafu { name })?; + fs::write_string(&pem_path, &pem).context(DlgWritePemFileSnafu { name })?; + DelegationKeyStorage::Pem { + format: PemFormat::Pbes2, + } + } + }; + + let spec = IdentitySpec::PendingDelegation { + algorithm: IdentityKeyAlgorithm::Prime256v1, + storage, + }; + identity_list.identities.insert(name.to_string(), spec); + identity_list.write_to(dirs)?; + + Ok(der_public_key) +} + +#[derive(Debug, Snafu)] +pub enum CompleteDelegationError { + #[snafu(transparent)] + LoadIdentityManifest { source: LoadIdentityManifestError }, + + #[snafu(transparent)] + WriteIdentityManifest { source: WriteIdentityManifestError }, + + #[snafu(display("no identity found with name `{name}`"))] + DelegationIdentityNotFound { name: String }, + + #[snafu(display("identity `{name}` is not a pending delegation"))] + IdentityNotPending { name: String }, + + #[snafu(display("invalid public key in delegation chain"))] + DecodeDelegationChainKey { source: hex::FromHexError }, + + #[snafu(display("failed to create delegation directory"))] + CreateDelegationChainDir { source: crate::fs::IoError }, + + #[snafu(display("failed to save delegation chain to `{path}`"))] + SaveDelegationChain { + path: PathBuf, + source: delegation::SaveError, + }, +} + +/// Completes a `PendingDelegation` identity by attaching a signed delegation chain. +/// +/// Updates the identity spec to `Delegation` with the root principal derived from +/// `chain.public_key`. After this call the identity is usable for signing. +/// Returns the storage mode so callers can warn about plaintext storage. +pub fn complete_delegation( + dirs: LWrite<&IdentityPaths>, + name: &str, + chain: &delegation::DelegationChain, +) -> Result { + let mut identity_list = IdentityList::load_from(dirs.read())?; + let spec = identity_list + .identities + .get(name) + .context(DelegationIdentityNotFoundSnafu { name })?; + + let (algorithm, storage) = match spec { + IdentitySpec::PendingDelegation { algorithm, storage } => (algorithm.clone(), *storage), + _ => return IdentityNotPendingSnafu { name }.fail(), + }; + + let from_key = hex::decode(&chain.public_key).context(DecodeDelegationChainKeySnafu)?; + let principal = ic_agent::export::Principal::self_authenticating(&from_key); + + let delegation_path = dirs + .ensure_delegation_chain_path(name) + .context(CreateDelegationChainDirSnafu)?; + delegation::save(&delegation_path, chain).context(SaveDelegationChainSnafu { + path: &delegation_path, + })?; + + let new_spec = IdentitySpec::Delegation { + algorithm, + principal, + storage, + }; + identity_list.identities.insert(name.to_string(), new_spec); + identity_list.write_to(dirs)?; + + Ok(storage) +} + fn encrypt_pki(pki: &PrivateKeyInfo<'_>, password: &str) -> Zeroizing { let mut salt = [0; 16]; let mut iv = [0; 16]; @@ -1296,6 +1544,9 @@ pub enum ExportIdentityError { #[snafu(display("cannot export an Internet Identity-backed identity"))] CannotExportInternetIdentity, + #[snafu(display("cannot export a delegation identity"))] + CannotExportDelegation, + #[snafu(display("failed to read PEM file"))] ReadPemFileForExport { source: fs::IoError }, @@ -1401,6 +1652,9 @@ pub fn export_identity( IdentitySpec::Anonymous => return CannotExportAnonymousSnafu.fail(), IdentitySpec::Hsm { .. } => return CannotExportHsmSnafu.fail(), IdentitySpec::InternetIdentity { .. } => return CannotExportInternetIdentitySnafu.fail(), + IdentitySpec::PendingDelegation { .. } | IdentitySpec::Delegation { .. } => { + return CannotExportDelegationSnafu.fail(); + } }; match export_format { diff --git a/crates/icp/src/identity/manifest.rs b/crates/icp/src/identity/manifest.rs index 992840bb..e44e6548 100644 --- a/crates/icp/src/identity/manifest.rs +++ b/crates/icp/src/identity/manifest.rs @@ -134,21 +134,40 @@ pub enum IdentitySpec { /// The principal at the root of the delegation chain /// (`Principal::self_authenticating(from_key)`), not the session key. principal: Principal, - storage: IiKeyStorage, + storage: DelegationKeyStorage, /// The host used for II login, stored so `icp identity login` can /// re-authenticate without requiring `--host` again. host: Url, }, + /// Session key created via `icp identity delegation request`; no delegation + /// chain yet. Cannot be used as an identity until `icp identity delegation use` + /// completes it. + PendingDelegation { + algorithm: IdentityKeyAlgorithm, + storage: DelegationKeyStorage, + }, + /// Fully delegated identity created via `icp identity delegation use`. + /// Behaves identically to `InternetIdentity` when loaded. + Delegation { + algorithm: IdentityKeyAlgorithm, + /// `Principal::self_authenticating(chain.public_key)` — root key principal. + principal: Principal, + storage: DelegationKeyStorage, + }, } impl IdentitySpec { - pub fn principal(&self) -> Principal { + /// Returns the principal associated with this identity, or `None` if the + /// identity has no delegation yet (`PendingDelegation`). + pub fn principal(&self) -> Option { match self { - IdentitySpec::Pem { principal, .. } => *principal, - IdentitySpec::Anonymous => Principal::anonymous(), - IdentitySpec::Keyring { principal, .. } => *principal, - IdentitySpec::Hsm { principal, .. } => *principal, - IdentitySpec::InternetIdentity { principal, .. } => *principal, + IdentitySpec::Pem { principal, .. } => Some(*principal), + IdentitySpec::Anonymous => Some(Principal::anonymous()), + IdentitySpec::Keyring { principal, .. } => Some(*principal), + IdentitySpec::Hsm { principal, .. } => Some(*principal), + IdentitySpec::InternetIdentity { principal, .. } => Some(*principal), + IdentitySpec::PendingDelegation { .. } => None, + IdentitySpec::Delegation { principal, .. } => Some(*principal), } } } @@ -162,7 +181,7 @@ pub enum PemFormat { #[derive(Copy, Clone, Eq, PartialEq, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", tag = "kind")] -pub enum IiKeyStorage { +pub enum DelegationKeyStorage { Keyring, Pem { format: PemFormat }, } diff --git a/crates/icp/src/telemetry_data.rs b/crates/icp/src/telemetry_data.rs index 883c68e8..0d2cdf96 100644 --- a/crates/icp/src/telemetry_data.rs +++ b/crates/icp/src/telemetry_data.rs @@ -72,6 +72,8 @@ pub enum IdentityStorageType { Hsm, Anonymous, InternetIdentity, + PendingDelegation, + Delegation, } /// Whether the network accessed by the command is managed locally or a remote @@ -91,6 +93,8 @@ impl From<&IdentitySpec> for IdentityStorageType { IdentitySpec::Hsm { .. } => Self::Hsm, IdentitySpec::Anonymous => Self::Anonymous, IdentitySpec::InternetIdentity { .. } => Self::InternetIdentity, + IdentitySpec::PendingDelegation { .. } => Self::PendingDelegation, + IdentitySpec::Delegation { .. } => Self::Delegation, } } } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 02ab715f..5f02cc38 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -43,6 +43,10 @@ This document contains the help content for the `icp` command-line program. * [`icp identity`↴](#icp-identity) * [`icp identity account-id`↴](#icp-identity-account-id) * [`icp identity default`↴](#icp-identity-default) +* [`icp identity delegation`↴](#icp-identity-delegation) +* [`icp identity delegation request`↴](#icp-identity-delegation-request) +* [`icp identity delegation sign`↴](#icp-identity-delegation-sign) +* [`icp identity delegation use`↴](#icp-identity-delegation-use) * [`icp identity delete`↴](#icp-identity-delete) * [`icp identity export`↴](#icp-identity-export) * [`icp identity import`↴](#icp-identity-import) @@ -901,6 +905,7 @@ Manage your identities * `account-id` — Display the ICP ledger or ICRC-1 account identifier for the current identity * `default` — Display or set the currently selected identity +* `delegation` — Manage delegations for identities * `delete` — Delete an identity * `export` — Print the PEM file for the identity * `import` — Import a new identity @@ -949,6 +954,77 @@ Display or set the currently selected identity +## `icp identity delegation` + +Manage delegations for identities + +**Usage:** `icp identity delegation ` + +###### **Subcommands:** + +* `request` — Create a pending delegation identity with a new P256 session key +* `sign` — Sign a delegation from the selected identity to a target key +* `use` — Complete a pending delegation identity by providing a signed delegation chain + + + +## `icp identity delegation request` + +Create a pending delegation identity with a new P256 session key + +Prints the session public key as a PEM-encoded SPKI to stdout. Pass this to `icp identity delegation sign --key-pem` on another machine to obtain a delegation chain, then complete the identity with `icp identity delegation use`. + +**Usage:** `icp identity delegation request [OPTIONS] ` + +###### **Arguments:** + +* `` — Name for the new identity + +###### **Options:** + +* `--storage ` — Where to store the session private key + + Default value: `keyring` + + Possible values: `plaintext`, `keyring`, `password` + +* `--storage-password-file ` — Read the storage password from a file instead of prompting (for --storage password) + + + +## `icp identity delegation sign` + +Sign a delegation from the selected identity to a target key + +**Usage:** `icp identity delegation sign [OPTIONS] --key-pem --duration ` + +###### **Options:** + +* `--key-pem ` — Public key PEM file of the key to delegate to +* `--duration ` — Delegation validity duration (e.g. "30d", "24h", "3600s", or plain seconds) +* `--canisters ` — Canister principals to restrict the delegation to (comma-separated) +* `--identity ` — The user identity to run this command as + + + +## `icp identity delegation use` + +Complete a pending delegation identity by providing a signed delegation chain + +Reads the JSON output of `icp identity delegation sign` from a file and attaches it to the named identity, making it usable for signing. + +**Usage:** `icp identity delegation use --from-json ` + +###### **Arguments:** + +* `` — Name of the pending delegation identity to complete + +###### **Options:** + +* `--from-json ` — Path to the delegation chain JSON file (output of `icp identity delegation sign`) + + + ## `icp identity delete` Delete an identity