diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index 0ceef4ede..1f16e406c 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -1,4 +1,7 @@ import { createConsola } from "consola"; +import { Context } from "@deepseek-ai/cordis"; +import { HostService } from "../src/features/host/service.ts"; +import { authTagOwner } from "../src/features/agents/owner-attestation.ts"; import { logSocketFrame } from "../src/features/developer/traffic.ts"; import { getLogger, setLogLevel } from "../src/features/developer/logging.ts"; import { brokerSocket, openBrokerSocket } from "../tests/broker-socket.mjs"; @@ -132,6 +135,71 @@ const success = (call) => : [], ); +test("remote agent authorization is owner-bound and works without a community", async () => { + // No default community is selected, and any upstream request fails. + const h = await harness( + () => { + throw new Error("Authorization must not contact upstream"); + }, + {}, + "", + ); + try { + const owner = (await (await h.get("identity")).json()).viewer; + const agentPubkey = getPublicKey(generateSecretKey()); + const http = globalThis.fetch; + vi.stubGlobal("fetch", (url, input) => http(new URL(url, h.base), input)); + vi.stubEnv("VITE_BUZZ_LIVE", "1"); + const context = new Context(); + const host = new HostService(context); + const tag = await host.prepareRemoteAgentAuthorization(agentPubkey); + // The host returns an unconditional proof for the requested agent and owner. + expect(await authTagOwner(agentPubkey, tag)).toBe(owner); + expect(tag.slice(0, 3)).toEqual(["auth", h.event.pubkey, ""]); + // The signature binds the exact agent key to the NIP-OA domain. + expect( + schnorr.verify( + Buffer.from(tag[3], "hex"), + createHash("sha256") + .update(`nostr:agent-auth:${agentPubkey}:`) + .digest(), + Buffer.from(tag[1], "hex"), + ), + ).toBe(true); + const route = "prepare-remote-agent-authorization"; + for (const rejected of [ + "invalid", // Malformed key. + "A".repeat(64), // Uppercase hex. + h.event.pubkey, // Self-attestation. + null, // Null agent key. + 42, // Non-string agent key. + ]) { + expect( + (await h.post(route, { owner: h.event.pubkey, agentPubkey: rejected })) + .status, + ).toBe(400); + } + // A different owner cannot use the broker's identity to sign. + expect( + (await h.post(route, { owner: "f".repeat(64), agentPubkey })).status, + ).toBe(403); + // The request must explicitly bind the current owner. + expect((await h.post(route, { agentPubkey })).status).toBe(403); + // A non-object request body is rejected. + expect((await h.post(route, null)).status).toBe(400); + // Oversized requests are rejected before signing. + expect( + (await h.post(route, { owner, agentPubkey: "a".repeat(4096) })).status, + ).toBe(413); + // Successful and rejected authorization requests stay local. + expect(h.calls).toEqual([]); + } finally { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + await h.close(); + } +}); + test("production transport obtains scoped broker harness log proofs for aliases and canonical origins", async () => { const h = await harness(success); const key = new Uint8Array(32); diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index eb6ddca51..b57bfaa40 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -861,6 +861,52 @@ export function relayBrokerPlugin({ return json(res, 200, { ...stats, connects: upstream.connects() }); if (url.pathname === "/api/relay/identity" && req.method === "GET") return json(res, 200, { viewer }); + if ( + url.pathname === "/api/relay/prepare-remote-agent-authorization" && + req.method === "POST" + ) { + let raw = ""; + for await (const part of req) { + raw += part; + if (Buffer.byteLength(raw) > 4096) + return json(res, 413, { + error: "Authorization request is too large", + }); + } + try { + const { owner, agentPubkey } = JSON.parse(raw); + if (owner !== viewer) + return json(res, 403, { + error: "The agent owner is not your signed-in identity", + }); + if ( + typeof agentPubkey !== "string" || + !/^[0-9a-f]{64}$/.test(agentPubkey) + ) + throw new Error( + "Agent pubkey must be 64 lowercase hex characters", + ); + if (agentPubkey === viewer) + throw new Error("Owner and agent pubkeys must differ"); + cancel.signal.throwIfAborted(); + const digest = createHash("sha256") + .update(`nostr:agent-auth:${agentPubkey}:`) + .digest(); + return json(res, 200, [ + "auth", + viewer, + "", + Buffer.from(schnorr.sign(digest, key)).toString("hex"), + ]); + } catch (error) { + return json(res, 400, { + error: + error instanceof Error + ? error.message + : "Owner authorization failed", + }); + } + } const parts = url.pathname.split("/").filter(Boolean); const scoped = parts.length === 4; let id; diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 842cb1e1e..0244fb4b4 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -29,6 +29,7 @@ fn main() { "identity_import", "identity_create", "identity_export", + "identity_prepare_remote_agent_authorization", "relay_sign", "relay_decode_read_state", "relay_sign_read_state", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ccd6c2173..1f6663de0 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "allow-identity-create", "allow-identity-export", "allow-relay-sign", + "allow-identity-prepare-remote-agent-authorization", "allow-relay-decode-read-state", "allow-relay-sign-read-state", "allow-relay-publish-read-state", diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index 550acde52..4dd3ac94f 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -65,6 +65,7 @@ fn native_command_permissions_allow_only_main_webview() { "identity_create", "identity_export", "relay_sign", + "identity_prepare_remote_agent_authorization", "relay_decode_read_state", "relay_sign_read_state", "relay_publish_read_state", diff --git a/src-tauri/src/identity.rs b/src-tauri/src/identity.rs index 531b69c8f..1cce7266e 100644 --- a/src-tauri/src/identity.rs +++ b/src-tauri/src/identity.rs @@ -696,13 +696,23 @@ impl IdentityHost { .await } - /// Callers bind `agent` to a key the app generated; the owner must be this identity. + /// Prepares a NIP-OA proof for one agent key; the owner must be this identity. pub(crate) async fn authorize_agent( &self, owner: String, agent: String, ) -> Result> { with_identity(self.clone(), move |identity| { + if agent.len() != 64 + || !agent + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err("Agent pubkey must be 64 lowercase hex characters".into()); + } + if agent == owner { + return Err("Owner and agent pubkeys must differ".into()); + } if identity.restore()?.as_deref() != Some(owner.as_str()) { return Err("The agent owner is not your signed-in identity".into()); } @@ -736,6 +746,15 @@ async fn with_identity( .await .map_err(|_| "Identity operation could not complete")? } +/// Prepares an unconditional NIP-OA proof; the caller submits it to the remote agent service. +#[tauri::command] +pub async fn identity_prepare_remote_agent_authorization( + host: tauri::State<'_, IdentityHost>, + owner: String, + agent_pubkey: String, +) -> Result> { + host.authorize_agent(owner, agent_pubkey).await +} #[tauri::command] pub async fn identity_restore(host: tauri::State<'_, IdentityHost>) -> Result> { with_identity(host.inner().clone(), Identity::restore).await diff --git a/src-tauri/src/identity/tests.rs b/src-tauri/src/identity/tests.rs index ec2331db5..17630d2fc 100644 --- a/src-tauri/src/identity/tests.rs +++ b/src-tauri/src/identity/tests.rs @@ -117,6 +117,78 @@ fn default_test_host_cannot_access_real_credentials() { assert!(IdentityHost::default().0.lock().unwrap().restore().is_err()); } +#[tokio::test] +async fn authorize_agent_signs_for_agent_and_rejects_invalid_requests() { + use secp256k1::{schnorr::Signature, Secp256k1, XOnlyPublicKey}; + let host = IdentityHost::fixture(); + let owner = host.viewer().await.unwrap(); + let agent = Key(Zeroizing::new([2; 32])).viewer().unwrap(); + let tag = host + .authorize_agent(owner.clone(), agent.clone()) + .await + .unwrap(); + assert_eq!(&tag[..3], &["auth", owner.as_str(), ""]); + let signature: Signature = tag[3].parse().unwrap(); + let pubkey: XOnlyPublicKey = owner.parse().unwrap(); + let digest = Sha256::digest(format!("nostr:agent-auth:{agent}:")); + Secp256k1::verification_only() + .verify_schnorr(&signature, &digest, &pubkey) + .unwrap(); + assert!(host + .authorize_agent(agent.clone(), owner.clone()) + .await + .unwrap_err() + .contains("signed-in identity")); + for invalid in ["invalid".into(), "A".repeat(64), owner.clone()] { + assert!(host.authorize_agent(owner.clone(), invalid).await.is_err()); + } + assert!(IdentityHost::default() + .authorize_agent(owner, agent) + .await + .is_err()); +} + +#[test] +fn remote_agent_authorization_reaches_signer_through_production_ipc() { + use tauri::test::{get_ipc_response, mock_builder, INVOKE_KEY}; + let app = mock_builder() + .manage(IdentityHost::fixture()) + .invoke_handler(crate::commands()) + .build(crate::app_context()) + .unwrap(); + let view = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap(); + let owner = fixture().viewer().unwrap(); + let agent = Key(Zeroizing::new([2; 32])).viewer().unwrap(); + let response = get_ipc_response( + &view, + tauri::webview::InvokeRequest { + cmd: "identity_prepare_remote_agent_authorization".into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: view.url().unwrap(), + body: tauri::ipc::InvokeBody::Json(serde_json::json!({ + "owner": owner, "agentPubkey": agent + })), + headers: Default::default(), + invoke_key: INVOKE_KEY.into(), + }, + ) + .unwrap() + .deserialize::>() + .unwrap(); + assert_eq!(&response[..3], &["auth", owner.as_str(), ""]); + let digest = Sha256::digest(format!("nostr:agent-auth:{agent}:")); + secp256k1::Secp256k1::verification_only() + .verify_schnorr( + &response[3].parse().unwrap(), + &digest, + &owner.parse().unwrap(), + ) + .unwrap(); +} + #[test] fn uppercase_import_keeps_the_exact_key_and_mixed_case_is_rejected() { let store = Arc::new(Memory::default()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e24dca706..78c3f2e5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,7 +16,10 @@ mod notifications; mod os_idle; use os_idle::get_os_idle_seconds; mod relay; -use identity::{identity_create, identity_export, identity_import, identity_restore, IdentityHost}; +use identity::{ + identity_create, identity_export, identity_import, identity_prepare_remote_agent_authorization, + identity_restore, IdentityHost, +}; use relay::{ media_download, relay_agent_library, relay_agent_log_proof, relay_agent_memories_read, relay_agent_observer, relay_agent_resolve, relay_channel_publish, relay_channel_sign, @@ -379,6 +382,7 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen identity_import, identity_create, identity_export, + identity_prepare_remote_agent_authorization, relay_sign, relay_decode_read_state, relay_sign_read_state, diff --git a/src/features/host/service.test.ts b/src/features/host/service.test.ts index e3f8cb062..3d6cbd75d 100644 --- a/src/features/host/service.test.ts +++ b/src/features/host/service.test.ts @@ -1,6 +1,6 @@ import { Context } from "@deepseek-ai/cordis"; import { invoke, isTauri } from "@tauri-apps/api/core"; -import { beforeEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { HostService } from "./service"; vi.mock("@tauri-apps/api/core", () => ({ @@ -11,6 +11,11 @@ vi.mock("@tauri-apps/api/core", () => ({ beforeEach(() => { vi.mocked(isTauri).mockReturnValue(true); vi.mocked(invoke).mockReset(); + vi.stubGlobal("navigator", { platform: "MacIntel" }); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); function pluginContext() { @@ -24,6 +29,139 @@ function pluginContext() { }; } +it("prepares a structured NIP-OA proof with the native identity without a community", async () => { + const identity = "ab".repeat(32); + const agentPubkey = "cd".repeat(32); + const authorization = ["auth", identity, "", "ef".repeat(64)]; + const { plugin } = pluginContext(); + vi.mocked(invoke) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(authorization); + expect( + await plugin.host.prepareRemoteAgentAuthorization?.(agentPubkey), + ).toEqual(authorization); + expect(invoke).toHaveBeenNthCalledWith(1, "identity_restore"); + expect(invoke).toHaveBeenNthCalledWith( + 2, + "identity_prepare_remote_agent_authorization", + { owner: identity, agentPubkey }, + ); +}); + +it.each([ + { mode: "browser", tauri: false, live: undefined }, + { mode: "live browser", tauri: false, live: "1" }, + { mode: "live desktop", tauri: true, live: "1" }, +])( + "uses the dev broker identity for $mode authorization without a selected relay", + async ({ tauri, live }) => { + const identity = "ab".repeat(32); + const agentPubkey = "cd".repeat(32); + const authorization = ["auth", identity, "", "ef".repeat(64)]; + vi.mocked(isTauri).mockReturnValue(tauri); + vi.stubEnv("VITE_BUZZ_LIVE", live); + const fetcher = vi.fn(async (_url: string, _input?: RequestInit) => + Response.json({ viewer: identity }), + ); + fetcher + .mockResolvedValueOnce(Response.json({ viewer: identity })) + .mockResolvedValueOnce(Response.json(authorization)); + vi.stubGlobal("fetch", fetcher); + const { plugin } = pluginContext(); + expect( + await plugin.host.prepareRemoteAgentAuthorization?.(agentPubkey), + ).toEqual(authorization); + expect(fetcher.mock.calls[0]?.[0]).toBe("/api/relay/identity"); + expect(fetcher.mock.calls[1]?.[0]).toBe( + "/api/relay/prepare-remote-agent-authorization", + ); + const input = fetcher.mock.calls[1]?.[1] as RequestInit; + expect(JSON.parse(String(input.body))).toEqual({ + owner: identity, + agentPubkey, + }); + expect(invoke).not.toHaveBeenCalled(); + }, +); + +it("does not sign invalid, self, missing-identity or pre-canceled requests", async () => { + const identity = "ab".repeat(32); + const agentPubkey = "cd".repeat(32); + const { plugin } = pluginContext(); + await expect( + plugin.host.prepareRemoteAgentAuthorization?.("invalid"), + ).rejects.toThrow("64 lowercase hex"); + expect(invoke).not.toHaveBeenCalled(); + vi.mocked(invoke).mockResolvedValueOnce(identity); + await expect( + plugin.host.prepareRemoteAgentAuthorization?.(identity), + ).rejects.toThrow("must differ"); + vi.mocked(invoke).mockResolvedValueOnce(null); + await expect( + plugin.host.prepareRemoteAgentAuthorization?.(agentPubkey), + ).rejects.toThrow("Set up your identity"); + const cancel = new AbortController(); + cancel.abort(); + await expect( + plugin.host.prepareRemoteAgentAuthorization?.(agentPubkey, cancel.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(invoke).toHaveBeenCalledTimes(2); +}); + +it.each(["identity_restore", "identity_prepare_remote_agent_authorization"])( + "fences cancellation during %s", + async (command) => { + const identity = "ab".repeat(32); + const agentPubkey = "cd".repeat(32); + const authorization = ["auth", identity, "", "ef".repeat(64)]; + let release!: (value: unknown) => void; + vi.mocked(invoke).mockImplementation((name) => + name === command + ? new Promise((resolve) => { + release = resolve; + }) + : Promise.resolve(identity), + ); + const { plugin } = pluginContext(); + const cancel = new AbortController(); + const pending = plugin.host.prepareRemoteAgentAuthorization?.( + agentPubkey, + cancel.signal, + ); + try { + await vi.waitFor(() => expect(release).toBeTypeOf("function")); + cancel.abort(); + } finally { + release?.(command === "identity_restore" ? identity : authorization); + } + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(invoke).toHaveBeenCalledTimes( + command === "identity_restore" ? 1 : 2, + ); + }, +); + +it.each(["different owner", "nonempty conditions", "malformed signature"])( + "rejects an invalid authorization: %s", + async (scenario) => { + const identity = "ab".repeat(32); + const agentPubkey = "cd".repeat(32); + const authorization = [ + "auth", + scenario === "different owner" ? agentPubkey : identity, + scenario === "nonempty conditions" ? "kind=0" : "", + scenario === "malformed signature" ? "invalid" : "ef".repeat(64), + ]; + const { plugin } = pluginContext(); + vi.mocked(invoke) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(authorization); + await expect( + plugin.host.prepareRemoteAgentAuthorization?.(agentPubkey), + ).rejects.toThrow("Invalid remote agent authorization"); + }, +); + it("uses the calling plugin identity and preserves bounded command output", async () => { const { plugin } = pluginContext(); vi.mocked(invoke).mockResolvedValue("ready \n"); diff --git a/src/features/host/service.ts b/src/features/host/service.ts index f771680a6..5cff87a15 100644 --- a/src/features/host/service.ts +++ b/src/features/host/service.ts @@ -1,6 +1,7 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import { Service, type Context } from "@deepseek-ai/cordis"; import type {} from "../../plugins/api"; +import { nativeIdentityEnabled } from "../identity/service"; export type HostRequest = Readonly<{ url: string; @@ -13,9 +14,20 @@ export type HostResponse = Readonly<{ headers: Readonly>; body: string; }>; +/** Reusable NIP-OA proof for one agent key; preparing it does not submit it. */ +export type NipOaAuthorization = readonly [ + "auth", + ownerPubkey: string, + conditions: string, + signature: string, +]; export interface Host { runCommand(id: string): Promise; request(input: HostRequest): Promise; + prepareRemoteAgentAuthorization?: ( + agentPubkey: string, + signal?: AbortSignal, + ) => Promise; } declare module "@deepseek-ai/cordis" { @@ -29,6 +41,66 @@ export class HostService extends Service implements Host { super(context, "host"); } + async prepareRemoteAgentAuthorization( + agentPubkey: string, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + if (!/^[0-9a-f]{64}$/.test(agentPubkey)) + throw new Error("Agent pubkey must be 64 lowercase hex characters"); + const native = nativeIdentityEnabled(); + const owner = native + ? await invoke("identity_restore") + : ( + await ( + await fetch("/api/relay/identity", { signal: signal ?? null }) + ).json() + ).viewer; + signal?.throwIfAborted(); + if (typeof owner !== "string" || !/^[0-9a-f]{64}$/.test(owner)) + throw new Error("Set up your identity first"); + if (owner === agentPubkey) + throw new Error("Owner and agent pubkeys must differ"); + let tag: NipOaAuthorization; + if (native) { + try { + tag = await invoke( + "identity_prepare_remote_agent_authorization", + { owner, agentPubkey }, + ); + } catch (error) { + throw typeof error === "string" ? new Error(error) : error; + } + } else { + const response = await fetch( + "/api/relay/prepare-remote-agent-authorization", + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ owner, agentPubkey }), + signal: signal ?? null, + }, + ); + const result = await response.json(); + if (!response.ok) + throw new Error(result.error ?? "Owner authorization failed"); + tag = result; + } + signal?.throwIfAborted(); + if ( + !Array.isArray(tag) || + tag.length !== 4 || + tag[0] !== "auth" || + tag[1] !== owner || + tag[2] !== "" || + typeof tag[3] !== "string" || + !/^[0-9a-f]{128}$/.test(tag[3]) + ) + throw new Error("Invalid remote agent authorization"); + return tag; + } + async runCommand(id: string): Promise { const owner = this.ctx.pluginOwner; if (!owner || !isTauri()) return null;