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
13 changes: 13 additions & 0 deletions crates/icp-cli/src/commands/identity/delegation/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
use clap::Subcommand;

pub(crate) mod request;
pub(crate) mod sign;
pub(crate) mod r#use;

/// Manage delegations for identities
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
Request(request::RequestArgs),
Sign(sign::SignArgs),
Use(r#use::UseArgs),
}
87 changes: 87 additions & 0 deletions crates/icp-cli/src/commands/identity/delegation/request.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use clap::Args;
use dialoguer::Password;
use elliptic_curve::zeroize::Zeroizing;
use icp::{context::Context, fs::read_to_string, identity::key, prelude::*};
use pem::Pem;
use snafu::{ResultExt, Snafu};
use tracing::warn;

use crate::commands::identity::StorageMode;

/// Create a pending delegation identity with a new P256 session key
///
/// Prints the session public key as a PEM-encoded SPKI to stdout. Pass this to
/// `icp identity delegation sign --key-pem` on another machine to obtain a
/// delegation chain, then complete the identity with `icp identity delegation use`.
#[derive(Debug, Args)]
pub(crate) struct RequestArgs {
/// Name for the new identity
name: String,

/// Where to store the session private key
#[arg(long, value_enum, default_value_t)]
storage: StorageMode,

/// Read the storage password from a file instead of prompting (for --storage password)
#[arg(long, value_name = "FILE")]
storage_password_file: Option<PathBuf>,
}

pub(crate) async fn exec(ctx: &Context, args: &RequestArgs) -> Result<(), RequestError> {
let create_format = match args.storage {
StorageMode::Plaintext => key::CreateFormat::Plaintext,
StorageMode::Keyring => key::CreateFormat::Keyring,
StorageMode::Password => {
let password = if let Some(path) = &args.storage_password_file {
read_to_string(path)
.context(ReadStoragePasswordFileSnafu)?
.trim()
.to_string()
} else {
Password::new()
.with_prompt("Enter password to encrypt identity")
.with_confirmation("Confirm password", "Passwords do not match")
.interact()
.context(StoragePasswordTermReadSnafu)?
};
key::CreateFormat::Pbes2 {
password: Zeroizing::new(password),
}
}
};

let der_public_key = ctx
.dirs
.identity()?
.with_write(async |dirs| key::create_pending_delegation(dirs, &args.name, create_format))
.await?
.context(CreateSnafu)?;

let pem = pem::encode(&Pem::new("PUBLIC KEY", der_public_key));
print!("{pem}");

if matches!(args.storage, StorageMode::Plaintext) {
warn!(
"This identity is stored in plaintext and is not secure. Do not use it for anything of significant value."
);
}

Ok(())
}

#[derive(Debug, Snafu)]
pub(crate) enum RequestError {
#[snafu(display("failed to read storage password file"))]
ReadStoragePasswordFile { source: icp::fs::IoError },

#[snafu(display("failed to read storage password from terminal"))]
StoragePasswordTermRead { source: dialoguer::Error },

#[snafu(transparent)]
LockIdentityDir { source: icp::fs::lock::LockError },

#[snafu(display("failed to create pending delegation identity"))]
Create {
source: key::CreatePendingDelegationError,
},
}
192 changes: 192 additions & 0 deletions crates/icp-cli/src/commands/identity/delegation/sign.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
use std::{
str::FromStr,
time::{SystemTime, UNIX_EPOCH},
};

use clap::Args;
use ic_agent::{Identity as _, export::Principal, identity::Delegation as AgentDelegation};
use icp::{
context::{Context, GetIdentityError},
fs::read_to_string,
identity::delegation::{
Delegation as WireDelegation, DelegationChain, SignedDelegation as WireSignedDelegation,
},
prelude::*,
};
use pem::Pem;
use snafu::{OptionExt, ResultExt, Snafu};

use crate::options::IdentityOpt;

/// Sign a delegation from the selected identity to a target key
#[derive(Debug, Args)]
pub(crate) struct SignArgs {
/// Public key PEM file of the key to delegate to
#[arg(long, value_name = "FILE")]
key_pem: PathBuf,

/// Delegation validity duration (e.g. "30d", "24h", "3600s", or plain seconds)
#[arg(long)]
duration: DurationArg,

/// Canister principals to restrict the delegation to (comma-separated)
#[arg(long, value_delimiter = ',')]
canisters: Option<Vec<Principal>>,

#[command(flatten)]
identity: IdentityOpt,
}

pub(crate) async fn exec(ctx: &Context, args: &SignArgs) -> Result<(), SignError> {
let identity = ctx
.get_identity(&args.identity.clone().into())
.await
.context(GetIdentitySnafu)?;

let signer_pubkey = identity.public_key().context(AnonymousIdentitySnafu)?;

let target_pubkey = der_pubkey_from_pem(&args.key_pem)?;

let now_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos() as u64;
let expiration = now_nanos.saturating_add(args.duration.as_nanos());

let delegation = AgentDelegation {
pubkey: target_pubkey.clone(),
expiration,
targets: args.canisters.clone(),
};

let sig = identity
.sign_delegation(&delegation)
.map_err(|message| SignError::SignDelegation { message })?;

let signature_bytes = sig.signature.context(AnonymousIdentitySnafu)?;

// For a DelegatedIdentity (e.g. Internet Identity), sig.delegations holds the existing
// chain linking the root key to the signing session key. These must be included before
// the new delegation so the verifier can walk the full chain.
let mut wire_delegations: Vec<WireSignedDelegation> = sig
.delegations
.unwrap_or_default()
.into_iter()
.map(|sd| WireSignedDelegation {
signature: hex::encode(&sd.signature),
delegation: WireDelegation {
pubkey: hex::encode(&sd.delegation.pubkey),
expiration: format!("{:x}", sd.delegation.expiration),
targets: sd
.delegation
.targets
.as_ref()
.map(|ts| ts.iter().map(|p| hex::encode(p.as_slice())).collect()),
},
})
.collect();

wire_delegations.push(WireSignedDelegation {
signature: hex::encode(&signature_bytes),
delegation: WireDelegation {
pubkey: hex::encode(&target_pubkey),
expiration: format!("{expiration:x}"),
targets: args
.canisters
.as_ref()
.map(|ts| ts.iter().map(|p| hex::encode(p.as_slice())).collect()),
},
});

let chain = DelegationChain {
public_key: hex::encode(&signer_pubkey),
delegations: wire_delegations,
};

let json = serde_json::to_string_pretty(&chain).context(SerializeSnafu)?;
println!("{json}");

Ok(())
}

/// Extract the DER-encoded SubjectPublicKeyInfo bytes from a `PUBLIC KEY` PEM file.
fn der_pubkey_from_pem(path: &Path) -> Result<Vec<u8>, SignError> {
let pem_str = read_to_string(path).context(ReadKeyPemSnafu)?;
let pem = pem_str.parse::<Pem>().context(ParseKeyPemSnafu { path })?;
if pem.tag() != "PUBLIC KEY" {
return UnexpectedPemTagSnafu {
path,
found: pem.tag().to_string(),
}
.fail();
}
Ok(pem.contents().to_vec())
}

/// A duration expressed as a plain number of seconds or with a unit suffix.
///
/// Accepted suffixes: `s` (seconds), `m` (minutes), `h` (hours), `d` (days).
/// A bare integer is interpreted as seconds.
#[derive(Debug, Clone)]
pub(crate) struct DurationArg(u64);

impl DurationArg {
/// Duration in nanoseconds.
pub(crate) fn as_nanos(&self) -> u64 {
self.0.saturating_mul(1_000_000_000)
}
}

impl FromStr for DurationArg {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let (digits, multiplier) = if let Some(d) = s.strip_suffix('d') {
(d, 86400u64)
} else if let Some(h) = s.strip_suffix('h') {
(h, 3600u64)
} else if let Some(m) = s.strip_suffix('m') {
(m, 60u64)
} else if let Some(s2) = s.strip_suffix('s') {
(s2, 1u64)
} else {
(s, 1u64)
};

let n: u64 = digits.parse().map_err(|_| {
format!("invalid duration `{s}`: expected a number with optional suffix (s/m/h/d)")
})?;

Ok(DurationArg(n.saturating_mul(multiplier)))
}
}

#[derive(Debug, Snafu)]
pub(crate) enum SignError {
#[snafu(display("failed to load identity"))]
GetIdentity {
#[snafu(source(from(GetIdentityError, Box::new)))]
source: Box<GetIdentityError>,
},

#[snafu(display("anonymous identity cannot sign delegations"))]
AnonymousIdentity,

#[snafu(display("failed to read key PEM file"))]
ReadKeyPem { source: icp::fs::IoError },

#[snafu(display("corrupted PEM file `{path}`"))]
ParseKeyPem {
path: PathBuf,
source: pem::PemError,
},

#[snafu(display("expected a PUBLIC KEY PEM in `{path}`, found `{found}`"))]
UnexpectedPemTag { path: PathBuf, found: String },

#[snafu(display("failed to sign delegation: {message}"))]
SignDelegation { message: String },

#[snafu(display("failed to serialize delegation chain"))]
Serialize { source: serde_json::Error },
}
67 changes: 67 additions & 0 deletions crates/icp-cli/src/commands/identity/delegation/use.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use clap::Args;
use icp::{
context::Context,
fs::json,
identity::{
delegation::DelegationChain,
key,
manifest::{DelegationKeyStorage, PemFormat},
},
prelude::*,
};
use snafu::{ResultExt, Snafu};
use tracing::{info, warn};

/// Complete a pending delegation identity by providing a signed delegation chain
///
/// Reads the JSON output of `icp identity delegation sign` from a file and attaches
/// it to the named identity, making it usable for signing.
#[derive(Debug, Args)]
pub(crate) struct UseArgs {
/// Name of the pending delegation identity to complete
name: String,

/// Path to the delegation chain JSON file (output of `icp identity delegation sign`)
#[arg(long, value_name = "FILE")]
from_json: PathBuf,
}

pub(crate) async fn exec(ctx: &Context, args: &UseArgs) -> Result<(), UseError> {
let chain: DelegationChain = json::load(&args.from_json)?;

let storage = ctx
.dirs
.identity()?
.with_write(async |dirs| key::complete_delegation(dirs, &args.name, &chain))
.await?
.context(CompleteSnafu)?;

info!("Identity `{}` delegation complete", args.name);

if matches!(
storage,
DelegationKeyStorage::Pem {
format: PemFormat::Plaintext
}
) {
warn!(
"This identity is stored in plaintext and is not secure. Do not use it for anything of significant value."
);
}

Ok(())
}

#[derive(Debug, Snafu)]
pub(crate) enum UseError {
#[snafu(transparent)]
LoadDelegationChain { source: json::Error },

#[snafu(transparent)]
LockIdentityDir { source: icp::fs::lock::LockError },

#[snafu(display("failed to complete delegation identity"))]
Complete {
source: key::CompleteDelegationError,
},
}
4 changes: 3 additions & 1 deletion crates/icp-cli/src/commands/identity/link/ii.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ pub(crate) enum IiError {
LockIdentityDir { source: icp::fs::lock::LockError },

#[snafu(display("failed to link II identity"))]
Link { source: key::LinkIiIdentityError },
Link {
source: key::CreatePendingDelegationError,
},
}

/// Fallback host. Dummy value until we get a real domain. A staging instance can be found at ut7yr-7iaaa-aaaag-ak7ca-caia.ic0.app
Expand Down
7 changes: 5 additions & 2 deletions crates/icp-cli/src/commands/identity/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ pub(crate) async fn exec(ctx: &Context, args: &ListArgs) -> Result<(), anyhow::E
.unwrap_or(0);

for (name, id) in sorted_identities.iter() {
let principal = id.principal();
let principal = id
.principal()
.map(|p| p.to_string())
.unwrap_or_else(|| "(pending delegation)".to_string());
let padded_name = format!("{name: <longest_identity_name_length$}");
if **name == defaults.default {
println!("* {padded_name} {principal}");
Expand All @@ -86,5 +89,5 @@ struct JsonIdentityList {
#[derive(Serialize)]
struct JsonIdentity {
name: String,
principal: Principal,
principal: Option<Principal>,
}
Loading
Loading