Skip to content
Merged
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
Expand Up @@ -7,6 +7,7 @@ bump. Currently experimental: sync plugins.

# 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 reauth <NAME> [--duration <DURATION>]`.
* feat!: Remove `--set-controller` and replace with a new flag `--remove-all-controllers`. For the old behavior, combine this flag with `--add-controller`

# v0.3.2
Expand Down
168 changes: 118 additions & 50 deletions crates/icp-cli/src/commands/identity/reauth.rs
Original file line number Diff line number Diff line change
@@ -1,80 +1,132 @@
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::web;
use crate::commands::identity::{delegation::sign::DurationArg, link::web};

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

/// Session delegation duration (e.g. "30m", "8h", "1d"). Note that 2m 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 web-auth identities.
#[arg(long)]
duration: Option<DurationArg>,
}

pub(crate) async fn exec(ctx: &Context, args: &ReauthArgs) -> Result<(), LoginError> {
let (algorithm, storage, host, domain, principal) = 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::WebAuth {
algorithm,
principal,
storage,
host,
domain,
} => Ok((
algorithm.clone(),
*storage,
host.clone(),
domain.clone(),
*principal,
)),
_ => NotDelegationSnafu { name: &args.name }.fail(),
}
.cloned()
.context(IdentityNotFoundSnafu { name: &args.name })
})
.await??;

let der_public_key = ctx
.dirs
.identity()?
.with_read(async |dirs| {
key::load_webauth_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::WebAuth {
algorithm,
storage,
host,
domain,
principal,
..
} => {
if args.duration.is_some() {
return DurationSnafu { name: &args.name }.fail();
}

// Re-auth must resolve to the same web-auth principal that was originally linked,
// so reuse the delegation domain captured at link time.
let chain = web::recv_delegation(&host, domain.as_deref(), &der_public_key, Some(principal))
.await
.context(PollSnafu)?;
let password_func = ctx.password_func.clone();
let der_public_key = ctx
.dirs
.identity()?
.with_read(async |dirs| {
key::load_webauth_session_public_key(
dirs,
&args.name,
&algorithm,
&storage,
password_func,
)
})
.await?
.context(LoadSessionKeySnafu)?;

ctx.dirs
.identity()?
.with_write(async |dirs| key::update_webauth_delegation(dirs, &args.name, &chain))
.await?
.context(UpdateDelegationSnafu)?;
// Re-auth must resolve to the same web-auth principal that was originally linked,
// so reuse the delegation domain captured at link time.
let chain =
web::recv_delegation(&host, domain.as_deref(), &der_public_key, Some(principal))
.await
.context(PollSnafu)?;

ctx.dirs
.identity()?
.with_write(async |dirs| key::update_webauth_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(2 * 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) + 2) * 60))
.context(DurationRequiredSnafu { name: &args.name })?
}
};

let password_func = ctx.password_func.clone();
ctx.dirs
.identity()?
.with_write(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(())
}
Expand All @@ -89,13 +141,24 @@ pub(crate) enum LoginError {
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 web-auth identity `{name}`"))]
Duration { name: String },

#[snafu(display(
"identity `{name}` is not delegation-based; this command is not required to use it"
"session caching is disabled; specify `--duration` to create a session delegation for `{name}`"
))]
NotDelegation { name: String },
DurationRequired { name: String },

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

#[snafu(display("failed to load web-auth session key"))]
LoadSessionKey { source: key::LoadIdentityError },
Expand All @@ -107,4 +170,9 @@ pub(crate) enum LoginError {
UpdateDelegation {
source: key::UpdateWebAuthDelegationError,
},

#[snafu(display("failed to create PEM session delegation"))]
CreatePemSession {
source: key::CreateExplicitPemSessionError,
},
}
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, 2 minutes are added to the given value,
/// so `5m` produces a 7-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) + 2) * 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