Skip to content
Open
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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 39 additions & 4 deletions crates/imcp2-core/src/identities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,12 @@ const II_CANISTER_ID_PROD_DEFAULT: &str = "rdmx6-jaaaa-aaaaa-aaadq-cai";
/// stored grant expiration has passed. Per the spec, any `Unauthorized` means the
/// session is over — the caller must start a fresh connect with a fresh session
/// key; it must NOT retry.
const RECONNECT_MSG: &str = "Your Internet Identity session is over (the grant expired, was revoked, \
/// The one answer that ends a session for good: the grant expired, was revoked
/// at id.ai, or was replaced by a newer connection, as Internet Identity itself
/// reported it. Every other failure to derive or refresh a delegation is
/// transient. Public so an embedder holding a long-lived signer (the local
/// server's deploy tools) can tell the two apart and stop signing on this one.
pub const RECONNECT_MSG: &str = "Your Internet Identity session is over (the grant expired, was revoked, \
or was replaced by a newer connection). Reconnect with Internet Identity to continue — do not retry.";

/// Shown when a tool needs update (write) access but the II session is
Expand Down Expand Up @@ -346,6 +351,10 @@ struct Session {
/// A cached on-demand per-app account delegation. The chain ends at a per-app key
/// distinct from the session key; its seed is kept so the identity can be rebuilt.
struct AppDelegation {
/// The account II resolved the delegation to: the number it was asked
/// for, or, asked for the default (`None`), the concrete account that was
/// the default at prepare time (`None` when that is the unnamed one).
account_number: Option<u64>,
user_key: Vec<u8>,
chain: Vec<SignedDelegation>,
expiration_ns: u64,
Expand Down Expand Up @@ -472,7 +481,9 @@ impl Identities {
/// transport, route provider, and root key, so calls signed as `identity`
/// go through the embedding application's boundary-node routing — never a
/// second, hard-coded endpoint.
pub(crate) fn agent_as<I: ic_agent::Identity + 'static>(&self, identity: I) -> Agent {
/// A clone of the injected base agent signing as `identity`: the same
/// endpoint, root key and boundary-node routing as every other call.
pub fn agent_as<I: ic_agent::Identity + 'static>(&self, identity: I) -> Agent {
let mut agent = self.agent.clone();
agent.set_identity(identity);
agent
Expand Down Expand Up @@ -1019,18 +1030,35 @@ impl Identities {
domain: &str,
account_number: Option<u64>,
) -> Result<DelegatedIdentity, String> {
Ok(self.delegated_identity_resolved(session_id, domain, account_number).await?.0)
}

/// [`Self::delegated_identity`], also returning the account the delegation
/// was resolved to. Asked for the default account (`None`), II resolves it
/// to whichever account is the default at that moment — a choice the user
/// can change — and this is that account: `Some(n)` for a named one, `None`
/// for the unnamed one. A caller that must keep signing as the same
/// principal across re-derivations (a long deploy) asks for this account
/// explicitly from then on, rather than for "the default" again.
pub async fn delegated_identity_resolved(
&self,
session_id: &str,
domain: &str,
account_number: Option<u64>,
) -> Result<(DelegatedIdentity, Option<u64>), String> {
self.ensure_session(session_id).await?;

// Reuse a cached, still-fresh delegation if present.
if let Some(app) = self.cached_fresh(session_id, domain, account_number).await {
return build_identity(&app);
return Ok((build_identity(&app)?, app.account_number));
}

// Otherwise derive a fresh one on demand against the II canister.
let app = self.derive_app_delegation(session_id, domain, account_number).await?;
let identity = build_identity(&app)?;
let resolved = app.account_number;
self.store(session_id, domain, account_number, app).await;
Ok(identity)
Ok((identity, resolved))
}

async fn cached_fresh(
Expand All @@ -1046,6 +1074,7 @@ impl Identities {
return None;
}
Some(AppDelegation {
account_number: app.account_number,
user_key: app.user_key.clone(),
chain: app.chain.clone(),
expiration_ns: app.expiration_ns,
Expand Down Expand Up @@ -1147,6 +1176,7 @@ impl Identities {

let chain = vec![signed.into_agent(&app_key_der)?];
Ok(AppDelegation {
account_number: prepared.account_number,
user_key: prepared.user_key,
chain,
expiration_ns: prepared.expiration,
Expand Down Expand Up @@ -1196,6 +1226,7 @@ impl Identities {
.signature
.ok_or("the test delegation carried no signature")?;
let app = AppDelegation {
account_number: None,
user_key: anchor_der,
chain: vec![SignedDelegation { delegation, signature }],
expiration_ns: expiration,
Expand Down Expand Up @@ -1759,6 +1790,7 @@ mod tests {
s.app_delegations.insert(
(domain.to_string(), account),
AppDelegation {
account_number: None,
user_key: vec![account.unwrap_or(0) as u8],
chain: vec![],
expiration_ns: exp,
Expand Down Expand Up @@ -1872,6 +1904,7 @@ mod tests {

fn app_delegation(expiration_ns: u64) -> AppDelegation {
AppDelegation {
account_number: None,
user_key: Vec::new(),
chain: Vec::new(),
expiration_ns,
Expand Down Expand Up @@ -2500,6 +2533,7 @@ mod tests {
// Build the delegated identity exactly as the server does; the chain
// validates because the anchor's signature covers this read-only delegation.
let app = AppDelegation {
account_number: None,
user_key: user_der.clone(),
chain: vec![signed.clone()],
expiration_ns: expiration,
Expand Down Expand Up @@ -2555,6 +2589,7 @@ mod tests {
let mut tampered = signed;
tampered.delegation.permissions = None;
let tampered_app = AppDelegation {
account_number: None,
user_key: user_der,
chain: vec![tampered],
expiration_ns: expiration,
Expand Down
4 changes: 4 additions & 0 deletions crates/imcp2-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ pub use calls::guard_candid_text;
/// crate links.
pub use ic_agent::{self, Agent};
pub use identities::{IiInstance, SessionGauges};
/// The cycles-ledger balance of a session's management principal: the
/// `icp_cycles_balance` tool's logic, for an embedder that serves that tool
/// on its own surface (the local server's deploy tools do).
pub use management::{cycles_balance, cycles_balance_of, CyclesBalance};
pub use tools::{IcCanisterTools, IcProtocolTools, IcTools, SessionResolver};

/// A sensible default IC API boundary node (the public mainnet endpoint) for
Expand Down
11 changes: 11 additions & 0 deletions crates/imcp2-core/src/management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,17 @@ fn default_init_arg() -> String {
/// here can operate a canister the user created.
pub async fn cycles_balance(ids: &Identities, session_id: &str) -> Result<CyclesBalance, String> {
let (agent, principal) = management_agent(ids, session_id).await?;
cycles_balance_of(&agent, principal).await
}

/// The cycles-ledger balance of `principal`, read through `agent`. The query
/// is anonymous-readable, so the agent's identity only matters for routing;
/// callers acting as a principal other than the management identity (the
/// deploy tools) pair this with their own agent.
pub async fn cycles_balance_of(
agent: &Agent,
principal: Principal,
) -> Result<CyclesBalance, String> {
let ledger = parse_principal(CYCLES_LEDGER)?;
let account = Account { owner: principal, subaccount: None };
let arg = Encode!(&account).map_err(|e| format!("encode account: {e}"))?;
Expand Down
125 changes: 101 additions & 24 deletions crates/imcp2-deploy/src/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use icp_project::{
deploy::{deploy, resolve_targets, DeployParams, DeployReport},
task::Task,
},
prelude::{IC_MAINNET_NETWORK_API_URL, IC_ROOT_KEY},
};

use crate::{calls::AgentCalls, host};
Expand Down Expand Up @@ -75,6 +76,13 @@ pub struct DeployRequest {
pub no_create: bool,
/// Skip the Candid interface compatibility check on upgrades.
pub skip_candid_check: bool,
/// Where the deploy's working directory is made (the system temporary
/// directory when `None`). A run that returns removes it; a run that is
/// cut off — the server stopped mid-deploy, the call cancelled — leaves
/// it, and with it `.icp/data/mappings/<environment>.ids.json`, which
/// records every canister the deploy created, so a caller who never got
/// the answer can recover the ids instead of creating a second set.
pub workdir: Option<Utf8PathBuf>,
Comment on lines +79 to +85

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any risk here of one user writing a malicious value to .icp/data/mappings/<environment>.ids.json that then is served to another user?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a given agent call, the server should create a temporary directory to run the deployment from and this file is a byproduct of the deployment. The directory is not supposed to be reused. even if it is reused the agent would still need a controller identity.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No run reads another's file. Each call makes a fresh, randomly named directory (deploy_directory in deploy.rs, called where the scratch directory is made) under the user's local data directory (main.rs, deploys_in), extracts the bundle there, and that run alone reads it. The ids file in it is seeded from the call's own canister_ids argument and written by the library during the run; the bundle's own .icp/ entries are refused at extraction (host.rs), so a bundle cannot plant one. imcp2-local is one user per process, and the deploy tools are not on the gateway. What was missing: the directory's mode followed the umask, so another OS user could read a left-behind one where the home allows. 08516e7 makes it 0700 on Unix, with a test.

}

/// How a deploy ended.
Expand Down Expand Up @@ -171,12 +179,21 @@ pub async fn deploy_bundle(
BundleSource::Path(path) => read_bundle_file(&path, host::MAX_BUNDLE_BYTES).await?,
};

// The scratch directory lives exactly as long as this function: the
// bundle's files, the ids the deploy records, and the build artifacts all
// go with it, whichever way the deploy ends.
let scratch = tempfile::Builder::new()
.prefix("imcp2-deploy-")
.tempdir()
// The working directory holds the bundle's files, the ids the deploy
// records, and the build artifacts. Until the deploy starts it is a
// temporary directory, gone with any early return; from then on it is
// kept on purpose and removed only once the outcome — the ids above all —
// has been assembled, so a run that is cut off leaves its record behind.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question as above, how do we make sure runs are isolated between users?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer as above: this is where the per-run directory is made, fresh and randomly named for every call, and a second deploy while one runs is refused before anything else (claim in lib.rs), so two runs never share one. As of 08516e7 it is created with mode 0700 on Unix.

let parent = match &request.workdir {
Some(dir) => {
std::fs::create_dir_all(dir).map_err(|e| {
RequestError(format!("cannot create the deploy directory '{dir}': {e}"))
})?;
dir.as_std_path().to_path_buf()
}
None => std::env::temp_dir(),
};
let scratch = deploy_directory(&parent)
.map_err(|e| RequestError(format!("cannot create a scratch directory: {e}")))?;
let dir = Utf8PathBuf::from_path_buf(scratch.path().to_path_buf()).map_err(|p| {
RequestError(format!("scratch directory path is not UTF-8: {}", p.display()))
Expand Down Expand Up @@ -270,26 +287,13 @@ pub async fn deploy_bundle(
};

// The agent is made by the first phase that reaches the network, as the
// CLI does: a deploy that fails to load its wasms never resolves a root
// key. The identity is already unlocked — it is the session's — so what
// is deferred here is only the network resolution.
let network = env.network.clone();
let host_for_agent = host.clone();
// CLI does: a deploy that fails to load its wasms never builds one. The
// network is the Internet Computer — `check_bundle` held the environment
// to it — so the agent is the mainnet one, as the balance check's is.
let calls = Deferred::new(move || {
let host = host_for_agent.clone();
let network = network.clone();
let identity = identity.clone();
async move {
let access =
host.network.access(&network).await.map_err(|e| RequestError(error_chain(&e)))?;
let agent = Agent::builder()
.with_url(access.api_url.as_str())
.with_arc_identity(identity)
.build()
.map_err(|e| {
RequestError(format!("cannot build an agent for {}: {e}", access.api_url))
})?;
agent.set_root_key(access.root_key);
let agent = mainnet_agent(identity).map_err(RequestError)?;
let calls: Arc<dyn CanisterCalls> =
Arc::new(AgentCalls::new(agent).map_err(RequestError)?);
Ok::<_, RequestError>(calls)
Expand All @@ -301,6 +305,8 @@ pub async fn deploy_bundle(
let (reporter, receiver) = icp_events::channel::<Task>();
let collector = tokio::spawn(collect_log(receiver));

// From here the directory outlives this future (see above).
let workdir = scratch.keep();
Comment thread
raymondk marked this conversation as resolved.
Resolved
let mut report = DeployReport::default();
let result = deploy(&host, &calls, &params, &reporter, &mut report).await;
// The receiver ends only once every reporter handle is gone.
Expand Down Expand Up @@ -332,10 +338,30 @@ pub async fn deploy_bundle(
outcome.canister_ids.insert(name.clone(), *id);
}

drop(scratch);
// The outcome carries everything the directory recorded; the directory
// has done its job.
drop(host);
if let Err(e) = tokio::fs::remove_dir_all(&workdir).await {
Comment thread
raymondk marked this conversation as resolved.
Resolved
outcome
.log
.push(format!("removing the deploy directory '{}' failed: {e}", workdir.display()));
}
Ok(outcome)
}

/// An agent for the Internet Computer signing as `identity`: the mainnet
/// endpoint and the pinned root key, the one network a deploy reaches. The
/// balance check uses the same, so what it reports is what a deploy spends.
pub fn mainnet_agent(identity: Arc<dyn Identity>) -> Result<Agent, String> {
let agent = Agent::builder()
.with_url(IC_MAINNET_NETWORK_API_URL)
.with_arc_identity(identity)
.build()
.map_err(|e| format!("cannot build an agent for the Internet Computer: {e}"))?;
agent.set_root_key(IC_ROOT_KEY.to_vec());
Ok(agent)
}

/// Read the bundle at `path`, which must be an absolute path to a regular
/// file of at most `limit` bytes, and nothing else: a device or a pipe reports
/// a length that says nothing about what a read of it produces (`/dev/zero`
Expand Down Expand Up @@ -444,6 +470,21 @@ fn describe(task: &Task) -> String {
}
}

/// A fresh directory for one deploy under `parent`, randomly named, and on
/// Unix readable by its owner alone: the bundle's files and the ids the
/// deploy records are the user's business, and the parent (the user's local
/// data directory, or the system temporary directory) may be more open.
fn deploy_directory(parent: &std::path::Path) -> std::io::Result<tempfile::TempDir> {
let mut builder = tempfile::Builder::new();
builder.prefix("imcp2-deploy-");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
builder.permissions(std::fs::Permissions::from_mode(0o700));
}
builder.tempdir_in(parent)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -493,6 +534,7 @@ mod tests {
// verified the bundled wasm.
no_create: true,
skip_candid_check: false,
workdir: None,
}
}

Expand All @@ -508,6 +550,24 @@ mod tests {
// phase of a bundle), then stop where `--no-create` stops `icp deploy`.
// The outcome reports the failure, the targets, and the log of the phase
// that succeeded before it.
// Each deploy gets its own fresh directory, and on Unix one no other
// user of the machine can read: the parent may be world-readable.
#[test]
fn a_deploy_directory_is_fresh_and_private() {
let parent = tempfile::tempdir().unwrap();
let a = deploy_directory(parent.path()).unwrap();
let b = deploy_directory(parent.path()).unwrap();
assert_ne!(a.path(), b.path());
assert!(a.path().starts_with(parent.path()));
assert!(a.path().file_name().unwrap().to_str().unwrap().starts_with("imcp2-deploy-"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(a.path()).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "mode {mode:o}");
}
}

#[tokio::test(flavor = "multi_thread")]
async fn a_bundle_deploys_up_to_the_create_phase_offline() {
let (wasm, sha) = wasm();
Expand Down Expand Up @@ -689,6 +749,23 @@ mod tests {
// A `bundle_path` is read only when it is a regular file: a directory
// here, and by the same check a device or a pipe, whose length says
// nothing about what reading it would produce.
// A deploy works under the directory it is given and removes its own
// when it returns, so a run that is cut off is the only one to leave a
// record there.
#[tokio::test(flavor = "multi_thread")]
async fn a_deploy_works_under_its_directory_and_removes_it_when_done() {
let (wasm, sha) = wasm();
let parent = tempfile::tempdir().unwrap();
let workdir = Utf8PathBuf::from_path_buf(parent.path().join("deploys")).unwrap();
let mut request = request(bundle(&sha, &wasm), "ic");
request.workdir = Some(workdir.clone());
let outcome = deploy_bundle(Arc::new(AnonymousIdentity), request).await.unwrap();
assert_eq!(outcome.status, Status::Failed, "{:?}", outcome.log);
assert!(workdir.is_dir(), "the parent is made");
let left: Vec<_> = std::fs::read_dir(&workdir).unwrap().collect();
assert!(left.is_empty(), "the run's own directory is removed: {left:?}");
}

// The library resolves the mode at install, after creation, and panics on
// a spelling it does not know: refused at entry instead, before anything
// is read.
Expand Down
Loading
Loading