From d0b406bf4e055ab715002174a8feb0cc698e315a Mon Sep 17 00:00:00 2001 From: hude Date: Tue, 12 May 2026 09:42:12 +0900 Subject: [PATCH 1/2] Harden icp-cli II login host and hash validation --- README.md | 11 +- apps/kinic-portal/package.json | 3 + apps/kinic-portal/src/app.tsx | 2 + .../src/routes/cli-login-page.test.ts | 53 +++ .../src/routes/cli-login-page.tsx | 210 ++++++++++++ apps/kinic-portal/src/ssr.tsx | 10 + apps/kinic-portal/src/worker.test.ts | 42 ++- apps/kinic-portal/src/worker.tsx | 61 +++- docs/cli.md | 28 +- pnpm-lock.yaml | 44 +++ rust/icp_cli_identity.rs | 322 ++++++++++++++++++ rust/icp_cli_identity_tests.rs | 124 +++++++ rust/lib.rs | 6 + 13 files changed, 901 insertions(+), 15 deletions(-) create mode 100644 apps/kinic-portal/src/routes/cli-login-page.test.ts create mode 100644 apps/kinic-portal/src/routes/cli-login-page.tsx create mode 100644 rust/icp_cli_identity.rs create mode 100644 rust/icp_cli_identity_tests.rs diff --git a/README.md b/README.md index 507198c..bfdc79d 100644 --- a/README.md +++ b/README.md @@ -337,17 +337,18 @@ dfx canister --ic call 73mez-iiaaa-aaaaq-aaasq-cai icrc1_balance_of '(record {ow Or purchase them from MEXC or swap at https://app.icpswap.com/ . -#### 3. Internet Identity Flow (`--ii`, CLI only) +#### 3. Internet Identity Flow (`icp-cli`, recommended) If you prefer browser login instead of a Keychain-backed dfx identity: ```bash -cargo run -- --ii login -cargo run -- --ii list +icp identity link ii --host https://memory.kinic.xyz +cargo run -- --ic --identity list ``` -Delegations are stored at `~/.config/kinic/identity.json` with a default TTL of 6 hours. -The login flow uses a local callback on port `8620`. +Requires `icp-cli` 0.2.4 or newer. When the delegation expires, refresh it with `icp identity login `. + +Legacy `kinic-cli --ii login` still works for CLI-only use, but it derives from the locally hosted login origin and stores delegations at `~/.config/kinic/identity.json`. #### 4. Deploy and Use Memory from Python diff --git a/apps/kinic-portal/package.json b/apps/kinic-portal/package.json index c997c97..9b1eeca 100644 --- a/apps/kinic-portal/package.json +++ b/apps/kinic-portal/package.json @@ -20,6 +20,9 @@ "typecheck": "tsc --project tsconfig.json --noEmit" }, "dependencies": { + "@dfinity/agent": "^2.4.1", + "@dfinity/auth-client": "^2.4.1", + "@dfinity/identity": "^2.4.1", "@kinic/kinic-share": "workspace:*", "@tailwindcss/postcss": "^4.2.2", "class-variance-authority": "^0.7.1", diff --git a/apps/kinic-portal/src/app.tsx b/apps/kinic-portal/src/app.tsx index 5237172..eadfe9a 100644 --- a/apps/kinic-portal/src/app.tsx +++ b/apps/kinic-portal/src/app.tsx @@ -4,6 +4,7 @@ import { Route, Routes } from "react-router"; import type { PortalRuntimeConfig } from "./runtime-config"; +import { CliLoginPage } from "./routes/cli-login-page"; import { HomePage } from "./routes/home-page"; import { MemoryPage } from "./routes/memory-page"; import { NotFoundPage } from "./routes/not-found-page"; @@ -12,6 +13,7 @@ export function App({ config }: { config: PortalRuntimeConfig }) { return ( } /> + } /> } /> } /> diff --git a/apps/kinic-portal/src/routes/cli-login-page.test.ts b/apps/kinic-portal/src/routes/cli-login-page.test.ts new file mode 100644 index 0000000..5d26bb0 --- /dev/null +++ b/apps/kinic-portal/src/routes/cli-login-page.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { isAllowedLocalCallback, parseCliLoginHash } from "./cli-login-page"; + +describe("cli login params", () => { + it("returns empty for direct visits without hash params", () => { + const result = parseCliLoginHash(""); + + expect(result).toEqual({ kind: "empty" }); + }); + + it("accepts localhost callback hashes from icp-cli", () => { + const result = parseCliLoginHash("#public_key=abc&callback=http%3A%2F%2F127.0.0.1%3A1234%2Fcallback"); + + expect(result).toEqual({ + kind: "ok", + params: { + publicKey: "abc", + callback: "http://127.0.0.1:1234/callback", + }, + }); + }); + + it("accepts ipv6 loopback callbacks", () => { + const result = parseCliLoginHash("#public_key=abc&callback=http%3A%2F%2F%5B%3A%3A1%5D%3A1234%2Fcallback"); + + expect(result).toEqual({ + kind: "ok", + params: { + publicKey: "abc", + callback: "http://[::1]:1234/callback", + }, + }); + }); + + it("rejects missing public keys", () => { + const result = parseCliLoginHash("#callback=http%3A%2F%2F127.0.0.1%3A1234%2Fcallback"); + + expect(result.kind).toBe("error"); + }); + + it("rejects missing callbacks", () => { + const result = parseCliLoginHash("#public_key=abc"); + + expect(result.kind).toBe("error"); + }); + + it("rejects remote callbacks", () => { + expect(isAllowedLocalCallback("https://memory.kinic.xyz/callback")).toBe(false); + expect(isAllowedLocalCallback("http://localhost:1234/callback")).toBe(false); + expect(parseCliLoginHash("#public_key=abc&callback=https%3A%2F%2Fmemory.kinic.xyz%2Fcallback").kind).toBe("error"); + expect(parseCliLoginHash("#public_key=abc&callback=http%3A%2F%2Flocalhost%3A1234%2Fcallback").kind).toBe("error"); + }); +}); diff --git a/apps/kinic-portal/src/routes/cli-login-page.tsx b/apps/kinic-portal/src/routes/cli-login-page.tsx new file mode 100644 index 0000000..d0213d9 --- /dev/null +++ b/apps/kinic-portal/src/routes/cli-login-page.tsx @@ -0,0 +1,210 @@ +// Where: browser route used by icp-cli's Internet Identity link flow. +// What: receives CLI session details, asks II for a delegation, and posts it to localhost. +// Why: terminal auth must derive from memory.kinic.xyz so CLI and portal principals match. + +import { useMemo, useState } from "react"; +import { AuthClient } from "@dfinity/auth-client"; +import { DelegationChain, DelegationIdentity } from "@dfinity/identity"; +import type { DerEncodedPublicKey } from "@dfinity/agent"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +const IDENTITY_PROVIDER = "https://id.ai"; +const DELEGATION_EXPIRATION_MS = 8 * 60 * 60 * 1000; +const NANOS_PER_MILLISECOND = 1_000_000n; + +type CliLoginParams = { + publicKey: string; + callback: string; +}; + +export type CliLoginParseResult = + | { kind: "empty" } + | { kind: "ok"; params: CliLoginParams } + | { kind: "error"; message: string }; + +type LoginState = "ready" | "signing-in" | "sending" | "finished" | "error"; + +export function CliLoginPage() { + const loginRequest = useMemo(() => parseCliLoginHash(readHash()), []); + const [state, setState] = useState("ready"); + const [error, setError] = useState(null); + + async function signIn() { + if (loginRequest.kind !== "ok") { + return; + } + setError(null); + setState("signing-in"); + try { + const authClient = await AuthClient.create({ keyType: "Ed25519" }); + await login(authClient); + setState("sending"); + const { params } = loginRequest; + const delegation = await createCliDelegation(authClient, params.publicKey); + const response = await fetch(params.callback, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(delegation), + redirect: "error", + }); + if (!response.ok) { + throw new Error(`Callback failed: ${response.status} ${response.statusText}`); + } + await authClient.logout(); + setState("finished"); + window.setTimeout(() => window.close(), 2000); + } catch (cause) { + setState("error"); + setError(cause instanceof Error ? cause.message : "Sign-in failed"); + } + } + + if (loginRequest.kind === "empty") { + return ( +
+ + + Start from your terminal. + + +

Run the command below and icp-cli will reopen this page with the required session parameters.

+
+              icp identity link ii <identity-name> --host https://memory.kinic.xyz
+            
+
+
+
+ ); + } + + if (loginRequest.kind === "error") { + return ( +
+ + + Terminal login link is invalid. + + + + Cannot authorize terminal + {loginRequest.message} + +
+              icp identity link ii <identity-name> --host https://memory.kinic.xyz
+            
+
+
+
+ ); + } + + return ( +
+ + + Authorize your terminal. + + +

Sign in with Internet Identity. Kinic will return a short-lived delegation to icp-cli on localhost.

+ {state === "ready" ? ( + + Local callback required + Approve the browser request to connect to the local address opened by icp-cli. + + ) : null} + {state === "signing-in" ?

Waiting for Internet Identity...

: null} + {state === "sending" ?

Sending delegation to your terminal...

: null} + {state === "finished" ?

Done. Return to your terminal.

: null} + {state === "error" && error ? ( + + Sign-in failed + {error} + + ) : null} + +
+
+
+ ); +} + +export function parseCliLoginHash(hash: string): CliLoginParseResult { + const normalized = hash.startsWith("#") ? hash.slice(1) : hash; + if (!normalized) { + return { kind: "empty" }; + } + const params = new URLSearchParams(normalized); + const publicKey = params.get("public_key"); + const callback = params.get("callback"); + if (!publicKey) { + return { kind: "error", message: "Missing public_key in the terminal login URL." }; + } + if (!callback) { + return { kind: "error", message: "Missing callback in the terminal login URL." }; + } + if (!isAllowedLocalCallback(callback)) { + return { kind: "error", message: "The callback must use http://127.0.0.1 or http://[::1]." }; + } + return { kind: "ok", params: { publicKey, callback } }; +} + +export function isAllowedLocalCallback(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + return url.protocol === "http:" + && !url.username + && !url.password + && (url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]"); +} + +async function login(authClient: AuthClient): Promise { + return new Promise((resolve, reject) => { + authClient.login({ + identityProvider: IDENTITY_PROVIDER, + maxTimeToLive: BigInt(DELEGATION_EXPIRATION_MS) * NANOS_PER_MILLISECOND, + onSuccess: resolve, + onError: reject, + }); + }); +} + +async function createCliDelegation(authClient: AuthClient, publicKey: string) { + const identity = authClient.getIdentity(); + if (!(identity instanceof DelegationIdentity)) { + throw new Error("Expected a delegated Internet Identity session."); + } + const key = decodeBase64Url(publicKey); + const sessionPublicKey = key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as DerEncodedPublicKey; + const delegation = await DelegationChain.create( + identity, + { toDer: () => sessionPublicKey }, + new Date(Date.now() + DELEGATION_EXPIRATION_MS), + { previous: identity.getDelegation() }, + ); + return delegation.toJSON(); +} + +function decodeBase64Url(value: string): Uint8Array { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + return Uint8Array.from(globalThis.atob(padded), (char) => char.charCodeAt(0)); +} + +function readHash(): string { + if (typeof window === "undefined") { + return ""; + } + const hash = window.location.hash; + if (hash) { + window.history.replaceState(null, "", window.location.pathname); + } + return hash; +} diff --git a/apps/kinic-portal/src/ssr.tsx b/apps/kinic-portal/src/ssr.tsx index 89f795f..352c47f 100644 --- a/apps/kinic-portal/src/ssr.tsx +++ b/apps/kinic-portal/src/ssr.tsx @@ -64,6 +64,16 @@ export function resolvePortalMetadata( }); } + if (pathname === "/cli-login") { + return buildMemoryUnavailableMetadata(pathname, { + portalOrigin: config.portalOrigin, + publicApiOrigin: config.publicApiOrigin, + title: "Terminal Login | Kinic", + description: "Authorize icp-cli with the same Internet Identity origin as the Kinic portal.", + status: 200, + }); + } + const memoryId = matchMemoryId(pathname); if (memoryId) { return resolveMemoryRouteMetadata(pathname, memoryId, config, memoryState, memorySummary); diff --git a/apps/kinic-portal/src/worker.test.ts b/apps/kinic-portal/src/worker.test.ts index f2c9140..1098036 100644 --- a/apps/kinic-portal/src/worker.test.ts +++ b/apps/kinic-portal/src/worker.test.ts @@ -71,6 +71,45 @@ describe("portal worker", () => { expect(mocks.renderPortalDocument).toHaveBeenCalledOnce(); }); + it("serves the icp-cli login discovery path", async () => { + const response = await worker.fetch( + new Request("https://portal.kinic.test/.well-known/ic-cli-login"), + env(), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(await response.text()).toBe("/cli-login"); + expect(mocks.renderPortalDocument).not.toHaveBeenCalled(); + }); + + it("adds CSP for localhost cli callbacks on documents", async () => { + const response = await worker.fetch( + new Request("https://portal.kinic.test/cli-login"), + env(), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Security-Policy")).toContain("http://127.0.0.1:*"); + expect(response.headers.get("Content-Security-Policy")).toContain("https://id.ai"); + }); + + it("adds runtime API and MCP origins to document CSP", async () => { + const response = await worker.fetch( + new Request("https://portal.preview.test/m/m1"), + env({ + KINIC_PUBLIC_API_ORIGIN: "https://api.preview.test", + KINIC_REMOTE_MCP_ORIGIN: "https://mcp.preview.test/mcp", + }), + ); + const csp = response.headers.get("Content-Security-Policy"); + + expect(csp).toContain("img-src 'self' data: https://api.preview.test"); + expect(csp).toContain("connect-src"); + expect(csp).toContain("https://api.preview.test"); + expect(csp).toContain("https://mcp.preview.test"); + }); + it("skips full SSR for HEAD requests", async () => { const response = await worker.fetch( new Request("https://portal.kinic.test/m/m1", { method: "HEAD" }), @@ -336,12 +375,13 @@ describe("portal worker", () => { }); }); -function env(): Env { +function env(overrides: Partial = {}): Env { return { ASSETS: { fetch: vi.fn() } as never, IC_HOST: "https://ic0.app", EMBEDDING_API_ENDPOINT: "https://api.kinic.test", KINIC_PORTAL_ORIGIN: "https://portal.kinic.test", KINIC_PUBLIC_API_ORIGIN: "https://api.kinic.test", + ...overrides, }; } diff --git a/apps/kinic-portal/src/worker.tsx b/apps/kinic-portal/src/worker.tsx index 6ca2798..e1130fc 100644 --- a/apps/kinic-portal/src/worker.tsx +++ b/apps/kinic-portal/src/worker.tsx @@ -7,10 +7,13 @@ import { resolvePublicSummary } from "../workers/shared/public-memory-summary-ru import { resolveSummaryLanguage } from "../workers/public-api/src/public-summary"; import { buildSummaryCacheKey, getSummaryCache, readSummaryCache } from "../workers/public-api/src/summary-cache"; import { buildRuntimeConfig } from "./runtime-config"; +import type { PortalRuntimeConfig } from "./runtime-config"; import { PORTAL_SCRIPT_PATH, PORTAL_STYLE_PATH, renderPortalDocument, resolvePortalMetadata } from "./ssr"; const DETAIL_ROUTE = /^\/api\/public\/memories\/([^/]+)$/; const SUMMARY_ROUTE = /^\/api\/public\/memories\/([^/]+)\/summary$/; +const CLI_LOGIN_DISCOVERY_PATH = "/.well-known/ic-cli-login"; +const CLI_LOGIN_PATH = "/cli-login"; const OGP_SUMMARY_LANGUAGE = "en"; export default { @@ -22,6 +25,10 @@ export default { return new Response("method not allowed", { status: 405 }); } + if (pathname === CLI_LOGIN_DISCOVERY_PATH) { + return textResponse(request.method, CLI_LOGIN_PATH, 200); + } + const detailMatch = pathname.match(DETAIL_ROUTE); if (detailMatch) { return handleMemoryDetail(request.method, env, detailMatch[1]); @@ -40,11 +47,11 @@ export default { const memorySummary = await resolveMemoryRouteSummary(env, memoryState); if (request.method === "HEAD") { const metadata = resolvePortalMetadata(pathname, config, memoryState, memorySummary); - return documentResponse(null, metadata.status); + return documentResponse(null, metadata.status, config); } const document = renderPortalDocument(pathname, config, memoryState, memorySummary); - return documentResponse(document.html, document.status); + return documentResponse(document.html, document.status, config); }, }; @@ -107,17 +114,65 @@ async function handleMemorySummary(method: "GET" | "HEAD", request: Request, env } } -function documentResponse(body: string | null, status: number): Response { +function documentResponse(body: string | null, status: number, config: PortalRuntimeConfig): Response { return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", "Cache-Control": "public, max-age=0, must-revalidate", + "Content-Security-Policy": buildDocumentCsp(config), Link: `<${PORTAL_STYLE_PATH}>; rel=preload; as=style, <${PORTAL_SCRIPT_PATH}>; rel=modulepreload; as=script`, }, }); } +function buildDocumentCsp(config: PortalRuntimeConfig): string { + const publicApiOrigin = safeOrigin(config.publicApiOrigin); + const mcpOrigin = config.mcpEndpoint ? safeOrigin(config.mcpEndpoint) : null; + const imageSources = ["'self'", "data:", publicApiOrigin].filter(Boolean); + const connectSources = [ + "'self'", + "https://id.ai", + "https://ic0.app", + "https://icp-api.io", + publicApiOrigin, + mcpOrigin, + "http://127.0.0.1:*", + "http://[::1]:*", + ].filter(Boolean); + + return [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + `img-src ${imageSources.join(" ")}`, + `connect-src ${connectSources.join(" ")}`, + "font-src 'self'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'", + "object-src 'none'", + ].join("; "); +} + +function safeOrigin(value: string): string | null { + try { + return new URL(value).origin; + } catch { + return null; + } +} + +function textResponse(method: "GET" | "HEAD", body: string, status: number): Response { + return new Response(method === "HEAD" ? null : body, { + status, + headers: { + "content-type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=300", + }, + }); +} + function jsonResponse(method: "GET" | "HEAD", body: unknown, status: number): Response { return new Response(method === "HEAD" ? null : JSON.stringify(body), { status, diff --git a/docs/cli.md b/docs/cli.md index 47f1ea5..21ee040 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -103,17 +103,33 @@ cargo run -- --ic --identity alice create \ --description "Mainnet memory" ``` -### Internet Identity flow (--ii) +### Internet Identity flow with icp-cli -First, open the browser login flow and store a delegation (default TTL: 6 hours): +Install `icp-cli` 0.2.4 or newer, then link an Internet Identity to a local identity on the Kinic portal origin: ```bash -cargo run -- login +icp identity link ii alice --host https://memory.kinic.xyz ``` -Then run commands with `--ii`: +Use the linked identity like any other `--identity` value: ```bash +cargo run -- --ic --identity alice list +cargo run -- --ic --identity alice create \ + --name "Demo memory" \ + --description "Mainnet memory" +``` + +Refresh an expired delegation with: + +```bash +icp identity login alice +``` + +Legacy `--ii` flow: + +```bash +cargo run -- --ii login cargo run -- --ic --ii list cargo run -- --ic --ii create \ --name "Demo memory" \ @@ -121,8 +137,8 @@ cargo run -- --ic --ii create \ ``` Notes: -- Delegations are stored at `~/.config/kinic/identity.json`. -- The login flow uses a local callback on port `8620`. +- The recommended path is `icp identity link ii ... --host https://memory.kinic.xyz`, because the principal derives from the Kinic portal origin. +- Legacy `--ii` stores delegations at `~/.config/kinic/identity.json` with a local callback on port `8620`. ### Convert PDF to markdown (inspect only) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a3b732..d5ad36c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,15 @@ importers: apps/kinic-portal: dependencies: + '@dfinity/agent': + specifier: ^2.4.1 + version: 2.4.1(@dfinity/candid@2.4.1(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1) + '@dfinity/auth-client': + specifier: ^2.4.1 + version: 2.4.1(@dfinity/agent@2.4.1(@dfinity/candid@2.4.1(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1))(@dfinity/identity@2.4.1(@dfinity/agent@2.4.1(@dfinity/candid@2.4.1(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1) + '@dfinity/identity': + specifier: ^2.4.1 + version: 2.4.1(@dfinity/agent@2.4.1(@dfinity/candid@2.4.1(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1) '@kinic/kinic-share': specifier: workspace:* version: link:packages/kinic-share @@ -268,12 +277,27 @@ packages: '@dfinity/candid': ^2.4.1 '@dfinity/principal': ^2.4.1 + '@dfinity/auth-client@2.4.1': + resolution: {integrity: sha512-osKgBWwMsMyQUNYhFxPqj14R2RhgdYVD0PoTGDig2sl1Hy4mQzPalCWbkW9R50vsZGmoMi/uiopvnXy036EyqA==} + deprecated: This package has been deprecated. Its functionality is now part of `@icp-sdk/auth` (https://js.icp.build/auth), under the `client` submodule. + peerDependencies: + '@dfinity/agent': ^2.4.1 + '@dfinity/identity': ^2.4.1 + '@dfinity/principal': ^2.4.1 + '@dfinity/candid@2.4.1': resolution: {integrity: sha512-kOaIKfhR2PYN8vD4M0Pc4s/7wb1nKjlTJUw+5E9jh26T03fITIZmaafIuwlX+wmdxwIT9Xoy7PlsxOEpzv203A==} deprecated: 'This package has been deprecated. Use @icp-sdk/core/candid instead. Migration guide: https://js.icp.build/core/latest/upgrading/v5' peerDependencies: '@dfinity/principal': ^2.4.1 + '@dfinity/identity@2.4.1': + resolution: {integrity: sha512-CXhTmdtqkA0vE6ue2GaF9ZwD0OQ5OinrGj77Eg0dX0zPZpxJQ+NCjyYNWkaIvsKxmnCaW+5yrCcchN8Sqk8uIA==} + deprecated: 'This package has been deprecated. Use @icp-sdk/core/identity instead. Migration guide: https://js.icp.build/core/latest/upgrading/v5' + peerDependencies: + '@dfinity/agent': ^2.4.1 + '@dfinity/principal': ^2.4.1 + '@dfinity/principal@2.4.1': resolution: {integrity: sha512-Cz6XQVOwq0TXDBClPbcidDd4SqK1lfr1/Kn34ruDD13xVQ4iaP1iCntzS9O97+vGpY/6jwDtKd32Gn5YJ9BQNw==} deprecated: 'This package has been deprecated. Use @icp-sdk/core/principal instead. Migration guide: https://js.icp.build/core/latest/upgrading/v5' @@ -1659,6 +1683,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2509,10 +2536,25 @@ snapshots: buffer: 6.0.3 simple-cbor: 0.4.1 + '@dfinity/auth-client@2.4.1(@dfinity/agent@2.4.1(@dfinity/candid@2.4.1(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1))(@dfinity/identity@2.4.1(@dfinity/agent@2.4.1(@dfinity/candid@2.4.1(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1)': + dependencies: + '@dfinity/agent': 2.4.1(@dfinity/candid@2.4.1(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1) + '@dfinity/identity': 2.4.1(@dfinity/agent@2.4.1(@dfinity/candid@2.4.1(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1) + '@dfinity/principal': 2.4.1 + idb: 7.1.1 + '@dfinity/candid@2.4.1(@dfinity/principal@2.4.1)': dependencies: '@dfinity/principal': 2.4.1 + '@dfinity/identity@2.4.1(@dfinity/agent@2.4.1(@dfinity/candid@2.4.1(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1)': + dependencies: + '@dfinity/agent': 2.4.1(@dfinity/candid@2.4.1(@dfinity/principal@2.4.1))(@dfinity/principal@2.4.1) + '@dfinity/principal': 2.4.1 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + borc: 2.1.2 + '@dfinity/principal@2.4.1': dependencies: '@noble/hashes': 1.8.0 @@ -3626,6 +3668,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb@7.1.1: {} + ieee754@1.2.1: {} inherits@2.0.4: {} diff --git a/rust/icp_cli_identity.rs b/rust/icp_cli_identity.rs new file mode 100644 index 0000000..6f75bed --- /dev/null +++ b/rust/icp_cli_identity.rs @@ -0,0 +1,322 @@ +//! rust/icp_cli_identity.rs +//! Where: identity loading bridge between icp-cli linked identities and Kinic CLI. +//! What: reads icp-cli Internet Identity delegations and turns them into ic-agent identities. +//! Why: let `kinic-cli --identity ` reuse II-linked identities created by `icp identity link ii`. + +use std::{ + collections::HashMap, + fs, + io::Cursor, + path::{Path, PathBuf}, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, anyhow}; +use ic_agent::{ + Identity, + export::Principal, + identity::{BasicIdentity, DelegatedIdentity, Delegation, DelegationError, SignedDelegation}, +}; +use serde::Deserialize; +use tracing::warn; + +use crate::identity_store::normalize_spki_key; + +const ICP_CLI_KEYRING_SERVICE_NAME: &str = "icp-cli"; +const ICP_CLI_DELEGATION_ACCOUNT_PREFIX: &str = "delegation_"; +const ICP_CLI_IDENTITY_DIR_ENV: &str = "KINIC_ICP_CLI_IDENTITY_DIR"; +const KINIC_PORTAL_ORIGIN: &str = "https://memory.kinic.xyz"; + +#[derive(Debug, Deserialize)] +struct IdentityList { + identities: HashMap, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct IcpCliIdentity { + pub(crate) kind: String, + pub(crate) storage: Option, + pub(crate) host: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct IcpCliStorage { + pub(crate) kind: String, +} + +#[derive(Debug, Deserialize)] +struct HexDelegationChain { + #[serde(rename = "publicKey")] + public_key: String, + delegations: Vec, +} + +#[derive(Debug, Deserialize)] +struct HexSignedDelegation { + delegation: HexDelegation, + signature: String, +} + +#[derive(Debug, Deserialize)] +struct HexDelegation { + pubkey: String, + expiration: String, + targets: Option>, +} + +pub(crate) fn load_icp_cli_internet_identity( + identity_name: &str, +) -> Result>> { + let identity_dir = match default_identity_dir()? { + Some(path) => path, + None => return Ok(None), + }; + load_icp_cli_internet_identity_from_dir(identity_name, &identity_dir) +} + +fn load_icp_cli_internet_identity_from_dir( + identity_name: &str, + identity_dir: &Path, +) -> Result>> { + let Some(identity) = read_internet_identity_metadata(identity_name, identity_dir)? else { + return Ok(None); + }; + ensure_kinic_portal_host(identity_name, &identity)?; + ensure_keyring_storage(identity_name, &identity)?; + + let delegation_path = identity_dir + .join("delegations") + .join(format!("{identity_name}.json")); + let chain = read_delegation_chain(&delegation_path)?; + ensure_chain_not_expired(identity_name, &chain.delegations)?; + + let session_pem = load_session_key_from_keyring(identity_name)?; + let delegated = new_delegated_identity(chain.public_key, &session_pem, chain.delegations)?; + Ok(Some(Arc::new(delegated))) +} + +pub(crate) fn read_internet_identity_metadata( + identity_name: &str, + identity_dir: &Path, +) -> Result> { + let list_path = identity_dir.join("identity_list.json"); + let payload = match fs::read_to_string(&list_path) { + Ok(payload) => payload, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to read icp-cli identity list at {}", list_path.display())); + } + }; + let list: IdentityList = serde_json::from_str(&payload) + .with_context(|| format!("Failed to parse icp-cli identity list at {}", list_path.display()))?; + let Some(identity) = list.identities.into_iter().find_map(|(name, identity)| { + if name == identity_name { + Some(identity) + } else { + None + } + }) else { + return Ok(None); + }; + if identity.kind == "internet-identity" { + Ok(Some(identity)) + } else { + Ok(None) + } +} + +pub(crate) fn ensure_keyring_storage(identity_name: &str, identity: &IcpCliIdentity) -> Result<()> { + let storage_kind = identity + .storage + .as_ref() + .map(|storage| storage.kind.as_str()) + .unwrap_or("keyring"); + if storage_kind != "keyring" { + anyhow::bail!( + "icp-cli Internet Identity `{identity_name}` uses unsupported storage `{storage_kind}`. Recreate it with `icp identity link ii {identity_name} --host https://memory.kinic.xyz --storage keyring`." + ); + } + Ok(()) +} + +pub(crate) fn ensure_kinic_portal_host(identity_name: &str, identity: &IcpCliIdentity) -> Result<()> { + let host = identity + .host + .as_deref() + .ok_or_else(|| kinic_portal_host_error(identity_name, "missing host"))?; + let origin = normalize_host_origin(host) + .ok_or_else(|| kinic_portal_host_error(identity_name, "invalid host"))?; + if origin != KINIC_PORTAL_ORIGIN { + return Err(kinic_portal_host_error(identity_name, &format!("host `{origin}`"))); + } + Ok(()) +} + +fn kinic_portal_host_error(identity_name: &str, reason: &str) -> anyhow::Error { + anyhow!( + "icp-cli Internet Identity `{identity_name}` is linked to {reason}. Recreate it with `icp identity link ii {identity_name} --host {KINIC_PORTAL_ORIGIN}`." + ) +} + +fn normalize_host_origin(host: &str) -> Option { + let trimmed = host.trim(); + if trimmed.is_empty() { + return None; + } + let without_trailing_slash = trimmed.trim_end_matches('/'); + let scheme_end = without_trailing_slash.find("://")?; + let scheme = without_trailing_slash[..scheme_end].to_ascii_lowercase(); + let authority_and_path = &without_trailing_slash[scheme_end + 3..]; + let authority = authority_and_path.split('/').next()?.to_ascii_lowercase(); + if authority.is_empty() { + return None; + } + Some(format!("{scheme}://{authority}")) +} + +fn read_delegation_chain(path: &Path) -> Result { + let payload = fs::read_to_string(path) + .with_context(|| format!("Failed to read icp-cli delegation file at {}", path.display()))?; + parse_delegation_chain_json(&payload) +} + +fn parse_delegation_chain_json(payload: &str) -> Result { + let chain: HexDelegationChain = + serde_json::from_str(payload).context("Failed to parse icp-cli delegation chain")?; + let public_key = decode_hex_key("delegation publicKey", &chain.public_key)?; + let public_key = normalize_spki_key(&public_key).context("Unsupported II user public key format")?; + let delegations = chain + .delegations + .into_iter() + .map(parse_signed_delegation) + .collect::>>()?; + Ok(ParsedDelegationChain { + public_key, + delegations, + }) +} + +fn parse_signed_delegation(entry: HexSignedDelegation) -> Result { + let pubkey = decode_hex_key("delegation pubkey", &entry.delegation.pubkey)?; + let pubkey = normalize_spki_key(&pubkey).context("Unsupported delegation public key format")?; + let expiration = u64::from_str_radix(entry.delegation.expiration.trim(), 16) + .context("Failed to parse delegation expiration")?; + let targets = entry + .delegation + .targets + .map(|targets| { + targets + .into_iter() + .map(Principal::from_text) + .collect::, _>>() + .context("Invalid delegation target principal") + }) + .transpose()?; + Ok(SignedDelegation { + delegation: Delegation { + pubkey, + expiration, + targets, + }, + signature: decode_hex_key("delegation signature", &entry.signature)?, + }) +} + +fn load_session_key_from_keyring(identity_name: &str) -> Result { + let account = format!("{ICP_CLI_DELEGATION_ACCOUNT_PREFIX}{identity_name}"); + let entry = keyring::Entry::new(ICP_CLI_KEYRING_SERVICE_NAME, &account)?; + entry.get_password().with_context(|| { + format!( + "Failed to read icp-cli session key for `{identity_name}` from keyring entry `{ICP_CLI_KEYRING_SERVICE_NAME}/{account}`" + ) + }) +} + +fn new_delegated_identity( + public_key: Vec, + session_pem: &str, + delegations: Vec, +) -> Result { + let session_identity = parse_session_identity(session_pem)?; + let delegated = DelegatedIdentity::new( + public_key.clone(), + Box::new(session_identity), + delegations.clone(), + ); + match delegated { + Ok(identity) => Ok(identity), + Err(DelegationError::UnknownAlgorithm) => { + warn!("icp-cli delegation chain uses an unknown algorithm; skipping local verification."); + let session_identity = parse_session_identity(session_pem)?; + Ok(DelegatedIdentity::new_unchecked( + public_key, + Box::new(session_identity), + delegations, + )) + } + Err(error) => Err(error.into()), + } +} + +fn parse_session_identity(session_pem: &str) -> Result { + BasicIdentity::from_pem(Cursor::new(session_pem.to_string())) + .context("Failed to parse icp-cli session key") +} + +pub(crate) fn ensure_chain_not_expired(identity_name: &str, delegations: &[SignedDelegation]) -> Result<()> { + let expiration = delegations + .iter() + .map(|entry| entry.delegation.expiration) + .min() + .ok_or_else(|| anyhow!("icp-cli delegation chain is empty"))?; + 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 >= expiration { + anyhow::bail!( + "icp-cli Internet Identity `{identity_name}` delegation has expired. Run `icp identity login {identity_name}`." + ); + } + Ok(()) +} + +fn decode_hex_key(label: &str, value: &str) -> Result> { + hex::decode(value.trim()).with_context(|| format!("Failed to decode {label}")) +} + +fn default_identity_dir() -> Result> { + if let Ok(path) = std::env::var(ICP_CLI_IDENTITY_DIR_ENV) { + return Ok(Some(PathBuf::from(path))); + } + let Some(home) = std::env::var_os("HOME") else { + return Ok(None); + }; + #[cfg(target_os = "macos")] + { + return Ok(Some( + PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("org.dfinity.icp-cli") + .join("identity"), + )); + } + #[cfg(not(target_os = "macos"))] + { + Ok(Some( + PathBuf::from(home) + .join(".local") + .join("share") + .join("org.dfinity.icp-cli") + .join("identity"), + )) + } +} + +struct ParsedDelegationChain { + public_key: Vec, + delegations: Vec, +} diff --git a/rust/icp_cli_identity_tests.rs b/rust/icp_cli_identity_tests.rs new file mode 100644 index 0000000..9d4ff18 --- /dev/null +++ b/rust/icp_cli_identity_tests.rs @@ -0,0 +1,124 @@ +//! rust/icp_cli_identity_tests.rs +//! Where: unit tests for icp-cli Internet Identity metadata handling. +//! What: verifies resolver branching and user-facing recovery messages. +//! Why: keep linked-II support safe without touching the real keychain in tests. + +use std::{fs, path::PathBuf}; + +use ic_agent::identity::{Delegation, SignedDelegation}; + +use crate::icp_cli_identity::{ + IcpCliIdentity, IcpCliStorage, ensure_chain_not_expired, ensure_keyring_storage, + ensure_kinic_portal_host, read_internet_identity_metadata, +}; + +#[test] +fn read_metadata_returns_none_for_keyring_identity() { + let dir = tempfile_dir("icp-cli-metadata-keyring"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("identity_list.json"), + r#"{"v":1,"identities":{"alice":{"kind":"keyring","algorithm":"secp256k1"}}}"#, + ) + .unwrap(); + + let metadata = read_internet_identity_metadata("alice", &dir).unwrap(); + + assert!(metadata.is_none()); +} + +#[test] +fn read_metadata_detects_internet_identity() { + let dir = tempfile_dir("icp-cli-metadata-ii"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("identity_list.json"), + r#"{"v":1,"identities":{"alice":{"kind":"internet-identity","storage":{"kind":"keyring"}}}}"#, + ) + .unwrap(); + + let metadata = read_internet_identity_metadata("alice", &dir).unwrap().unwrap(); + + assert_eq!(metadata.kind, "internet-identity"); + assert_eq!(metadata.storage.unwrap().kind, "keyring"); +} + +#[test] +fn kinic_portal_host_accepts_exact_origin() { + let identity = internet_identity_with_host(Some("https://memory.kinic.xyz")); + + ensure_kinic_portal_host("alice", &identity).unwrap(); +} + +#[test] +fn kinic_portal_host_accepts_trailing_slash() { + let identity = internet_identity_with_host(Some("https://memory.kinic.xyz/")); + + ensure_kinic_portal_host("alice", &identity).unwrap(); +} + +#[test] +fn kinic_portal_host_rejects_other_origin() { + let identity = internet_identity_with_host(Some("https://cli.id.ai/")); + + let error = ensure_kinic_portal_host("alice", &identity).unwrap_err(); + + assert!(error.to_string().contains("--host https://memory.kinic.xyz")); +} + +#[test] +fn kinic_portal_host_rejects_missing_host() { + let identity = internet_identity_with_host(None); + + let error = ensure_kinic_portal_host("alice", &identity).unwrap_err(); + + assert!(error.to_string().contains("--host https://memory.kinic.xyz")); +} + +#[test] +fn unsupported_storage_mentions_recreate_command() { + let identity = IcpCliIdentity { + kind: "internet-identity".to_string(), + storage: Some(IcpCliStorage { + kind: "plaintext".to_string(), + }), + host: Some("https://memory.kinic.xyz".to_string()), + }; + + let error = ensure_keyring_storage("alice", &identity).unwrap_err(); + + assert!(error.to_string().contains("--storage keyring")); +} + +#[test] +fn expired_chain_mentions_icp_login() { + let entry = SignedDelegation { + delegation: Delegation { + pubkey: vec![1], + expiration: 1, + targets: None, + }, + signature: vec![2], + }; + + let error = ensure_chain_not_expired("alice", &[entry]).unwrap_err(); + + assert!(error.to_string().contains("icp identity login alice")); +} + +fn tempfile_dir(name: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&path); + path +} + +fn internet_identity_with_host(host: Option<&str>) -> IcpCliIdentity { + IcpCliIdentity { + kind: "internet-identity".to_string(), + storage: Some(IcpCliStorage { + kind: "keyring".to_string(), + }), + host: host.map(ToString::to_string), + } +} diff --git a/rust/lib.rs b/rust/lib.rs index da9fecd..e366ae1 100644 --- a/rust/lib.rs +++ b/rust/lib.rs @@ -7,6 +7,9 @@ mod commands; pub(crate) mod create_domain; mod embedding; pub(crate) mod identity_store; +pub(crate) mod icp_cli_identity; +#[cfg(test)] +mod icp_cli_identity_tests; pub(crate) mod insert_service; mod ledger; pub(crate) mod memory_client_builder; @@ -196,6 +199,9 @@ pub(crate) fn build_cli_command_context( )) } else { let identity = resolve_required_identity(global)?; + if let Some(identity) = icp_cli_identity::load_icp_cli_internet_identity(&identity)? { + return Ok((AgentFactory::new_with_arc_identity(global.ic, identity), None)); + } Ok((build_keyring_agent_factory(global.ic, &identity), None)) } } From 5479b3a3829f5c15b805278a6bba629dfd6a2f94 Mon Sep 17 00:00:00 2001 From: hude Date: Wed, 13 May 2026 13:26:03 +0900 Subject: [PATCH 2/2] Harden portal CSP and CLI login cleanup --- .../src/routes/cli-login-page.test.ts | 72 ++++++++++- .../src/routes/cli-login-page.tsx | 57 ++++++--- apps/kinic-portal/src/ssr.test.tsx | 4 +- apps/kinic-portal/src/ssr.tsx | 10 +- apps/kinic-portal/src/worker.test.ts | 42 ++++++- apps/kinic-portal/src/worker.tsx | 33 +++-- rust/icp_cli_identity.rs | 114 ++++++++++++------ rust/icp_cli_identity_tests.rs | 105 ++++++++++++++-- rust/lib.rs | 7 +- tests/fixtures/icp_cli_delegation_chain.json | 12 ++ 10 files changed, 379 insertions(+), 77 deletions(-) create mode 100644 tests/fixtures/icp_cli_delegation_chain.json diff --git a/apps/kinic-portal/src/routes/cli-login-page.test.ts b/apps/kinic-portal/src/routes/cli-login-page.test.ts index 5d26bb0..05fc984 100644 --- a/apps/kinic-portal/src/routes/cli-login-page.test.ts +++ b/apps/kinic-portal/src/routes/cli-login-page.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from "vitest"; -import { isAllowedLocalCallback, parseCliLoginHash } from "./cli-login-page"; +import { AnonymousIdentity } from "@dfinity/agent"; +import { describe, expect, it, vi } from "vitest"; +import { + type CliLoginAuthClient, + isAllowedLocalCallback, + parseCliLoginHash, + sendCliLoginDelegation, +} from "./cli-login-page"; describe("cli login params", () => { it("returns empty for direct visits without hash params", () => { @@ -51,3 +57,65 @@ describe("cli login params", () => { expect(parseCliLoginHash("#public_key=abc&callback=http%3A%2F%2Flocalhost%3A1234%2Fcallback").kind).toBe("error"); }); }); + +describe("cli login flow", () => { + it("logs out after a successful callback", async () => { + const logout = vi.fn<() => Promise>().mockResolvedValue(undefined); + const fetchCallback: typeof fetch = async () => new Response(null, { status: 200 }); + + await sendCliLoginDelegation(params(), { + createAuthClient: async () => authClient(logout), + loginClient: async () => undefined, + createDelegation: async () => ({ ok: true }), + fetchCallback, + }); + + expect(logout).toHaveBeenCalledOnce(); + }); + + it("logs out after a callback network failure", async () => { + const logout = vi.fn<() => Promise>().mockResolvedValue(undefined); + const fetchCallback: typeof fetch = async () => { + throw new Error("network failed"); + }; + + await expect(sendCliLoginDelegation(params(), { + createAuthClient: async () => authClient(logout), + loginClient: async () => undefined, + createDelegation: async () => ({ ok: true }), + fetchCallback, + })).rejects.toThrow("network failed"); + expect(logout).toHaveBeenCalledOnce(); + }); + + it("logs out after a non-2xx callback and preserves the callback error", async () => { + const logout = vi.fn<() => Promise>().mockRejectedValue(new Error("logout failed")); + const fetchCallback: typeof fetch = async () => new Response(null, { + status: 500, + statusText: "Nope", + }); + + await expect(sendCliLoginDelegation(params(), { + createAuthClient: async () => authClient(logout), + loginClient: async () => undefined, + createDelegation: async () => ({ ok: true }), + fetchCallback, + })).rejects.toThrow("Callback failed: 500 Nope"); + expect(logout).toHaveBeenCalledOnce(); + }); +}); + +function authClient(logout: () => Promise): CliLoginAuthClient { + return { + getIdentity: () => new AnonymousIdentity(), + login: async () => undefined, + logout, + }; +} + +function params() { + return { + publicKey: "abc", + callback: "http://127.0.0.1:8620/callback", + }; +} diff --git a/apps/kinic-portal/src/routes/cli-login-page.tsx b/apps/kinic-portal/src/routes/cli-login-page.tsx index d0213d9..07ed6fd 100644 --- a/apps/kinic-portal/src/routes/cli-login-page.tsx +++ b/apps/kinic-portal/src/routes/cli-login-page.tsx @@ -26,6 +26,16 @@ export type CliLoginParseResult = type LoginState = "ready" | "signing-in" | "sending" | "finished" | "error"; +export type CliLoginAuthClient = Pick; + +type CliLoginFlowOptions = { + createAuthClient?: () => Promise; + loginClient?: (authClient: CliLoginAuthClient) => Promise; + createDelegation?: (authClient: CliLoginAuthClient, publicKey: string) => Promise; + fetchCallback?: typeof fetch; + afterLogin?: () => void; +}; + export function CliLoginPage() { const loginRequest = useMemo(() => parseCliLoginHash(readHash()), []); const [state, setState] = useState("ready"); @@ -38,21 +48,9 @@ export function CliLoginPage() { setError(null); setState("signing-in"); try { - const authClient = await AuthClient.create({ keyType: "Ed25519" }); - await login(authClient); - setState("sending"); - const { params } = loginRequest; - const delegation = await createCliDelegation(authClient, params.publicKey); - const response = await fetch(params.callback, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(delegation), - redirect: "error", + await sendCliLoginDelegation(loginRequest.params, { + afterLogin: () => setState("sending"), }); - if (!response.ok) { - throw new Error(`Callback failed: ${response.status} ${response.statusText}`); - } - await authClient.logout(); setState("finished"); window.setTimeout(() => window.close(), 2000); } catch (cause) { @@ -132,6 +130,33 @@ export function CliLoginPage() { ); } +export async function sendCliLoginDelegation( + params: CliLoginParams, + options: CliLoginFlowOptions = {}, +): Promise { + const createAuthClient = options.createAuthClient || (() => AuthClient.create({ keyType: "Ed25519" })); + const loginClient = options.loginClient || login; + const createDelegation = options.createDelegation || createCliDelegation; + const fetchCallback = options.fetchCallback || fetch; + const authClient = await createAuthClient(); + try { + await loginClient(authClient); + options.afterLogin?.(); + const delegation = await createDelegation(authClient, params.publicKey); + const response = await fetchCallback(params.callback, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(delegation), + redirect: "error", + }); + if (!response.ok) { + throw new Error(`Callback failed: ${response.status} ${response.statusText}`); + } + } finally { + await authClient.logout().catch(() => undefined); + } +} + export function parseCliLoginHash(hash: string): CliLoginParseResult { const normalized = hash.startsWith("#") ? hash.slice(1) : hash; if (!normalized) { @@ -165,7 +190,7 @@ export function isAllowedLocalCallback(value: string): boolean { && (url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]"); } -async function login(authClient: AuthClient): Promise { +async function login(authClient: CliLoginAuthClient): Promise { return new Promise((resolve, reject) => { authClient.login({ identityProvider: IDENTITY_PROVIDER, @@ -176,7 +201,7 @@ async function login(authClient: AuthClient): Promise { }); } -async function createCliDelegation(authClient: AuthClient, publicKey: string) { +async function createCliDelegation(authClient: CliLoginAuthClient, publicKey: string) { const identity = authClient.getIdentity(); if (!(identity instanceof DelegationIdentity)) { throw new Error("Expected a delegated Internet Identity session."); diff --git a/apps/kinic-portal/src/ssr.test.tsx b/apps/kinic-portal/src/ssr.test.tsx index e05dd91..17a6bcc 100644 --- a/apps/kinic-portal/src/ssr.test.tsx +++ b/apps/kinic-portal/src/ssr.test.tsx @@ -53,7 +53,7 @@ describe("renderPortalDocument", () => { stable_memory_size: 10, cycle_amount: 20, }, - }, "Cached summary text"); + }, "Cached summary text", "test-nonce"); expect(document.status).toBe(200); expect(document.html).toContain("Skill Store ยท ywega-gaaaa-aaaak-apg6q-cai | Kinic"); @@ -61,6 +61,8 @@ describe("renderPortalDocument", () => { expect(document.html).toContain('meta property="og:image" content="https://api.example.com/api/public/og/memories/ywega-gaaaa-aaaak-apg6q-cai?v=0.2.5"'); expect(document.html).toContain('link rel="canonical" href="https://portal.example.com/m/ywega-gaaaa-aaaak-apg6q-cai"'); expect(document.html).toContain("window.__KINIC_PORTAL_CONFIG__"); + expect(document.html).toContain('`, + `window.__KINIC_PORTAL_CONFIG__=${serializeForScript(documentConfig)};`, ``, "", "", @@ -51,6 +52,13 @@ export function renderPortalDocument( }; } +function renderNonceAttribute(scriptNonce: string | undefined): string { + if (!scriptNonce) { + return ""; + } + return ` nonce="${escapeHtml(scriptNonce)}"`; +} + export function resolvePortalMetadata( pathname: string, config: PortalRuntimeConfig, diff --git a/apps/kinic-portal/src/worker.test.ts b/apps/kinic-portal/src/worker.test.ts index 1098036..a16c92c 100644 --- a/apps/kinic-portal/src/worker.test.ts +++ b/apps/kinic-portal/src/worker.test.ts @@ -27,7 +27,10 @@ import worker from "./worker"; describe("portal worker", () => { beforeEach(() => { vi.resetAllMocks(); - mocks.renderPortalDocument.mockReturnValue({ html: "ok", status: 200 }); + mocks.renderPortalDocument.mockImplementation((...args: unknown[]) => { + const scriptNonce = args[4]; + return { html: `ok`, status: 200 }; + }); mocks.resolvePortalMetadata.mockReturnValue({ title: "Kinic Portal", description: "desc", @@ -66,7 +69,7 @@ describe("portal worker", () => { ); expect(response.status).toBe(200); - expect(await response.text()).toBe("ok"); + expect(await response.text()).toMatch(/^ok<\/html>$/); expect(mocks.resolvePublicMemory).toHaveBeenCalledWith({ IC_HOST: "https://ic0.app" }, "m1"); expect(mocks.renderPortalDocument).toHaveBeenCalledOnce(); }); @@ -83,7 +86,28 @@ describe("portal worker", () => { expect(mocks.renderPortalDocument).not.toHaveBeenCalled(); }); - it("adds CSP for localhost cli callbacks on documents", async () => { + it("adds CSP nonce matching the rendered document", async () => { + const response = await worker.fetch( + new Request("https://portal.kinic.test/m/m1"), + env(), + ); + + const html = await response.text(); + const csp = response.headers.get("Content-Security-Policy") || ""; + const htmlNonce = html.match(/data-nonce="([0-9a-f]{32})"/)?.[1]; + + expect(htmlNonce).toBeDefined(); + expect(csp).toContain(`script-src 'self' 'nonce-${htmlNonce}'`); + expect(mocks.renderPortalDocument).toHaveBeenCalledWith( + "/m/m1", + expect.anything(), + expect.anything(), + null, + htmlNonce, + ); + }); + + it("adds CSP for localhost cli callbacks on cli login documents", async () => { const response = await worker.fetch( new Request("https://portal.kinic.test/cli-login"), env(), @@ -94,6 +118,18 @@ describe("portal worker", () => { expect(response.headers.get("Content-Security-Policy")).toContain("https://id.ai"); }); + it("omits loopback CSP sources from non-cli documents", async () => { + const response = await worker.fetch( + new Request("https://portal.kinic.test/m/m1"), + env(), + ); + + const csp = response.headers.get("Content-Security-Policy") || ""; + expect(csp).not.toContain("http://127.0.0.1:*"); + expect(csp).not.toContain("http://[::1]:*"); + expect(csp).not.toContain("https://id.ai"); + }); + it("adds runtime API and MCP origins to document CSP", async () => { const response = await worker.fetch( new Request("https://portal.preview.test/m/m1"), diff --git a/apps/kinic-portal/src/worker.tsx b/apps/kinic-portal/src/worker.tsx index e1130fc..90c7d0c 100644 --- a/apps/kinic-portal/src/worker.tsx +++ b/apps/kinic-portal/src/worker.tsx @@ -47,11 +47,12 @@ export default { const memorySummary = await resolveMemoryRouteSummary(env, memoryState); if (request.method === "HEAD") { const metadata = resolvePortalMetadata(pathname, config, memoryState, memorySummary); - return documentResponse(null, metadata.status, config); + return documentResponse(pathname, null, metadata.status, config, generateScriptNonce()); } - const document = renderPortalDocument(pathname, config, memoryState, memorySummary); - return documentResponse(document.html, document.status, config); + const scriptNonce = generateScriptNonce(); + const document = renderPortalDocument(pathname, config, memoryState, memorySummary, scriptNonce); + return documentResponse(pathname, document.html, document.status, config, scriptNonce); }, }; @@ -114,36 +115,42 @@ async function handleMemorySummary(method: "GET" | "HEAD", request: Request, env } } -function documentResponse(body: string | null, status: number, config: PortalRuntimeConfig): Response { +function documentResponse( + pathname: string, + body: string | null, + status: number, + config: PortalRuntimeConfig, + scriptNonce: string, +): Response { return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", "Cache-Control": "public, max-age=0, must-revalidate", - "Content-Security-Policy": buildDocumentCsp(config), + "Content-Security-Policy": buildDocumentCsp(pathname, config, scriptNonce), Link: `<${PORTAL_STYLE_PATH}>; rel=preload; as=style, <${PORTAL_SCRIPT_PATH}>; rel=modulepreload; as=script`, }, }); } -function buildDocumentCsp(config: PortalRuntimeConfig): string { +function buildDocumentCsp(pathname: string, config: PortalRuntimeConfig, scriptNonce: string): string { const publicApiOrigin = safeOrigin(config.publicApiOrigin); const mcpOrigin = config.mcpEndpoint ? safeOrigin(config.mcpEndpoint) : null; const imageSources = ["'self'", "data:", publicApiOrigin].filter(Boolean); const connectSources = [ "'self'", - "https://id.ai", "https://ic0.app", "https://icp-api.io", publicApiOrigin, mcpOrigin, - "http://127.0.0.1:*", - "http://[::1]:*", ].filter(Boolean); + if (pathname === CLI_LOGIN_PATH) { + connectSources.push("https://id.ai", "http://127.0.0.1:*", "http://[::1]:*"); + } return [ "default-src 'self'", - "script-src 'self'", + `script-src 'self' 'nonce-${scriptNonce}'`, "style-src 'self' 'unsafe-inline'", `img-src ${imageSources.join(" ")}`, `connect-src ${connectSources.join(" ")}`, @@ -155,6 +162,12 @@ function buildDocumentCsp(config: PortalRuntimeConfig): string { ].join("; "); } +function generateScriptNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + function safeOrigin(value: string): string | null { try { return new URL(value).origin; diff --git a/rust/icp_cli_identity.rs b/rust/icp_cli_identity.rs index 6f75bed..360d12f 100644 --- a/rust/icp_cli_identity.rs +++ b/rust/icp_cli_identity.rs @@ -5,6 +5,7 @@ use std::{ collections::HashMap, + ffi::OsString, fs, io::Cursor, path::{Path, PathBuf}, @@ -105,12 +106,20 @@ pub(crate) fn read_internet_identity_metadata( Ok(payload) => payload, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => { - return Err(error) - .with_context(|| format!("Failed to read icp-cli identity list at {}", list_path.display())); + return Err(error).with_context(|| { + format!( + "Failed to read icp-cli identity list at {}", + list_path.display() + ) + }); } }; - let list: IdentityList = serde_json::from_str(&payload) - .with_context(|| format!("Failed to parse icp-cli identity list at {}", list_path.display()))?; + let list: IdentityList = serde_json::from_str(&payload).with_context(|| { + format!( + "Failed to parse icp-cli identity list at {}", + list_path.display() + ) + })?; let Some(identity) = list.identities.into_iter().find_map(|(name, identity)| { if name == identity_name { Some(identity) @@ -141,7 +150,10 @@ pub(crate) fn ensure_keyring_storage(identity_name: &str, identity: &IcpCliIdent Ok(()) } -pub(crate) fn ensure_kinic_portal_host(identity_name: &str, identity: &IcpCliIdentity) -> Result<()> { +pub(crate) fn ensure_kinic_portal_host( + identity_name: &str, + identity: &IcpCliIdentity, +) -> Result<()> { let host = identity .host .as_deref() @@ -149,7 +161,10 @@ pub(crate) fn ensure_kinic_portal_host(identity_name: &str, identity: &IcpCliIde let origin = normalize_host_origin(host) .ok_or_else(|| kinic_portal_host_error(identity_name, "invalid host"))?; if origin != KINIC_PORTAL_ORIGIN { - return Err(kinic_portal_host_error(identity_name, &format!("host `{origin}`"))); + return Err(kinic_portal_host_error( + identity_name, + &format!("host `{origin}`"), + )); } Ok(()) } @@ -177,16 +192,21 @@ fn normalize_host_origin(host: &str) -> Option { } fn read_delegation_chain(path: &Path) -> Result { - let payload = fs::read_to_string(path) - .with_context(|| format!("Failed to read icp-cli delegation file at {}", path.display()))?; + let payload = fs::read_to_string(path).with_context(|| { + format!( + "Failed to read icp-cli delegation file at {}", + path.display() + ) + })?; parse_delegation_chain_json(&payload) } -fn parse_delegation_chain_json(payload: &str) -> Result { +pub(crate) fn parse_delegation_chain_json(payload: &str) -> Result { let chain: HexDelegationChain = serde_json::from_str(payload).context("Failed to parse icp-cli delegation chain")?; let public_key = decode_hex_key("delegation publicKey", &chain.public_key)?; - let public_key = normalize_spki_key(&public_key).context("Unsupported II user public key format")?; + let public_key = + normalize_spki_key(&public_key).context("Unsupported II user public key format")?; let delegations = chain .delegations .into_iter() @@ -248,7 +268,9 @@ fn new_delegated_identity( match delegated { Ok(identity) => Ok(identity), Err(DelegationError::UnknownAlgorithm) => { - warn!("icp-cli delegation chain uses an unknown algorithm; skipping local verification."); + warn!( + "icp-cli delegation chain uses an unknown algorithm; skipping local verification." + ); let session_identity = parse_session_identity(session_pem)?; Ok(DelegatedIdentity::new_unchecked( public_key, @@ -265,7 +287,10 @@ fn parse_session_identity(session_pem: &str) -> Result { .context("Failed to parse icp-cli session key") } -pub(crate) fn ensure_chain_not_expired(identity_name: &str, delegations: &[SignedDelegation]) -> Result<()> { +pub(crate) fn ensure_chain_not_expired( + identity_name: &str, + delegations: &[SignedDelegation], +) -> Result<()> { let expiration = delegations .iter() .map(|entry| entry.delegation.expiration) @@ -294,29 +319,48 @@ fn default_identity_dir() -> Result> { let Some(home) = std::env::var_os("HOME") else { return Ok(None); }; - #[cfg(target_os = "macos")] - { - return Ok(Some( - PathBuf::from(home) - .join("Library") - .join("Application Support") - .join("org.dfinity.icp-cli") - .join("identity"), - )); - } - #[cfg(not(target_os = "macos"))] - { - Ok(Some( - PathBuf::from(home) - .join(".local") - .join("share") - .join("org.dfinity.icp-cli") - .join("identity"), - )) - } + Ok(Some(resolve_default_identity_dir( + home, + std::env::var_os("XDG_DATA_HOME"), + ))) } -struct ParsedDelegationChain { - public_key: Vec, - delegations: Vec, +pub(crate) fn resolve_default_identity_dir( + home: OsString, + xdg_data_home: Option, +) -> PathBuf { + platform_identity_dir(home, xdg_data_home) +} + +#[cfg(target_os = "macos")] +fn platform_identity_dir(home: OsString, xdg_data_home: Option) -> PathBuf { + let _ = xdg_data_home; + PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("org.dfinity.icp-cli") + .join("identity") +} + +#[cfg(target_os = "linux")] +fn platform_identity_dir(home: OsString, xdg_data_home: Option) -> PathBuf { + let data_home = xdg_data_home + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(home).join(".local").join("share")); + data_home.join("org.dfinity.icp-cli").join("identity") +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] +fn platform_identity_dir(home: OsString, xdg_data_home: Option) -> PathBuf { + let _ = xdg_data_home; + PathBuf::from(home) + .join(".local") + .join("share") + .join("org.dfinity.icp-cli") + .join("identity") +} + +pub(crate) struct ParsedDelegationChain { + pub(crate) public_key: Vec, + pub(crate) delegations: Vec, } diff --git a/rust/icp_cli_identity_tests.rs b/rust/icp_cli_identity_tests.rs index 9d4ff18..cce6e98 100644 --- a/rust/icp_cli_identity_tests.rs +++ b/rust/icp_cli_identity_tests.rs @@ -3,13 +3,17 @@ //! What: verifies resolver branching and user-facing recovery messages. //! Why: keep linked-II support safe without touching the real keychain in tests. -use std::{fs, path::PathBuf}; +use std::{ffi::OsString, fs, path::PathBuf}; use ic_agent::identity::{Delegation, SignedDelegation}; -use crate::icp_cli_identity::{ - IcpCliIdentity, IcpCliStorage, ensure_chain_not_expired, ensure_keyring_storage, - ensure_kinic_portal_host, read_internet_identity_metadata, +use crate::{ + icp_cli_identity::{ + IcpCliIdentity, IcpCliStorage, ensure_chain_not_expired, ensure_keyring_storage, + ensure_kinic_portal_host, parse_delegation_chain_json, read_internet_identity_metadata, + resolve_default_identity_dir, + }, + identity_store::normalize_spki_key, }; #[test] @@ -37,7 +41,9 @@ fn read_metadata_detects_internet_identity() { ) .unwrap(); - let metadata = read_internet_identity_metadata("alice", &dir).unwrap().unwrap(); + let metadata = read_internet_identity_metadata("alice", &dir) + .unwrap() + .unwrap(); assert_eq!(metadata.kind, "internet-identity"); assert_eq!(metadata.storage.unwrap().kind, "keyring"); @@ -63,7 +69,11 @@ fn kinic_portal_host_rejects_other_origin() { let error = ensure_kinic_portal_host("alice", &identity).unwrap_err(); - assert!(error.to_string().contains("--host https://memory.kinic.xyz")); + assert!( + error + .to_string() + .contains("--host https://memory.kinic.xyz") + ); } #[test] @@ -72,7 +82,11 @@ fn kinic_portal_host_rejects_missing_host() { let error = ensure_kinic_portal_host("alice", &identity).unwrap_err(); - assert!(error.to_string().contains("--host https://memory.kinic.xyz")); + assert!( + error + .to_string() + .contains("--host https://memory.kinic.xyz") + ); } #[test] @@ -106,6 +120,83 @@ fn expired_chain_mentions_icp_login() { assert!(error.to_string().contains("icp identity login alice")); } +#[test] +fn parses_icp_cli_delegation_chain_fixture() { + let chain = parse_delegation_chain_json(include_str!( + "../tests/fixtures/icp_cli_delegation_chain.json" + )) + .unwrap(); + let expected_user_key = normalize_spki_key(&hex::decode( + "302a300506032b657003210079b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + ) + .unwrap()) + .unwrap(); + let expected_session_key = normalize_spki_key(&hex::decode( + "302a300506032b6570032100da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + ) + .unwrap()) + .unwrap(); + + assert_eq!(chain.public_key, expected_user_key); + assert_eq!(chain.delegations.len(), 1); + assert_eq!(chain.delegations[0].delegation.pubkey, expected_session_key); + assert_eq!( + chain.delegations[0].delegation.expiration, + u64::from_str_radix("1a46e83335d50000", 16).unwrap() + ); + assert!(chain.delegations[0].delegation.targets.is_none()); + assert_eq!( + hex::encode(&chain.delegations[0].signature), + "f28335854ee8d4d398c598a09287dc6e5361bb1235c558b06b68c9752e0d5872c725f20b6787e9b9396aeafb4f9356ad3347b2b636bb47ed71477e7b603c5b0d" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn default_identity_dir_uses_macos_application_support() { + let path = resolve_default_identity_dir(OsString::from("/Users/alice"), None); + + assert_eq!( + path, + PathBuf::from("/Users/alice") + .join("Library") + .join("Application Support") + .join("org.dfinity.icp-cli") + .join("identity") + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn default_identity_dir_uses_xdg_data_home_on_linux() { + let path = resolve_default_identity_dir( + OsString::from("/home/alice"), + Some(OsString::from("/tmp/data-home")), + ); + + assert_eq!( + path, + PathBuf::from("/tmp/data-home") + .join("org.dfinity.icp-cli") + .join("identity") + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn default_identity_dir_falls_back_to_local_share_on_linux() { + let path = resolve_default_identity_dir(OsString::from("/home/alice"), None); + + assert_eq!( + path, + PathBuf::from("/home/alice") + .join(".local") + .join("share") + .join("org.dfinity.icp-cli") + .join("identity") + ); +} + fn tempfile_dir(name: &str) -> PathBuf { let mut path = std::env::temp_dir(); path.push(format!("{name}-{}", std::process::id())); diff --git a/rust/lib.rs b/rust/lib.rs index e366ae1..b42f745 100644 --- a/rust/lib.rs +++ b/rust/lib.rs @@ -6,10 +6,10 @@ pub(crate) mod clients; mod commands; pub(crate) mod create_domain; mod embedding; -pub(crate) mod identity_store; pub(crate) mod icp_cli_identity; #[cfg(test)] mod icp_cli_identity_tests; +pub(crate) mod identity_store; pub(crate) mod insert_service; mod ledger; pub(crate) mod memory_client_builder; @@ -200,7 +200,10 @@ pub(crate) fn build_cli_command_context( } else { let identity = resolve_required_identity(global)?; if let Some(identity) = icp_cli_identity::load_icp_cli_internet_identity(&identity)? { - return Ok((AgentFactory::new_with_arc_identity(global.ic, identity), None)); + return Ok(( + AgentFactory::new_with_arc_identity(global.ic, identity), + None, + )); } Ok((build_keyring_agent_factory(global.ic, &identity), None)) } diff --git a/tests/fixtures/icp_cli_delegation_chain.json b/tests/fixtures/icp_cli_delegation_chain.json new file mode 100644 index 0000000..0aa6d25 --- /dev/null +++ b/tests/fixtures/icp_cli_delegation_chain.json @@ -0,0 +1,12 @@ +{ + "delegations": [ + { + "delegation": { + "expiration": "1a46e83335d50000", + "pubkey": "302a300506032b6570032100da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0" + }, + "signature": "f28335854ee8d4d398c598a09287dc6e5361bb1235c558b06b68c9752e0d5872c725f20b6787e9b9396aeafb4f9356ad3347b2b636bb47ed71477e7b603c5b0d" + } + ], + "publicKey": "302a300506032b657003210079b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" +}