Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions dev/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
46 changes: 46 additions & 0 deletions dev/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/browser_permissions_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 20 additions & 1 deletion src-tauri/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>> {
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());
}
Expand Down Expand Up @@ -736,6 +746,15 @@ async fn with_identity<T: Send + 'static>(
.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<Vec<String>> {
host.authorize_agent(owner, agent_pubkey).await
}
#[tauri::command]
pub async fn identity_restore(host: tauri::State<'_, IdentityHost>) -> Result<Option<String>> {
with_identity(host.inner().clone(), Identity::restore).await
Expand Down
72 changes: 72 additions & 0 deletions src-tauri/src/identity/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<String>>()
.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());
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -379,6 +382,7 @@ fn commands<R: tauri::Runtime>() -> impl Fn(tauri::ipc::Invoke<R>) -> bool + Sen
identity_import,
identity_create,
identity_export,
identity_prepare_remote_agent_authorization,
relay_sign,
relay_decode_read_state,
relay_sign_read_state,
Expand Down
Loading
Loading