Skip to content

Commit 47b32cc

Browse files
authored
Merge pull request #4 from humandebri/ii-login
Add Internet Identity login flow
2 parents 0daa971 + 936e72c commit 47b32cc

20 files changed

Lines changed: 1063 additions & 73 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,7 @@ pylate-index/
3030
.DS_Store
3131
.idea/
3232
.vscode/
33+
34+
#web
35+
.next/
36+
node_modules/

Cargo.lock

Lines changed: 93 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ edition = "2024"
55

66
[dependencies]
77
anyhow = "1.0.100"
8+
axum = "0.7.9"
89
candid = "0.10.20"
910
clap = { version = "4.5.51", features = ["derive"] }
1011
hex = "0.4.3"
11-
ic-agent = "0.44.3"
12+
ic-agent = { version = "0.44.3", features = ["ring"] }
1213
keyring = { version = "3", features = [
1314
"apple-native",
1415
"windows-native",
@@ -31,10 +32,15 @@ serde_json = "1.0.145"
3132
pyo3 = { version = "0.27", features = ["extension-module", "abi3-py38"], optional = true }
3233
pdf-extract = "0.8"
3334
gag = "1.0"
35+
ring = "0.17.14"
36+
der = "0.7.10"
37+
pkcs8 = "0.10.2"
38+
ic-ed25519 = "0.2.0"
3439

3540
[features]
3641
default = []
3742
python-bindings = ["pyo3"]
43+
experimental = []
3844

3945
[lib]
4046
name = "_lib"

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,24 @@ dfx canister --ic call 73mez-iiaaa-aaaaq-aaasq-cai icrc1_balance_of '(record {ow
9090

9191
# Example: (100000000 : nat) == 1 KINIC
9292
```
93+
94+
### 3. Internet Identity flow (--ii, CLI only)
95+
96+
If you prefer browser login instead of a keychain-backed dfx identity:
97+
98+
```bash
99+
cargo run -- --ii login
100+
cargo run -- --ii list
101+
```
102+
103+
Delegations are stored at `~/.config/kinic/identity.json` (default TTL: 6 hours).
104+
The login flow uses a local callback on port `8620`.
105+
93106
**DM https://x.com/wyatt_benno for KINIC prod tokens** with your principal ID.
94107

95108
Or purchase them from MEXC or swap at https://app.icpswap.com/ .
96109

97-
### 3. Deploy and Use Memory
110+
### 4. Deploy and Use Memory
98111

99112
```python
100113
from kinic_py import KinicMemories

docs/cli.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Command-line companion for deploying and operating Kinic “memory” canisters.
4848

4949
## Running the CLI
5050

51-
All commands require `--identity`. Use `--ic` to talk to mainnet; omit it (or leave false) for the local replica.
51+
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 <name>` is required for CLI commands.
5252

5353
```bash
5454
cargo run -- --identity alice list
@@ -57,6 +57,27 @@ cargo run -- --identity alice create \
5757
--description "Local test canister"
5858
```
5959

60+
### Internet Identity flow (--ii)
61+
62+
First, open the browser login flow and store a delegation (default TTL: 6 hours):
63+
64+
```bash
65+
cargo run -- --ii login
66+
```
67+
68+
Then run commands with `--ii`:
69+
70+
```bash
71+
cargo run -- --ii list
72+
cargo run -- --ii create \
73+
--name "Demo memory" \
74+
--description "Local test canister"
75+
```
76+
77+
Notes:
78+
- Delegations are stored at `~/.config/kinic/identity.json`.
79+
- The login flow uses a local callback on port `8620`.
80+
6081
### Convert PDF to markdown (inspect only)
6182

6283
```bash

docs/ii-login-architecture.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Internet Identity CLI Login Overview
2+
3+
Where
4+
- Component: rust/commands/ii_login.rs
5+
- Data store: ~/.config/kinic/identity.json (or --identity-path)
6+
7+
What
8+
- The CLI opens a browser page that talks to Internet Identity.
9+
- A local axum callback server receives delegations and stores them for future CLI calls.
10+
11+
Why
12+
- Allows CLI-only login without relying on a keychain-backed dfx identity.
13+
14+
Flow (high level)
15+
1) CLI generates a session key pair and a random state token, then starts a local HTTP listener on 127.0.0.1:8620.
16+
- The session key pair is used to request a short-lived delegation from Internet Identity.
17+
- The state token is embedded in the page and must match the callback payload.
18+
- The local listener is the callback endpoint for the browser to POST the signed delegation.
19+
- Binding to 127.0.0.1 ensures the callback is only reachable from the same machine.
20+
2) CLI serves an HTML page that opens the Internet Identity authorize URL.
21+
3) Internet Identity returns signed delegations to the local callback endpoint.
22+
4) CLI verifies the delegation public key matches the session key.
23+
5) CLI persists the delegation bundle with expiration and metadata to ~/.config/kinic/identity.json (or --identity-path).
24+
- Stored fields include: identity provider URL, user public key, session key (pkcs8), delegations, expiration, created timestamp.
25+
- Delegations may include target canisters; those targets are preserved in the saved delegation list.
26+
27+
Server lifetime
28+
- The callback server accepts a single successful callback, then exits.
29+
- If no valid callback arrives before the timeout, the login flow fails.
30+
31+
Key data exchanged
32+
- Session public key (SPKI) from CLI to browser page.
33+
- Delegations + user public key from browser to CLI callback.
34+
35+
Security notes
36+
- The callback is bound to localhost only.
37+
- Callback payloads are rejected if the state token does not match.
38+
- Delegations are verified against the session key before saving.
39+
- Expiration is computed and stored to prevent stale reuse.
40+
- On reuse, the CLI validates the stored file, checks expiration, and normalizes/verifies the delegation chain.
41+
- 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.
42+
43+
Related files
44+
- rust/commands/ii_login.rs
45+
- rust/identity_store.rs

rust/agent.rs

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
use std::io::Cursor;
1+
use std::{io::Cursor, sync::Arc};
22

33
use anyhow::Result;
44
use ic_agent::{
5-
Agent,
5+
Agent, Identity,
66
export::reqwest::Url,
77
identity::{BasicIdentity, Secp256k1Identity},
88
};
@@ -14,31 +14,47 @@ pub const KEYRING_IDENTITY_PREFIX: &str = "internet_computer_identity_";
1414
pub struct AgentFactory {
1515
use_mainnet: bool,
1616
identity_suffix: String,
17+
identity_override: Option<Arc<dyn Identity>>,
1718
}
1819

1920
impl AgentFactory {
2021
pub fn new(use_mainnet: bool, identity_suffix: impl Into<String>) -> Self {
2122
Self {
2223
use_mainnet,
2324
identity_suffix: identity_suffix.into(),
25+
identity_override: None,
2426
}
2527
}
2628

27-
pub async fn build(&self) -> Result<Agent> {
28-
let pem_bytes = load_pem_from_keyring(&self.identity_suffix)?;
29-
let pem_text = String::from_utf8(pem_bytes.clone())?;
30-
let pem = pem::parse(pem_text.as_bytes())?;
29+
pub fn new_with_identity<I>(use_mainnet: bool, identity: I) -> Self
30+
where
31+
I: Identity + 'static,
32+
{
33+
Self {
34+
use_mainnet,
35+
identity_suffix: String::new(),
36+
identity_override: Some(Arc::new(identity)),
37+
}
38+
}
3139

32-
let builder = match pem.tag() {
33-
"PRIVATE KEY" => {
34-
let identity = BasicIdentity::from_pem(Cursor::new(pem_text.clone()))?;
35-
Agent::builder().with_identity(identity)
36-
}
37-
"EC PRIVATE KEY" => {
38-
let identity = Secp256k1Identity::from_pem(Cursor::new(pem_text.clone()))?;
39-
Agent::builder().with_identity(identity)
40+
pub async fn build(&self) -> Result<Agent> {
41+
let builder = if let Some(identity) = &self.identity_override {
42+
Agent::builder().with_arc_identity(identity.clone())
43+
} else {
44+
let pem_bytes = load_pem_from_keyring(&self.identity_suffix)?;
45+
let pem_text = String::from_utf8(pem_bytes.clone())?;
46+
let pem = pem::parse(pem_text.as_bytes())?;
47+
match pem.tag() {
48+
"PRIVATE KEY" => {
49+
let identity = BasicIdentity::from_pem(Cursor::new(pem_text.clone()))?;
50+
Agent::builder().with_identity(identity)
51+
}
52+
"EC PRIVATE KEY" => {
53+
let identity = Secp256k1Identity::from_pem(Cursor::new(pem_text.clone()))?;
54+
Agent::builder().with_identity(identity)
55+
}
56+
_ => anyhow::bail!("Unsupported PEM tag: {}", pem.tag()),
4057
}
41-
_ => anyhow::bail!("Unsupported PEM tag: {}", pem.tag()),
4258
};
4359

4460
let url = if self.use_mainnet {

0 commit comments

Comments
 (0)