Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Unreleased

* feat: Password-protected identities now only need your password once per session. The session length defaults to 5 minutes and can be changed with `icp settings session-length <DURATION>` (e.g. `30m`, `1h`) or turned off with `icp settings session-length disabled`. You can also explicitly create or refresh a session with `icp identity login <NAME> [--duration <DURATION>]`.
* fix: `icp canister call --json` no longer produces blank output.

# v0.2.4
Expand Down
152 changes: 110 additions & 42 deletions crates/icp-cli/src/commands/identity/login.rs
Original file line number Diff line number Diff line change
@@ -1,92 +1,155 @@
use std::time::Duration;

use clap::Args;
use dialoguer::Password;
use icp::{
context::Context,
identity::{
key,
manifest::{IdentityList, IdentitySpec},
manifest::{IdentityList, IdentitySpec, PemFormat},
},
settings::Settings,
};
use snafu::{OptionExt, ResultExt, Snafu};
use tracing::info;

use crate::commands::identity::link::ii;
use crate::commands::identity::{delegation::sign::DurationArg, link::ii};

/// Re-authenticate an Internet Identity delegation
/// Re-authenticate an Internet Identity delegation or create a PEM session delegation
#[derive(Debug, Args)]
pub(crate) struct LoginArgs {
/// Name of the identity to re-authenticate
name: String,

/// Session delegation duration (e.g. "30m", "8h", "1d"). Note that 5m extra is
/// added when creating the delegation to account for clock drift.
/// Required for PEM identities when session caching is disabled in settings.
/// Not applicable for Internet Identity (yet).
#[arg(long)]
duration: Option<DurationArg>,
}

pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginError> {
let (algorithm, storage, host) = ctx
let spec = ctx
.dirs
.identity()?
.with_read(async |dirs| {
let list = IdentityList::load_from(dirs)?;
let spec = list
.identities
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(),
}
.cloned()
.context(IdentityNotFoundSnafu { name: &args.name })
})
.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)?;
match spec {
IdentitySpec::InternetIdentity {
algorithm,
storage,
host,
..
} => {
if args.duration.is_some() {
return DurationSnafu { name: &args.name }.fail();
}

let chain = ii::recv_delegation(&host, &der_public_key)
.await
.context(PollSnafu)?;
let password_func = ctx.password_func.clone();
let der_public_key = ctx
.dirs
.identity()?
.with_read(async |dirs| {
key::load_ii_session_public_key(dirs, &args.name, &algorithm, &storage, || {
password_func()
})
})
.await?
.context(LoadSessionKeySnafu)?;

ctx.dirs
.identity()?
.with_write(async |dirs| key::update_ii_delegation(dirs, &args.name, &chain))
.await?
.context(UpdateDelegationSnafu)?;
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);
info!("Identity `{}` re-authenticated", args.name);
}

IdentitySpec::Pem {
format: PemFormat::Pbes2,
algorithm,
..
} => {
let duration = match &args.duration {
Some(d) => Duration::from_nanos(d.as_nanos()) + Duration::from_secs(5 * 60),
None => {
let settings = ctx
.dirs
.settings()?
.with_read(async |dirs| Settings::load_from(dirs))
.await??;
settings
.session_length
.map(|m| Duration::from_secs((u64::from(m) + 5) * 60))
.context(DurationRequiredSnafu { name: &args.name })?
}
};

let password_func = ctx.password_func.clone();
ctx.dirs
.identity()?
.with_read(async |dirs| {
key::create_explicit_pem_session(
dirs,
&args.name,
&algorithm,
|| password_func(),
duration,
)
})
.await?
.context(CreatePemSessionSnafu)?;

info!("Session delegation created for identity `{}`", args.name);
}
_ => {
return UnsupportedIdentityTypeSnafu { name: &args.name }.fail();
}
}

Ok(())
}

#[derive(Debug, Snafu)]
pub(crate) enum LoginError {
#[snafu(transparent)]
LockIdentityDir { source: icp::fs::lock::LockError },
LockDir { source: icp::fs::lock::LockError },

#[snafu(transparent)]
LoadManifest {
source: icp::identity::manifest::LoadIdentityManifestError,
},

#[snafu(transparent)]
LoadSettings {
source: icp::settings::LoadSettingsError,
},

#[snafu(display("no identity found with name `{name}`"))]
IdentityNotFound { name: String },

#[snafu(display("`--duration` cannot be used with Internet Identity `{name}`"))]
Duration { name: String },

#[snafu(display(
"identity `{name}` is not an Internet Identity; use `icp identity link ii` instead"
"session caching is disabled; specify `--duration` to create a session delegation for `{name}`"
))]
NotIi { name: String },
DurationRequired { name: String },

#[snafu(display("identity `{name}` does not support logins"))]
UnsupportedIdentityType { name: String },

#[snafu(display("failed to load II session key"))]
LoadSessionKey { source: key::LoadIdentityError },
Expand All @@ -98,4 +161,9 @@ pub(crate) enum LoginError {
UpdateDelegation {
source: key::UpdateIiDelegationError,
},

#[snafu(display("failed to create PEM session delegation"))]
CreatePemSession {
source: key::CreateExplicitPemSessionError,
},
}
1 change: 0 additions & 1 deletion crates/icp-cli/src/commands/identity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ pub(crate) enum Command {
#[command(subcommand)]
Link(link::Command),
List(list::ListArgs),
#[command(hide = true)] // todo remove when II login is out of beta
Login(login::LoginArgs),
New(new::NewArgs),
Principal(principal::PrincipalArgs),
Expand Down
85 changes: 85 additions & 0 deletions crates/icp-cli/src/commands/settings.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::{fmt, str::FromStr};

use clap::{Args, Subcommand};
use icp::{
context::Context,
Expand Down Expand Up @@ -28,6 +30,8 @@ enum Setting {
Telemetry(TelemetryArgs),
/// Enable or disable the CLI update check
UpdateCheck(UpdateCheckArgs),
/// Set the session length for password-protected PEM identities
SessionLength(SessionLengthArgs),
}

#[derive(Debug, Args)]
Expand All @@ -49,11 +53,66 @@ struct UpdateCheckArgs {
value: Option<UpdateCheck>,
}

#[derive(Debug, Args)]
struct SessionLengthArgs {
/// Duration (e.g. `5m`, `1h`, `2d`) or `disabled`. If omitted, prints the current value.
///
/// Note that due to clock drift, 5 minutes are added to the given value,
/// so `5m` produces a 10-minute-expiry delegation. `disabled` turns off
/// session caching entirely.
value: Option<SessionLengthValue>,
}

/// A session-length value: a duration with suffix (`m`, `h`, `d`) or `disabled`.
#[derive(Debug, Clone)]
pub struct SessionLengthValue(pub Option<u32>);

impl FromStr for SessionLengthValue {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == "disabled" {
return Ok(Self(None));
}
let (digits, unit_secs) = if let Some(d) = s.strip_suffix('m') {
(d, 60u64)
} else if let Some(d) = s.strip_suffix('h') {
(d, 3600)
} else if let Some(d) = s.strip_suffix('d') {
(d, 86400)
} else {
return Err(format!(
"expected a duration like `5m`, `1h`, `2d`, or `disabled`; got `{s}`"
));
};
let n: u64 = digits
.parse()
.map_err(|_| format!("expected a whole number before the suffix, got `{digits}`"))?;
let total_secs = n
.checked_mul(unit_secs)
.ok_or_else(|| "duration too large".to_string())?;
// Round up to whole minutes.
let minutes = total_secs.div_ceil(60);
let minutes = u32::try_from(minutes).map_err(|_| "duration too large".to_string())?;
Ok(Self(Some(minutes)))
}
}

impl fmt::Display for SessionLengthValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Some(n) => write!(f, "{n}m"),
None => write!(f, "disabled"),
}
}
}

pub(crate) async fn exec(ctx: &Context, args: &SettingsArgs) -> Result<(), anyhow::Error> {
match &args.setting {
Setting::Autocontainerize(sub_args) => exec_autocontainerize(ctx, sub_args).await,
Setting::Telemetry(sub_args) => exec_telemetry(ctx, sub_args).await,
Setting::UpdateCheck(sub_args) => exec_update_check(ctx, sub_args).await,
Setting::SessionLength(sub_args) => exec_session_length(ctx, sub_args).await,
}
}

Expand Down Expand Up @@ -143,3 +202,29 @@ async fn exec_update_check(ctx: &Context, args: &UpdateCheckArgs) -> Result<(),
}
}
}

async fn exec_session_length(ctx: &Context, args: &SessionLengthArgs) -> Result<(), anyhow::Error> {
let dirs = ctx.dirs.settings()?;

match &args.value {
Some(SessionLengthValue(value)) => {
let value = *value;
dirs.with_write(async |dirs| {
let mut settings = Settings::load_from(dirs.read())?;
settings.session_length = value;
settings.write_to(dirs)?;
info!("Set session-length to {}", SessionLengthValue(value));
Ok(())
})
.await?
}

None => {
let settings = dirs
.with_read(async |dirs| Settings::load_from(dirs))
.await??;
println!("{}", SessionLengthValue(settings.session_length));
Ok(())
}
}
}
25 changes: 21 additions & 4 deletions crates/icp-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use std::sync::Arc;

use anyhow::Error;
use clap::{CommandFactory, Parser};
use commands::Command;
use icp::prelude::*;
use icp::{directories::Access, prelude::*};
use tracing::{Instrument, debug, info, subscriber::set_global_default, trace_span};
use tracing_subscriber::{Registry, layer::SubscriberExt};

Expand Down Expand Up @@ -140,19 +142,34 @@ async fn main() -> Result<(), Error> {
);

let password_func: icp::identity::PasswordFunc = match cli.identity_password_file {
Some(path) => Box::new(move || {
Some(path) => Arc::new(move || {
icp::fs::read_to_string(&path)
.map(|s| s.trim().to_string())
.map_err(|e| e.to_string())
}),
None => Box::new(|| {
None => Arc::new(|| {
dialoguer::Password::new()
.with_prompt("Enter identity password")
.interact()
.map_err(|e| e.to_string())
}),
};
let ctx = icp::context::initialize(cli.project_root_override, cli.debug, password_func)?;
let pem_session_duration = {
let dirs = icp::directories::Directories::new()?;
let settings_dirs = dirs.settings()?;
let settings = settings_dirs
.with_read(async |dirs| icp::settings::Settings::load_from(dirs))
.await??;
settings
.session_length
.map(|m| std::time::Duration::from_secs((u64::from(m) + 5) * 60))
};
let ctx = icp::context::initialize(
cli.project_root_override,
cli.debug,
password_func,
pem_session_duration,
)?;

let telemetry_session = telemetry::setup(&ctx, &raw_args, &Cli::command()).await;

Expand Down
Loading
Loading