diff --git a/.gitignore b/.gitignore index 9092c74..1f794ad 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ pip-wheel-metadata/ .DS_Store .idea/ .vscode/ + +#web +.next/ +node_modules/ diff --git a/Cargo.lock b/Cargo.lock index a5728d6..9672a72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,6 +166,61 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backoff" version = "0.4.0" @@ -1047,6 +1102,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.7.0" @@ -1060,6 +1121,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1145,6 +1207,7 @@ dependencies = [ "rand 0.8.5", "rangemap", "reqwest", + "ring", "sec1", "serde", "serde_bytes", @@ -1512,18 +1575,23 @@ name = "kinic-cli" version = "0.1.2" dependencies = [ "anyhow", + "axum", "candid", "clap", + "der", "dotenvy", "gag", "hex", "ic-agent", + "ic-ed25519", "icrc-ledger-types", "keyring", "pdf-extract", "pem 3.0.6", + "pkcs8", "pyo3", "reqwest", + "ring", "serde", "serde_json", "thiserror 2.0.17", @@ -1618,6 +1686,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "md-5" version = "0.10.6" @@ -1655,6 +1729,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minicbor" version = "0.19.1" @@ -2480,6 +2560,17 @@ dependencies = [ "serde_core", ] +[[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" @@ -2889,6 +2980,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2927,6 +3019,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index 14c4813..b7678e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,10 +5,11 @@ edition = "2024" [dependencies] anyhow = "1.0.100" +axum = "0.7.9" candid = "0.10.20" clap = { version = "4.5.51", features = ["derive"] } hex = "0.4.3" -ic-agent = "0.44.3" +ic-agent = { version = "0.44.3", features = ["ring"] } keyring = { version = "3", features = [ "apple-native", "windows-native", @@ -31,6 +32,10 @@ serde_json = "1.0.145" pyo3 = { version = "0.27", features = ["extension-module", "abi3-py38"], optional = true } pdf-extract = "0.8" gag = "1.0" +ring = "0.17.14" +der = "0.7.10" +pkcs8 = "0.10.2" +ic-ed25519 = "0.2.0" [features] default = [] diff --git a/README.md b/README.md index 67dc944..b6bbc0a 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,24 @@ dfx canister --ic call 73mez-iiaaa-aaaaq-aaasq-cai icrc1_balance_of '(record {ow # Example: (100000000 : nat) == 1 KINIC ``` + +### 3. Internet Identity flow (--ii, CLI only) + +If you prefer browser login instead of a keychain-backed dfx identity: + +```bash +cargo run -- --ii login +cargo run -- --ii list +``` + +Delegations are stored at `~/.config/kinic/identity.json` (default TTL: 6 hours). +The login flow uses a local callback on port `8620`. + **DM https://x.com/wyatt_benno for KINIC prod tokens** with your principal ID. Or purchase them from MEXC or swap at https://app.icpswap.com/ . -### 3. Deploy and Use Memory +### 4. Deploy and Use Memory ```python from kinic_py import KinicMemories diff --git a/docs/cli.md b/docs/cli.md index f099c11..a31ec1e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -48,7 +48,7 @@ Command-line companion for deploying and operating Kinic “memory” canisters. ## Running the CLI -All commands require `--identity`. Use `--ic` to talk to mainnet; omit it (or leave false) for the local replica. +Use either `--identity` (dfx identity name stored in the system keychain) or `--ii` (Internet Identity login). Use `--ic` to talk to mainnet; omit it (or leave false) for the local replica. If you are not using `--ii`, `--identity ` is required for CLI commands. ```bash cargo run -- --identity alice list @@ -57,6 +57,27 @@ cargo run -- --identity alice create \ --description "Local test canister" ``` +### Internet Identity flow (--ii) + +First, open the browser login flow and store a delegation (default TTL: 6 hours): + +```bash +cargo run -- --ii login +``` + +Then run commands with `--ii`: + +```bash +cargo run -- --ii list +cargo run -- --ii create \ + --name "Demo memory" \ + --description "Local test canister" +``` + +Notes: +- Delegations are stored at `~/.config/kinic/identity.json`. +- The login flow uses a local callback on port `8620`. + ### Convert PDF to markdown (inspect only) ```bash diff --git a/docs/ii-login-architecture.md b/docs/ii-login-architecture.md new file mode 100644 index 0000000..c33b829 --- /dev/null +++ b/docs/ii-login-architecture.md @@ -0,0 +1,45 @@ +# Internet Identity CLI Login Overview + +Where +- Component: rust/commands/ii_login.rs +- Data store: ~/.config/kinic/identity.json (or --identity-path) + +What +- The CLI opens a browser page that talks to Internet Identity. +- A local axum callback server receives delegations and stores them for future CLI calls. + +Why +- Allows CLI-only login without relying on a keychain-backed dfx identity. + +Flow (high level) +1) CLI generates a session key pair and a random state token, then starts a local HTTP listener on 127.0.0.1:8620. + - The session key pair is used to request a short-lived delegation from Internet Identity. + - The state token is embedded in the page and must match the callback payload. + - The local listener is the callback endpoint for the browser to POST the signed delegation. + - Binding to 127.0.0.1 ensures the callback is only reachable from the same machine. +2) CLI serves an HTML page that opens the Internet Identity authorize URL. +3) Internet Identity returns signed delegations to the local callback endpoint. +4) CLI verifies the delegation public key matches the session key. +5) CLI persists the delegation bundle with expiration and metadata to ~/.config/kinic/identity.json (or --identity-path). + - Stored fields include: identity provider URL, user public key, session key (pkcs8), delegations, expiration, created timestamp. + - Delegations may include target canisters; those targets are preserved in the saved delegation list. + +Server lifetime +- The callback server accepts a single successful callback, then exits. +- If no valid callback arrives before the timeout, the login flow fails. + +Key data exchanged +- Session public key (SPKI) from CLI to browser page. +- Delegations + user public key from browser to CLI callback. + +Security notes +- The callback is bound to localhost only. +- Callback payloads are rejected if the state token does not match. +- Delegations are verified against the session key before saving. +- Expiration is computed and stored to prevent stale reuse. +- On reuse, the CLI validates the stored file, checks expiration, and normalizes/verifies the delegation chain. +- Callback requests must be JSON and are capped at 256 KB. If a Content-Length header is present, it is validated against the same limit. + +Related files +- rust/commands/ii_login.rs +- rust/identity_store.rs diff --git a/rust/agent.rs b/rust/agent.rs index 17ff4c0..cee9325 100644 --- a/rust/agent.rs +++ b/rust/agent.rs @@ -1,8 +1,8 @@ -use std::io::Cursor; +use std::{io::Cursor, sync::Arc}; use anyhow::Result; use ic_agent::{ - Agent, + Agent, Identity, export::reqwest::Url, identity::{BasicIdentity, Secp256k1Identity}, }; @@ -14,6 +14,7 @@ pub const KEYRING_IDENTITY_PREFIX: &str = "internet_computer_identity_"; pub struct AgentFactory { use_mainnet: bool, identity_suffix: String, + identity_override: Option>, } impl AgentFactory { @@ -21,24 +22,39 @@ impl AgentFactory { Self { use_mainnet, identity_suffix: identity_suffix.into(), + identity_override: None, } } - pub async fn build(&self) -> Result { - let pem_bytes = load_pem_from_keyring(&self.identity_suffix)?; - let pem_text = String::from_utf8(pem_bytes.clone())?; - let pem = pem::parse(pem_text.as_bytes())?; + pub fn new_with_identity(use_mainnet: bool, identity: I) -> Self + where + I: Identity + 'static, + { + Self { + use_mainnet, + identity_suffix: String::new(), + identity_override: Some(Arc::new(identity)), + } + } - let builder = match pem.tag() { - "PRIVATE KEY" => { - let identity = BasicIdentity::from_pem(Cursor::new(pem_text.clone()))?; - Agent::builder().with_identity(identity) - } - "EC PRIVATE KEY" => { - let identity = Secp256k1Identity::from_pem(Cursor::new(pem_text.clone()))?; - Agent::builder().with_identity(identity) + pub async fn build(&self) -> Result { + let builder = if let Some(identity) = &self.identity_override { + Agent::builder().with_arc_identity(identity.clone()) + } else { + let pem_bytes = load_pem_from_keyring(&self.identity_suffix)?; + let pem_text = String::from_utf8(pem_bytes.clone())?; + let pem = pem::parse(pem_text.as_bytes())?; + match pem.tag() { + "PRIVATE KEY" => { + let identity = BasicIdentity::from_pem(Cursor::new(pem_text.clone()))?; + Agent::builder().with_identity(identity) + } + "EC PRIVATE KEY" => { + let identity = Secp256k1Identity::from_pem(Cursor::new(pem_text.clone()))?; + Agent::builder().with_identity(identity) + } + _ => anyhow::bail!("Unsupported PEM tag: {}", pem.tag()), } - _ => anyhow::bail!("Unsupported PEM tag: {}", pem.tag()), }; let url = if self.use_mainnet { diff --git a/rust/cli_defs.rs b/rust/cli_defs.rs index f58b085..c5ecc45 100644 --- a/rust/cli_defs.rs +++ b/rust/cli_defs.rs @@ -29,10 +29,24 @@ pub struct GlobalOpts { #[arg( long, - required = true, + conflicts_with = "ii", + required_unless_present = "ii", help = "Dfx identity name used to load credentials from the system keyring" )] - pub identity: String, + pub identity: Option, + + #[arg( + long, + help = "Use Internet Identity login (delegation saved to identity.json)" + )] + pub ii: bool, + + #[arg( + long, + value_name = "PATH", + help = "Path to identity.json (default: ~/.config/kinic/identity.json)" + )] + pub identity_path: Option, } #[derive(Subcommand, Debug)] @@ -57,6 +71,8 @@ pub enum Command { Balance(BalanceArgs), #[command(about = "Ask Kinic AI using memory search results (LLM placeholder)")] AskAi(AskAiArgs), + #[command(about = "Login via Internet Identity and store a delegation")] + Login(LoginArgs), } #[derive(Args, Debug)] @@ -104,7 +120,12 @@ pub struct InsertPdfArgs { )] pub memory_id: String, - #[arg(long, value_name = "PATH", required = true, help = "PDF file to convert to markdown and insert")] + #[arg( + long, + value_name = "PATH", + required = true, + help = "PDF file to convert to markdown and insert" + )] pub file_path: PathBuf, #[arg(long, required = true, help = "Tag metadata stored alongside the text")] @@ -113,7 +134,12 @@ pub struct InsertPdfArgs { #[derive(Args, Debug)] pub struct ConvertPdfArgs { - #[arg(long, value_name = "PATH", required = true, help = "PDF file to convert to markdown")] + #[arg( + long, + value_name = "PATH", + required = true, + help = "PDF file to convert to markdown" + )] pub file_path: PathBuf, } @@ -181,3 +207,6 @@ pub struct AskAiArgs { )] pub top_k: usize, } + +#[derive(Args, Debug)] +pub struct LoginArgs {} diff --git a/rust/clients/memory.rs b/rust/clients/memory.rs index b0548d8..037fa93 100644 --- a/rust/clients/memory.rs +++ b/rust/clients/memory.rs @@ -56,17 +56,6 @@ impl MemoryClient { pub fn canister_id(&self) -> &Principal { &self.canister_id } - - pub async fn update_instance(&self, instance_pid_str: String) -> Result<()> { - let payload = encode_update_instance_args(instance_pid_str)?; - self.agent - .update(&self.canister_id, "update_instance") - .with_arg(payload) - .call_and_wait() - .await - .context("Failed to call update_instance on memory canister")?; - Ok(()) - } } fn encode_insert_args(embedding: Vec, text: &str) -> Result> { @@ -78,6 +67,3 @@ fn encode_search_args(embedding: Vec) -> Result> { fn encode_add_user_args(principal: Principal, role: u8) -> Result> { Ok(candid::encode_args((principal, role))?) } -fn encode_update_instance_args(instance_pid_str: String) -> Result> { - Ok(candid::encode_one(instance_pid_str)?) -} diff --git a/rust/commands/ask_ai.rs b/rust/commands/ask_ai.rs index 4dbc912..ccb4451 100644 --- a/rust/commands/ask_ai.rs +++ b/rust/commands/ask_ai.rs @@ -29,8 +29,8 @@ pub struct AskAiResult { } pub async fn handle(args: AskAiArgs, ctx: &CommandContext) -> Result<()> { - let memory = - Principal::from_text(&args.memory_id).context("Failed to parse canister id for ask-ai command")?; + let memory = Principal::from_text(&args.memory_id) + .context("Failed to parse canister id for ask-ai command")?; let result = ask_ai_flow(&ctx.agent_factory, &memory, &args.query, args.top_k, "en").await?; info!( @@ -63,7 +63,7 @@ pub async fn ask_ai_flow( language: &str, ) -> Result { let agent = agent_factory.build().await?; - let client = MemoryClient::new(agent, memory_id.clone()); + let client = MemoryClient::new(agent, *memory_id); let embedding = fetch_embedding(query).await?; let mut results = client.search(embedding).await?; @@ -109,10 +109,11 @@ async fn call_llm(prompt: &str) -> Result { if payload.is_empty() { continue; } - if let Ok(chunk) = serde_json::from_str::(payload) { - if let Some(content) = chunk.content { - acc.push_str(&content); - } + if let Ok(ChatChunk { + content: Some(content), + }) = serde_json::from_str::(payload) + { + acc.push_str(&content); } } } diff --git a/rust/commands/config.rs b/rust/commands/config.rs index af26918..49ece2a 100644 --- a/rust/commands/config.rs +++ b/rust/commands/config.rs @@ -2,10 +2,7 @@ use anyhow::{Context, Result, bail}; use ic_agent::export::Principal; use tracing::info; -use crate::{ - cli::ConfigArgs, - clients::memory::MemoryClient, -}; +use crate::{cli::ConfigArgs, clients::memory::MemoryClient}; use super::CommandContext; @@ -49,7 +46,6 @@ impl Role { } } - #[allow(dead_code)] fn code(&self) -> u8 { match self { Role::Admin => 1, @@ -87,6 +83,7 @@ fn parse_add_user(values: Vec) -> Result<(Principal, Role)> { async fn build_memory_client(id: &str, ctx: &CommandContext) -> Result { let agent = ctx.agent_factory.build().await?; - let memory = Principal::from_text(id).context("Failed to parse canister id for config command")?; + let memory = + Principal::from_text(id).context("Failed to parse canister id for config command")?; Ok(MemoryClient::new(agent, memory)) } diff --git a/rust/commands/convert_pdf.rs b/rust/commands/convert_pdf.rs index 6248a39..92f974c 100644 --- a/rust/commands/convert_pdf.rs +++ b/rust/commands/convert_pdf.rs @@ -40,8 +40,7 @@ fn extract_with_pdftotext(path: &Path) -> Result { )); } - String::from_utf8(output.stdout) - .with_context(|| "pdftotext output was not valid UTF-8") + String::from_utf8(output.stdout).with_context(|| "pdftotext output was not valid UTF-8") } fn extract_with_pdf_extract_quiet(path: &Path) -> Result { diff --git a/rust/commands/ii_login.rs b/rust/commands/ii_login.rs new file mode 100644 index 0000000..c6123a7 --- /dev/null +++ b/rust/commands/ii_login.rs @@ -0,0 +1,413 @@ +//! rust/commands/ii_login.rs +//! Where: Internet Identity login flow in the Kinic CLI. +//! What: Hosts a local callback server, opens the browser, and saves delegation. +//! Why: Avoids requiring a keychain-backed dfx identity for CLI-only login. + +use std::{ + net::SocketAddr, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, anyhow}; +use axum::{ + Json, Router, + body::Bytes, + extract::{DefaultBodyLimit, State}, + http::{ + HeaderMap, StatusCode, + header::{CONTENT_LENGTH, CONTENT_TYPE}, + }, + response::{Html, IntoResponse}, + routing::{get, post}, +}; +use ic_agent::export::Principal; +use ic_agent::identity::{Delegation, SignedDelegation}; +use ring::rand::{SecureRandom, SystemRandom}; +use serde::Deserialize; +use serde_json::json; +use tokio::{ + net::TcpListener, + sync::{Mutex, oneshot}, +}; + +use crate::{ + cli::LoginArgs, + commands::CommandContext, + identity_store::{ + SessionKeyMaterial, StoredIdentity, derive_principal_from_user_key, generate_session_key, + normalize_spki_key, save_identity, + }, +}; + +const IDENTITY_PROVIDER_URL: &str = "https://id.ai/#authorize"; +const IDENTITY_PROVIDER_ORIGIN: &str = "https://id.ai"; +const CALLBACK_PORT: u16 = 8620; +const CALLBACK_TIMEOUT_SECS: u64 = 300; +const MAX_CALLBACK_BODY_BYTES: usize = 256 * 1024; +const DEFAULT_TTL_HOURS: u64 = 6; +const SECONDS_PER_HOUR: u64 = 3_600; +const NANOS_PER_SECOND: u64 = 1_000_000_000; + +#[derive(Deserialize)] +struct BrowserPayload { + #[serde(rename = "delegations")] + delegations: Vec, + #[serde(rename = "userPublicKey")] + user_public_key: Vec, + state: String, +} + +struct CallbackData { + payload: BrowserPayload, + principal: Principal, +} + +struct CallbackState { + html: String, + expected_state: String, + sender: Mutex>>, +} + +#[derive(Deserialize)] +struct BrowserSignedDelegation { + delegation: BrowserDelegation, + signature: Vec, +} + +#[derive(Deserialize)] +struct BrowserDelegation { + pubkey: Vec, + #[serde(deserialize_with = "deserialize_u64_from_str_or_int")] + expiration: u64, + targets: Option>, +} + +pub async fn handle(_args: LoginArgs, ctx: &CommandContext) -> Result<()> { + let identity_path = ctx + .identity_path + .clone() + .ok_or_else(|| anyhow!("Identity path is missing"))?; + let ttl_ns = ttl_nanos()?; + // CSRF mitigation: random state token is generated per session and verified on callback. + let state_token = generate_state()?; + // Session key is generated locally and shared with the browser page. + let session = generate_session_key()?; + let session_pubkey = normalize_spki_key(&session.public_key)?; + let html = build_login_page(&session, ttl_ns, &state_token); + + // Bind a local callback port for the browser to send delegations back. + let addr = SocketAddr::from(([127, 0, 0, 1], CALLBACK_PORT)); + let listener = match TcpListener::bind(addr).await { + Ok(listener) => listener, + Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => { + anyhow::bail!( + "Failed to bind to {addr}: port {port} is already in use. Stop the process using it and try again.", + port = CALLBACK_PORT + ); + } + Err(err) => { + return Err(err).with_context(|| format!("Failed to bind to {addr}")); + } + }; + + let (callback_tx, callback_rx) = oneshot::channel(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let state = Arc::new(CallbackState { + html, + expected_state: state_token, + sender: Mutex::new(Some(callback_tx)), + }); + + let app = Router::new() + .route("/", get(root_handler)) + .route("/callback", post(callback_handler)) + .with_state(state) + .layer(DefaultBodyLimit::max(MAX_CALLBACK_BODY_BYTES)); + + let server_handle = tokio::spawn(async move { + if let Err(err) = axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + { + eprintln!("II login callback server failed: {err}"); + } + }); + + // Launch the browser so the user can authenticate with Internet Identity. + open_browser(CALLBACK_PORT)?; + + // Block until the browser posts back the delegation payload. + let callback = tokio::time::timeout(Duration::from_secs(CALLBACK_TIMEOUT_SECS), callback_rx) + .await + .map_err(|_| anyhow!("Login timed out waiting for browser callback"))? + .map_err(|_| anyhow!("Login callback channel closed"))?; + + let _ = shutdown_tx.send(()); + let _ = server_handle.await; + // Verify delegation targets match our session key. + let delegations = convert_delegations(callback.payload.delegations, &session_pubkey)?; + let expiration_ns = delegation_expiration(&delegations)?; + let principal = callback.principal; + let stored = StoredIdentity { + version: 1, + identity_provider: IDENTITY_PROVIDER_URL.to_string(), + user_public_key_hex: hex::encode(callback.payload.user_public_key), + session_pkcs8_hex: hex::encode(session.pkcs8), + delegations, + expiration_ns, + created_at_ns: current_time_ns()?, + }; + save_identity(&identity_path, &stored)?; + println!( + "Saved Internet Identity delegation to {}", + identity_path.display() + ); + println!("Principal: {}", principal); + Ok(()) +} + +fn build_login_page(session: &SessionKeyMaterial, ttl_ns: u64, state: &str) -> String { + let session_public_key_hex = hex::encode(&session.public_key); + let template = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/rust/commands/ii_login_page.html" + )); + template + .replace("{{II_URL}}", IDENTITY_PROVIDER_URL) + .replace("{{II_ORIGIN}}", IDENTITY_PROVIDER_ORIGIN) + .replace("{{SESSION_KEY_HEX}}", &session_public_key_hex) + .replace("{{STATE}}", state) + .replace("{{TTL_NS}}", &ttl_ns.to_string()) +} + +async fn root_handler(State(state): State>) -> Html { + Html(state.html.clone()) +} + +async fn callback_handler( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> axum::response::Response { + if let Some(value) = headers.get(CONTENT_LENGTH) { + let content_length = match value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + { + Some(length) => length, + None => { + return ( + StatusCode::BAD_REQUEST, + "Invalid Content-Length".to_string(), + ) + .into_response(); + } + }; + + if content_length > MAX_CALLBACK_BODY_BYTES { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + "Request body too large".to_string(), + ) + .into_response(); + } + } + + if body.len() > MAX_CALLBACK_BODY_BYTES { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + "Request body too large".to_string(), + ) + .into_response(); + } + + let content_type_ok = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(is_json_content_type) + .unwrap_or(false); + if !content_type_ok { + return ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "Content-Type must be application/json".to_string(), + ) + .into_response(); + } + + let payload: BrowserPayload = match serde_json::from_slice(&body) { + Ok(payload) => payload, + Err(_) => { + return (StatusCode::BAD_REQUEST, "Invalid JSON payload".to_string()).into_response(); + } + }; + + if payload.state != state.expected_state { + return (StatusCode::BAD_REQUEST, "Invalid state".to_string()).into_response(); + } + + let principal = match derive_principal_from_user_key(&payload.user_public_key) { + Ok(principal) => principal, + Err(_) => { + return (StatusCode::BAD_REQUEST, "Invalid public key".to_string()).into_response(); + } + }; + let principal_text = principal.to_text(); + + let mut sender = state.sender.lock().await; + if let Some(tx) = sender.take() { + let _ = tx.send(CallbackData { payload, principal }); + } else { + return (StatusCode::CONFLICT, "Login already completed".to_string()).into_response(); + } + + ( + StatusCode::OK, + Json(json!({ + "status": "ok", + "principal": principal_text, + })), + ) + .into_response() +} + +fn is_json_content_type(value: &str) -> bool { + value + .split(';') + .next() + .map(|part| part.trim().eq_ignore_ascii_case("application/json")) + .unwrap_or(false) +} + +fn convert_delegations( + entries: Vec, + expected_pubkey: &[u8], +) -> Result> { + entries + .into_iter() + .map(|entry| { + let normalized_pubkey = normalize_spki_key(&entry.delegation.pubkey) + .context("Unsupported delegation public key format")?; + if normalized_pubkey != expected_pubkey { + anyhow::bail!("Delegation public key does not match session key"); + } + let targets = match entry.delegation.targets { + Some(list) => { + let principals = list + .into_iter() + .map(Principal::from_text) + .collect::, _>>() + .context("Invalid delegation target principal")?; + Some(principals) + } + None => None, + }; + Ok(SignedDelegation { + delegation: Delegation { + pubkey: normalized_pubkey, + expiration: entry.delegation.expiration, + targets, + }, + signature: entry.signature, + }) + }) + .collect() +} + +fn delegation_expiration(entries: &[SignedDelegation]) -> Result { + let expiration = entries + .iter() + .map(|entry| entry.delegation.expiration) + .min() + .ok_or_else(|| anyhow!("Missing delegation expiration"))?; + Ok(expiration) +} + +fn ttl_nanos() -> Result { + let ttl_seconds = DEFAULT_TTL_HOURS + .checked_mul(SECONDS_PER_HOUR) + .ok_or_else(|| anyhow!("TTL overflow"))?; + ttl_seconds + .checked_mul(NANOS_PER_SECOND) + .ok_or_else(|| anyhow!("TTL overflow")) +} + +fn current_time_ns() -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System time before UNIX_EPOCH")?; + u64::try_from(now.as_nanos()).context("System time overflow") +} + +fn open_browser(port: u16) -> Result<()> { + let url = format!("http://127.0.0.1:{}/", port); + let mut cmd = if cfg!(target_os = "macos") { + let mut cmd = std::process::Command::new("open"); + cmd.arg(&url); + cmd + } else if cfg!(target_os = "windows") { + let mut cmd = std::process::Command::new("cmd"); + cmd.args(["/C", "start", "", &url]); + cmd + } else { + let mut cmd = std::process::Command::new("xdg-open"); + cmd.arg(&url); + cmd + }; + let status = cmd.status().context("Failed to open browser")?; + if !status.success() { + return Err(anyhow!("Failed to open browser")); + } + Ok(()) +} + +fn deserialize_u64_from_str_or_int<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = u64; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("u64 as string or integer") + } + + fn visit_u64(self, value: u64) -> Result + where + E: serde::de::Error, + { + Ok(value) + } + + fn visit_i64(self, value: i64) -> Result + where + E: serde::de::Error, + { + u64::try_from(value).map_err(|_| E::custom("negative value")) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + value + .parse::() + .map_err(|_| E::custom("invalid number")) + } + } + + deserializer.deserialize_any(Visitor) +} + +fn generate_state() -> Result { + let rng = SystemRandom::new(); + let mut state_bytes = [0u8; 32]; + rng.fill(&mut state_bytes) + .map_err(|_| anyhow!("Failed to generate state token"))?; + Ok(hex::encode(state_bytes)) +} diff --git a/rust/commands/ii_login_page.html b/rust/commands/ii_login_page.html new file mode 100644 index 0000000..89791cc --- /dev/null +++ b/rust/commands/ii_login_page.html @@ -0,0 +1,123 @@ + + + + + Kinic CLI Login + + + +

Kinic CLI Login

+

Click the button below to open Internet Identity.

+

+ + + + diff --git a/rust/commands/insert_pdf.rs b/rust/commands/insert_pdf.rs index 4e72824..1646bf5 100644 --- a/rust/commands/insert_pdf.rs +++ b/rust/commands/insert_pdf.rs @@ -3,9 +3,7 @@ use ic_agent::export::Principal; use tracing::info; use crate::{ - cli::InsertPdfArgs, - clients::memory::MemoryClient, - commands::convert_pdf::pdf_to_markdown, + cli::InsertPdfArgs, clients::memory::MemoryClient, commands::convert_pdf::pdf_to_markdown, embedding::late_chunking, }; diff --git a/rust/commands/mod.rs b/rust/commands/mod.rs index 216ab55..209887b 100644 --- a/rust/commands/mod.rs +++ b/rust/commands/mod.rs @@ -2,20 +2,22 @@ use anyhow::Result; use crate::{agent::AgentFactory, cli::Command}; -pub mod create; +pub mod ask_ai; +pub mod balance; pub mod config; +pub mod convert_pdf; +pub mod create; +pub mod ii_login; pub mod insert; pub mod insert_pdf; pub mod list; -pub mod convert_pdf; pub mod search; pub mod update; -pub mod balance; -pub mod ask_ai; #[derive(Clone)] pub struct CommandContext { pub agent_factory: AgentFactory, + pub identity_path: Option, } pub async fn run_command(command: Command, ctx: CommandContext) -> Result<()> { @@ -30,5 +32,6 @@ pub async fn run_command(command: Command, ctx: CommandContext) -> Result<()> { Command::Update(args) => update::handle(args, &ctx).await, Command::Balance(args) => balance::handle(args, &ctx).await, Command::AskAi(args) => ask_ai::handle(args, &ctx).await, + Command::Login(args) => ii_login::handle(args, &ctx).await, } } diff --git a/rust/embedding.rs b/rust/embedding.rs index 2d519c1..7422956 100644 --- a/rust/embedding.rs +++ b/rust/embedding.rs @@ -40,7 +40,7 @@ pub async fn fetch_embedding(text: &str) -> Result> { .json::() .await .context("Failed to decode embedding response")?; - Ok(payload.embedding.into_iter().map(|v| v as f32).collect()) + Ok(payload.embedding) } async fn ensure_success(response: reqwest::Response) -> Result { diff --git a/rust/identity_store.rs b/rust/identity_store.rs new file mode 100644 index 0000000..9683f7f --- /dev/null +++ b/rust/identity_store.rs @@ -0,0 +1,210 @@ +use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, anyhow}; +use der::{Decode, SliceReader}; +use ic_agent::Identity; +use ic_agent::export::Principal; +use ic_agent::identity::{BasicIdentity, DelegatedIdentity, DelegationError, SignedDelegation}; +use ic_ed25519::PublicKey; +use pkcs8::{ObjectIdentifier, spki::SubjectPublicKeyInfoRef}; +use ring::signature::Ed25519KeyPair; +use serde::{Deserialize, Serialize}; +use std::fs::OpenOptions; +use std::io::Write; +use tracing::warn; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredIdentity { + pub version: u8, + pub identity_provider: String, + pub user_public_key_hex: String, + pub session_pkcs8_hex: String, + pub delegations: Vec, + pub expiration_ns: u64, + pub created_at_ns: u64, +} + +pub struct SessionKeyMaterial { + pub pkcs8: Vec, + pub public_key: Vec, +} + +pub fn default_identity_path() -> Result { + let home = std::env::var("HOME").context("HOME is not set")?; + Ok(PathBuf::from(home).join(".config/kinic/identity.json")) +} + +pub fn generate_session_key() -> Result { + let rng = ring::rand::SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng) + .map_err(|_| anyhow!("Failed to generate session key"))? + .as_ref() + .to_vec(); + let key_pair = + Ed25519KeyPair::from_pkcs8(&pkcs8).map_err(|_| anyhow!("Invalid session key"))?; + let identity = BasicIdentity::from_key_pair(key_pair); + let public_key = identity + .public_key() + .ok_or_else(|| anyhow!("Session public key missing"))?; + Ok(SessionKeyMaterial { pkcs8, public_key }) +} + +pub fn load_delegated_identity(path: &Path) -> Result { + let payload = fs::read_to_string(path) + .with_context(|| format!("Failed to read identity file at {}", path.display()))?; + let stored: StoredIdentity = + serde_json::from_str(&payload).context("Failed to parse identity.json")?; + ensure_not_expired(&stored)?; + + let user_public_key_raw = + hex::decode(&stored.user_public_key_hex).context("Failed to decode user public key")?; + let user_public_key = + normalize_spki_key(&user_public_key_raw).context("Unsupported user public key format")?; + let pkcs8 = hex::decode(&stored.session_pkcs8_hex).context("Failed to decode session key")?; + let key_pair = + Ed25519KeyPair::from_pkcs8(&pkcs8).map_err(|_| anyhow!("Invalid session key"))?; + let session_identity = BasicIdentity::from_key_pair(key_pair); + let delegations = normalize_delegations(&stored.delegations)?; + + if is_canister_signature_key(&user_public_key)? { + warn!("Delegation chain uses canister signature keys; skipping local verification."); + eprintln!("Warning: delegation uses canister signature keys; skipped local verification."); + return Ok(DelegatedIdentity::new_unchecked( + user_public_key, + Box::new(session_identity), + delegations, + )); + } + + let delegated = DelegatedIdentity::new( + user_public_key.clone(), + Box::new(session_identity), + delegations.clone(), + ); + match delegated { + Ok(identity) => Ok(identity), + Err(DelegationError::UnknownAlgorithm) => { + warn!("Delegation chain uses an unknown algorithm; skipping local verification."); + eprintln!("Warning: delegation uses an unknown algorithm; skipped local verification."); + let key_pair = + Ed25519KeyPair::from_pkcs8(&pkcs8).map_err(|_| anyhow!("Invalid session key"))?; + let session_identity = BasicIdentity::from_key_pair(key_pair); + Ok(DelegatedIdentity::new_unchecked( + user_public_key, + Box::new(session_identity), + delegations, + )) + } + Err(err) => Err(err.into()), + } +} + +pub fn derive_principal_from_user_key(user_public_key_raw: &[u8]) -> Result { + // Internet Identity may return either SPKI DER or raw Ed25519. Normalize to SPKI before deriving. + let user_public_key = + normalize_spki_key(user_public_key_raw).context("Unsupported user public key format")?; + Ok(Principal::self_authenticating(&user_public_key)) +} + +pub fn save_identity(path: &Path, stored: &StoredIdentity) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create identity directory at {}", + parent.display() + ) + })?; + } + let payload = serde_json::to_string_pretty(stored).context("Failed to encode identity.json")?; + + // Write atomically with restricted permissions (0600) to protect the session key. + let tmp_path = path.with_extension("tmp"); + { + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&tmp_path) + .with_context(|| { + format!( + "Failed to open temp identity file at {}", + tmp_path.display() + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perm = fs::Permissions::from_mode(0o600); + fs::set_permissions(&tmp_path, perm) + .with_context(|| format!("Failed to set permissions on {}", tmp_path.display()))?; + } + file.write_all(payload.as_bytes()) + .context("Failed to write identity payload")?; + file.sync_all().context("Failed to sync identity file")?; + } + fs::rename(&tmp_path, path).with_context(|| { + format!( + "Failed to move temp identity file into place at {}", + path.display() + ) + })?; + Ok(()) +} + +fn ensure_not_expired(stored: &StoredIdentity) -> Result<()> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System time before UNIX_EPOCH")?; + let now_ns = u64::try_from(now.as_nanos()).context("System time overflow")?; + if now_ns >= stored.expiration_ns { + return Err(anyhow!( + "Saved Internet Identity delegation has expired. Run `kinic-cli login` again." + )); + } + Ok(()) +} + +fn normalize_delegations(entries: &[SignedDelegation]) -> Result> { + entries + .iter() + .map(|entry| { + let pubkey = normalize_spki_key(&entry.delegation.pubkey) + .context("Unsupported delegation public key format")?; + Ok(SignedDelegation { + delegation: ic_agent::identity::Delegation { + pubkey, + expiration: entry.delegation.expiration, + targets: entry.delegation.targets.clone(), + }, + signature: entry.signature.clone(), + }) + }) + .collect() +} + +pub fn normalize_spki_key(bytes: &[u8]) -> Result> { + if SubjectPublicKeyInfoRef::decode(&mut SliceReader::new(bytes).map_err(|_| anyhow!("parse"))?) + .is_ok() + { + return Ok(bytes.to_vec()); + } + if bytes.len() == 32 { + let public_key = + PublicKey::deserialize_raw(bytes).map_err(|_| anyhow!("Invalid Ed25519 raw key"))?; + return Ok(public_key.serialize_rfc8410_der()); + } + Err(anyhow!("Unknown public key encoding")) +} + +fn is_canister_signature_key(bytes: &[u8]) -> Result { + let spki = SubjectPublicKeyInfoRef::decode( + &mut SliceReader::new(bytes).map_err(|_| anyhow!("parse"))?, + ) + .map_err(|_| anyhow!("parse"))?; + let canister_sig_oid = ObjectIdentifier::new_unwrap("1.3.6.1.4.1.56387.1.2"); + Ok(spki.algorithm.oid == canister_sig_oid) +} diff --git a/rust/lib.rs b/rust/lib.rs index fe9a96b..bd0ad33 100644 --- a/rust/lib.rs +++ b/rust/lib.rs @@ -4,6 +4,7 @@ pub mod cli; pub(crate) mod clients; mod commands; mod embedding; +pub(crate) mod identity_store; #[cfg(feature = "python-bindings")] mod python; @@ -41,8 +42,36 @@ pub async fn run() -> Result<()> { fmt().with_max_level(max).without_time().try_init().ok(); + let needs_identity_path = matches!(cli.command, cli::Command::Login(_)) || cli.global.ii; + let identity_path = if needs_identity_path { + Some(match cli.global.identity_path.clone() { + Some(path) => path, + None => identity_store::default_identity_path()?, + }) + } else { + None + }; + + let agent_factory = if matches!(cli.command, cli::Command::Login(_)) { + AgentFactory::new(cli.global.ic, String::new()) + } else if cli.global.ii { + let path = identity_path + .clone() + .ok_or_else(|| anyhow::anyhow!("Identity path is missing"))?; + let delegated = identity_store::load_delegated_identity(&path)?; + AgentFactory::new_with_identity(cli.global.ic, delegated) + } else { + let identity_suffix = cli + .global + .identity + .clone() + .ok_or_else(|| anyhow::anyhow!("--identity is required unless --ii is set"))?; + AgentFactory::new(cli.global.ic, identity_suffix) + }; + let context = CommandContext { - agent_factory: AgentFactory::new(cli.global.ic, cli.global.identity.clone()), + agent_factory, + identity_path, }; run_command(cli.command, context).await diff --git a/rust/python.rs b/rust/python.rs index a6565f3..6bbe064 100644 --- a/rust/python.rs +++ b/rust/python.rs @@ -1,6 +1,6 @@ use std::{cmp::Ordering, fs, path::PathBuf}; -use anyhow::{anyhow, Context, Result, bail}; +use anyhow::{Context, Result, anyhow, bail}; use ic_agent::export::Principal; use serde_json::json; @@ -11,8 +11,8 @@ use crate::{ launcher::{LauncherClient, State}, memory::MemoryClient, }, + commands::ask_ai::{AskAiResult, ask_ai_flow}, commands::convert_pdf, - commands::ask_ai::{ask_ai_flow, AskAiResult}, embedding::{fetch_embedding, late_chunking}, }; use icrc_ledger_types::icrc1::account::Account; @@ -79,15 +79,7 @@ pub(crate) async fn insert_memory_pdf( file_path: PathBuf, ) -> Result { let markdown = convert_pdf::pdf_to_markdown(&file_path)?; - insert_memory( - use_mainnet, - identity, - memory_id, - tag, - Some(markdown), - None, - ) - .await + insert_memory(use_mainnet, identity, memory_id, tag, Some(markdown), None).await } pub(crate) async fn search_memories( @@ -226,8 +218,7 @@ fn parse_principal(user_id: &str, role_code: u8) -> Result { } Ok(Principal::anonymous()) } else { - Principal::from_text(user_id) - .with_context(|| format!("invalid principal text: {user_id}")) + Principal::from_text(user_id).with_context(|| format!("invalid principal text: {user_id}")) } }