From 5adbeb3a5d60df5b59f1388d7f461712d300ce1e Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 1 Apr 2026 07:42:35 -0700 Subject: [PATCH 01/12] initial impl --- Cargo.lock | 38 +++ Cargo.toml | 2 + crates/icp-cli/Cargo.toml | 2 + .../icp-cli/src/commands/identity/ii_poll.rs | 80 ++++++ .../icp-cli/src/commands/identity/link/ii.rs | 140 ++++++++++ .../icp-cli/src/commands/identity/link/mod.rs | 2 + crates/icp-cli/src/commands/identity/login.rs | 231 +++++++++++++++++ crates/icp-cli/src/commands/identity/mod.rs | 3 + crates/icp-cli/src/main.rs | 7 + crates/icp/src/identity/delegation.rs | 150 +++++++++++ crates/icp/src/identity/key.rs | 243 +++++++++++++++++- crates/icp/src/identity/manifest.rs | 7 + crates/icp/src/identity/mod.rs | 10 + crates/icp/src/telemetry_data.rs | 2 + docs/reference/cli.md | 36 +++ 15 files changed, 951 insertions(+), 2 deletions(-) create mode 100644 crates/icp-cli/src/commands/identity/ii_poll.rs create mode 100644 crates/icp-cli/src/commands/identity/link/ii.rs create mode 100644 crates/icp-cli/src/commands/identity/login.rs create mode 100644 crates/icp/src/identity/delegation.rs diff --git a/Cargo.lock b/Cargo.lock index 8e8952a4..a06984f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3389,6 +3389,7 @@ dependencies = [ "async-trait", "axoupdater", "backoff", + "base64", "bigdecimal", "bip32", "byte-unit", @@ -3423,6 +3424,7 @@ dependencies = [ "num-bigint 0.4.6", "num-integer", "num-traits", + "open", "p256", "pem", "phf", @@ -3720,6 +3722,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -4650,6 +4671,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.76" @@ -4833,6 +4865,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pbkdf2" version = "0.12.2" diff --git a/Cargo.toml b/Cargo.toml index 7477930a..9acadae8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ assert_cmd = "2" async-dropper = { version = "0.3.0", features = ["tokio", "simple"] } async-trait = "0.1.88" axoupdater = "0.10.0" +base64 = "0.22" backoff = { version = "0.4", features = ["tokio"] } bigdecimal = "0.4.10" bip32 = "0.5.0" @@ -67,6 +68,7 @@ mockall = "0.14.0" nix = { version = "0.31.2", features = ["process", "signal"] } notify = "8.2.0" num-bigint = "0.4.6" +open = "5" num-integer = "0.1.46" num-traits = "0.2.19" p256 = { version = "0.13.2", features = ["pem", "pkcs8", "std"] } diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 3209a1c6..119436b9 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -16,6 +16,7 @@ anyhow.workspace = true async-trait.workspace = true axoupdater.workspace = true backoff.workspace = true +base64.workspace = true bigdecimal.workspace = true bip32.workspace = true byte-unit.workspace = true @@ -45,6 +46,7 @@ itertools.workspace = true k256.workspace = true lazy_static.workspace = true num-bigint.workspace = true +open.workspace = true num-integer.workspace = true num-traits.workspace = true p256.workspace = true diff --git a/crates/icp-cli/src/commands/identity/ii_poll.rs b/crates/icp-cli/src/commands/identity/ii_poll.rs new file mode 100644 index 00000000..c6838134 --- /dev/null +++ b/crates/icp-cli/src/commands/identity/ii_poll.rs @@ -0,0 +1,80 @@ +use base64::engine::{Engine as _, general_purpose::URL_SAFE_NO_PAD}; +use candid::{Decode, Encode}; +use ic_agent::{Agent, export::Principal}; +use icp::{identity::delegation::DelegationChain, network::custom_domains, signal}; +use indicatif::{ProgressBar, ProgressStyle}; +use snafu::{ResultExt, Snafu}; +use url::Url; + +#[derive(Debug, Snafu)] +pub(crate) enum IiPollError { + #[snafu(display("failed to open browser"))] + OpenBrowser { source: std::io::Error }, + + #[snafu(display("failed to query cli-backend canister"))] + Query { source: ic_agent::AgentError }, + + #[snafu(display("failed to decode candid response"))] + CandidDecode { source: candid::Error }, + + #[snafu(display("interrupted"))] + Interrupted, +} + +/// Opens a browser for II authentication and polls the cli-backend canister +/// until the delegation chain is stored. Returns the received delegation chain. +pub(crate) async fn poll_for_delegation( + agent: &Agent, + clii_backend_id: Principal, + clii_frontend_id: Principal, + der_public_key: &[u8], + http_gateway_url: &Url, + friendly_name: Option<(&str, &str)>, +) -> Result { + let uuid = uuid::Uuid::new_v4().to_string(); + let key_b64 = URL_SAFE_NO_PAD.encode(der_public_key); + + let mut frontend_url = + custom_domains::canister_gateway_url(http_gateway_url, clii_frontend_id, friendly_name); + frontend_url.set_query(Some(&format!("k={key_b64}&uuid={uuid}"))); + + tracing::info!("Opening browser for Internet Identity authentication..."); + tracing::debug!("Frontend URL: {frontend_url}"); + open::that(frontend_url.as_str()).context(OpenBrowserSnafu)?; + + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg}") + .expect("valid template"), + ); + spinner.set_message("Waiting for Internet Identity authentication..."); + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + + let args = Encode!(&uuid).expect("infallible candid encode"); + + loop { + tokio::select! { + _ = signal::stop_signal() => { + spinner.finish_and_clear(); + return InterruptedSnafu.fail(); + } + _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => { + let response = agent + .query(&clii_backend_id, "get_delegation") + .with_arg(args.clone()) + .call() + .await + .context(QuerySnafu)?; + + let chain = Decode!(&response, Option) + .context(CandidDecodeSnafu)?; + + if let Some(chain) = chain { + spinner.finish_and_clear(); + return Ok(chain); + } + } + } + } +} diff --git a/crates/icp-cli/src/commands/identity/link/ii.rs b/crates/icp-cli/src/commands/identity/link/ii.rs new file mode 100644 index 00000000..edf60aaf --- /dev/null +++ b/crates/icp-cli/src/commands/identity/link/ii.rs @@ -0,0 +1,140 @@ +use clap::Args; +use ic_agent::{Identity as _, export::Principal, identity::BasicIdentity}; +use icp::{ + context::{CanisterSelection, Context, EnvironmentSelection}, + identity::{IdentitySelection, key}, +}; +use snafu::{OptionExt, ResultExt, Snafu}; +use tracing::info; + +use crate::{commands::identity::ii_poll, options::EnvironmentOpt}; + +/// Link an Internet Identity to a new identity +#[derive(Debug, Args)] +pub(crate) struct IiArgs { + /// Name for the linked identity + name: String, + + #[command(flatten)] + environment: EnvironmentOpt, +} + +pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { + let environment: EnvironmentSelection = args.environment.clone().into(); + + // Generate an Ed25519 keypair for the session key + let secret_key = ic_ed25519::PrivateKey::generate(); + let identity_key = key::IdentityKey::Ed25519(secret_key.clone()); + let basic = BasicIdentity::from_raw_key(&secret_key.serialize_raw()); + let der_public_key = basic.public_key().expect("ed25519 always has a public key"); + + // Resolve the environment to get network access + let env = ctx + .get_environment(&environment) + .await + .context(GetEnvSnafu)?; + let network_access = ctx + .network + .access(&env.network) + .await + .context(NetworkAccessSnafu)?; + + let http_gateway_url = network_access + .http_gateway_url + .as_ref() + .context(NoHttpGatewaySnafu)?; + + // Create an anonymous agent for polling + let agent = ctx + .get_agent_for_env(&IdentitySelection::Anonymous, &environment) + .await + .context(CreateAgentSnafu)?; + + // Look up the cli-backend canister ID + let clii_backend_id = ctx + .get_canister_id_for_env( + &CanisterSelection::Named("backend".to_string()), + &environment, + ) + .await + .context(LookupCanisterSnafu)?; + + let clii_frontend_id = ctx + .get_canister_id_for_env( + &CanisterSelection::Named("frontend".to_string()), + &environment, + ) + .await + .context(LookupCanisterSnafu)?; + + let friendly = if network_access.use_friendly_domains { + Some(("frontend", env.name.as_str())) + } else { + None + }; + + // Open browser and poll for delegation + let chain = ii_poll::poll_for_delegation( + &agent, + clii_backend_id, + clii_frontend_id, + &der_public_key, + http_gateway_url, + friendly, + ) + .await + .context(PollSnafu)?; + + // Derive the II principal from the root of the delegation chain + let from_key = hex::decode(&chain.public_key).context(DecodeFromKeySnafu)?; + let ii_principal = Principal::self_authenticating(&from_key); + + // Save the identity + ctx.dirs + .identity()? + .with_write(async |dirs| { + key::link_ii_identity(dirs, &args.name, identity_key, &chain, ii_principal) + }) + .await? + .context(LinkSnafu)?; + + info!("Identity \"{}\" linked to Internet Identity", args.name); + + Ok(()) +} + +#[derive(Debug, Snafu)] +pub(crate) enum IiError { + #[snafu(display("failed to resolve environment"))] + GetEnv { + source: icp::context::GetEnvironmentError, + }, + + #[snafu(display("failed to access network"))] + NetworkAccess { source: icp::network::AccessError }, + + #[snafu(display("network has no HTTP gateway URL configured"))] + NoHttpGateway, + + #[snafu(display("failed to create agent"))] + CreateAgent { + source: icp::context::GetAgentForEnvError, + }, + + #[snafu(display("failed to look up cli-backend canister ID"))] + LookupCanister { + source: icp::context::GetCanisterIdForEnvError, + }, + + #[snafu(display("failed during II authentication polling"))] + Poll { source: ii_poll::IiPollError }, + + #[snafu(display("invalid public key in delegation chain"))] + DecodeFromKey { source: hex::FromHexError }, + + #[snafu(transparent)] + LockIdentityDir { source: icp::fs::lock::LockError }, + + #[snafu(display("failed to link II identity"))] + Link { source: key::LinkIiIdentityError }, +} diff --git a/crates/icp-cli/src/commands/identity/link/mod.rs b/crates/icp-cli/src/commands/identity/link/mod.rs index 59812314..a3e7c50e 100644 --- a/crates/icp-cli/src/commands/identity/link/mod.rs +++ b/crates/icp-cli/src/commands/identity/link/mod.rs @@ -1,9 +1,11 @@ use clap::Subcommand; pub(crate) mod hsm; +pub(crate) mod ii; /// Link an external key to a new identity #[derive(Debug, Subcommand)] pub(crate) enum Command { Hsm(hsm::HsmArgs), + Ii(ii::IiArgs), } diff --git a/crates/icp-cli/src/commands/identity/login.rs b/crates/icp-cli/src/commands/identity/login.rs new file mode 100644 index 00000000..e4a70d98 --- /dev/null +++ b/crates/icp-cli/src/commands/identity/login.rs @@ -0,0 +1,231 @@ +use clap::Args; +use ic_agent::Identity as _; +use icp::{ + context::{CanisterSelection, Context, EnvironmentSelection}, + identity::{ + IdentitySelection, key, + manifest::{IdentityKeyAlgorithm, IdentityList, IdentitySpec}, + }, +}; +use pem::Pem; +use pkcs8::DecodePrivateKey as _; +use sec1::pem::PemLabel as _; +use snafu::{OptionExt, ResultExt, Snafu}; +use tracing::info; + +use crate::{commands::identity::ii_poll, options::EnvironmentOpt}; + +/// Re-authenticate an Internet Identity delegation +#[derive(Debug, Args)] +pub(crate) struct LoginArgs { + /// Name of the identity to re-authenticate + name: String, + + #[command(flatten)] + environment: EnvironmentOpt, +} + +pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginError> { + let environment: EnvironmentSelection = args.environment.clone().into(); + + // Load the identity list and verify this is an II identity + let (_algorithm, der_public_key) = + ctx.dirs + .identity()? + .with_read(async |dirs| { + let list = IdentityList::load_from(dirs)?; + let spec = list + .identities + .get(&args.name) + .context(IdentityNotFoundSnafu { name: &args.name })?; + + let algorithm = match spec { + IdentitySpec::InternetIdentity { algorithm, .. } => algorithm.clone(), + _ => return NotIiSnafu { name: &args.name }.fail(), + }; + + // Load the existing PEM to get the public key + let pem_path = dirs.key_pem_path(&args.name); + let origin = key::PemOrigin::File { + path: pem_path.clone(), + }; + let doc = icp::fs::read_to_string(&pem_path)? + .parse::() + .map_err(|e| LoginError::ParsePem { + origin: origin.clone(), + source: Box::new(e), + })?; + + assert!( + doc.tag() == pkcs8::PrivateKeyInfo::PEM_LABEL, + "II identity PEM should be plaintext" + ); + + let der_public_key = match algorithm { + IdentityKeyAlgorithm::Ed25519 => { + let key = ic_ed25519::PrivateKey::deserialize_pkcs8(doc.contents()) + .map_err(|e| LoginError::ParseKey { + origin: origin.clone(), + source: Box::new(e), + })?; + let basic = + ic_agent::identity::BasicIdentity::from_raw_key(&key.serialize_raw()); + basic.public_key().expect("ed25519 always has a public key") + } + IdentityKeyAlgorithm::Secp256k1 => { + let key = k256::SecretKey::from_pkcs8_der(doc.contents()).map_err(|e| { + LoginError::ParseKey { + origin: origin.clone(), + source: Box::new(e), + } + })?; + let id = ic_agent::identity::Secp256k1Identity::from_private_key(key); + id.public_key().expect("secp256k1 always has a public key") + } + IdentityKeyAlgorithm::Prime256v1 => { + let key = p256::SecretKey::from_pkcs8_der(doc.contents()).map_err(|e| { + LoginError::ParseKey { + origin: origin.clone(), + source: Box::new(e), + } + })?; + let id = ic_agent::identity::Prime256v1Identity::from_private_key(key); + id.public_key().expect("p256 always has a public key") + } + }; + + Ok((algorithm, der_public_key)) + }) + .await??; + + // Resolve the environment to get network access + let env = ctx + .get_environment(&environment) + .await + .context(GetEnvSnafu)?; + let network_access = ctx + .network + .access(&env.network) + .await + .context(NetworkAccessSnafu)?; + + let http_gateway_url = network_access + .http_gateway_url + .as_ref() + .context(NoHttpGatewaySnafu)?; + + // Create an anonymous agent for polling + let agent = ctx + .get_agent_for_env(&IdentitySelection::Anonymous, &environment) + .await + .context(CreateAgentSnafu)?; + + // Look up the cli-backend canister ID + let cli_backend_id = ctx + .get_canister_id_for_env( + &CanisterSelection::Named("backend".to_string()), + &environment, + ) + .await + .context(LookupCanisterSnafu)?; + let cli_frontend_id = ctx + .get_canister_id_for_env( + &CanisterSelection::Named("frontend".to_string()), + &environment, + ) + .await + .context(LookupCanisterSnafu)?; + + let friendly = if network_access.use_friendly_domains { + Some(("frontend", env.name.as_str())) + } else { + None + }; + + // Open browser and poll for delegation + let chain = ii_poll::poll_for_delegation( + &agent, + cli_backend_id, + cli_frontend_id, + &der_public_key, + http_gateway_url, + friendly, + ) + .await + .context(PollSnafu)?; + + // Update the delegation chain + ctx.dirs + .identity()? + .with_write(async |dirs| key::update_ii_delegation(dirs, &args.name, &chain)) + .await? + .context(UpdateDelegationSnafu)?; + + info!("Identity \"{}\" re-authenticated", args.name); + + Ok(()) +} + +#[derive(Debug, Snafu)] +pub(crate) enum LoginError { + #[snafu(transparent)] + LockIdentityDir { source: icp::fs::lock::LockError }, + + #[snafu(transparent)] + LoadManifest { + source: icp::identity::manifest::LoadIdentityManifestError, + }, + + #[snafu(display("no identity found with name `{name}`"))] + IdentityNotFound { name: String }, + + #[snafu(display( + "identity `{name}` is not an Internet Identity; use `icp identity link ii` instead" + ))] + NotIi { name: String }, + + #[snafu(transparent)] + ReadFile { source: icp::fs::IoError }, + + #[snafu(display("failed to parse PEM from `{origin}`"))] + ParsePem { + origin: key::PemOrigin, + #[snafu(source(from(pem::PemError, Box::new)))] + source: Box, + }, + + #[snafu(display("failed to parse key from `{origin}`"))] + ParseKey { + origin: key::PemOrigin, + source: Box, + }, + + #[snafu(display("failed to resolve environment"))] + GetEnv { + source: icp::context::GetEnvironmentError, + }, + + #[snafu(display("failed to access network"))] + NetworkAccess { source: icp::network::AccessError }, + + #[snafu(display("network has no HTTP gateway URL configured"))] + NoHttpGateway, + + #[snafu(display("failed to create agent"))] + CreateAgent { + source: icp::context::GetAgentForEnvError, + }, + + #[snafu(display("failed to look up cli-backend canister ID"))] + LookupCanister { + source: icp::context::GetCanisterIdForEnvError, + }, + + #[snafu(display("failed during II authentication polling"))] + Poll { source: ii_poll::IiPollError }, + + #[snafu(display("failed to update delegation"))] + UpdateDelegation { + source: key::UpdateIiDelegationError, + }, +} diff --git a/crates/icp-cli/src/commands/identity/mod.rs b/crates/icp-cli/src/commands/identity/mod.rs index b06ac691..b55f6af9 100644 --- a/crates/icp-cli/src/commands/identity/mod.rs +++ b/crates/icp-cli/src/commands/identity/mod.rs @@ -4,9 +4,11 @@ pub(crate) mod account_id; pub(crate) mod default; pub(crate) mod delete; pub(crate) mod export; +pub(crate) mod ii_poll; pub(crate) mod import; pub(crate) mod link; pub(crate) mod list; +pub(crate) mod login; pub(crate) mod new; pub(crate) mod principal; pub(crate) mod rename; @@ -22,6 +24,7 @@ pub(crate) enum Command { #[command(subcommand)] Link(link::Command), List(list::ListArgs), + Login(login::LoginArgs), New(new::NewArgs), Principal(principal::PrincipalArgs), Rename(rename::RenameArgs), diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index fbcc5ba7..573a7ae1 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -333,6 +333,9 @@ async fn dispatch(ctx: &icp::context::Context, command: Command) -> Result<(), E commands::identity::link::Command::Hsm(args) => { commands::identity::link::hsm::exec(ctx, &args).await? } + commands::identity::link::Command::Ii(args) => { + commands::identity::link::ii::exec(ctx, &args).await? + } }, commands::identity::Command::List(args) => { @@ -347,6 +350,10 @@ async fn dispatch(ctx: &icp::context::Context, command: Command) -> Result<(), E commands::identity::principal::exec(ctx, &args).await? } + commands::identity::Command::Login(args) => { + commands::identity::login::exec(ctx, &args).await? + } + commands::identity::Command::Rename(args) => { commands::identity::rename::exec(ctx, &args).await? } diff --git a/crates/icp/src/identity/delegation.rs b/crates/icp/src/identity/delegation.rs new file mode 100644 index 00000000..cd504701 --- /dev/null +++ b/crates/icp/src/identity/delegation.rs @@ -0,0 +1,150 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use candid::CandidType; +use ic_agent::export::Principal; +use serde::{Deserialize, Serialize}; +use snafu::{ResultExt, Snafu}; + +use crate::{fs, prelude::*}; + +/// Matches the Candid `DelegationChain` record from the cli-backend canister. +/// All byte fields are hex-encoded strings on the wire. +#[derive(Debug, Clone, Serialize, Deserialize, CandidType)] +pub struct DelegationChain { + #[serde(rename = "publicKey")] + pub public_key: String, + pub delegations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, CandidType)] +pub struct SignedDelegation { + pub signature: String, + pub delegation: Delegation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, CandidType)] +pub struct Delegation { + pub pubkey: String, + pub expiration: String, + pub targets: Option>, +} + +/// Convert a [`DelegationChain`] from the Candid wire format (hex strings) into +/// the ic-agent types used by [`ic_agent::identity::DelegatedIdentity`]. +/// +/// Returns `(from_key, delegations)` where `from_key` is the DER-encoded root +/// public key of the delegation chain. +pub fn to_agent_types( + chain: &DelegationChain, +) -> Result<(Vec, Vec), ConversionError> { + let from_key = + hex::decode(&chain.public_key).context(InvalidHexSnafu { field: "publicKey" })?; + + let delegations = chain + .delegations + .iter() + .map(|sd| { + let signature = + hex::decode(&sd.signature).context(InvalidHexSnafu { field: "signature" })?; + + let pubkey = + hex::decode(&sd.delegation.pubkey).context(InvalidHexSnafu { field: "pubkey" })?; + + let expiration = u64::from_str_radix(&sd.delegation.expiration, 16).context( + InvalidExpirationSnafu { + value: &sd.delegation.expiration, + }, + )?; + + let targets = sd + .delegation + .targets + .as_ref() + .map(|ts| { + ts.iter() + .map(|t| { + let bytes = + hex::decode(t).context(InvalidHexSnafu { field: "targets" })?; + Ok(Principal::from_slice(&bytes)) + }) + .collect::, ConversionError>>() + }) + .transpose()?; + + Ok(ic_agent::identity::SignedDelegation { + delegation: ic_agent::identity::Delegation { + pubkey, + expiration, + targets, + }, + signature, + }) + }) + .collect::, ConversionError>>()?; + + Ok((from_key, delegations)) +} + +/// Returns the earliest expiration (nanoseconds since epoch) across all +/// delegations in the chain. +pub fn earliest_expiration(chain: &DelegationChain) -> Result { + chain + .delegations + .iter() + .map(|sd| { + u64::from_str_radix(&sd.delegation.expiration, 16).context(InvalidExpirationSnafu { + value: &sd.delegation.expiration, + }) + }) + .try_fold(u64::MAX, |acc, exp| Ok(acc.min(exp?))) +} + +/// Returns `true` if the delegation chain has already expired or will expire +/// within `grace_nanos` nanoseconds from now. +pub fn is_expiring_soon( + chain: &DelegationChain, + grace_nanos: u64, +) -> Result { + let earliest = earliest_expiration(chain)?; + let now_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos() as u64; + Ok(earliest <= now_nanos.saturating_add(grace_nanos)) +} + +pub fn load(path: &Path) -> Result { + Ok(fs::json::load(path)?) +} + +pub fn save(path: &Path, chain: &DelegationChain) -> Result<(), SaveError> { + fs::json::save(path, chain)?; + Ok(()) +} + +#[derive(Debug, Snafu)] +pub enum ConversionError { + #[snafu(display("invalid hex in delegation field `{field}`"))] + InvalidHex { + field: String, + source: hex::FromHexError, + }, + + #[snafu(display("invalid expiration timestamp `{value}`"))] + InvalidExpiration { + value: String, + source: std::num::ParseIntError, + }, +} + +#[derive(Debug, Snafu)] +pub enum LoadError { + #[snafu(transparent)] + Json { source: fs::json::Error }, +} + +#[derive(Debug, Snafu)] +pub enum SaveError { + #[snafu(transparent)] + Json { source: fs::json::Error }, +} diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index 9f6cfc11..412e1b99 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -5,7 +5,9 @@ use std::{ use ic_agent::{ Identity, - identity::{AnonymousIdentity, BasicIdentity, Prime256v1Identity, Secp256k1Identity}, + identity::{ + AnonymousIdentity, BasicIdentity, DelegatedIdentity, Prime256v1Identity, Secp256k1Identity, + }, }; use ic_ed25519::PrivateKeyFormat; use ic_identity_hsm::HardwareIdentity; @@ -27,7 +29,7 @@ use crate::{ lock::{LRead, LWrite}, }, identity::{ - IdentityPaths, + IdentityPaths, delegation, manifest::{ IdentityDefaults, IdentityKeyAlgorithm, IdentityList, IdentitySpec, LoadIdentityManifestError, PemFormat, WriteIdentityManifestError, @@ -104,6 +106,24 @@ pub enum LoadIdentityError { LoadHsmError { source: ic_identity_hsm::HardwareIdentityError, }, + + #[snafu(display("failed to load delegation chain from `{path}`"))] + LoadDelegationChain { + path: PathBuf, + source: delegation::LoadError, + }, + + #[snafu(display( + "delegation for identity `{name}` has expired or will expire within 5 minutes; \ + run `icp identity login {name}` to re-authenticate" + ))] + DelegationExpired { name: String }, + + #[snafu(display("failed to convert delegation chain"))] + DelegationConversion { source: delegation::ConversionError }, + + #[snafu(display("delegation chain for identity `{name}` is invalid: {message}"))] + DelegationChainInvalid { name: String, message: String }, } pub fn load_identity( @@ -129,6 +149,7 @@ pub fn load_identity( .. } => load_hsm_identity(module, *slot, key_id, password_func), IdentitySpec::Anonymous => Ok(Arc::new(AnonymousIdentity)), + IdentitySpec::InternetIdentity { algorithm, .. } => load_ii_identity(dirs, name, algorithm), } } @@ -286,6 +307,67 @@ fn load_hsm_identity( Ok(Arc::new(identity)) } +const FIVE_MINUTES_NANOS: u64 = 5 * 60 * 1_000_000_000; + +fn load_ii_identity( + dirs: LRead<&IdentityPaths>, + name: &str, + algorithm: &IdentityKeyAlgorithm, +) -> Result, LoadIdentityError> { + // Load the session keypair PEM (same path as regular PEM identities) + let pem_path = dirs.key_pem_path(name); + let origin = PemOrigin::File { + path: pem_path.clone(), + }; + let doc = fs::read_to_string(&pem_path)? + .parse::() + .context(ParsePemSnafu { origin: &origin })?; + + // Load the delegation chain + let chain_path = dirs.delegation_chain_path(name); + let stored_chain = + delegation::load(&chain_path).context(LoadDelegationChainSnafu { path: &chain_path })?; + + // Check expiry (5 minutes grace) + if delegation::is_expiring_soon(&stored_chain, FIVE_MINUTES_NANOS) + .context(DelegationConversionSnafu)? + { + 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)?; + + // Load the inner identity from the plaintext PEM + let inner: Box = match algorithm { + IdentityKeyAlgorithm::Ed25519 => { + let key = ic_ed25519::PrivateKey::deserialize_pkcs8(doc.contents()) + .context(ParseEd25519KeySnafu { origin: &origin })?; + Box::new(BasicIdentity::from_raw_key(&key.serialize_raw())) + } + IdentityKeyAlgorithm::Secp256k1 => { + let key = k256::SecretKey::from_pkcs8_der(doc.contents()) + .context(ParsePkcs8Snafu { origin: &origin })?; + Box::new(Secp256k1Identity::from_private_key(key)) + } + IdentityKeyAlgorithm::Prime256v1 => { + let key = p256::SecretKey::from_pkcs8_der(doc.contents()) + .context(ParsePkcs8Snafu { origin: &origin })?; + Box::new(Prime256v1Identity::from_private_key(key)) + } + }; + + let delegated = DelegatedIdentity::new(from_key, inner, signed_delegations).map_err(|e| { + LoadIdentityError::DelegationChainInvalid { + name: name.to_string(), + message: e.to_string(), + } + })?; + + Ok(Arc::new(delegated)) +} + #[derive(Debug, Snafu)] pub enum LoadIdentityInContextError { #[snafu(transparent)] @@ -551,6 +633,7 @@ pub fn rename_identity( // Copy key material to new location before updating the list enum OldKeyMaterial { Pem(PathBuf), + PemAndDelegation(PathBuf, PathBuf), Keyring(Entry), None, } @@ -580,6 +663,22 @@ pub fn rename_identity( OldKeyMaterial::Keyring(old_entry) } + IdentitySpec::InternetIdentity { .. } => { + // Copy both PEM and delegation chain + 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)?; + + 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)?; + + OldKeyMaterial::PemAndDelegation(old_pem, old_delegation) + } IdentitySpec::Hsm { .. } => { // No migration required - HSM key stays on device OldKeyMaterial::None @@ -610,6 +709,10 @@ pub fn rename_identity( .delete_credential() .context(DeleteKeyringEntrySnafu { old_name })?; } + OldKeyMaterial::PemAndDelegation(old_pem, old_delegation) => { + fs::remove_file(&old_pem).context(DeleteOldKeyFileSnafu)?; + fs::remove_file(&old_delegation).context(DeleteOldKeyFileSnafu)?; + } OldKeyMaterial::None => { // Nothing to clean up (HSM identities) } @@ -694,6 +797,12 @@ pub fn delete_identity( .delete_credential() .context(DeleteKeyringEntryForDeleteSnafu { name })?; } + IdentitySpec::InternetIdentity { .. } => { + 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)?; + } IdentitySpec::Hsm { .. } => { // no deletion required } @@ -757,6 +866,132 @@ pub fn link_hsm_identity( Ok(()) } +#[derive(Debug, Snafu)] +pub enum LinkIiIdentityError { + #[snafu(transparent)] + LoadIdentityManifest { source: LoadIdentityManifestError }, + + #[snafu(transparent)] + WriteIdentityManifest { source: WriteIdentityManifestError }, + + #[snafu(display("identity `{name}` already exists"))] + IiNameTaken { name: String }, + + #[snafu(transparent)] + WriteIiKey { source: WriteIdentityError }, + + #[snafu(display("failed to create delegation directory"))] + CreateIiDelegationDir { source: crate::fs::IoError }, + + #[snafu(display("failed to save delegation chain to `{path}`"))] + SaveIiDelegation { + path: PathBuf, + source: delegation::SaveError, + }, +} + +/// Links an Internet Identity delegation to a new named identity. +/// +/// Stores the session keypair as a plaintext PEM and the delegation chain as +/// a separate JSON file. +pub fn link_ii_identity( + dirs: LWrite<&IdentityPaths>, + name: &str, + key: IdentityKey, + chain: &delegation::DelegationChain, + principal: ic_agent::export::Principal, +) -> Result<(), LinkIiIdentityError> { + let mut identity_list = IdentityList::load_from(dirs.read())?; + ensure!( + !identity_list.identities.contains_key(name), + IiNameTakenSnafu { name } + ); + + let algorithm = match &key { + IdentityKey::Secp256k1(_) => IdentityKeyAlgorithm::Secp256k1, + IdentityKey::Prime256v1(_) => IdentityKeyAlgorithm::Prime256v1, + IdentityKey::Ed25519(_) => IdentityKeyAlgorithm::Ed25519, + }; + + 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"), + IdentityKey::Ed25519(key) => key + .serialize_pkcs8(PrivateKeyFormat::Pkcs8v2) + .try_into() + .expect("infallible PKI encoding"), + }; + + let pem = doc + .to_pem(PrivateKeyInfo::PEM_LABEL, Default::default()) + .expect("infallible PKI encoding"); + write_identity(dirs, name, &pem)?; + + let delegation_path = dirs + .ensure_delegation_chain_path(name) + .context(CreateIiDelegationDirSnafu)?; + delegation::save(&delegation_path, chain).context(SaveIiDelegationSnafu { + path: &delegation_path, + })?; + + let spec = IdentitySpec::InternetIdentity { + algorithm, + principal, + }; + identity_list.identities.insert(name.to_string(), spec); + identity_list.write_to(dirs)?; + + Ok(()) +} + +#[derive(Debug, Snafu)] +pub enum UpdateIiDelegationError { + #[snafu(transparent)] + LoadIdentityManifest { source: LoadIdentityManifestError }, + + #[snafu(display("no identity found with name `{name}`"))] + IiIdentityNotFound { name: String }, + + #[snafu(display("identity `{name}` is not an Internet Identity"))] + NotInternetIdentity { name: String }, + + #[snafu(display("failed to save delegation chain to `{path}`"))] + UpdateIiDelegationSave { + path: PathBuf, + source: delegation::SaveError, + }, + + #[snafu(display("failed to create delegation directory"))] + UpdateIiCreateDir { source: crate::fs::IoError }, +} + +/// Updates the delegation chain for an existing Internet Identity. +pub fn update_ii_delegation( + dirs: LWrite<&IdentityPaths>, + name: &str, + chain: &delegation::DelegationChain, +) -> Result<(), UpdateIiDelegationError> { + let identity_list = IdentityList::load_from(dirs.read())?; + let spec = identity_list + .identities + .get(name) + .context(IiIdentityNotFoundSnafu { name })?; + + ensure!( + matches!(spec, IdentitySpec::InternetIdentity { .. }), + NotInternetIdentitySnafu { name } + ); + + let delegation_path = dirs + .ensure_delegation_chain_path(name) + .context(UpdateIiCreateDirSnafu)?; + delegation::save(&delegation_path, chain).context(UpdateIiDelegationSaveSnafu { + path: &delegation_path, + })?; + + Ok(()) +} + fn encrypt_pki(pki: &PrivateKeyInfo<'_>, password: &str) -> Zeroizing { let mut salt = [0; 16]; let mut iv = [0; 16]; @@ -801,6 +1036,9 @@ pub enum ExportIdentityError { #[snafu(display("cannot export an HSM-backed identity"))] CannotExportHsm, + #[snafu(display("cannot export an Internet Identity-backed identity"))] + CannotExportInternetIdentity, + #[snafu(display("failed to read PEM file"))] ReadPemFileForExport { source: fs::IoError }, @@ -905,6 +1143,7 @@ pub fn export_identity( } IdentitySpec::Anonymous => return CannotExportAnonymousSnafu.fail(), IdentitySpec::Hsm { .. } => return CannotExportHsmSnafu.fail(), + IdentitySpec::InternetIdentity { .. } => return CannotExportInternetIdentitySnafu.fail(), }; match export_format { diff --git a/crates/icp/src/identity/manifest.rs b/crates/icp/src/identity/manifest.rs index 260f312a..56e6831b 100644 --- a/crates/icp/src/identity/manifest.rs +++ b/crates/icp/src/identity/manifest.rs @@ -128,6 +128,12 @@ pub enum IdentitySpec { slot: usize, key_id: String, }, + InternetIdentity { + algorithm: IdentityKeyAlgorithm, + /// The principal at the root of the delegation chain + /// (`Principal::self_authenticating(from_key)`), not the session key. + principal: Principal, + }, } impl IdentitySpec { @@ -137,6 +143,7 @@ impl IdentitySpec { IdentitySpec::Anonymous => Principal::anonymous(), IdentitySpec::Keyring { principal, .. } => *principal, IdentitySpec::Hsm { principal, .. } => *principal, + IdentitySpec::InternetIdentity { principal, .. } => *principal, } } } diff --git a/crates/icp/src/identity/mod.rs b/crates/icp/src/identity/mod.rs index e277efe3..c5938464 100644 --- a/crates/icp/src/identity/mod.rs +++ b/crates/icp/src/identity/mod.rs @@ -16,6 +16,7 @@ use crate::{ telemetry_data::{IdentityStorageType, TelemetryData}, }; +pub mod delegation; pub mod key; pub mod keyring_mock; pub mod manifest; @@ -62,6 +63,15 @@ impl IdentityPaths { crate::fs::create_dir_all(&self.dir.join("keys"))?; Ok(self.dir.join(format!("keys/{name}.pem"))) } + + pub fn delegation_chain_path(&self, name: &str) -> PathBuf { + self.dir.join(format!("delegations/{name}.json")) + } + + pub fn ensure_delegation_chain_path(&self, name: &str) -> Result { + crate::fs::create_dir_all(&self.dir.join("delegations"))?; + Ok(self.dir.join(format!("delegations/{name}.json"))) + } } pub type IdentityDirectories = DirectoryStructureLock; diff --git a/crates/icp/src/telemetry_data.rs b/crates/icp/src/telemetry_data.rs index c0c41161..883c68e8 100644 --- a/crates/icp/src/telemetry_data.rs +++ b/crates/icp/src/telemetry_data.rs @@ -71,6 +71,7 @@ pub enum IdentityStorageType { Keyring, Hsm, Anonymous, + InternetIdentity, } /// Whether the network accessed by the command is managed locally or a remote @@ -89,6 +90,7 @@ impl From<&IdentitySpec> for IdentityStorageType { IdentitySpec::Keyring { .. } => Self::Keyring, IdentitySpec::Hsm { .. } => Self::Hsm, IdentitySpec::Anonymous => Self::Anonymous, + IdentitySpec::InternetIdentity { .. } => Self::InternetIdentity, } } } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 22562467..9e30c202 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -48,7 +48,9 @@ This document contains the help content for the `icp` command-line program. * [`icp identity import`↴](#icp-identity-import) * [`icp identity link`↴](#icp-identity-link) * [`icp identity link hsm`↴](#icp-identity-link-hsm) +* [`icp identity link ii`↴](#icp-identity-link-ii) * [`icp identity list`↴](#icp-identity-list) +* [`icp identity login`↴](#icp-identity-login) * [`icp identity new`↴](#icp-identity-new) * [`icp identity principal`↴](#icp-identity-principal) * [`icp identity rename`↴](#icp-identity-rename) @@ -904,6 +906,7 @@ Manage your identities * `import` — Import a new identity * `link` — Link an external key to a new identity * `list` — List the identities +* `login` — Re-authenticate an Internet Identity delegation * `new` — Create a new identity * `principal` — Display the principal for the current identity * `rename` — Rename an identity @@ -1015,6 +1018,7 @@ Link an external key to a new identity ###### **Subcommands:** * `hsm` — Link an HSM key to a new identity +* `ii` — Link an Internet Identity to a new identity @@ -1039,6 +1043,22 @@ Link an HSM key to a new identity +## `icp identity link ii` + +Link an Internet Identity to a new identity + +**Usage:** `icp identity link ii [OPTIONS] ` + +###### **Arguments:** + +* `` — Name for the linked identity + +###### **Options:** + +* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used + + + ## `icp identity list` List the identities @@ -1052,6 +1072,22 @@ List the identities +## `icp identity login` + +Re-authenticate an Internet Identity delegation + +**Usage:** `icp identity login [OPTIONS] ` + +###### **Arguments:** + +* `` — Name of the identity to re-authenticate + +###### **Options:** + +* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used + + + ## `icp identity new` Create a new identity From 592c7b564cfcbb74caf41bc9b4e324acef729f92 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 1 Apr 2026 10:00:00 -0700 Subject: [PATCH 02/12] fix canister signature code --- crates/icp/src/identity/key.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index 412e1b99..bdb97aa3 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -121,9 +121,6 @@ pub enum LoadIdentityError { #[snafu(display("failed to convert delegation chain"))] DelegationConversion { source: delegation::ConversionError }, - - #[snafu(display("delegation chain for identity `{name}` is invalid: {message}"))] - DelegationChainInvalid { name: String, message: String }, } pub fn load_identity( @@ -358,12 +355,10 @@ fn load_ii_identity( } }; - let delegated = DelegatedIdentity::new(from_key, inner, signed_delegations).map_err(|e| { - LoadIdentityError::DelegationChainInvalid { - name: name.to_string(), - message: e.to_string(), - } - })?; + // Use new_unchecked because the root of the II delegation chain uses + // canister signatures (OID 1.3.6.1.4.1.56387.1.2) which DelegatedIdentity::new + // cannot verify client-side. The replica validates the chain on each request. + let delegated = DelegatedIdentity::new_unchecked(from_key, inner, signed_delegations); Ok(Arc::new(delegated)) } From a97a46eafbebc2b38947a836fc0a7760f16ab40b Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 2 Apr 2026 02:54:06 -0700 Subject: [PATCH 03/12] Shortcode flow --- Cargo.lock | 37 --------- crates/icp-cli/Cargo.toml | 1 - .../icp-cli/src/commands/identity/ii_poll.rs | 76 ++++++++++++++----- .../icp-cli/src/commands/identity/import.rs | 2 +- .../icp-cli/src/commands/identity/link/hsm.rs | 2 +- .../icp-cli/src/commands/identity/link/ii.rs | 14 ++-- crates/icp-cli/src/commands/identity/login.rs | 14 ++-- 7 files changed, 73 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a06984f0..dd04bb10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3424,7 +3424,6 @@ dependencies = [ "num-bigint 0.4.6", "num-integer", "num-traits", - "open", "p256", "pem", "phf", @@ -3722,25 +3721,6 @@ dependencies = [ "serde", ] -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -4671,17 +4651,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "open" -version = "5.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - [[package]] name = "openssl" version = "0.10.76" @@ -4865,12 +4834,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pbkdf2" version = "0.12.2" diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 119436b9..e9bfe9c4 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -46,7 +46,6 @@ itertools.workspace = true k256.workspace = true lazy_static.workspace = true num-bigint.workspace = true -open.workspace = true num-integer.workspace = true num-traits.workspace = true p256.workspace = true diff --git a/crates/icp-cli/src/commands/identity/ii_poll.rs b/crates/icp-cli/src/commands/identity/ii_poll.rs index c6838134..e05cbcbe 100644 --- a/crates/icp-cli/src/commands/identity/ii_poll.rs +++ b/crates/icp-cli/src/commands/identity/ii_poll.rs @@ -1,15 +1,30 @@ +use std::time::Duration; + use base64::engine::{Engine as _, general_purpose::URL_SAFE_NO_PAD}; -use candid::{Decode, Encode}; +use candid::{CandidType, Decode, Encode}; use ic_agent::{Agent, export::Principal}; use icp::{identity::delegation::DelegationChain, network::custom_domains, signal}; use indicatif::{ProgressBar, ProgressStyle}; +use serde::Deserialize; use snafu::{ResultExt, Snafu}; use url::Url; +/// Candid `RegisterResult` variant from the cli-backend canister. +#[derive(Debug, Clone, CandidType, Deserialize)] +pub(crate) enum RegisterResult { + #[serde(rename = "ok")] + Ok(String), + #[serde(rename = "err")] + Err(String), +} + #[derive(Debug, Snafu)] pub(crate) enum IiPollError { - #[snafu(display("failed to open browser"))] - OpenBrowser { source: std::io::Error }, + #[snafu(display("failed to register session with cli-backend canister"))] + Register { source: ic_agent::AgentError }, + + #[snafu(display("cli-backend canister rejected registration: {message}"))] + RegisterRejected { message: String }, #[snafu(display("failed to query cli-backend canister"))] Query { source: ic_agent::AgentError }, @@ -21,26 +36,49 @@ pub(crate) enum IiPollError { Interrupted, } -/// Opens a browser for II authentication and polls the cli-backend canister -/// until the delegation chain is stored. Returns the received delegation chain. +/// Registers a session with the cli-backend canister, prints a one-time code +/// for the user to enter on the login website, and polls until the delegation +/// chain is stored. Returns the received delegation chain. pub(crate) async fn poll_for_delegation( agent: &Agent, - clii_backend_id: Principal, - clii_frontend_id: Principal, + delegator_backend_id: Principal, + delegator_frontend_id: Principal, der_public_key: &[u8], http_gateway_url: &Url, - friendly_name: Option<(&str, &str)>, + delegator_frontend_friendly_name: Option<(&str, &str)>, ) -> Result { let uuid = uuid::Uuid::new_v4().to_string(); let key_b64 = URL_SAFE_NO_PAD.encode(der_public_key); - let mut frontend_url = - custom_domains::canister_gateway_url(http_gateway_url, clii_frontend_id, friendly_name); - frontend_url.set_query(Some(&format!("k={key_b64}&uuid={uuid}"))); + // Register the session with the backend canister + let register_args = Encode!(&uuid, &key_b64).expect("infallible candid encode"); + let register_response = agent + .update(&delegator_backend_id, "register") + .with_arg(register_args) + .call_and_wait() + .await + .context(RegisterSnafu)?; + + let result = Decode!(®ister_response, RegisterResult).context(CandidDecodeSnafu)?; + + let code = match result { + RegisterResult::Ok(code) => code, + RegisterResult::Err(message) => return RegisterRejectedSnafu { message }.fail(), + }; + + // Construct the frontend login URL + let mut login_url = custom_domains::canister_gateway_url( + http_gateway_url, + delegator_frontend_id, + delegator_frontend_friendly_name, + ); + login_url.set_path("/cli-login"); - tracing::info!("Opening browser for Internet Identity authentication..."); - tracing::debug!("Frontend URL: {frontend_url}"); - open::that(frontend_url.as_str()).context(OpenBrowserSnafu)?; + eprintln!(); + eprintln!(" Open {login_url} and enter this code:"); + eprintln!(); + eprintln!(" {code}"); + eprintln!(); let spinner = ProgressBar::new_spinner(); spinner.set_style( @@ -49,9 +87,9 @@ pub(crate) async fn poll_for_delegation( .expect("valid template"), ); spinner.set_message("Waiting for Internet Identity authentication..."); - spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + spinner.enable_steady_tick(Duration::from_millis(100)); - let args = Encode!(&uuid).expect("infallible candid encode"); + let poll_args = Encode!(&uuid).expect("infallible candid encode"); loop { tokio::select! { @@ -59,10 +97,10 @@ pub(crate) async fn poll_for_delegation( spinner.finish_and_clear(); return InterruptedSnafu.fail(); } - _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => { + _ = tokio::time::sleep(Duration::from_secs(2)) => { let response = agent - .query(&clii_backend_id, "get_delegation") - .with_arg(args.clone()) + .query(&delegator_backend_id, "get_delegation") + .with_arg(poll_args.clone()) .call() .await .context(QuerySnafu)?; diff --git a/crates/icp-cli/src/commands/identity/import.rs b/crates/icp-cli/src/commands/identity/import.rs index 1b901af4..f8cf8801 100644 --- a/crates/icp-cli/src/commands/identity/import.rs +++ b/crates/icp-cli/src/commands/identity/import.rs @@ -106,7 +106,7 @@ pub(crate) async fn exec(ctx: &Context, args: &ImportArgs) -> Result<(), anyhow: unreachable!(); } - info!("Identity \"{}\" created", args.name); + info!("Identity `{}` created", args.name); if matches!(args.storage, StorageMode::Plaintext) { warn!( diff --git a/crates/icp-cli/src/commands/identity/link/hsm.rs b/crates/icp-cli/src/commands/identity/link/hsm.rs index 05ac4672..0cd838ce 100644 --- a/crates/icp-cli/src/commands/identity/link/hsm.rs +++ b/crates/icp-cli/src/commands/identity/link/hsm.rs @@ -60,7 +60,7 @@ pub(crate) async fn exec(ctx: &Context, args: &HsmArgs) -> Result<(), HsmError> .await? .context(LinkHsmSnafu)?; - info!("Identity \"{}\" linked to HSM", args.name); + info!("Identity `{}` linked to HSM", args.name); Ok(()) } diff --git a/crates/icp-cli/src/commands/identity/link/ii.rs b/crates/icp-cli/src/commands/identity/link/ii.rs index edf60aaf..8ee9d6c9 100644 --- a/crates/icp-cli/src/commands/identity/link/ii.rs +++ b/crates/icp-cli/src/commands/identity/link/ii.rs @@ -51,7 +51,7 @@ pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { .context(CreateAgentSnafu)?; // Look up the cli-backend canister ID - let clii_backend_id = ctx + let delegator_backend_id = ctx .get_canister_id_for_env( &CanisterSelection::Named("backend".to_string()), &environment, @@ -59,7 +59,7 @@ pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { .await .context(LookupCanisterSnafu)?; - let clii_frontend_id = ctx + let delegator_frontend_id = ctx .get_canister_id_for_env( &CanisterSelection::Named("frontend".to_string()), &environment, @@ -67,7 +67,7 @@ pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { .await .context(LookupCanisterSnafu)?; - let friendly = if network_access.use_friendly_domains { + let delegator_frontend_friendly = if network_access.use_friendly_domains { Some(("frontend", env.name.as_str())) } else { None @@ -76,11 +76,11 @@ pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { // Open browser and poll for delegation let chain = ii_poll::poll_for_delegation( &agent, - clii_backend_id, - clii_frontend_id, + delegator_backend_id, + delegator_frontend_id, &der_public_key, http_gateway_url, - friendly, + delegator_frontend_friendly, ) .await .context(PollSnafu)?; @@ -98,7 +98,7 @@ pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { .await? .context(LinkSnafu)?; - info!("Identity \"{}\" linked to Internet Identity", args.name); + info!("Identity `{}` linked to Internet Identity", args.name); Ok(()) } diff --git a/crates/icp-cli/src/commands/identity/login.rs b/crates/icp-cli/src/commands/identity/login.rs index e4a70d98..c390909f 100644 --- a/crates/icp-cli/src/commands/identity/login.rs +++ b/crates/icp-cli/src/commands/identity/login.rs @@ -121,14 +121,14 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr .context(CreateAgentSnafu)?; // Look up the cli-backend canister ID - let cli_backend_id = ctx + let delegator_backend_id = ctx .get_canister_id_for_env( &CanisterSelection::Named("backend".to_string()), &environment, ) .await .context(LookupCanisterSnafu)?; - let cli_frontend_id = ctx + let delegator_frontend_id = ctx .get_canister_id_for_env( &CanisterSelection::Named("frontend".to_string()), &environment, @@ -136,7 +136,7 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr .await .context(LookupCanisterSnafu)?; - let friendly = if network_access.use_friendly_domains { + let delegator_frontend_friendly = if network_access.use_friendly_domains { Some(("frontend", env.name.as_str())) } else { None @@ -145,11 +145,11 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr // Open browser and poll for delegation let chain = ii_poll::poll_for_delegation( &agent, - cli_backend_id, - cli_frontend_id, + delegator_backend_id, + delegator_frontend_id, &der_public_key, http_gateway_url, - friendly, + delegator_frontend_friendly, ) .await .context(PollSnafu)?; @@ -161,7 +161,7 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr .await? .context(UpdateDelegationSnafu)?; - info!("Identity \"{}\" re-authenticated", args.name); + info!("Identity `{}` re-authenticated", args.name); Ok(()) } From 447dcd5143c16df79e9880f93c975531e569b918 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 2 Apr 2026 05:14:44 -0700 Subject: [PATCH 04/12] mv --- crates/icp-cli/src/commands/identity/link/ii.rs | 2 +- crates/icp-cli/src/commands/identity/login.rs | 2 +- crates/icp-cli/src/commands/identity/mod.rs | 1 - crates/icp-cli/src/{commands/identity => operations}/ii_poll.rs | 0 crates/icp-cli/src/operations/mod.rs | 1 + 5 files changed, 3 insertions(+), 3 deletions(-) rename crates/icp-cli/src/{commands/identity => operations}/ii_poll.rs (100%) diff --git a/crates/icp-cli/src/commands/identity/link/ii.rs b/crates/icp-cli/src/commands/identity/link/ii.rs index 8ee9d6c9..8a74ee8b 100644 --- a/crates/icp-cli/src/commands/identity/link/ii.rs +++ b/crates/icp-cli/src/commands/identity/link/ii.rs @@ -7,7 +7,7 @@ use icp::{ use snafu::{OptionExt, ResultExt, Snafu}; use tracing::info; -use crate::{commands::identity::ii_poll, options::EnvironmentOpt}; +use crate::{operations::ii_poll, options::EnvironmentOpt}; /// Link an Internet Identity to a new identity #[derive(Debug, Args)] diff --git a/crates/icp-cli/src/commands/identity/login.rs b/crates/icp-cli/src/commands/identity/login.rs index c390909f..a96d1d80 100644 --- a/crates/icp-cli/src/commands/identity/login.rs +++ b/crates/icp-cli/src/commands/identity/login.rs @@ -13,7 +13,7 @@ use sec1::pem::PemLabel as _; use snafu::{OptionExt, ResultExt, Snafu}; use tracing::info; -use crate::{commands::identity::ii_poll, options::EnvironmentOpt}; +use crate::{operations::ii_poll, options::EnvironmentOpt}; /// Re-authenticate an Internet Identity delegation #[derive(Debug, Args)] diff --git a/crates/icp-cli/src/commands/identity/mod.rs b/crates/icp-cli/src/commands/identity/mod.rs index b55f6af9..de089bd4 100644 --- a/crates/icp-cli/src/commands/identity/mod.rs +++ b/crates/icp-cli/src/commands/identity/mod.rs @@ -4,7 +4,6 @@ pub(crate) mod account_id; pub(crate) mod default; pub(crate) mod delete; pub(crate) mod export; -pub(crate) mod ii_poll; pub(crate) mod import; pub(crate) mod link; pub(crate) mod list; diff --git a/crates/icp-cli/src/commands/identity/ii_poll.rs b/crates/icp-cli/src/operations/ii_poll.rs similarity index 100% rename from crates/icp-cli/src/commands/identity/ii_poll.rs rename to crates/icp-cli/src/operations/ii_poll.rs diff --git a/crates/icp-cli/src/operations/mod.rs b/crates/icp-cli/src/operations/mod.rs index f1d8ba2b..7f304db6 100644 --- a/crates/icp-cli/src/operations/mod.rs +++ b/crates/icp-cli/src/operations/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod build; pub(crate) mod candid_compat; pub(crate) mod canister_migration; pub(crate) mod create; +pub(crate) mod ii_poll; pub(crate) mod install; pub(crate) mod proxy; pub(crate) mod proxy_management; From 8d73890e9092146603a30df8f5915673078907e0 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 2 Apr 2026 06:48:50 -0700 Subject: [PATCH 05/12] Automatically open the URL --- Cargo.lock | 37 ++++++++++++++++++++++++ crates/icp-cli/Cargo.toml | 1 + crates/icp-cli/src/operations/ii_poll.rs | 27 +++++++++++++++-- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd04bb10..a06984f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3424,6 +3424,7 @@ dependencies = [ "num-bigint 0.4.6", "num-integer", "num-traits", + "open", "p256", "pem", "phf", @@ -3721,6 +3722,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -4651,6 +4671,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.76" @@ -4834,6 +4865,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pbkdf2" version = "0.12.2" diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index e9bfe9c4..10aa0d45 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -48,6 +48,7 @@ lazy_static.workspace = true num-bigint.workspace = true num-integer.workspace = true num-traits.workspace = true +open.workspace = true p256.workspace = true pem.workspace = true phf.workspace = true diff --git a/crates/icp-cli/src/operations/ii_poll.rs b/crates/icp-cli/src/operations/ii_poll.rs index e05cbcbe..e7011f21 100644 --- a/crates/icp-cli/src/operations/ii_poll.rs +++ b/crates/icp-cli/src/operations/ii_poll.rs @@ -75,10 +75,11 @@ pub(crate) async fn poll_for_delegation( login_url.set_path("/cli-login"); eprintln!(); - eprintln!(" Open {login_url} and enter this code:"); + eprintln!(" Your one-time code is:"); eprintln!(); eprintln!(" {code}"); eprintln!(); + eprintln!(" Press Enter to open {login_url}"); let spinner = ProgressBar::new_spinner(); spinner.set_style( @@ -86,17 +87,37 @@ pub(crate) async fn poll_for_delegation( .template("{spinner:.green} {msg}") .expect("valid template"), ); - spinner.set_message("Waiting for Internet Identity authentication..."); - spinner.enable_steady_tick(Duration::from_millis(100)); let poll_args = Encode!(&uuid).expect("infallible candid encode"); + // Spawn a detached thread for stdin. + // If this is done with tokio instead, because the read never completes, the runtime hangs. + let (enter_tx, mut enter_rx) = tokio::sync::mpsc::channel::<()>(1); + std::thread::spawn(move || { + let mut buf = String::new(); + let _ = std::io::stdin().read_line(&mut buf); + let _ = enter_tx.blocking_send(()); + }); + + let mut browser_opened = false; + let mut fuse = false; + loop { + if browser_opened && !fuse { + spinner.set_message("Waiting for Internet Identity authentication..."); + spinner.enable_steady_tick(Duration::from_millis(100)); + fuse = true; + } + tokio::select! { _ = signal::stop_signal() => { spinner.finish_and_clear(); return InterruptedSnafu.fail(); } + _ = enter_rx.recv(), if !browser_opened => { + browser_opened = true; + let _ = open::that(login_url.as_str()); + } _ = tokio::time::sleep(Duration::from_secs(2)) => { let response = agent .query(&delegator_backend_id, "get_delegation") From 961cb7a5ffec540d7bb0d55297d4cb5d5a8e3652 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 7 Apr 2026 14:22:03 -0700 Subject: [PATCH 06/12] Switch to keyring storage --- crates/icp-cli/src/commands/identity/login.rs | 109 +++------------ crates/icp/src/identity/key.rs | 130 +++++++++++++++--- 2 files changed, 129 insertions(+), 110 deletions(-) diff --git a/crates/icp-cli/src/commands/identity/login.rs b/crates/icp-cli/src/commands/identity/login.rs index a96d1d80..bd942b88 100644 --- a/crates/icp-cli/src/commands/identity/login.rs +++ b/crates/icp-cli/src/commands/identity/login.rs @@ -1,15 +1,11 @@ use clap::Args; -use ic_agent::Identity as _; use icp::{ context::{CanisterSelection, Context, EnvironmentSelection}, identity::{ IdentitySelection, key, - manifest::{IdentityKeyAlgorithm, IdentityList, IdentitySpec}, + manifest::{IdentityList, IdentitySpec}, }, }; -use pem::Pem; -use pkcs8::DecodePrivateKey as _; -use sec1::pem::PemLabel as _; use snafu::{OptionExt, ResultExt, Snafu}; use tracing::info; @@ -29,74 +25,24 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr let environment: EnvironmentSelection = args.environment.clone().into(); // Load the identity list and verify this is an II identity - let (_algorithm, der_public_key) = - ctx.dirs - .identity()? - .with_read(async |dirs| { - let list = IdentityList::load_from(dirs)?; - let spec = list - .identities - .get(&args.name) - .context(IdentityNotFoundSnafu { name: &args.name })?; - - let algorithm = match spec { - IdentitySpec::InternetIdentity { algorithm, .. } => algorithm.clone(), - _ => return NotIiSnafu { name: &args.name }.fail(), - }; - - // Load the existing PEM to get the public key - let pem_path = dirs.key_pem_path(&args.name); - let origin = key::PemOrigin::File { - path: pem_path.clone(), - }; - let doc = icp::fs::read_to_string(&pem_path)? - .parse::() - .map_err(|e| LoginError::ParsePem { - origin: origin.clone(), - source: Box::new(e), - })?; - - assert!( - doc.tag() == pkcs8::PrivateKeyInfo::PEM_LABEL, - "II identity PEM should be plaintext" - ); - - let der_public_key = match algorithm { - IdentityKeyAlgorithm::Ed25519 => { - let key = ic_ed25519::PrivateKey::deserialize_pkcs8(doc.contents()) - .map_err(|e| LoginError::ParseKey { - origin: origin.clone(), - source: Box::new(e), - })?; - let basic = - ic_agent::identity::BasicIdentity::from_raw_key(&key.serialize_raw()); - basic.public_key().expect("ed25519 always has a public key") - } - IdentityKeyAlgorithm::Secp256k1 => { - let key = k256::SecretKey::from_pkcs8_der(doc.contents()).map_err(|e| { - LoginError::ParseKey { - origin: origin.clone(), - source: Box::new(e), - } - })?; - let id = ic_agent::identity::Secp256k1Identity::from_private_key(key); - id.public_key().expect("secp256k1 always has a public key") - } - IdentityKeyAlgorithm::Prime256v1 => { - let key = p256::SecretKey::from_pkcs8_der(doc.contents()).map_err(|e| { - LoginError::ParseKey { - origin: origin.clone(), - source: Box::new(e), - } - })?; - let id = ic_agent::identity::Prime256v1Identity::from_private_key(key); - id.public_key().expect("p256 always has a public key") - } - }; - - Ok((algorithm, der_public_key)) - }) - .await??; + let algorithm = ctx + .dirs + .identity()? + .with_read(async |dirs| { + let list = IdentityList::load_from(dirs)?; + let spec = list + .identities + .get(&args.name) + .context(IdentityNotFoundSnafu { name: &args.name })?; + match spec { + IdentitySpec::InternetIdentity { algorithm, .. } => Ok(algorithm.clone()), + _ => NotIiSnafu { name: &args.name }.fail(), + } + }) + .await??; + + let der_public_key = + key::load_ii_session_public_key(&args.name, &algorithm).context(LoadSessionKeySnafu)?; // Resolve the environment to get network access let env = ctx @@ -184,21 +130,8 @@ pub(crate) enum LoginError { ))] NotIi { name: String }, - #[snafu(transparent)] - ReadFile { source: icp::fs::IoError }, - - #[snafu(display("failed to parse PEM from `{origin}`"))] - ParsePem { - origin: key::PemOrigin, - #[snafu(source(from(pem::PemError, Box::new)))] - source: Box, - }, - - #[snafu(display("failed to parse key from `{origin}`"))] - ParseKey { - origin: key::PemOrigin, - source: Box, - }, + #[snafu(display("failed to load II session key from keyring"))] + LoadSessionKey { source: key::LoadIdentityError }, #[snafu(display("failed to resolve environment"))] GetEnv { diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index bdb97aa3..21d5b79b 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -244,6 +244,15 @@ fn load_plaintext_identity( const SERVICE_NAME: &str = "icp-cli"; +/// Returns the keyring username for an II session key. +/// +/// The `ii:` prefix discriminates II session keys from regular identities — +/// no code path that operates on regular identity names can accidentally +/// access or export these keys. +fn ii_keyring_key(name: &str) -> String { + format!("ii:{name}") +} + fn load_keyring_identity( name: &str, algorithm: &IdentityKeyAlgorithm, @@ -311,12 +320,15 @@ fn load_ii_identity( name: &str, algorithm: &IdentityKeyAlgorithm, ) -> Result, LoadIdentityError> { - // Load the session keypair PEM (same path as regular PEM identities) - let pem_path = dirs.key_pem_path(name); - let origin = PemOrigin::File { - path: pem_path.clone(), + // Load the session keypair from the keyring + let username = ii_keyring_key(name); + let entry = Entry::new(SERVICE_NAME, &username).context(LoadEntrySnafu)?; + let password = entry.get_password().context(LoadPasswordFromEntrySnafu)?; + let origin = PemOrigin::Keyring { + service: SERVICE_NAME.to_string(), + username, }; - let doc = fs::read_to_string(&pem_path)? + let doc = password .parse::() .context(ParsePemSnafu { origin: &origin })?; @@ -336,7 +348,7 @@ fn load_ii_identity( let (from_key, signed_delegations) = delegation::to_agent_types(&stored_chain).context(DelegationConversionSnafu)?; - // Load the inner identity from the plaintext PEM + // Load the inner identity from the keyring-stored PEM let inner: Box = match algorithm { IdentityKeyAlgorithm::Ed25519 => { let key = ic_ed25519::PrivateKey::deserialize_pkcs8(doc.contents()) @@ -363,6 +375,49 @@ fn load_ii_identity( Ok(Arc::new(delegated)) } +/// Returns the DER-encoded public key for a stored II session key. +/// +/// Used during re-authentication to obtain the session public key to present +/// to the II delegator backend, without re-loading the full delegated identity. +pub fn load_ii_session_public_key( + name: &str, + algorithm: &IdentityKeyAlgorithm, +) -> Result, LoadIdentityError> { + let username = ii_keyring_key(name); + let entry = Entry::new(SERVICE_NAME, &username).context(LoadEntrySnafu)?; + let password = entry.get_password().context(LoadPasswordFromEntrySnafu)?; + let origin = PemOrigin::Keyring { + service: SERVICE_NAME.to_string(), + username, + }; + let doc = password + .parse::() + .context(ParsePemSnafu { origin: &origin })?; + match algorithm { + IdentityKeyAlgorithm::Ed25519 => { + let key = ic_ed25519::PrivateKey::deserialize_pkcs8(doc.contents()) + .context(ParseEd25519KeySnafu { origin: &origin })?; + Ok(BasicIdentity::from_raw_key(&key.serialize_raw()) + .public_key() + .expect("ed25519 always has a public key")) + } + IdentityKeyAlgorithm::Secp256k1 => { + let key = k256::SecretKey::from_pkcs8_der(doc.contents()) + .context(ParsePkcs8Snafu { origin: &origin })?; + Ok(Secp256k1Identity::from_private_key(key) + .public_key() + .expect("secp256k1 always has a public key")) + } + IdentityKeyAlgorithm::Prime256v1 => { + let key = p256::SecretKey::from_pkcs8_der(doc.contents()) + .context(ParsePkcs8Snafu { origin: &origin })?; + Ok(Prime256v1Identity::from_private_key(key) + .public_key() + .expect("p256 always has a public key")) + } + } +} + #[derive(Debug, Snafu)] pub enum LoadIdentityInContextError { #[snafu(transparent)] @@ -628,8 +683,8 @@ pub fn rename_identity( // Copy key material to new location before updating the list enum OldKeyMaterial { Pem(PathBuf), - PemAndDelegation(PathBuf, PathBuf), Keyring(Entry), + IiKeyringAndDelegation(Entry, PathBuf), None, } @@ -659,11 +714,18 @@ pub fn rename_identity( OldKeyMaterial::Keyring(old_entry) } IdentitySpec::InternetIdentity { .. } => { - // Copy both PEM and delegation chain - 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)?; + // Copy the keyring entry (ii:-prefixed) and the delegation chain + 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 })?; let old_delegation = dirs.delegation_chain_path(old_name); let new_delegation = dirs @@ -672,7 +734,7 @@ pub fn rename_identity( let delegation_contents = fs::read(&old_delegation).context(CopyKeyFileSnafu)?; fs::write(&new_delegation, &delegation_contents).context(CopyKeyFileSnafu)?; - OldKeyMaterial::PemAndDelegation(old_pem, old_delegation) + OldKeyMaterial::IiKeyringAndDelegation(old_entry, old_delegation) } IdentitySpec::Hsm { .. } => { // No migration required - HSM key stays on device @@ -704,8 +766,10 @@ pub fn rename_identity( .delete_credential() .context(DeleteKeyringEntrySnafu { old_name })?; } - OldKeyMaterial::PemAndDelegation(old_pem, old_delegation) => { - fs::remove_file(&old_pem).context(DeleteOldKeyFileSnafu)?; + OldKeyMaterial::IiKeyringAndDelegation(old_entry, old_delegation) => { + old_entry + .delete_credential() + .context(DeleteKeyringEntrySnafu { old_name })?; fs::remove_file(&old_delegation).context(DeleteOldKeyFileSnafu)?; } OldKeyMaterial::None => { @@ -793,8 +857,11 @@ pub fn delete_identity( .context(DeleteKeyringEntryForDeleteSnafu { name })?; } IdentitySpec::InternetIdentity { .. } => { - let pem_path = dirs.key_pem_path(name); - fs::remove_file(&pem_path)?; + let entry = Entry::new(SERVICE_NAME, &ii_keyring_key(name)) + .context(LoadKeyringEntryForDeleteSnafu { name })?; + entry + .delete_credential() + .context(DeleteKeyringEntryForDeleteSnafu { name })?; let delegation_path = dirs.delegation_chain_path(name); fs::remove_file(&delegation_path)?; } @@ -872,8 +939,17 @@ pub enum LinkIiIdentityError { #[snafu(display("identity `{name}` already exists"))] IiNameTaken { name: String }, - #[snafu(transparent)] - WriteIiKey { source: WriteIdentityError }, + #[snafu(display("failed to create II session key keyring entry"))] + CreateIiKeyringEntry { source: keyring::Error }, + + #[snafu(display("failed to store II session key in keyring"))] + SetIiKeyringEntryPassword { 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, #[snafu(display("failed to create delegation directory"))] CreateIiDelegationDir { source: crate::fs::IoError }, @@ -887,8 +963,8 @@ pub enum LinkIiIdentityError { /// Links an Internet Identity delegation to a new named identity. /// -/// Stores the session keypair as a plaintext PEM and the delegation chain as -/// a separate JSON file. +/// Stores the session keypair in the system keyring (under the `ii:{name}` key) +/// and the delegation chain as a separate JSON file. pub fn link_ii_identity( dirs: LWrite<&IdentityPaths>, name: &str, @@ -920,7 +996,17 @@ pub fn link_ii_identity( let pem = doc .to_pem(PrivateKeyInfo::PEM_LABEL, Default::default()) .expect("infallible PKI encoding"); - write_identity(dirs, name, &pem)?; + + let entry = + Entry::new(SERVICE_NAME, &ii_keyring_key(name)).context(CreateIiKeyringEntrySnafu)?; + 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()?; + } + res.context(SetIiKeyringEntryPasswordSnafu)?; let delegation_path = dirs .ensure_delegation_chain_path(name) From 615130d8f776de1d68ee825963540bfe1a8bdefd Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 9 Apr 2026 12:44:31 -0700 Subject: [PATCH 07/12] New flow and fixed mainnet canister --- Cargo.lock | 74 +++++- Cargo.toml | 1 + crates/icp-cli/Cargo.toml | 1 + .../icp-cli/src/commands/identity/link/ii.rs | 97 +------ crates/icp-cli/src/commands/identity/login.rs | 92 +------ crates/icp-cli/src/operations/ii_poll.rs | 244 +++++++++++------- docs/reference/cli.md | 12 +- 7 files changed, 241 insertions(+), 280 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a06984f0..48269bbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -531,6 +531,58 @@ dependencies = [ "url", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backoff" version = "0.4.0" @@ -3388,6 +3440,7 @@ dependencies = [ "assert_cmd", "async-trait", "axoupdater", + "axum", "backoff", "base64", "bigdecimal", @@ -4256,6 +4309,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -6197,6 +6256,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -6700,7 +6770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -7058,6 +7128,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -7096,6 +7167,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index 9acadae8..20052b94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ assert_cmd = "2" async-dropper = { version = "0.3.0", features = ["tokio", "simple"] } async-trait = "0.1.88" axoupdater = "0.10.0" +axum = "0.8" base64 = "0.22" backoff = { version = "0.4", features = ["tokio"] } bigdecimal = "0.4.10" diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 10aa0d45..f940f703 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -15,6 +15,7 @@ anstyle.workspace = true anyhow.workspace = true async-trait.workspace = true axoupdater.workspace = true +axum.workspace = true backoff.workspace = true base64.workspace = true bigdecimal.workspace = true diff --git a/crates/icp-cli/src/commands/identity/link/ii.rs b/crates/icp-cli/src/commands/identity/link/ii.rs index 8a74ee8b..08943a6b 100644 --- a/crates/icp-cli/src/commands/identity/link/ii.rs +++ b/crates/icp-cli/src/commands/identity/link/ii.rs @@ -1,95 +1,31 @@ use clap::Args; use ic_agent::{Identity as _, export::Principal, identity::BasicIdentity}; -use icp::{ - context::{CanisterSelection, Context, EnvironmentSelection}, - identity::{IdentitySelection, key}, -}; -use snafu::{OptionExt, ResultExt, Snafu}; +use icp::{context::Context, identity::key}; +use snafu::{ResultExt, Snafu}; use tracing::info; -use crate::{operations::ii_poll, options::EnvironmentOpt}; +use crate::operations::ii_poll; /// Link an Internet Identity to a new identity #[derive(Debug, Args)] pub(crate) struct IiArgs { /// Name for the linked identity name: String, - - #[command(flatten)] - environment: EnvironmentOpt, } pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { - let environment: EnvironmentSelection = args.environment.clone().into(); - - // Generate an Ed25519 keypair for the session key let secret_key = ic_ed25519::PrivateKey::generate(); let identity_key = key::IdentityKey::Ed25519(secret_key.clone()); let basic = BasicIdentity::from_raw_key(&secret_key.serialize_raw()); let der_public_key = basic.public_key().expect("ed25519 always has a public key"); - // Resolve the environment to get network access - let env = ctx - .get_environment(&environment) - .await - .context(GetEnvSnafu)?; - let network_access = ctx - .network - .access(&env.network) + let chain = ii_poll::poll_for_delegation(&der_public_key) .await - .context(NetworkAccessSnafu)?; + .context(PollSnafu)?; - let http_gateway_url = network_access - .http_gateway_url - .as_ref() - .context(NoHttpGatewaySnafu)?; - - // Create an anonymous agent for polling - let agent = ctx - .get_agent_for_env(&IdentitySelection::Anonymous, &environment) - .await - .context(CreateAgentSnafu)?; - - // Look up the cli-backend canister ID - let delegator_backend_id = ctx - .get_canister_id_for_env( - &CanisterSelection::Named("backend".to_string()), - &environment, - ) - .await - .context(LookupCanisterSnafu)?; - - let delegator_frontend_id = ctx - .get_canister_id_for_env( - &CanisterSelection::Named("frontend".to_string()), - &environment, - ) - .await - .context(LookupCanisterSnafu)?; - - let delegator_frontend_friendly = if network_access.use_friendly_domains { - Some(("frontend", env.name.as_str())) - } else { - None - }; - - // Open browser and poll for delegation - let chain = ii_poll::poll_for_delegation( - &agent, - delegator_backend_id, - delegator_frontend_id, - &der_public_key, - http_gateway_url, - delegator_frontend_friendly, - ) - .await - .context(PollSnafu)?; - - // Derive the II principal from the root of the delegation chain let from_key = hex::decode(&chain.public_key).context(DecodeFromKeySnafu)?; let ii_principal = Principal::self_authenticating(&from_key); - // Save the identity ctx.dirs .identity()? .with_write(async |dirs| { @@ -105,28 +41,7 @@ pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { #[derive(Debug, Snafu)] pub(crate) enum IiError { - #[snafu(display("failed to resolve environment"))] - GetEnv { - source: icp::context::GetEnvironmentError, - }, - - #[snafu(display("failed to access network"))] - NetworkAccess { source: icp::network::AccessError }, - - #[snafu(display("network has no HTTP gateway URL configured"))] - NoHttpGateway, - - #[snafu(display("failed to create agent"))] - CreateAgent { - source: icp::context::GetAgentForEnvError, - }, - - #[snafu(display("failed to look up cli-backend canister ID"))] - LookupCanister { - source: icp::context::GetCanisterIdForEnvError, - }, - - #[snafu(display("failed during II authentication polling"))] + #[snafu(display("failed during II authentication"))] Poll { source: ii_poll::IiPollError }, #[snafu(display("invalid public key in delegation chain"))] diff --git a/crates/icp-cli/src/commands/identity/login.rs b/crates/icp-cli/src/commands/identity/login.rs index bd942b88..058f5738 100644 --- a/crates/icp-cli/src/commands/identity/login.rs +++ b/crates/icp-cli/src/commands/identity/login.rs @@ -1,30 +1,24 @@ use clap::Args; use icp::{ - context::{CanisterSelection, Context, EnvironmentSelection}, + context::Context, identity::{ - IdentitySelection, key, + key, manifest::{IdentityList, IdentitySpec}, }, }; use snafu::{OptionExt, ResultExt, Snafu}; use tracing::info; -use crate::{operations::ii_poll, options::EnvironmentOpt}; +use crate::operations::ii_poll; /// Re-authenticate an Internet Identity delegation #[derive(Debug, Args)] pub(crate) struct LoginArgs { /// Name of the identity to re-authenticate name: String, - - #[command(flatten)] - environment: EnvironmentOpt, } pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginError> { - let environment: EnvironmentSelection = args.environment.clone().into(); - - // Load the identity list and verify this is an II identity let algorithm = ctx .dirs .identity()? @@ -44,63 +38,10 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr let der_public_key = key::load_ii_session_public_key(&args.name, &algorithm).context(LoadSessionKeySnafu)?; - // Resolve the environment to get network access - let env = ctx - .get_environment(&environment) - .await - .context(GetEnvSnafu)?; - let network_access = ctx - .network - .access(&env.network) - .await - .context(NetworkAccessSnafu)?; - - let http_gateway_url = network_access - .http_gateway_url - .as_ref() - .context(NoHttpGatewaySnafu)?; - - // Create an anonymous agent for polling - let agent = ctx - .get_agent_for_env(&IdentitySelection::Anonymous, &environment) - .await - .context(CreateAgentSnafu)?; - - // Look up the cli-backend canister ID - let delegator_backend_id = ctx - .get_canister_id_for_env( - &CanisterSelection::Named("backend".to_string()), - &environment, - ) + let chain = ii_poll::poll_for_delegation(&der_public_key) .await - .context(LookupCanisterSnafu)?; - let delegator_frontend_id = ctx - .get_canister_id_for_env( - &CanisterSelection::Named("frontend".to_string()), - &environment, - ) - .await - .context(LookupCanisterSnafu)?; - - let delegator_frontend_friendly = if network_access.use_friendly_domains { - Some(("frontend", env.name.as_str())) - } else { - None - }; + .context(PollSnafu)?; - // Open browser and poll for delegation - let chain = ii_poll::poll_for_delegation( - &agent, - delegator_backend_id, - delegator_frontend_id, - &der_public_key, - http_gateway_url, - delegator_frontend_friendly, - ) - .await - .context(PollSnafu)?; - - // Update the delegation chain ctx.dirs .identity()? .with_write(async |dirs| key::update_ii_delegation(dirs, &args.name, &chain)) @@ -133,28 +74,7 @@ pub(crate) enum LoginError { #[snafu(display("failed to load II session key from keyring"))] LoadSessionKey { source: key::LoadIdentityError }, - #[snafu(display("failed to resolve environment"))] - GetEnv { - source: icp::context::GetEnvironmentError, - }, - - #[snafu(display("failed to access network"))] - NetworkAccess { source: icp::network::AccessError }, - - #[snafu(display("network has no HTTP gateway URL configured"))] - NoHttpGateway, - - #[snafu(display("failed to create agent"))] - CreateAgent { - source: icp::context::GetAgentForEnvError, - }, - - #[snafu(display("failed to look up cli-backend canister ID"))] - LookupCanister { - source: icp::context::GetCanisterIdForEnvError, - }, - - #[snafu(display("failed during II authentication polling"))] + #[snafu(display("failed during II authentication"))] Poll { source: ii_poll::IiPollError }, #[snafu(display("failed to update delegation"))] diff --git a/crates/icp-cli/src/operations/ii_poll.rs b/crates/icp-cli/src/operations/ii_poll.rs index e7011f21..2d7b8a53 100644 --- a/crates/icp-cli/src/operations/ii_poll.rs +++ b/crates/icp-cli/src/operations/ii_poll.rs @@ -1,85 +1,82 @@ -use std::time::Duration; - +use std::net::SocketAddr; + +use axum::{ + Router, + extract::State, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::IntoResponse, + routing::post, +}; use base64::engine::{Engine as _, general_purpose::URL_SAFE_NO_PAD}; -use candid::{CandidType, Decode, Encode}; -use ic_agent::{Agent, export::Principal}; -use icp::{identity::delegation::DelegationChain, network::custom_domains, signal}; +use icp::{identity::delegation::DelegationChain, signal}; use indicatif::{ProgressBar, ProgressStyle}; -use serde::Deserialize; use snafu::{ResultExt, Snafu}; +use tokio::{net::TcpListener, sync::oneshot}; use url::Url; -/// Candid `RegisterResult` variant from the cli-backend canister. -#[derive(Debug, Clone, CandidType, Deserialize)] -pub(crate) enum RegisterResult { - #[serde(rename = "ok")] - Ok(String), - #[serde(rename = "err")] - Err(String), -} +/// The hosted II login frontend for the dev account canister. +/// `ut7yr-7iaaa-aaaag-ak7ca-cai` (text) = canister on the IC mainnet. +const CLI_LOGIN_BASE: &str = "https://ut7yr-7iaaa-aaaag-ak7ca-cai.icp0.io/cli-login"; #[derive(Debug, Snafu)] pub(crate) enum IiPollError { - #[snafu(display("failed to register session with cli-backend canister"))] - Register { source: ic_agent::AgentError }, - - #[snafu(display("cli-backend canister rejected registration: {message}"))] - RegisterRejected { message: String }, + #[snafu(display("failed to bind local callback server"))] + BindServer { source: std::io::Error }, - #[snafu(display("failed to query cli-backend canister"))] - Query { source: ic_agent::AgentError }, - - #[snafu(display("failed to decode candid response"))] - CandidDecode { source: candid::Error }, + #[snafu(display("failed to run local callback server"))] + ServeServer { source: std::io::Error }, #[snafu(display("interrupted"))] Interrupted, } -/// Registers a session with the cli-backend canister, prints a one-time code -/// for the user to enter on the login website, and polls until the delegation -/// chain is stored. Returns the received delegation chain. +/// Starts a local HTTP server to receive the delegation callback from the II +/// frontend, prints the login URL for the user to open, and returns the +/// delegation chain once the frontend POSTs it back. pub(crate) async fn poll_for_delegation( - agent: &Agent, - delegator_backend_id: Principal, - delegator_frontend_id: Principal, der_public_key: &[u8], - http_gateway_url: &Url, - delegator_frontend_friendly_name: Option<(&str, &str)>, ) -> Result { - let uuid = uuid::Uuid::new_v4().to_string(); let key_b64 = URL_SAFE_NO_PAD.encode(der_public_key); - // Register the session with the backend canister - let register_args = Encode!(&uuid, &key_b64).expect("infallible candid encode"); - let register_response = agent - .update(&delegator_backend_id, "register") - .with_arg(register_args) - .call_and_wait() + // Bind on a random port before opening the browser so the callback URL is known. + let listener = TcpListener::bind("127.0.0.1:0") .await - .context(RegisterSnafu)?; + .context(BindServerSnafu)?; + let addr: SocketAddr = listener.local_addr().context(BindServerSnafu)?; + let callback_url = format!("http://127.0.0.1:{}/", addr.port()); + + // Build the fragment as a URLSearchParams-compatible string so the frontend + // can parse it with `new URLSearchParams(location.hash.slice(1))`. + let fragment = { + let mut scratch = Url::parse("x:?").expect("infallible"); + scratch + .query_pairs_mut() + .append_pair("public_key", &key_b64) + .append_pair("callback", &callback_url); + scratch.query().expect("just set").to_owned() + }; + let mut login_url = Url::parse(CLI_LOGIN_BASE).expect("valid constant"); + login_url.set_fragment(Some(&fragment)); - let result = Decode!(®ister_response, RegisterResult).context(CandidDecodeSnafu)?; + eprintln!(); + eprintln!(" Press Enter to open {}", { + let mut display = login_url.clone(); + display.set_fragment(None); + display + }); - let code = match result { - RegisterResult::Ok(code) => code, - RegisterResult::Err(message) => return RegisterRejectedSnafu { message }.fail(), - }; + let (chain_tx, chain_rx) = oneshot::channel::(); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - // Construct the frontend login URL - let mut login_url = custom_domains::canister_gateway_url( - http_gateway_url, - delegator_frontend_id, - delegator_frontend_friendly_name, - ); - login_url.set_path("/cli-login"); + // chain_tx is wrapped in an Option so the handler can take ownership. + let state = CallbackState { + chain_tx: std::sync::Mutex::new(Some(chain_tx)), + shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)), + }; - eprintln!(); - eprintln!(" Your one-time code is:"); - eprintln!(); - eprintln!(" {code}"); - eprintln!(); - eprintln!(" Press Enter to open {login_url}"); + let app = Router::new() + .route("/", post(handle_callback).options(handle_preflight)) + .with_state(std::sync::Arc::new(state)); let spinner = ProgressBar::new_spinner(); spinner.set_style( @@ -88,10 +85,7 @@ pub(crate) async fn poll_for_delegation( .expect("valid template"), ); - let poll_args = Encode!(&uuid).expect("infallible candid encode"); - - // Spawn a detached thread for stdin. - // If this is done with tokio instead, because the read never completes, the runtime hangs. + // Detached thread for stdin — tokio's async stdin keeps the runtime alive on drop. let (enter_tx, mut enter_rx) = tokio::sync::mpsc::channel::<()>(1); std::thread::spawn(move || { let mut buf = String::new(); @@ -99,41 +93,107 @@ pub(crate) async fn poll_for_delegation( let _ = enter_tx.blocking_send(()); }); + let serve = axum::serve(listener, app).with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }); + let mut browser_opened = false; - let mut fuse = false; - loop { - if browser_opened && !fuse { - spinner.set_message("Waiting for Internet Identity authentication..."); - spinner.enable_steady_tick(Duration::from_millis(100)); - fuse = true; + let result = tokio::select! { + _ = signal::stop_signal() => { + spinner.finish_and_clear(); + return InterruptedSnafu.fail(); } - - tokio::select! { - _ = signal::stop_signal() => { - spinner.finish_and_clear(); - return InterruptedSnafu.fail(); - } - _ = enter_rx.recv(), if !browser_opened => { - browser_opened = true; - let _ = open::that(login_url.as_str()); - } - _ = tokio::time::sleep(Duration::from_secs(2)) => { - let response = agent - .query(&delegator_backend_id, "get_delegation") - .with_arg(poll_args.clone()) - .call() - .await - .context(QuerySnafu)?; - - let chain = Decode!(&response, Option) - .context(CandidDecodeSnafu)?; - - if let Some(chain) = chain { - spinner.finish_and_clear(); - return Ok(chain); + res = serve.into_future() => { + res.context(ServeServerSnafu)?; + // Server shut down before we got a chain — shouldn't happen. + return InterruptedSnafu.fail(); + } + _ = async { + loop { + tokio::select! { + _ = enter_rx.recv(), if !browser_opened => { + browser_opened = true; + spinner.set_message("Waiting for Internet Identity authentication..."); + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + let _ = open::that(login_url.as_str()); + } + // Yield so the other branches in the outer select! can fire. + _ = tokio::task::yield_now() => {} } } + } => { unreachable!() } + chain = chain_rx => chain, + }; + + spinner.finish_and_clear(); + Ok(result.expect("sender only dropped after sending")) +} + +#[derive(Debug)] +struct CallbackState { + chain_tx: std::sync::Mutex>>, + shutdown_tx: std::sync::Mutex>>, +} + +fn cors_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("POST, OPTIONS"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("content-type"), + ); + headers +} + +async fn handle_preflight() -> impl IntoResponse { + (StatusCode::NO_CONTENT, cors_headers()) +} + +async fn handle_callback( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> impl IntoResponse { + // Only accept POST with JSON content. + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if !content_type.starts_with("application/json") { + return ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + cors_headers(), + "expected application/json", + ) + .into_response(); + } + + let chain: DelegationChain = match serde_json::from_slice(&body) { + Ok(c) => c, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + cors_headers(), + "invalid delegation chain", + ) + .into_response(); } + }; + + if let Some(tx) = state.chain_tx.lock().unwrap().take() { + let _ = tx.send(chain); } + if let Some(tx) = state.shutdown_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + + (StatusCode::OK, cors_headers(), "").into_response() } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9e30c202..77559c2d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1047,16 +1047,12 @@ Link an HSM key to a new identity Link an Internet Identity to a new identity -**Usage:** `icp identity link ii [OPTIONS] ` +**Usage:** `icp identity link ii ` ###### **Arguments:** * `` — Name for the linked identity -###### **Options:** - -* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used - ## `icp identity list` @@ -1076,16 +1072,12 @@ List the identities Re-authenticate an Internet Identity delegation -**Usage:** `icp identity login [OPTIONS] ` +**Usage:** `icp identity login ` ###### **Arguments:** * `` — Name of the identity to re-authenticate -###### **Options:** - -* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used - ## `icp identity new` From 543a252dcc74577c29ed7ca06a6fefed2ab7b60d Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 10 Apr 2026 06:03:18 -0700 Subject: [PATCH 08/12] Add storage mode parameter --- .../icp-cli/src/commands/identity/link/ii.rs | 59 +++- crates/icp-cli/src/commands/identity/login.rs | 24 +- crates/icp-cli/src/operations/ii_poll.rs | 1 - crates/icp/src/identity/key.rs | 330 +++++++++++++----- crates/icp/src/identity/manifest.rs | 8 + docs/reference/cli.md | 12 +- 6 files changed, 342 insertions(+), 92 deletions(-) diff --git a/crates/icp-cli/src/commands/identity/link/ii.rs b/crates/icp-cli/src/commands/identity/link/ii.rs index 08943a6b..1abd8386 100644 --- a/crates/icp-cli/src/commands/identity/link/ii.rs +++ b/crates/icp-cli/src/commands/identity/link/ii.rs @@ -1,19 +1,51 @@ use clap::Args; +use dialoguer::Password; +use elliptic_curve::zeroize::Zeroizing; use ic_agent::{Identity as _, export::Principal, identity::BasicIdentity}; -use icp::{context::Context, identity::key}; +use icp::{context::Context, fs::read_to_string, identity::key, prelude::*}; use snafu::{ResultExt, Snafu}; -use tracing::info; +use tracing::{info, warn}; -use crate::operations::ii_poll; +use crate::{commands::identity::StorageMode, operations::ii_poll}; /// Link an Internet Identity to a new identity #[derive(Debug, Args)] pub(crate) struct IiArgs { /// Name for the linked 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: &IiArgs) -> Result<(), IiError> { + 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 secret_key = ic_ed25519::PrivateKey::generate(); let identity_key = key::IdentityKey::Ed25519(secret_key.clone()); let basic = BasicIdentity::from_raw_key(&secret_key.serialize_raw()); @@ -29,18 +61,37 @@ pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { ctx.dirs .identity()? .with_write(async |dirs| { - key::link_ii_identity(dirs, &args.name, identity_key, &chain, ii_principal) + key::link_ii_identity( + dirs, + &args.name, + identity_key, + &chain, + ii_principal, + create_format, + ) }) .await? .context(LinkSnafu)?; info!("Identity `{}` linked to Internet Identity", args.name); + 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 IiError { + #[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(display("failed during II authentication"))] Poll { source: ii_poll::IiPollError }, diff --git a/crates/icp-cli/src/commands/identity/login.rs b/crates/icp-cli/src/commands/identity/login.rs index 058f5738..f89db6f0 100644 --- a/crates/icp-cli/src/commands/identity/login.rs +++ b/crates/icp-cli/src/commands/identity/login.rs @@ -1,4 +1,5 @@ use clap::Args; +use dialoguer::Password; use icp::{ context::Context, identity::{ @@ -19,7 +20,7 @@ pub(crate) struct LoginArgs { } pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginError> { - let algorithm = ctx + let (algorithm, storage) = ctx .dirs .identity()? .with_read(async |dirs| { @@ -29,14 +30,27 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr .get(&args.name) .context(IdentityNotFoundSnafu { name: &args.name })?; match spec { - IdentitySpec::InternetIdentity { algorithm, .. } => Ok(algorithm.clone()), + IdentitySpec::InternetIdentity { + algorithm, storage, .. + } => Ok((algorithm.clone(), storage.clone())), _ => NotIiSnafu { name: &args.name }.fail(), } }) .await??; - let der_public_key = - key::load_ii_session_public_key(&args.name, &algorithm).context(LoadSessionKeySnafu)?; + let der_public_key = ctx + .dirs + .identity()? + .with_read(async |dirs| { + key::load_ii_session_public_key(dirs, &args.name, &algorithm, &storage, || { + Password::new() + .with_prompt("Enter identity password") + .interact() + .map_err(|e| e.to_string()) + }) + }) + .await? + .context(LoadSessionKeySnafu)?; let chain = ii_poll::poll_for_delegation(&der_public_key) .await @@ -71,7 +85,7 @@ pub(crate) enum LoginError { ))] NotIi { name: String }, - #[snafu(display("failed to load II session key from keyring"))] + #[snafu(display("failed to load II session key"))] LoadSessionKey { source: key::LoadIdentityError }, #[snafu(display("failed during II authentication"))] diff --git a/crates/icp-cli/src/operations/ii_poll.rs b/crates/icp-cli/src/operations/ii_poll.rs index 2d7b8a53..c6ecd328 100644 --- a/crates/icp-cli/src/operations/ii_poll.rs +++ b/crates/icp-cli/src/operations/ii_poll.rs @@ -15,7 +15,6 @@ use tokio::{net::TcpListener, sync::oneshot}; use url::Url; /// The hosted II login frontend for the dev account canister. -/// `ut7yr-7iaaa-aaaag-ak7ca-cai` (text) = canister on the IC mainnet. const CLI_LOGIN_BASE: &str = "https://ut7yr-7iaaa-aaaag-ak7ca-cai.icp0.io/cli-login"; #[derive(Debug, Snafu)] diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index 21d5b79b..97f0a9a9 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -31,7 +31,7 @@ use crate::{ identity::{ IdentityPaths, delegation, manifest::{ - IdentityDefaults, IdentityKeyAlgorithm, IdentityList, IdentitySpec, + IdentityDefaults, IdentityKeyAlgorithm, IdentityList, IdentitySpec, IiKeyStorage, LoadIdentityManifestError, PemFormat, WriteIdentityManifestError, }, }, @@ -146,7 +146,9 @@ pub fn load_identity( .. } => load_hsm_identity(module, *slot, key_id, password_func), IdentitySpec::Anonymous => Ok(Arc::new(AnonymousIdentity)), - IdentitySpec::InternetIdentity { algorithm, .. } => load_ii_identity(dirs, name, algorithm), + IdentitySpec::InternetIdentity { + algorithm, storage, .. + } => load_ii_identity(dirs, name, algorithm, storage, password_func), } } @@ -319,18 +321,10 @@ fn load_ii_identity( dirs: LRead<&IdentityPaths>, name: &str, algorithm: &IdentityKeyAlgorithm, + storage: &IiKeyStorage, + password_func: impl FnOnce() -> Result, ) -> Result, LoadIdentityError> { - // Load the session keypair from the keyring - let username = ii_keyring_key(name); - let entry = Entry::new(SERVICE_NAME, &username).context(LoadEntrySnafu)?; - let password = entry.get_password().context(LoadPasswordFromEntrySnafu)?; - let origin = PemOrigin::Keyring { - service: SERVICE_NAME.to_string(), - username, - }; - let doc = password - .parse::() - .context(ParsePemSnafu { origin: &origin })?; + let (doc, origin) = load_ii_session_pem(dirs, name, storage)?; // Load the delegation chain let chain_path = dirs.delegation_chain_path(name); @@ -348,22 +342,59 @@ fn load_ii_identity( let (from_key, signed_delegations) = delegation::to_agent_types(&stored_chain).context(DelegationConversionSnafu)?; - // Load the inner identity from the keyring-stored PEM - let inner: Box = match algorithm { - IdentityKeyAlgorithm::Ed25519 => { - let key = ic_ed25519::PrivateKey::deserialize_pkcs8(doc.contents()) - .context(ParseEd25519KeySnafu { origin: &origin })?; - Box::new(BasicIdentity::from_raw_key(&key.serialize_raw())) - } - IdentityKeyAlgorithm::Secp256k1 => { - let key = k256::SecretKey::from_pkcs8_der(doc.contents()) - .context(ParsePkcs8Snafu { origin: &origin })?; - Box::new(Secp256k1Identity::from_private_key(key)) - } - IdentityKeyAlgorithm::Prime256v1 => { - let key = p256::SecretKey::from_pkcs8_der(doc.contents()) - .context(ParsePkcs8Snafu { origin: &origin })?; - Box::new(Prime256v1Identity::from_private_key(key)) + let pem_format = match storage { + IiKeyStorage::Keyring + | IiKeyStorage::Pem { + format: PemFormat::Plaintext, + } => PemFormat::Plaintext, + IiKeyStorage::Pem { + format: PemFormat::Pbes2, + } => PemFormat::Pbes2, + }; + + let inner: Box = match pem_format { + PemFormat::Plaintext => match algorithm { + IdentityKeyAlgorithm::Ed25519 => { + let key = ic_ed25519::PrivateKey::deserialize_pkcs8(doc.contents()) + .context(ParseEd25519KeySnafu { origin: &origin })?; + Box::new(BasicIdentity::from_raw_key(&key.serialize_raw())) + } + IdentityKeyAlgorithm::Secp256k1 => { + let key = k256::SecretKey::from_pkcs8_der(doc.contents()) + .context(ParsePkcs8Snafu { origin: &origin })?; + Box::new(Secp256k1Identity::from_private_key(key)) + } + IdentityKeyAlgorithm::Prime256v1 => { + let key = p256::SecretKey::from_pkcs8_der(doc.contents()) + .context(ParsePkcs8Snafu { origin: &origin })?; + Box::new(Prime256v1Identity::from_private_key(key)) + } + }, + PemFormat::Pbes2 => { + let pw = password_func() + .map_err(|message| LoadIdentityError::GetPasswordError { message })?; + match algorithm { + IdentityKeyAlgorithm::Ed25519 => { + let encrypted = EncryptedPrivateKeyInfo::from_der(doc.contents()) + .context(ParseDerSnafu { origin: &origin })?; + let decrypted: SecretDocument = encrypted + .decrypt(&pw) + .context(ParsePkcs8Snafu { origin: &origin })?; + let key = ic_ed25519::PrivateKey::deserialize_pkcs8(decrypted.as_bytes()) + .context(ParseEd25519KeySnafu { origin: &origin })?; + Box::new(BasicIdentity::from_raw_key(&key.serialize_raw())) + } + IdentityKeyAlgorithm::Secp256k1 => { + let key = k256::SecretKey::from_pkcs8_encrypted_der(doc.contents(), &pw) + .context(ParsePkcs8Snafu { origin: &origin })?; + Box::new(Secp256k1Identity::from_private_key(key)) + } + IdentityKeyAlgorithm::Prime256v1 => { + let key = p256::SecretKey::from_pkcs8_encrypted_der(doc.contents(), &pw) + .context(ParsePkcs8Snafu { origin: &origin })?; + Box::new(Prime256v1Identity::from_private_key(key)) + } + } } }; @@ -377,40 +408,122 @@ fn load_ii_identity( /// Returns the DER-encoded public key for a stored II session key. /// -/// Used during re-authentication to obtain the session public key to present -/// to the II delegator backend, without re-loading the full delegated identity. +/// Used during re-authentication to obtain the session public key without +/// re-loading the full delegated identity. pub fn load_ii_session_public_key( + dirs: LRead<&IdentityPaths>, name: &str, algorithm: &IdentityKeyAlgorithm, + storage: &IiKeyStorage, + password_func: impl FnOnce() -> Result, +) -> Result, LoadIdentityError> { + let (doc, origin) = load_ii_session_pem(dirs, name, storage)?; + + match storage { + IiKeyStorage::Keyring + | IiKeyStorage::Pem { + format: PemFormat::Plaintext, + } => load_ii_public_key_plaintext(&doc, algorithm, &origin), + IiKeyStorage::Pem { + format: PemFormat::Pbes2, + } => { + let pw = password_func() + .map_err(|message| LoadIdentityError::GetPasswordError { message })?; + load_ii_public_key_pbes2(&doc, algorithm, &origin, &pw) + } + } +} + +fn load_ii_session_pem( + dirs: LRead<&IdentityPaths>, + name: &str, + storage: &IiKeyStorage, +) -> Result<(Pem, PemOrigin), LoadIdentityError> { + match storage { + IiKeyStorage::Keyring => { + let username = ii_keyring_key(name); + let entry = Entry::new(SERVICE_NAME, &username).context(LoadEntrySnafu)?; + let pem_str = entry.get_password().context(LoadPasswordFromEntrySnafu)?; + let origin = PemOrigin::Keyring { + service: SERVICE_NAME.to_string(), + username, + }; + let doc = pem_str + .parse::() + .context(ParsePemSnafu { origin: &origin })?; + Ok((doc, origin)) + } + IiKeyStorage::Pem { .. } => { + let pem_path = dirs.key_pem_path(name); + let origin = PemOrigin::File { + path: pem_path.clone(), + }; + let doc = fs::read_to_string(&pem_path)? + .parse::() + .context(ParsePemSnafu { origin: &origin })?; + Ok((doc, origin)) + } + } +} + +fn load_ii_public_key_plaintext( + doc: &Pem, + algorithm: &IdentityKeyAlgorithm, + origin: &PemOrigin, ) -> Result, LoadIdentityError> { - let username = ii_keyring_key(name); - let entry = Entry::new(SERVICE_NAME, &username).context(LoadEntrySnafu)?; - let password = entry.get_password().context(LoadPasswordFromEntrySnafu)?; - let origin = PemOrigin::Keyring { - service: SERVICE_NAME.to_string(), - username, - }; - let doc = password - .parse::() - .context(ParsePemSnafu { origin: &origin })?; match algorithm { IdentityKeyAlgorithm::Ed25519 => { let key = ic_ed25519::PrivateKey::deserialize_pkcs8(doc.contents()) - .context(ParseEd25519KeySnafu { origin: &origin })?; + .context(ParseEd25519KeySnafu { origin })?; Ok(BasicIdentity::from_raw_key(&key.serialize_raw()) .public_key() .expect("ed25519 always has a public key")) } IdentityKeyAlgorithm::Secp256k1 => { let key = k256::SecretKey::from_pkcs8_der(doc.contents()) - .context(ParsePkcs8Snafu { origin: &origin })?; + .context(ParsePkcs8Snafu { origin })?; Ok(Secp256k1Identity::from_private_key(key) .public_key() .expect("secp256k1 always has a public key")) } IdentityKeyAlgorithm::Prime256v1 => { let key = p256::SecretKey::from_pkcs8_der(doc.contents()) - .context(ParsePkcs8Snafu { origin: &origin })?; + .context(ParsePkcs8Snafu { origin })?; + Ok(Prime256v1Identity::from_private_key(key) + .public_key() + .expect("p256 always has a public key")) + } + } +} + +fn load_ii_public_key_pbes2( + doc: &Pem, + algorithm: &IdentityKeyAlgorithm, + origin: &PemOrigin, + pw: &str, +) -> Result, LoadIdentityError> { + match algorithm { + IdentityKeyAlgorithm::Ed25519 => { + let encrypted = EncryptedPrivateKeyInfo::from_der(doc.contents()) + .context(ParseDerSnafu { origin })?; + let decrypted: SecretDocument = + encrypted.decrypt(pw).context(ParsePkcs8Snafu { origin })?; + let key = ic_ed25519::PrivateKey::deserialize_pkcs8(decrypted.as_bytes()) + .context(ParseEd25519KeySnafu { origin })?; + Ok(BasicIdentity::from_raw_key(&key.serialize_raw()) + .public_key() + .expect("ed25519 always has a public key")) + } + IdentityKeyAlgorithm::Secp256k1 => { + let key = k256::SecretKey::from_pkcs8_encrypted_der(doc.contents(), pw) + .context(ParsePkcs8Snafu { origin })?; + Ok(Secp256k1Identity::from_private_key(key) + .public_key() + .expect("secp256k1 always has a public key")) + } + IdentityKeyAlgorithm::Prime256v1 => { + let key = p256::SecretKey::from_pkcs8_encrypted_der(doc.contents(), pw) + .context(ParsePkcs8Snafu { origin })?; Ok(Prime256v1Identity::from_private_key(key) .public_key() .expect("p256 always has a public key")) @@ -685,6 +798,7 @@ pub fn rename_identity( Pem(PathBuf), Keyring(Entry), IiKeyringAndDelegation(Entry, PathBuf), + IiPemAndDelegation(PathBuf, PathBuf), None, } @@ -713,20 +827,7 @@ pub fn rename_identity( OldKeyMaterial::Keyring(old_entry) } - IdentitySpec::InternetIdentity { .. } => { - // Copy the keyring entry (ii:-prefixed) and the delegation chain - 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 })?; - + IdentitySpec::InternetIdentity { storage, .. } => { let old_delegation = dirs.delegation_chain_path(old_name); let new_delegation = dirs .ensure_delegation_chain_path(new_name) @@ -734,7 +835,28 @@ pub fn rename_identity( let delegation_contents = fs::read(&old_delegation).context(CopyKeyFileSnafu)?; fs::write(&new_delegation, &delegation_contents).context(CopyKeyFileSnafu)?; - OldKeyMaterial::IiKeyringAndDelegation(old_entry, old_delegation) + match storage { + IiKeyStorage::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) + } + IiKeyStorage::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) + } + } } IdentitySpec::Hsm { .. } => { // No migration required - HSM key stays on device @@ -772,6 +894,10 @@ pub fn rename_identity( .context(DeleteKeyringEntrySnafu { old_name })?; fs::remove_file(&old_delegation).context(DeleteOldKeyFileSnafu)?; } + OldKeyMaterial::IiPemAndDelegation(old_pem, old_delegation) => { + fs::remove_file(&old_pem).context(DeleteOldKeyFileSnafu)?; + fs::remove_file(&old_delegation).context(DeleteOldKeyFileSnafu)?; + } OldKeyMaterial::None => { // Nothing to clean up (HSM identities) } @@ -856,12 +982,20 @@ pub fn delete_identity( .delete_credential() .context(DeleteKeyringEntryForDeleteSnafu { name })?; } - IdentitySpec::InternetIdentity { .. } => { - let entry = Entry::new(SERVICE_NAME, &ii_keyring_key(name)) - .context(LoadKeyringEntryForDeleteSnafu { name })?; - entry - .delete_credential() - .context(DeleteKeyringEntryForDeleteSnafu { name })?; + IdentitySpec::InternetIdentity { storage, .. } => { + match storage { + IiKeyStorage::Keyring => { + let entry = Entry::new(SERVICE_NAME, &ii_keyring_key(name)) + .context(LoadKeyringEntryForDeleteSnafu { name })?; + entry + .delete_credential() + .context(DeleteKeyringEntryForDeleteSnafu { name })?; + } + IiKeyStorage::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)?; } @@ -951,6 +1085,12 @@ pub enum LinkIiIdentityError { ))] NoIiKeyring, + #[snafu(display("failed to write II session key PEM file for `{name}`"))] + WriteIiPemFile { + name: String, + source: crate::fs::IoError, + }, + #[snafu(display("failed to create delegation directory"))] CreateIiDelegationDir { source: crate::fs::IoError }, @@ -963,14 +1103,15 @@ pub enum LinkIiIdentityError { /// Links an Internet Identity delegation to a new named identity. /// -/// Stores the session keypair in the system keyring (under the `ii:{name}` key) -/// and the delegation chain as a separate JSON file. +/// Stores the session keypair according to `storage` and the delegation chain +/// as a separate JSON file. pub fn link_ii_identity( dirs: LWrite<&IdentityPaths>, name: &str, key: IdentityKey, chain: &delegation::DelegationChain, principal: ic_agent::export::Principal, + create_format: CreateFormat, ) -> Result<(), LinkIiIdentityError> { let mut identity_list = IdentityList::load_from(dirs.read())?; ensure!( @@ -993,20 +1134,46 @@ pub fn link_ii_identity( .expect("infallible PKI encoding"), }; - 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(CreateIiKeyringEntrySnafu)?; - 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()?; - } - res.context(SetIiKeyringEntryPasswordSnafu)?; + let ii_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(CreateIiKeyringEntrySnafu)?; + 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()?; + } + res.context(SetIiKeyringEntryPasswordSnafu)?; + IiKeyStorage::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(WriteIiPemFileSnafu { name })?; + fs::write_string(&pem_path, &pem).context(WriteIiPemFileSnafu { name })?; + IiKeyStorage::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(WriteIiPemFileSnafu { name })?; + fs::write_string(&pem_path, &pem).context(WriteIiPemFileSnafu { name })?; + IiKeyStorage::Pem { + format: PemFormat::Pbes2, + } + } + }; let delegation_path = dirs .ensure_delegation_chain_path(name) @@ -1018,6 +1185,7 @@ pub fn link_ii_identity( let spec = IdentitySpec::InternetIdentity { algorithm, principal, + storage: ii_storage, }; identity_list.identities.insert(name.to_string(), spec); identity_list.write_to(dirs)?; diff --git a/crates/icp/src/identity/manifest.rs b/crates/icp/src/identity/manifest.rs index 56e6831b..1abae22d 100644 --- a/crates/icp/src/identity/manifest.rs +++ b/crates/icp/src/identity/manifest.rs @@ -133,6 +133,7 @@ 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, }, } @@ -155,6 +156,13 @@ pub enum PemFormat { Pbes2, } +#[derive(Copy, Clone, Eq, PartialEq, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum IiKeyStorage { + Keyring, + Pem { format: PemFormat }, +} + #[derive(Deserialize, Serialize, Clone, Debug, EnumString, Display)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] pub enum IdentityKeyAlgorithm { diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 77559c2d..7d8d2502 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1047,12 +1047,22 @@ Link an HSM key to a new identity Link an Internet Identity to a new identity -**Usage:** `icp identity link ii ` +**Usage:** `icp identity link ii [OPTIONS] ` ###### **Arguments:** * `` — Name for the linked 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 list` From fdbc4e7702fcbca180f24c259614d5624ca01fec Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 10 Apr 2026 11:45:22 -0700 Subject: [PATCH 09/12] Add .well-known record and host param --- Cargo.lock | 79 +------------------ .../icp-cli/src/commands/identity/link/ii.rs | 9 ++- crates/icp-cli/src/commands/identity/login.rs | 11 ++- crates/icp-cli/src/operations/ii_poll.rs | 49 ++++++++++-- crates/icp/src/identity/key.rs | 18 +++-- crates/icp/src/identity/manifest.rs | 4 + docs/reference/cli.md | 3 + 7 files changed, 81 insertions(+), 92 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 48269bbb..fd9e3589 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -976,32 +976,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c" dependencies = [ "ahash 0.8.12", - "cached_proc_macro", - "cached_proc_macro_types", "hashbrown 0.15.5", "once_cell", "thiserror 2.0.18", "web-time", ] -[[package]] -name = "cached_proc_macro" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "cached_proc_macro_types" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" - [[package]] name = "camino" version = "1.2.2" @@ -1928,15 +1908,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "endi" version = "1.1.1" @@ -3013,11 +2984,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -3037,9 +3006,7 @@ dependencies = [ [[package]] name = "ic-agent" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "087c953695a2581a1e58a23a88e16a19e5eb2638ecefeea0bde22f23d8896786" +version = "0.47.1" dependencies = [ "arc-swap", "async-channel 2.5.0", @@ -3202,9 +3169,7 @@ dependencies = [ [[package]] name = "ic-identity-hsm" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "979ad4a529f16a1d2fea2904c0a8dd9500668b02f9fcb8ebcb7694621bd9f369" +version = "0.47.1" dependencies = [ "hex", "ic-agent", @@ -3262,9 +3227,7 @@ dependencies = [ [[package]] name = "ic-transport-types" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a91a2dc71282291a7c26ec7c23e57335fc4a562a6b0b571ede0044790a68a3f" +version = "0.47.1" dependencies = [ "candid", "hex", @@ -5659,7 +5622,6 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-channel", "futures-core", "futures-util", @@ -5672,7 +5634,6 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime", "percent-encoding", "pin-project-lite", "quinn", @@ -6725,27 +6686,6 @@ dependencies = [ "windows 0.62.2", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tap" version = "1.0.1" @@ -6770,7 +6710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -7794,17 +7734,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.3.4" diff --git a/crates/icp-cli/src/commands/identity/link/ii.rs b/crates/icp-cli/src/commands/identity/link/ii.rs index 1abd8386..0991b994 100644 --- a/crates/icp-cli/src/commands/identity/link/ii.rs +++ b/crates/icp-cli/src/commands/identity/link/ii.rs @@ -5,6 +5,7 @@ use ic_agent::{Identity as _, export::Principal, identity::BasicIdentity}; use icp::{context::Context, fs::read_to_string, identity::key, prelude::*}; use snafu::{ResultExt, Snafu}; use tracing::{info, warn}; +use url::Url; use crate::{commands::identity::StorageMode, operations::ii_poll}; @@ -14,6 +15,10 @@ pub(crate) struct IiArgs { /// Name for the linked identity name: String, + /// Host of the II login frontend (e.g. https://example.icp0.io) + #[arg(long, default_value = ii_poll::DEFAULT_HOST)] + host: Url, + /// Where to store the session private key #[arg(long, value_enum, default_value_t)] storage: StorageMode, @@ -51,13 +56,14 @@ pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { let basic = BasicIdentity::from_raw_key(&secret_key.serialize_raw()); let der_public_key = basic.public_key().expect("ed25519 always has a public key"); - let chain = ii_poll::poll_for_delegation(&der_public_key) + let chain = ii_poll::poll_for_delegation(&args.host, &der_public_key) .await .context(PollSnafu)?; let from_key = hex::decode(&chain.public_key).context(DecodeFromKeySnafu)?; let ii_principal = Principal::self_authenticating(&from_key); + let host = args.host.clone(); ctx.dirs .identity()? .with_write(async |dirs| { @@ -68,6 +74,7 @@ pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { &chain, ii_principal, create_format, + host, ) }) .await? diff --git a/crates/icp-cli/src/commands/identity/login.rs b/crates/icp-cli/src/commands/identity/login.rs index f89db6f0..f485b0d1 100644 --- a/crates/icp-cli/src/commands/identity/login.rs +++ b/crates/icp-cli/src/commands/identity/login.rs @@ -20,7 +20,7 @@ pub(crate) struct LoginArgs { } pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginError> { - let (algorithm, storage) = ctx + let (algorithm, storage, host) = ctx .dirs .identity()? .with_read(async |dirs| { @@ -31,8 +31,11 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr .context(IdentityNotFoundSnafu { name: &args.name })?; match spec { IdentitySpec::InternetIdentity { - algorithm, storage, .. - } => Ok((algorithm.clone(), storage.clone())), + algorithm, + storage, + host, + .. + } => Ok((algorithm.clone(), storage.clone(), host.clone())), _ => NotIiSnafu { name: &args.name }.fail(), } }) @@ -52,7 +55,7 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr .await? .context(LoadSessionKeySnafu)?; - let chain = ii_poll::poll_for_delegation(&der_public_key) + let chain = ii_poll::poll_for_delegation(&host, &der_public_key) .await .context(PollSnafu)?; diff --git a/crates/icp-cli/src/operations/ii_poll.rs b/crates/icp-cli/src/operations/ii_poll.rs index c6ecd328..b08ebf8c 100644 --- a/crates/icp-cli/src/operations/ii_poll.rs +++ b/crates/icp-cli/src/operations/ii_poll.rs @@ -14,8 +14,8 @@ use snafu::{ResultExt, Snafu}; use tokio::{net::TcpListener, sync::oneshot}; use url::Url; -/// The hosted II login frontend for the dev account canister. -const CLI_LOGIN_BASE: &str = "https://ut7yr-7iaaa-aaaag-ak7ca-cai.icp0.io/cli-login"; +/// Fallback host. Dummy value until we get a real domain. A staging instance can be found at ut7yr-7iaaa-aaaag-ak7ca-caia.ic0.app +pub(crate) const DEFAULT_HOST: &str = "https://not.a.domain"; #[derive(Debug, Snafu)] pub(crate) enum IiPollError { @@ -25,18 +25,53 @@ pub(crate) enum IiPollError { #[snafu(display("failed to run local callback server"))] ServeServer { source: std::io::Error }, + #[snafu(display("failed to fetch `{url}`"))] + FetchDiscovery { url: String, source: reqwest::Error }, + + #[snafu(display("failed to read discovery response from `{url}`"))] + ReadDiscovery { url: String, source: reqwest::Error }, + + #[snafu(display( + "`{url}` returned an empty login path — the response must be a single non-empty line" + ))] + EmptyLoginPath { url: String }, + #[snafu(display("interrupted"))] Interrupted, } -/// Starts a local HTTP server to receive the delegation callback from the II -/// frontend, prints the login URL for the user to open, and returns the -/// delegation chain once the frontend POSTs it back. +/// Discovers the login path from `{host}/.well-known/ic-cli-login`, then opens +/// a local HTTP server, builds the login URL, and returns the delegation chain +/// once the frontend POSTs it back. pub(crate) async fn poll_for_delegation( + host: &Url, der_public_key: &[u8], ) -> Result { let key_b64 = URL_SAFE_NO_PAD.encode(der_public_key); + // Discover the login path. + let discovery_url = host + .join("/.well-known/ic-cli-login") + .expect("joining an absolute path is infallible"); + let discovery_url_str = discovery_url.to_string(); + let login_path = reqwest::get(discovery_url) + .await + .context(FetchDiscoverySnafu { + url: &discovery_url_str, + })? + .text() + .await + .context(ReadDiscoverySnafu { + url: &discovery_url_str, + })?; + let login_path = login_path.trim(); + if login_path.is_empty() { + return EmptyLoginPathSnafu { + url: discovery_url_str, + } + .fail(); + } + // Bind on a random port before opening the browser so the callback URL is known. let listener = TcpListener::bind("127.0.0.1:0") .await @@ -54,11 +89,11 @@ pub(crate) async fn poll_for_delegation( .append_pair("callback", &callback_url); scratch.query().expect("just set").to_owned() }; - let mut login_url = Url::parse(CLI_LOGIN_BASE).expect("valid constant"); + let mut login_url = host.join(login_path).expect("login_path is a valid path"); login_url.set_fragment(Some(&fragment)); eprintln!(); - eprintln!(" Press Enter to open {}", { + eprintln!(" Press Enter to log in at {}", { let mut display = login_url.clone(); display.set_fragment(None); display diff --git a/crates/icp/src/identity/key.rs b/crates/icp/src/identity/key.rs index 97f0a9a9..c9d7efb4 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -6,7 +6,8 @@ use std::{ use ic_agent::{ Identity, identity::{ - AnonymousIdentity, BasicIdentity, DelegatedIdentity, Prime256v1Identity, Secp256k1Identity, + AnonymousIdentity, BasicIdentity, DelegatedIdentity, DelegationError, Prime256v1Identity, + Secp256k1Identity, }, }; use ic_ed25519::PrivateKeyFormat; @@ -21,6 +22,7 @@ use rand::Rng; use scrypt::Params; use sec1::{der::Decode, pem::PemLabel}; use snafu::{OptionExt, ResultExt, Snafu, ensure}; +use url::Url; use zeroize::Zeroizing; use crate::{ @@ -113,6 +115,12 @@ pub enum LoadIdentityError { source: delegation::LoadError, }, + #[snafu(display("failed to validate delegation chain loaded from `{path}`"))] + ValidateDelegationChain { + path: PathBuf, + source: DelegationError, + }, + #[snafu(display( "delegation for identity `{name}` has expired or will expire within 5 minutes; \ run `icp identity login {name}` to re-authenticate" @@ -398,10 +406,8 @@ fn load_ii_identity( } }; - // Use new_unchecked because the root of the II delegation chain uses - // canister signatures (OID 1.3.6.1.4.1.56387.1.2) which DelegatedIdentity::new - // cannot verify client-side. The replica validates the chain on each request. - let delegated = DelegatedIdentity::new_unchecked(from_key, inner, signed_delegations); + let delegated = DelegatedIdentity::new(from_key, inner, signed_delegations) + .context(ValidateDelegationChainSnafu { path: &chain_path })?; Ok(Arc::new(delegated)) } @@ -1112,6 +1118,7 @@ pub fn link_ii_identity( chain: &delegation::DelegationChain, principal: ic_agent::export::Principal, create_format: CreateFormat, + host: Url, ) -> Result<(), LinkIiIdentityError> { let mut identity_list = IdentityList::load_from(dirs.read())?; ensure!( @@ -1186,6 +1193,7 @@ pub fn link_ii_identity( algorithm, principal, storage: ii_storage, + host, }; identity_list.identities.insert(name.to_string(), spec); identity_list.write_to(dirs)?; diff --git a/crates/icp/src/identity/manifest.rs b/crates/icp/src/identity/manifest.rs index 1abae22d..992840bb 100644 --- a/crates/icp/src/identity/manifest.rs +++ b/crates/icp/src/identity/manifest.rs @@ -4,6 +4,7 @@ use ic_agent::export::Principal; use serde::{Deserialize, Serialize}; use snafu::{Snafu, ensure}; use strum::{Display, EnumString}; +use url::Url; use crate::{ fs::{ @@ -134,6 +135,9 @@ pub enum IdentitySpec { /// (`Principal::self_authenticating(from_key)`), not the session key. principal: Principal, storage: IiKeyStorage, + /// The host used for II login, stored so `icp identity login` can + /// re-authenticate without requiring `--host` again. + host: Url, }, } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7d8d2502..02ab715f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1055,6 +1055,9 @@ Link an Internet Identity to a new identity ###### **Options:** +* `--host ` — Host of the II login frontend (e.g. https://example.icp0.io) + + Default value: `https://not.a.domain` * `--storage ` — Where to store the session private key Default value: `keyring` From a15ce472b1c2b9a14168851c012b0b3a0f6819f5 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 10 Apr 2026 12:12:56 -0700 Subject: [PATCH 10/12] clippy --- crates/icp-cli/src/commands/identity/login.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/icp-cli/src/commands/identity/login.rs b/crates/icp-cli/src/commands/identity/login.rs index f485b0d1..07a56a2a 100644 --- a/crates/icp-cli/src/commands/identity/login.rs +++ b/crates/icp-cli/src/commands/identity/login.rs @@ -35,7 +35,7 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr storage, host, .. - } => Ok((algorithm.clone(), storage.clone(), host.clone())), + } => Ok((algorithm.clone(), *storage, host.clone())), _ => NotIiSnafu { name: &args.name }.fail(), } }) From 605d923475acf6ef9f84f75321b015d418ea2252 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 10 Apr 2026 13:05:04 -0700 Subject: [PATCH 11/12] move out of operations module --- .../icp-cli/src/commands/identity/link/ii.rs | 246 +++++++++++++++++- crates/icp-cli/src/commands/identity/login.rs | 6 +- crates/icp-cli/src/operations/ii_poll.rs | 233 ----------------- crates/icp-cli/src/operations/mod.rs | 1 - crates/icp-cli/tests/identity_tests.rs | 2 +- 5 files changed, 245 insertions(+), 243 deletions(-) delete mode 100644 crates/icp-cli/src/operations/ii_poll.rs diff --git a/crates/icp-cli/src/commands/identity/link/ii.rs b/crates/icp-cli/src/commands/identity/link/ii.rs index 0991b994..b78d5961 100644 --- a/crates/icp-cli/src/commands/identity/link/ii.rs +++ b/crates/icp-cli/src/commands/identity/link/ii.rs @@ -1,13 +1,31 @@ +use std::net::SocketAddr; + +use axum::{ + Router, + extract::State, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::IntoResponse, + routing::post, +}; +use base64::engine::{Engine as _, general_purpose::URL_SAFE_NO_PAD}; use clap::Args; use dialoguer::Password; use elliptic_curve::zeroize::Zeroizing; use ic_agent::{Identity as _, export::Principal, identity::BasicIdentity}; -use icp::{context::Context, fs::read_to_string, identity::key, prelude::*}; +use icp::{ + context::Context, + fs::read_to_string, + identity::{delegation::DelegationChain, key}, + prelude::*, + signal, +}; +use indicatif::{ProgressBar, ProgressStyle}; use snafu::{ResultExt, Snafu}; +use tokio::{net::TcpListener, sync::oneshot}; use tracing::{info, warn}; use url::Url; -use crate::{commands::identity::StorageMode, operations::ii_poll}; +use crate::commands::identity::StorageMode; /// Link an Internet Identity to a new identity #[derive(Debug, Args)] @@ -16,7 +34,7 @@ pub(crate) struct IiArgs { name: String, /// Host of the II login frontend (e.g. https://example.icp0.io) - #[arg(long, default_value = ii_poll::DEFAULT_HOST)] + #[arg(long, default_value = DEFAULT_HOST)] host: Url, /// Where to store the session private key @@ -56,7 +74,7 @@ pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> { let basic = BasicIdentity::from_raw_key(&secret_key.serialize_raw()); let der_public_key = basic.public_key().expect("ed25519 always has a public key"); - let chain = ii_poll::poll_for_delegation(&args.host, &der_public_key) + let chain = recv_delegation(&args.host, &der_public_key) .await .context(PollSnafu)?; @@ -100,7 +118,7 @@ pub(crate) enum IiError { StoragePasswordTermRead { source: dialoguer::Error }, #[snafu(display("failed during II authentication"))] - Poll { source: ii_poll::IiPollError }, + Poll { source: IiRecvError }, #[snafu(display("invalid public key in delegation chain"))] DecodeFromKey { source: hex::FromHexError }, @@ -111,3 +129,221 @@ pub(crate) enum IiError { #[snafu(display("failed to link II identity"))] Link { source: key::LinkIiIdentityError }, } + +/// Fallback host. Dummy value until we get a real domain. A staging instance can be found at ut7yr-7iaaa-aaaag-ak7ca-caia.ic0.app +pub(crate) const DEFAULT_HOST: &str = "https://not.a.domain"; + +#[derive(Debug, Snafu)] +pub(crate) enum IiRecvError { + #[snafu(display("failed to bind local callback server"))] + BindServer { source: std::io::Error }, + + #[snafu(display("failed to run local callback server"))] + ServeServer { source: std::io::Error }, + + #[snafu(display("failed to fetch `{url}`"))] + FetchDiscovery { url: String, source: reqwest::Error }, + + #[snafu(display("failed to read discovery response from `{url}`"))] + ReadDiscovery { url: String, source: reqwest::Error }, + + #[snafu(display( + "`{url}` returned an empty login path — the response must be a single non-empty line" + ))] + EmptyLoginPath { url: String }, + + #[snafu(display("interrupted"))] + Interrupted, +} + +/// Discovers the login path from `{host}/.well-known/ic-cli-login`, then opens +/// a local HTTP server, builds the login URL, and returns the delegation chain +/// once the frontend POSTs it back. +pub(crate) async fn recv_delegation( + host: &Url, + der_public_key: &[u8], +) -> Result { + let key_b64 = URL_SAFE_NO_PAD.encode(der_public_key); + + // Discover the login path. + let discovery_url = host + .join("/.well-known/ic-cli-login") + .expect("joining an absolute path is infallible"); + let discovery_url_str = discovery_url.to_string(); + let login_path = reqwest::get(discovery_url) + .await + .context(FetchDiscoverySnafu { + url: &discovery_url_str, + })? + .text() + .await + .context(ReadDiscoverySnafu { + url: &discovery_url_str, + })?; + let login_path = login_path.trim(); + if login_path.is_empty() { + return EmptyLoginPathSnafu { + url: discovery_url_str, + } + .fail(); + } + + // Bind on a random port before opening the browser so the callback URL is known. + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context(BindServerSnafu)?; + let addr: SocketAddr = listener.local_addr().context(BindServerSnafu)?; + let callback_url = format!("http://127.0.0.1:{}/", addr.port()); + + // Build the fragment as a URLSearchParams-compatible string so the frontend + // can parse it with `new URLSearchParams(location.hash.slice(1))`. + let fragment = { + let mut scratch = Url::parse("x:?").expect("infallible"); + scratch + .query_pairs_mut() + .append_pair("public_key", &key_b64) + .append_pair("callback", &callback_url); + scratch.query().expect("just set").to_owned() + }; + let mut login_url = host.join(login_path).expect("login_path is a valid path"); + login_url.set_fragment(Some(&fragment)); + + eprintln!(); + eprintln!(" Press Enter to log in at {}", { + let mut display = login_url.clone(); + display.set_fragment(None); + display + }); + + let (chain_tx, chain_rx) = oneshot::channel::(); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + + // chain_tx is wrapped in an Option so the handler can take ownership. + let state = CallbackState { + chain_tx: std::sync::Mutex::new(Some(chain_tx)), + shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)), + }; + + let app = Router::new() + .route("/", post(handle_callback).options(handle_preflight)) + .with_state(std::sync::Arc::new(state)); + + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg}") + .expect("valid template"), + ); + + // Detached thread for stdin — tokio's async stdin keeps the runtime alive on drop. + let (enter_tx, mut enter_rx) = tokio::sync::mpsc::channel::<()>(1); + std::thread::spawn(move || { + let mut buf = String::new(); + let _ = std::io::stdin().read_line(&mut buf); + let _ = enter_tx.blocking_send(()); + }); + + let serve = axum::serve(listener, app).with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }); + + let mut browser_opened = false; + + let result = tokio::select! { + _ = signal::stop_signal() => { + spinner.finish_and_clear(); + return InterruptedSnafu.fail(); + } + res = serve.into_future() => { + res.context(ServeServerSnafu)?; + // Server shut down before we got a chain — shouldn't happen. + return InterruptedSnafu.fail(); + } + _ = async { + loop { + tokio::select! { + _ = enter_rx.recv(), if !browser_opened => { + browser_opened = true; + spinner.set_message("Waiting for Internet Identity authentication..."); + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + let _ = open::that(login_url.as_str()); + } + // Yield so the other branches in the outer select! can fire. + _ = tokio::task::yield_now() => {} + } + } + } => { unreachable!() } + chain = chain_rx => chain, + }; + + spinner.finish_and_clear(); + Ok(result.expect("sender only dropped after sending")) +} + +#[derive(Debug)] +struct CallbackState { + chain_tx: std::sync::Mutex>>, + shutdown_tx: std::sync::Mutex>>, +} + +fn cors_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("POST, OPTIONS"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("content-type"), + ); + headers +} + +async fn handle_preflight() -> impl IntoResponse { + (StatusCode::NO_CONTENT, cors_headers()) +} + +async fn handle_callback( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> impl IntoResponse { + // Only accept POST with JSON content. + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if !content_type.starts_with("application/json") { + return ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + cors_headers(), + "expected application/json", + ) + .into_response(); + } + + let chain: DelegationChain = match serde_json::from_slice(&body) { + Ok(c) => c, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + cors_headers(), + "invalid delegation chain", + ) + .into_response(); + } + }; + + if let Some(tx) = state.chain_tx.lock().unwrap().take() { + let _ = tx.send(chain); + } + if let Some(tx) = state.shutdown_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + + (StatusCode::OK, cors_headers(), "").into_response() +} diff --git a/crates/icp-cli/src/commands/identity/login.rs b/crates/icp-cli/src/commands/identity/login.rs index 07a56a2a..b2652e54 100644 --- a/crates/icp-cli/src/commands/identity/login.rs +++ b/crates/icp-cli/src/commands/identity/login.rs @@ -10,7 +10,7 @@ use icp::{ use snafu::{OptionExt, ResultExt, Snafu}; use tracing::info; -use crate::operations::ii_poll; +use crate::commands::identity::link::ii; /// Re-authenticate an Internet Identity delegation #[derive(Debug, Args)] @@ -55,7 +55,7 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr .await? .context(LoadSessionKeySnafu)?; - let chain = ii_poll::poll_for_delegation(&host, &der_public_key) + let chain = ii::recv_delegation(&host, &der_public_key) .await .context(PollSnafu)?; @@ -92,7 +92,7 @@ pub(crate) enum LoginError { LoadSessionKey { source: key::LoadIdentityError }, #[snafu(display("failed during II authentication"))] - Poll { source: ii_poll::IiPollError }, + Poll { source: ii::IiRecvError }, #[snafu(display("failed to update delegation"))] UpdateDelegation { diff --git a/crates/icp-cli/src/operations/ii_poll.rs b/crates/icp-cli/src/operations/ii_poll.rs deleted file mode 100644 index b08ebf8c..00000000 --- a/crates/icp-cli/src/operations/ii_poll.rs +++ /dev/null @@ -1,233 +0,0 @@ -use std::net::SocketAddr; - -use axum::{ - Router, - extract::State, - http::{HeaderMap, HeaderValue, StatusCode, header}, - response::IntoResponse, - routing::post, -}; -use base64::engine::{Engine as _, general_purpose::URL_SAFE_NO_PAD}; -use icp::{identity::delegation::DelegationChain, signal}; -use indicatif::{ProgressBar, ProgressStyle}; -use snafu::{ResultExt, Snafu}; -use tokio::{net::TcpListener, sync::oneshot}; -use url::Url; - -/// Fallback host. Dummy value until we get a real domain. A staging instance can be found at ut7yr-7iaaa-aaaag-ak7ca-caia.ic0.app -pub(crate) const DEFAULT_HOST: &str = "https://not.a.domain"; - -#[derive(Debug, Snafu)] -pub(crate) enum IiPollError { - #[snafu(display("failed to bind local callback server"))] - BindServer { source: std::io::Error }, - - #[snafu(display("failed to run local callback server"))] - ServeServer { source: std::io::Error }, - - #[snafu(display("failed to fetch `{url}`"))] - FetchDiscovery { url: String, source: reqwest::Error }, - - #[snafu(display("failed to read discovery response from `{url}`"))] - ReadDiscovery { url: String, source: reqwest::Error }, - - #[snafu(display( - "`{url}` returned an empty login path — the response must be a single non-empty line" - ))] - EmptyLoginPath { url: String }, - - #[snafu(display("interrupted"))] - Interrupted, -} - -/// Discovers the login path from `{host}/.well-known/ic-cli-login`, then opens -/// a local HTTP server, builds the login URL, and returns the delegation chain -/// once the frontend POSTs it back. -pub(crate) async fn poll_for_delegation( - host: &Url, - der_public_key: &[u8], -) -> Result { - let key_b64 = URL_SAFE_NO_PAD.encode(der_public_key); - - // Discover the login path. - let discovery_url = host - .join("/.well-known/ic-cli-login") - .expect("joining an absolute path is infallible"); - let discovery_url_str = discovery_url.to_string(); - let login_path = reqwest::get(discovery_url) - .await - .context(FetchDiscoverySnafu { - url: &discovery_url_str, - })? - .text() - .await - .context(ReadDiscoverySnafu { - url: &discovery_url_str, - })?; - let login_path = login_path.trim(); - if login_path.is_empty() { - return EmptyLoginPathSnafu { - url: discovery_url_str, - } - .fail(); - } - - // Bind on a random port before opening the browser so the callback URL is known. - let listener = TcpListener::bind("127.0.0.1:0") - .await - .context(BindServerSnafu)?; - let addr: SocketAddr = listener.local_addr().context(BindServerSnafu)?; - let callback_url = format!("http://127.0.0.1:{}/", addr.port()); - - // Build the fragment as a URLSearchParams-compatible string so the frontend - // can parse it with `new URLSearchParams(location.hash.slice(1))`. - let fragment = { - let mut scratch = Url::parse("x:?").expect("infallible"); - scratch - .query_pairs_mut() - .append_pair("public_key", &key_b64) - .append_pair("callback", &callback_url); - scratch.query().expect("just set").to_owned() - }; - let mut login_url = host.join(login_path).expect("login_path is a valid path"); - login_url.set_fragment(Some(&fragment)); - - eprintln!(); - eprintln!(" Press Enter to log in at {}", { - let mut display = login_url.clone(); - display.set_fragment(None); - display - }); - - let (chain_tx, chain_rx) = oneshot::channel::(); - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - - // chain_tx is wrapped in an Option so the handler can take ownership. - let state = CallbackState { - chain_tx: std::sync::Mutex::new(Some(chain_tx)), - shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)), - }; - - let app = Router::new() - .route("/", post(handle_callback).options(handle_preflight)) - .with_state(std::sync::Arc::new(state)); - - let spinner = ProgressBar::new_spinner(); - spinner.set_style( - ProgressStyle::default_spinner() - .template("{spinner:.green} {msg}") - .expect("valid template"), - ); - - // Detached thread for stdin — tokio's async stdin keeps the runtime alive on drop. - let (enter_tx, mut enter_rx) = tokio::sync::mpsc::channel::<()>(1); - std::thread::spawn(move || { - let mut buf = String::new(); - let _ = std::io::stdin().read_line(&mut buf); - let _ = enter_tx.blocking_send(()); - }); - - let serve = axum::serve(listener, app).with_graceful_shutdown(async move { - let _ = shutdown_rx.await; - }); - - let mut browser_opened = false; - - let result = tokio::select! { - _ = signal::stop_signal() => { - spinner.finish_and_clear(); - return InterruptedSnafu.fail(); - } - res = serve.into_future() => { - res.context(ServeServerSnafu)?; - // Server shut down before we got a chain — shouldn't happen. - return InterruptedSnafu.fail(); - } - _ = async { - loop { - tokio::select! { - _ = enter_rx.recv(), if !browser_opened => { - browser_opened = true; - spinner.set_message("Waiting for Internet Identity authentication..."); - spinner.enable_steady_tick(std::time::Duration::from_millis(100)); - let _ = open::that(login_url.as_str()); - } - // Yield so the other branches in the outer select! can fire. - _ = tokio::task::yield_now() => {} - } - } - } => { unreachable!() } - chain = chain_rx => chain, - }; - - spinner.finish_and_clear(); - Ok(result.expect("sender only dropped after sending")) -} - -#[derive(Debug)] -struct CallbackState { - chain_tx: std::sync::Mutex>>, - shutdown_tx: std::sync::Mutex>>, -} - -fn cors_headers() -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert( - header::ACCESS_CONTROL_ALLOW_ORIGIN, - HeaderValue::from_static("*"), - ); - headers.insert( - header::ACCESS_CONTROL_ALLOW_METHODS, - HeaderValue::from_static("POST, OPTIONS"), - ); - headers.insert( - header::ACCESS_CONTROL_ALLOW_HEADERS, - HeaderValue::from_static("content-type"), - ); - headers -} - -async fn handle_preflight() -> impl IntoResponse { - (StatusCode::NO_CONTENT, cors_headers()) -} - -async fn handle_callback( - State(state): State>, - headers: HeaderMap, - body: axum::body::Bytes, -) -> impl IntoResponse { - // Only accept POST with JSON content. - let content_type = headers - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if !content_type.starts_with("application/json") { - return ( - StatusCode::UNSUPPORTED_MEDIA_TYPE, - cors_headers(), - "expected application/json", - ) - .into_response(); - } - - let chain: DelegationChain = match serde_json::from_slice(&body) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - cors_headers(), - "invalid delegation chain", - ) - .into_response(); - } - }; - - if let Some(tx) = state.chain_tx.lock().unwrap().take() { - let _ = tx.send(chain); - } - if let Some(tx) = state.shutdown_tx.lock().unwrap().take() { - let _ = tx.send(()); - } - - (StatusCode::OK, cors_headers(), "").into_response() -} diff --git a/crates/icp-cli/src/operations/mod.rs b/crates/icp-cli/src/operations/mod.rs index 7f304db6..f1d8ba2b 100644 --- a/crates/icp-cli/src/operations/mod.rs +++ b/crates/icp-cli/src/operations/mod.rs @@ -3,7 +3,6 @@ pub(crate) mod build; pub(crate) mod candid_compat; pub(crate) mod canister_migration; pub(crate) mod create; -pub(crate) mod ii_poll; pub(crate) mod install; pub(crate) mod proxy; pub(crate) mod proxy_management; diff --git a/crates/icp-cli/tests/identity_tests.rs b/crates/icp-cli/tests/identity_tests.rs index 400ff782..448c53b6 100644 --- a/crates/icp-cli/tests/identity_tests.rs +++ b/crates/icp-cli/tests/identity_tests.rs @@ -1026,7 +1026,7 @@ async fn identity_link_hsm() { .arg(&pin_file) .assert() .success() - .stderr(contains("Identity \"hsm-identity\" linked to HSM")); + .stderr(contains("Identity `hsm-identity` linked to HSM")); // Verify the identity appears in the list ctx.icp() From e3f2117338651f1075f35e1a4bdb6f7971b8e8f9 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 15 Apr 2026 14:10:27 -0700 Subject: [PATCH 12/12] fix lockfile --- Cargo.lock | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index fd9e3589..b1c4566c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -976,12 +976,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c" dependencies = [ "ahash 0.8.12", + "cached_proc_macro", + "cached_proc_macro_types", "hashbrown 0.15.5", "once_cell", "thiserror 2.0.18", "web-time", ] +[[package]] +name = "cached_proc_macro" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + [[package]] name = "camino" version = "1.2.2" @@ -1908,6 +1928,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -2984,9 +3013,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3007,6 +3038,8 @@ dependencies = [ [[package]] name = "ic-agent" version = "0.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "694f78a861d8a0643ecee96926573fc44ab43de9fc74e4443c2174a527d243ad" dependencies = [ "arc-swap", "async-channel 2.5.0", @@ -3170,6 +3203,8 @@ dependencies = [ [[package]] name = "ic-identity-hsm" version = "0.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fbebd7ee04ef421df6d170cdfea8fe77259a9fec00f7dbc1dc7f4535c605941" dependencies = [ "hex", "ic-agent", @@ -3228,6 +3263,8 @@ dependencies = [ [[package]] name = "ic-transport-types" version = "0.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa56b9d37b063451c2aadd864030d587d7c050001a26f55b2416cb33a74646f3" dependencies = [ "candid", "hex", @@ -5622,6 +5659,7 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", @@ -5634,6 +5672,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", "quinn", @@ -6686,6 +6725,27 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -7734,6 +7794,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4"