diff --git a/Cargo.lock b/Cargo.lock index 8e8952a4..b1c4566c 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" @@ -2985,9 +3037,9 @@ dependencies = [ [[package]] name = "ic-agent" -version = "0.47.0" +version = "0.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "087c953695a2581a1e58a23a88e16a19e5eb2638ecefeea0bde22f23d8896786" +checksum = "694f78a861d8a0643ecee96926573fc44ab43de9fc74e4443c2174a527d243ad" dependencies = [ "arc-swap", "async-channel 2.5.0", @@ -3150,9 +3202,9 @@ dependencies = [ [[package]] name = "ic-identity-hsm" -version = "0.47.0" +version = "0.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "979ad4a529f16a1d2fea2904c0a8dd9500668b02f9fcb8ebcb7694621bd9f369" +checksum = "8fbebd7ee04ef421df6d170cdfea8fe77259a9fec00f7dbc1dc7f4535c605941" dependencies = [ "hex", "ic-agent", @@ -3210,9 +3262,9 @@ dependencies = [ [[package]] name = "ic-transport-types" -version = "0.47.0" +version = "0.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a91a2dc71282291a7c26ec7c23e57335fc4a562a6b0b571ede0044790a68a3f" +checksum = "aa56b9d37b063451c2aadd864030d587d7c050001a26f55b2416cb33a74646f3" dependencies = [ "candid", "hex", @@ -3388,7 +3440,9 @@ dependencies = [ "assert_cmd", "async-trait", "axoupdater", + "axum", "backoff", + "base64", "bigdecimal", "bip32", "byte-unit", @@ -3423,6 +3477,7 @@ dependencies = [ "num-bigint 0.4.6", "num-integer", "num-traits", + "open", "p256", "pem", "phf", @@ -3720,6 +3775,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" @@ -4235,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" @@ -4650,6 +4730,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 +4924,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" @@ -6159,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" @@ -7020,6 +7128,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -7058,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 7477930a..20052b94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ 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" bip32 = "0.5.0" @@ -67,6 +69,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..f940f703 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -15,7 +15,9 @@ 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 bip32.workspace = true byte-unit.workspace = true @@ -47,6 +49,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/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 new file mode 100644 index 00000000..b78d5961 --- /dev/null +++ b/crates/icp-cli/src/commands/identity/link/ii.rs @@ -0,0 +1,349 @@ +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::{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; + +/// Link an Internet Identity to a new identity +#[derive(Debug, Args)] +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 = DEFAULT_HOST)] + host: Url, + + /// 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()); + let der_public_key = basic.public_key().expect("ed25519 always has a public key"); + + let chain = recv_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| { + key::link_ii_identity( + dirs, + &args.name, + identity_key, + &chain, + ii_principal, + create_format, + host, + ) + }) + .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: IiRecvError }, + + #[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 }, +} + +/// 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/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..b2652e54 --- /dev/null +++ b/crates/icp-cli/src/commands/identity/login.rs @@ -0,0 +1,101 @@ +use clap::Args; +use dialoguer::Password; +use icp::{ + context::Context, + identity::{ + key, + manifest::{IdentityList, IdentitySpec}, + }, +}; +use snafu::{OptionExt, ResultExt, Snafu}; +use tracing::info; + +use crate::commands::identity::link::ii; + +/// Re-authenticate an Internet Identity delegation +#[derive(Debug, Args)] +pub(crate) struct LoginArgs { + /// Name of the identity to re-authenticate + name: String, +} + +pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginError> { + let (algorithm, storage, host) = 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, + storage, + host, + .. + } => Ok((algorithm.clone(), *storage, host.clone())), + _ => NotIiSnafu { name: &args.name }.fail(), + } + }) + .await??; + + 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::recv_delegation(&host, &der_public_key) + .await + .context(PollSnafu)?; + + 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(display("failed to load II session key"))] + LoadSessionKey { source: key::LoadIdentityError }, + + #[snafu(display("failed during II authentication"))] + Poll { source: ii::IiRecvError }, + + #[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..de089bd4 100644 --- a/crates/icp-cli/src/commands/identity/mod.rs +++ b/crates/icp-cli/src/commands/identity/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod export; 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 +23,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-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() 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..c9d7efb4 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp/src/identity/key.rs @@ -5,7 +5,10 @@ use std::{ use ic_agent::{ Identity, - identity::{AnonymousIdentity, BasicIdentity, Prime256v1Identity, Secp256k1Identity}, + identity::{ + AnonymousIdentity, BasicIdentity, DelegatedIdentity, DelegationError, Prime256v1Identity, + Secp256k1Identity, + }, }; use ic_ed25519::PrivateKeyFormat; use ic_identity_hsm::HardwareIdentity; @@ -19,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::{ @@ -27,9 +31,9 @@ use crate::{ lock::{LRead, LWrite}, }, identity::{ - IdentityPaths, + IdentityPaths, delegation, manifest::{ - IdentityDefaults, IdentityKeyAlgorithm, IdentityList, IdentitySpec, + IdentityDefaults, IdentityKeyAlgorithm, IdentityList, IdentitySpec, IiKeyStorage, LoadIdentityManifestError, PemFormat, WriteIdentityManifestError, }, }, @@ -104,6 +108,27 @@ 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("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" + ))] + DelegationExpired { name: String }, + + #[snafu(display("failed to convert delegation chain"))] + DelegationConversion { source: delegation::ConversionError }, } pub fn load_identity( @@ -129,6 +154,9 @@ pub fn load_identity( .. } => load_hsm_identity(module, *slot, key_id, password_func), IdentitySpec::Anonymous => Ok(Arc::new(AnonymousIdentity)), + IdentitySpec::InternetIdentity { + algorithm, storage, .. + } => load_ii_identity(dirs, name, algorithm, storage, password_func), } } @@ -226,6 +254,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, @@ -286,6 +323,220 @@ 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, + storage: &IiKeyStorage, + password_func: impl FnOnce() -> Result, +) -> Result, LoadIdentityError> { + let (doc, origin) = load_ii_session_pem(dirs, name, storage)?; + + // 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)?; + + 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)) + } + } + } + }; + + let delegated = DelegatedIdentity::new(from_key, inner, signed_delegations) + .context(ValidateDelegationChainSnafu { path: &chain_path })?; + + 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 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> { + match algorithm { + IdentityKeyAlgorithm::Ed25519 => { + let key = ic_ed25519::PrivateKey::deserialize_pkcs8(doc.contents()) + .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 })?; + 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 })?; + 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")) + } + } +} + #[derive(Debug, Snafu)] pub enum LoadIdentityInContextError { #[snafu(transparent)] @@ -552,6 +803,8 @@ pub fn rename_identity( enum OldKeyMaterial { Pem(PathBuf), Keyring(Entry), + IiKeyringAndDelegation(Entry, PathBuf), + IiPemAndDelegation(PathBuf, PathBuf), None, } @@ -580,6 +833,37 @@ pub fn rename_identity( OldKeyMaterial::Keyring(old_entry) } + IdentitySpec::InternetIdentity { storage, .. } => { + let old_delegation = dirs.delegation_chain_path(old_name); + let new_delegation = dirs + .ensure_delegation_chain_path(new_name) + .context(CopyKeyFileSnafu)?; + let delegation_contents = fs::read(&old_delegation).context(CopyKeyFileSnafu)?; + fs::write(&new_delegation, &delegation_contents).context(CopyKeyFileSnafu)?; + + match storage { + 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 OldKeyMaterial::None @@ -610,6 +894,16 @@ pub fn rename_identity( .delete_credential() .context(DeleteKeyringEntrySnafu { old_name })?; } + OldKeyMaterial::IiKeyringAndDelegation(old_entry, old_delegation) => { + old_entry + .delete_credential() + .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) } @@ -694,6 +988,23 @@ pub fn delete_identity( .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)?; + } IdentitySpec::Hsm { .. } => { // no deletion required } @@ -757,6 +1068,187 @@ 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(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 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 }, + + #[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 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, + host: Url, +) -> 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 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) + .context(CreateIiDelegationDirSnafu)?; + delegation::save(&delegation_path, chain).context(SaveIiDelegationSnafu { + path: &delegation_path, + })?; + + let spec = IdentitySpec::InternetIdentity { + algorithm, + principal, + storage: ii_storage, + host, + }; + 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 +1293,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 +1400,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..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::{ @@ -128,6 +129,16 @@ 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, + storage: IiKeyStorage, + /// The host used for II login, stored so `icp identity login` can + /// re-authenticate without requiring `--host` again. + host: Url, + }, } impl IdentitySpec { @@ -137,6 +148,7 @@ impl IdentitySpec { IdentitySpec::Anonymous => Principal::anonymous(), IdentitySpec::Keyring { principal, .. } => *principal, IdentitySpec::Hsm { principal, .. } => *principal, + IdentitySpec::InternetIdentity { principal, .. } => *principal, } } } @@ -148,6 +160,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/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..02ab715f 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,31 @@ 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:** + +* `--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` + + Possible values: `plaintext`, `keyring`, `password` + +* `--storage-password-file ` — Read the storage password from a file instead of prompting (for --storage password) + + + ## `icp identity list` List the identities @@ -1052,6 +1081,18 @@ List the identities +## `icp identity login` + +Re-authenticate an Internet Identity delegation + +**Usage:** `icp identity login ` + +###### **Arguments:** + +* `` — Name of the identity to re-authenticate + + + ## `icp identity new` Create a new identity